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 | 含义 |
|---|---|---|
| 400 | bad_request | 查询参数或请求体不合法 |
| 401 | unauthorized | 缺少密钥、密钥无效或已过期 |
| 403 | wrong_scope | 密钥是为另一个接口范围签发的 |
| 403 | forbidden | 持有者被封禁或已注销,或者缺少所需权限 |
| 429 | rate_limited | 超过每分钟 300 次 |
| 500 | internal | 意外错误,已记录在服务端日志里 |
Admin API
/api/v1/admin/* 下全是只读的 GET。管理密钥继承持有者的角色,每个接口要求的权限,和展示同一份数据的后台页面相同。一个只有 billing.read 的人,他的密钥能读订单,读用户时则返回 403。见角色与权限。
| 接口 | 所需权限 |
|---|---|
/api/v1/admin/stats | admin.access |
/api/v1/admin/users | users.read |
/api/v1/admin/subscriptions、/orders | billing.read |
/api/v1/admin/credits、/credits/balances | credits.read |
/api/v1/admin/audit | audit.read |
/api/v1/admin/emails | email.read |
/api/v1/admin/feedback | feedback.read |
/api/v1/admin/waitlist | waitlist.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 才能找到。