Errors and status codes
Every failure has the same shape and a stable machine-readable code. Branch on the code, not on the message: messages are written for people and may be reworded, codes are part of the contract and changing one requires a new API version.
Last updated
The shape
{
"success": false,
"error": {
"code": "ENTITLEMENT_REQUIRED",
"message": "Your plan does not include \"Full-text search endpoint.\"",
"details": {
"capability": "blog.search.enabled",
"currentPlans": ["blog_free"],
"upgradeUrl": "https://app.cmskite.com/billing"
}
},
"requestId": "01a0b962-1da2-7000-8be2-6d650010f16d"
}An error never carries a stack trace, a SQL statement, a file path or anything else about how the platform is built — in any environment. What it carries is a code, a sentence, whatever detail is useful to act on, and the request id to quote.
Every code
| Code | HTTP | What it means | What to do |
|---|---|---|---|
VALIDATION_FAILED | 400 | The body did not match the schema | Read details.issues; each names a field path |
INVALID_REQUEST | 400 | A well-formed request that cannot be honoured | Read the message; it names the problem |
INVALID_CURSOR | 400 | A cursor that was tampered with, truncated or came from another version | Start the listing again without a cursor |
INVALID_FILTER | 400 | A filter naming something that cannot be filtered on | Check the parameter against the endpoint’s docs |
UNSUPPORTED_SORT | 400 | A sort= value outside the closed list | Use one of the documented sorts |
UNAUTHENTICATED | 401 | No credential, or one that does not verify | Send Authorization: Bearer … |
INVALID_API_KEY | 401 | The key does not exist | Check you copied the whole secret |
API_KEY_EXPIRED | 401 | The key is past its expiry | Rotate it in the dashboard |
API_KEY_REVOKED | 401 | The key was revoked | Mint another |
SESSION_EXPIRED | 401 | The access token is past its expiry | Refresh, then retry once |
FORBIDDEN | 403 | Allowed to authenticate, not allowed to do this | Read the message |
INSUFFICIENT_SCOPE | 403 | The API key lacks a required scope | Use a credential that has it |
INSUFFICIENT_PERMISSION | 403 | The role or agent grant does not cover it | Ask an owner, or grant the ability when minting |
ENTITLEMENT_REQUIRED | 403 | The plan does not include this | details.capability names it; upgrade or ask |
TENANT_SUSPENDED | 403 | The workspace is suspended | Contact support |
ORIGIN_NOT_ALLOWED | 403 | The browser origin is not on the project’s list | Add it in project settings |
NOT_FOUND | 404 | It does not exist, or is not yours to see | See the note below |
CONFLICT | 409 | The state changed under you | Re-read and decide again |
SLUG_TAKEN | 409 | That slug is in use by a live resource | Pick another, or let one be generated |
IDEMPOTENCY_IN_FLIGHT | 409 | The same key is still being processed | Retry after the Retry-After interval |
PAYLOAD_TOO_LARGE | 413 | The body is over 1 MB | Send less; media goes to object storage, not through the API |
UNSUPPORTED_MEDIA_TYPE | 415 | The content type is not JSON | Send content-type: application/json |
UNPROCESSABLE | 422 | Understood, and still cannot be done | Read the message |
IDEMPOTENCY_KEY_REUSED | 422 | The key was used with a different body | Use a new key for a new request |
RATE_LIMIT_EXCEEDED | 429 | Too many requests per second | Back off for Retry-After seconds |
QUOTA_EXCEEDED | 429 | The period’s request allowance is spent | Wait for the period, or upgrade |
INTERNAL_ERROR | 500 | Ours | Retry; if it persists, quote the requestId |
SERVICE_UNAVAILABLE | 503 | At capacity, deliberately refusing | Retry after Retry-After; it is short |
IDEMPOTENCY_UNAVAILABLE | 503 | Idempotency cannot be guaranteed right now | Retry; the request was not performed |
Why some refusals are 404s
A 403 confirms the thing exists. For anything belonging to another workspace, and for the administrative surface, that confirmation is itself the leak — so those are 404s and look exactly like a path that was never there.
The same applies inside your own project: a draft post asked for with a public key is a 404, not a 403, because a 403 would tell the holder of that key that the slug exists and is being worked on.
Retrying safely
Retry 429, 500, 503 and network failures. Do not retry 4xx other than 429 — the request will fail the same way again.
For anything that creates something, send an Idempotency-Key. A retry with the same key returns the original result instead of creating a second resource, for twenty-four hours.
async function call(path: string, init: RequestInit = {}, attempt = 0): Promise<Response> {
const res = await fetch(`https://api.cmskite.com${path}`, init)
if (res.ok || attempt >= 4) return res
if (![429, 500, 503].includes(res.status)) return res
// Honour the server's own number when it gives one. Guessing is how a
// thundering herd is built.
const after = Number(res.headers.get('retry-after') ?? 0)
const wait = after > 0 ? after * 1000 : 2 ** attempt * 250 + Math.random() * 250
await new Promise((r) => setTimeout(r, wait))
return call(path, init, attempt + 1)
}