API 密钥

两种 API 密钥,/api/v1/admin/* 管理数据 API 和 /api/v1/me/* 用户 API,统一的响应格式,以及怎样新增一个接口。

ShipKit 自带两套对外的 HTTP API,各配一种密钥。它们用途不同,是刻意分开的。

管理密钥用户密钥
接口范围/api/v1/admin/*/api/v1/me/*
能读什么所有账户的数据只有持有者自己的数据
谁在用做分析的 AI agent、脚本、报表你的客户自己的代码
在哪里创建管理后台 → 开发者 → API 密钥(/admin/api-keys)应用侧边栏里的 API 密钥(/api-keys)
谁能创建持有 api_keys.manage 的角色任何已登录用户
前缀tsk_adm_tsk_usr_

为一个接口范围签发的密钥,在另一个范围一律被拒,两个方向都是:管理员的用户密钥读不了 admin API,管理密钥也调不了 /api/v1/me/*。吊销其中一种不会影响另一种。这样做是为了控制泄露后的影响面:用户密钥泄露只暴露一个账户,而两种分开之后,一把自动化脚本用的密钥永远不可能是能暴露所有账户的那一种。

创建密钥

两个页面都通过 better-auth 的 API key 插件创建密钥。密钥只在创建后显示一次,同时附带一段可以直接粘贴的内容:用户页面给出一条请求 /api/v1/me 的 curl;管理页面给出一段写给 AI agent 的提示词,里面有 base URL、认证头以及接口文档的地址。之后列表里只显示密钥的前几个字符。在同一页面上可以吊销密钥。

密钥不会变成会话。每个请求都会校验密钥,再重新读取它的持有者:账户一旦被封禁或注销,它的密钥立即失效。

调用 API

把密钥作为 bearer token 发送,或者放在 x-api-key 头里:

curl -H "Authorization: Bearer $SHIPKIT_KEY" https://your-app.com/api/v1/me

每把密钥限速每分钟 300 次请求,超过后返回 429。

列表类接口返回一页数据:

{ "data": [ … ], "meta": { "total": 42, "limit": 100, "offset": 0 } }

单个对象的接口返回 { "data": { … } }。所有列表接口都支持同一组分页和日期参数:limit(1–500,默认 100)、offset,以及 since/until(ISO 日期或毫秒时间戳,作用于 createdAt)。金额是整数分,并带一个 currency 字段。

错误的格式统一,永远不会带出堆栈:

{ "error": { "code": "forbidden", "message": "API key owner lacks the billing.read permission." } }
状态码code含义
400bad_request查询参数或请求体不合法
401unauthorized缺少密钥、密钥无效或已过期
403wrong_scope密钥是为另一个接口范围签发的
403forbidden持有者被封禁或已注销,或者缺少所需权限
429rate_limited超过每分钟 300 次
500internal意外错误,已记录在服务端日志里

Admin API

/api/v1/admin/* 下全是只读的 GET。管理密钥继承持有者的角色,每个接口要求的权限,和展示同一份数据的后台页面相同。一个只有 billing.read 的人,他的密钥能读订单,读用户时则返回 403。见角色与权限。

接口所需权限
/api/v1/admin/statsadmin.access
/api/v1/admin/usersusers.read
/api/v1/admin/subscriptions、/ordersbilling.read
/api/v1/admin/credits、/credits/balancescredits.read
/api/v1/admin/auditaudit.read
/api/v1/admin/emailsemail.read
/api/v1/admin/feedbackfeedback.read
/api/v1/admin/waitlistwaitlist.read

大多数接口在通用参数之外还有自己的筛选条件,比如订阅接口的 status、planId 和 userId。删除某个模块时,它的接口也会一起删除。

用户 API

/api/v1/me/* 只作用于密钥持有者本人。大部分接口用来查询账户信息,有一个接口会做计费的工作。

接口返回
GET /api/v1/me持有者的 id、邮箱和名字,是检查密钥是否可用的最省事的方式
GET /api/v1/me/subscription当前订阅,或 null
GET /api/v1/me/orders持有者的购买记录
GET /api/v1/me/credits积分流水,余额在 meta.balance 里
POST /api/v1/me/run按量计费接口的完整示例

如果你要按用量收费,就照着 /api/v1/me/run 写:先按一次调用最多可能花的积分预扣,再干活,最后按实际花费结算;它支持 Idempotency-Key 请求头,所以重试的请求不会被重复扣费。余额不够支付上限时,返回 402 insufficient_credits。具体模式见积分和按量计费。

新增接口

接口是 src/routes/api/v1/ 下的 TanStack Start server route。src/core/server/api.ts 里的包装函数负责密钥、接口范围、权限、查询参数解析和响应格式,所以一个路由只需要做三件事:解析、查询、返回。

Admin 接口用 adminApi 包住 GET,并写明所需权限:

// src/routes/api/v1/admin/reports.ts
export const Route = createFileRoute('/api/v1/admin/reports')({
  server: {
    handlers: {
      GET: adminApi(
        ({ request, query }) =>
          listReportsForApi({ ...query, ...parseQuery(request, reportsFilterSchema) }),
        'reports.read',
      ),
    },
  },
})

handler 返回 { data, total }(可以再带一个 meta)。查询本身放在功能里的features/<name>/server/admin.ts,用 dateRange helper 让 since/until生效。Admin API 保持只读。

用户接口有三种包装函数可选,它们都会校验用户密钥,并把持有者交给你:

  • userApi:返回一页数据的 GET;
  • userDoc:返回单个对象的 GET;
  • userAction(schema, handler):带 JSON 请求体的 POST,请求体由 zod schema 校验。
// src/routes/api/v1/me.reports.ts
export const Route = createFileRoute('/api/v1/me/reports')({
  server: {
    handlers: {
      GET: userApi(({ user, query }) => myReports(user.id, query)),
    },
  },
})

包装函数没办法替你限定查询范围。用户接口背后的每个查询都必须自己按 user.id过滤,这也是这些查询放在功能自己的 server/me.ts 里、和其余代码挨在一起的原因。要拒绝一次调用,就抛出 new ApiError(status, code, message),包装函数会把它转成统一的错误格式。不要手写密钥校验,也不要让一个接口同时接受两种密钥。

新增路由文件后运行 bun run generate-routes。如果接口属于某个可选功能,还要把路由文件加进 scripts/verify-deletion.ts 里该功能的删除步骤,这样删除功能时它也会被一并删掉。

admin-api skill

.claude/skills/admin-api/SKILL.md 是写给 AI agent 的 admin API 文档:每个接口的筛选条件和字段,以及增长、收入、流失、积分消耗等常见分析的做法。在Claude Code 里它就是 /admin-api skill。这个目录是自包含的,可以直接复制给其他 agent 使用。

应用本身也在 /api/v1/admin/skill.md 提供同一个文件,公开访问、不需要密钥,因为里面没有任何机密。管理密钥页面给出的提示词会让 agent 先去读它。新增admin 接口时,也要把它写进这个 skill 文件,agent 才能找到。