API tokens
Workspace-scoped CRUD over the per-workspace API tokens that non-admin clients (chat callers, integrations, the bw CLI) present on the /v1/* surface. These rows live in the tokens table with kind = "api_key"; the operator-level BIGWAVE_ADMIN_TOKEN is issued out-of-band and is not represented here.
The router lives at packages/shared/api_token_routes.py. Pydantic models are at packages/shared/api_tokens.py:73 (ApiTokenEntry), :88 (ApiTokenSecret), and :102 (ApiTokenCreateRequest).
Endpoints
| Method | Path | Auth | Summary |
|---|---|---|---|
| GET | /admin/api-tokens | Bearer | List tokens for the caller's workspace. |
| POST | /admin/api-tokens | Bearer | Mint a new token; raw secret is returned exactly once. |
| GET | /admin/api-tokens/{token_id} | Bearer | Fetch one token entry; 404 cross-tenant. |
| POST | /admin/api-tokens/{token_id}/revoke | Bearer | Mark a token revoked. Idempotent. |
GET /admin/api-tokens
Returns {"tokens": ApiTokenEntry[]} — every non-secret field of each token in the caller's workspace. The raw secret is never returned by this route; once minted, only the hash lives server-side.
curl -H "Authorization: Bearer $BIGWAVE_ADMIN_TOKEN" \
http://localhost:8000/admin/api-tokensPOST /admin/api-tokens
Mint a new bw_…-prefixed token. Body ApiTokenCreateRequest:
{
"name": "ci-runner",
"user_id": "alice@example.com",
"expires_at": "2026-12-31T00:00:00Z"
}name is required (1–256 chars); user_id and expires_at are optional. Returns 201 with ApiTokenSecret:
{
"entry": {
"id": "tok_01HF…",
"workspace_id": "default",
"user_id": "alice@example.com",
"name": "ci-runner",
"kind": "api_key",
"created_at": "2026-05-01T12:00:00Z",
"expires_at": "2026-12-31T00:00:00Z",
"revoked_at": null
},
"secret": "bw_AbCdEf…"
}The raw secret is returned exactly once — store it immediately or rotate. The server only persists its hash.
curl -X POST -H "Authorization: Bearer $BIGWAVE_ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{"name":"ci-runner"}' \
http://localhost:8000/admin/api-tokensGET /admin/api-tokens/{token_id}
Returns the same ApiTokenEntry shape as the list route. Returns 404 when the id does not exist or belongs to a different workspace — absence and cross-tenant-existence collapse into the same response so workspace-id existence does not leak.
POST /admin/api-tokens/{token_id}/revoke
Sets revoked_at = now() on the row. Subsequent chat traffic authenticated with the raw secret is rejected as 401. Idempotent — calling it on an already-revoked token returns the same row.
curl -X POST -H "Authorization: Bearer $BIGWAVE_ADMIN_TOKEN" \
http://localhost:8000/admin/api-tokens/tok_01HF.../revokeOperator notes
- Workspace fence. Every route resolves the workspace from the bearer principal; the
{token_id}path segment alone never identifies a token. See workspace fence. - Admin token vs API token. The bearer presented to these routes is the operator-level
BIGWAVE_ADMIN_TOKEN(or the proxy-headers principal). The tokens managed here gate the OpenAI-shaped/v1/*surface for end-user clients. - Re-issuing. There is intentionally no rotation route. Operators rotate by minting a new token and revoking the old.
503causes.app.state.api_token_repois wired in lifespan; routes return503with"api token repository not initialised"when the lifespan did not complete.- Audit.
POSTandrevokewriteaudit_logrows when anAuditLogRepositoryis wired (see Audit).