Authentication and keys
Developer·6 minutes to read
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 acts as a person, so every call it makes runs as that person under the same row-level checks the app applies.
Keys are made on Settings → API keys (/settings/api-keys; on a team it is also in the organisation nav), which shows a new key once, with a copy button, and only its prefix afterwards. Any member creates read and write keys that act as them, with nobody to ask; an individual is their personal organisation's owner and creates every kind. An organisation may only restrict this: an administrator can limit key creation to administrators, and members then see the page read-only with that reason. An admin key is an administrator's, and so is an organisation key, which acts as the organisation's earliest owner rather than as the person who made it, so it keeps working after that person leaves the organisation or erases their account. POST /keys below makes the same keys from a script that already holds an admin key.
Authentication
Every request carries the key as a bearer token, authorization: Bearer <key>, or as the HTTP Basic username with an empty password (curl -u "$BOTSEON_API_KEY:", which sends authorization: Basic <base64 of key + ":">). The two are the same credential; a Basic header with a non-empty password is refused like any malformed header. 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 except the admin ones |
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. A member's own key stops when they are removed from the organisation or become a guest, and an admin key stops when its creator is no longer an administrator (a read or write key carries on, reaching what a member may). An organisation key is not affected by what happens to the person who made it. A refused key's row stays live on the API keys page until it is revoked 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_URL/api/v1/keys?limit=20" \
-H "authorization: Bearer $BOTSEON_API_KEY" \
-H "accept: application/json"
const res = await fetch(`${process.env.BOTSEON_API_URL}/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 (the person who made it), 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 an admin key whose creator 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.