Authentication and keys
Developer·5 minutes to read
Verified against docs/superpowers/specs/2026-09-15-p4-a-typed-http-api-openapi-webhooks.md §1.1, §3.1, §4.1, §4.2, §4.4, §5.1; re-verified against the code at close-out.
What it does
An organisation API key is the API's only tenant credential: it is stored as a digest, shows a twelve-character prefix, carries a required expiry, holds one of three scopes, and is bound to the admin who created it, so every call it makes runs as that person under the same row-level checks the app applies (F-ID-7, F-DEV-5).
Authentication
Every request carries the key as a bearer token: authorization: Bearer <key>. The endpoints on this page are the admin ones — a read or write key is refused on them, with the same answer as no credential at all. The organisation is never read from the request: it comes from the key's own row, so a tenant id in a path, a body or a query changes nothing but the chance of not_found.
The operator's platform key (prefix bsp_) is a different class. It reaches the four organisation-creation verbs on Organisations and nothing else, it is minted by the operator's command-line tool — botseon platform-key mint --name <name> [--expires-days <1..365>], which prints the key once and never again — rather than by any screen, and it is never handed to a tenant.
| Scope | Reaches |
|---|---|
read | every GET |
write | every GET, and every POST, PATCH and DELETE except the admin ones |
admin | everything the organisation class reaches, including this page's endpoints, Webhooks, the organisation rename and the member and invitation writes |
Five things refuse a key, and all five answer alike: the key is unknown, revoked, expired, presented on a route its class does not reach, or presented with a scope the route does not admit. A sixth is the one worth knowing about: the person behind the key is re-read on every request, so a creator who has been demoted to member or removed from the organisation takes the key down with them. The row stays live on the Developer page until an admin revokes it for the record.
Request
GET /keys
<!-- generated:begin GET /keys --> <!-- generated:end GET /keys -->POST /keys
<!-- generated:begin POST /keys --> <!-- generated:end POST /keys -->GET /keys/{id}
<!-- generated:begin GET /keys/{id} --> <!-- generated:end GET /keys/{id} -->DELETE /keys/{id}
<!-- generated:begin DELETE /keys/{id} --> <!-- generated:end DELETE /keys/{id} -->A key's name is 1 to 60 characters, its scope is read, write or admin and defaults to read, and its life is 1 to 365 days and defaults to 90. An organisation may hold twenty live keys; revoked and expired keys do not count against that.
curl -sS "$BOTSEON_API_ORIGIN/api/v1/keys?limit=20" \
-H "authorization: Bearer $BOTSEON_API_KEY" \
-H "accept: application/json"
const res = await fetch(`${process.env.BOTSEON_API_ORIGIN}/api/v1/keys?limit=20`, {
headers: {
authorization: `Bearer ${process.env.BOTSEON_API_KEY}`,
accept: 'application/json',
},
});
if (!res.ok) throw new Error(`keys: ${res.status}`);
const page = (await res.json()) as {
data: Array<{ id: string; name: string; prefix: string; scope: string; expiresAt: string }>;
nextCursor: string | null;
};
for (const key of page.data) console.log(key.prefix, key.scope, key.expiresAt);
Response
A key row carries its id, name, twelve-character prefix, scope, creator, creation time, expiry, last use and revocation time. It never carries the key. POST /keys is the one response in the whole API that does, under a key field, and it is shown once:
{
"id": "0f3a2b71-6c4d-4a1e-9b2c-7d5e8f0a1b2c",
"name": "Nightly export",
"prefix": "bsk_",
"scope": "read",
"createdBy": "8c1d4e5f-2a3b-4c5d-6e7f-8a9b0c1d2e3f",
"createdAt": "2026-09-18T08:00:00.000Z",
"expiresAt": "2026-12-17T08:00:00.000Z",
"lastUsedAt": null,
"revokedAt": null,
"key": "<shown once, at creation>"
}
The prefix field holds the first twelve characters of the key itself, which begin bsk_; the sample above shows the stem alone, because a documentation page carries no credential shape.
Errors
| Status | Code | When | What to do |
|---|---|---|---|
| 401 | unauthenticated | No key, an unknown, revoked or expired key, the wrong key class, a scope below admin, or a creator who is no longer an admin. | Check the header, then the key's row and the person's role. |
| 400 | invalid_request | The name is outside 1 to 60 characters, the expiry is outside 1 to 365 days, or the idempotency-key header is missing on the POST. | Read details for the failing path. |
| 404 | not_found | No key of this organisation has that id. | Take the id from a list call. |
| 409 | idempotency_in_progress | A request with the same Idempotency-Key is still running. | Retry once it has answered. |
| 422 | idempotency_mismatch | The same Idempotency-Key arrived with a different body. | Mint a new key for a new body. |
| 429 | quota_exceeded | The organisation already holds twenty live keys. | Revoke one first. |
| 402 | payment_required | The organisation needs a trial or a payment. | Settle the account, then retry. |
| 429 | rate_limited | A bucket is full. | Wait for Retry-After and retry. |
| 503 | unavailable | The service could not answer. | Retry with backoff. |
Rate limits
Three buckets, each a fixed-size window of one minute, and every refusal carries Retry-After: 60. A request that authenticates is counted against its key (300 a minute) and against its organisation (1 000 a minute); a platform key has its own bucket of 60 a minute. A request that does not authenticate is counted against the caller's address instead, 60 a minute, and that bucket is consumed only on a refusal — so a key making its 300 good requests costs its neighbours nothing. Where the deployment gives no caller address, every refusal shares one route-wide bucket of 3 000 a minute rather than none at all. The limiter is a per-process window, so the effective ceiling on a multi-instance deployment is the per-key number times the instance count; a ceiling that survives a restart is not yet specified.
Last verified against build c0f77aa.