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 keyUser key
Surface/api/v1/admin/*/api/v1/me/*
Readsevery account's dataonly the owner's own data
Used byanalysis agents, scripts, reportsyour customers' own code
Issued atAdmin → Developers → API keys (/admin/api-keys)API keys in the app sidebar (/api-keys)
Who can issueroles holding api_keys.manageany signed-in user
Prefixtsk_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." } }
StatuscodeMeaning
400bad_requestinvalid query or body
401unauthorizedmissing, invalid or expired key
403wrong_scopethe key was issued for the other surface
403forbiddenthe owner is banned or closed, or lacks the permission
429rate_limitedover 300 requests per minute
500internalan 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.

EndpointPermission
/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

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.

EndpointReturns
GET /api/v1/methe owner's id, email and name, a cheap check that a key works
GET /api/v1/me/subscriptionthe current subscription, or null
GET /api/v1/me/ordersthe owner's purchases
GET /api/v1/me/creditsthe credit ledger, with the balance in meta.balance
POST /api/v1/me/runa 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:

  • userApi for a GET that returns a page of rows;
  • userDoc for a GET that returns one object;
  • userAction(schema, handler) for a POST with 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.