API keys
The two kinds of API key, the admin data API at /api/v1/admin/* and the user API at /api/v1/me/*, the response envelope, and how to add an endpoint.
ShipKit ships two external HTTP APIs, each with its own kind of key. They are for different jobs and are kept apart on purpose.
| Admin key | User key | |
|---|---|---|
| Surface | /api/v1/admin/* | /api/v1/me/* |
| Reads | every account's data | only the owner's own data |
| Used by | analysis agents, scripts, reports | your customers' own code |
| Issued at | Admin → Developers → API keys (/admin/api-keys) | API keys in the app sidebar (/api-keys) |
| Who can issue | roles holding api_keys.manage | any signed-in user |
| Prefix | tsk_adm_ | tsk_usr_ |
A key issued for one surface is refused by the other, in both directions: an
admin's user key cannot read the admin API, and an admin key cannot call
/api/v1/me/*. Revoking one never disturbs the other. The point is blast
radius: a leaked user key exposes one account, and keeping the kinds separate
means an automation key can never turn out to be the kind that exposes all of
them.
Issuing a key
Both pages create keys through better-auth's API key plugin. A key is shown
once, right after it is created, with something ready to paste: the user page
gives a curl against /api/v1/me, and the admin page gives a short prompt
for an AI agent that includes the base URL, the auth header and where to find
the endpoint documentation. After that the list shows only the key's first
characters. Revoke a key from the same page.
Keys do not become sessions. Every request verifies the key and then re-reads its owner: a banned or closed account's keys stop working at once.
Calling the API
Send the key as a bearer token, or in x-api-key:
curl -H "Authorization: Bearer $SHIPKIT_KEY" https://your-app.com/api/v1/me
Each key is rate-limited to 300 requests per minute; over that, the API
answers 429.
A list endpoint answers with a page of rows:
{ "data": [ … ], "meta": { "total": 42, "limit": 100, "offset": 0 } }
and a single-object endpoint with { "data": { … } }. Every list endpoint
accepts the same paging and date filters: limit (1–500, default 100),
offset, and since/until (an ISO date or epoch milliseconds, applied to
createdAt). Money is integer cents with a currency field.
Errors share one shape and never include a stack trace:
{ "error": { "code": "forbidden", "message": "API key owner lacks the billing.read permission." } }
| Status | code | Meaning |
|---|---|---|
| 400 | bad_request | invalid query or body |
| 401 | unauthorized | missing, invalid or expired key |
| 403 | wrong_scope | the key was issued for the other surface |
| 403 | forbidden | the owner is banned or closed, or lacks the permission |
| 429 | rate_limited | over 300 requests per minute |
| 500 | internal | an unexpected error, logged on the server |
The admin API
Everything under /api/v1/admin/* is a read-only GET. An admin key carries
its owner's role, and each endpoint asks for the same permission as the
console page that shows the same data. A key held by someone with only
billing.read reads orders and gets a 403 on users. See
Roles and permissions.
| Endpoint | Permission |
|---|---|
/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 |
Most endpoints take their own filters on top of the shared ones, for example
status, planId and userId on subscriptions. The endpoints of a deleted
module go with it.
The user API
/api/v1/me/* is scoped to the key's owner. Most of it describes the account;
one endpoint does billable work.
| Endpoint | Returns |
|---|---|
GET /api/v1/me | the owner's id, email and name, a cheap check that a key works |
GET /api/v1/me/subscription | the current subscription, or null |
GET /api/v1/me/orders | the owner's purchases |
GET /api/v1/me/credits | the credit ledger, with the balance in meta.balance |
POST /api/v1/me/run | a worked example of a metered endpoint |
/api/v1/me/run is the file to copy when you sell usage: it holds the most a
call can cost in credits, does the work, then settles the actual cost, and it
honours an Idempotency-Key header so a retried request is not charged
twice. It answers 402 insufficient_credits when the balance cannot cover the
ceiling. Credits explains the pattern.
Adding an endpoint
Endpoints are TanStack Start server routes under src/routes/api/v1/. The
wrappers in src/core/server/api.ts handle the key, the scope, the
permission, query parsing and the envelope, so a route is: parse, query,
return.
An admin endpoint wraps its GET in adminApi and names its permission:
// 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',
),
},
},
})
The handler returns { data, total } (plus optional meta). Put the query
itself in the feature, in features/<name>/server/admin.ts, and use the
dateRange helper so since/until work. Keep the admin API read-only.
A user endpoint uses one of three wrappers, all of which authenticate a user key and hand you its owner:
userApifor aGETthat returns a page of rows;userDocfor aGETthat returns one object;userAction(schema, handler)for aPOSTwith a JSON body validated by a 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)),
},
},
})
Nothing in the wrapper can scope a query for you. Every query behind a user
endpoint must filter on user.id itself, which is why those queries live in
the feature's server/me.ts next to the rest of its code. To refuse a call,
throw new ApiError(status, code, message) and the wrapper turns it into the
error envelope. Never authenticate a key by hand, and never let one endpoint
accept both kinds of key.
Run bun run generate-routes after adding a route file. If the endpoint
belongs to an optional feature, add the route file to that feature's recipe
in scripts/verify-deletion.ts so deleting the feature removes it too.
The admin-api skill
The admin API is documented for agents in .claude/skills/admin-api/SKILL.md:
every endpoint with its filters and fields, plus recipes for common analysis
such as growth, revenue, churn and credit burn. In Claude Code it is the
/admin-api skill. The folder is self-contained, so you can copy it to any
other agent.
The app also serves the same file at /api/v1/admin/skill.md, publicly and
without a key, since it holds no secrets. The prompt the admin keys page hands
you points an agent there first. When you add an admin endpoint, add it to
the skill file so agents can find it.