Skip to content

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

Examplejson
{
  "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

CodeHTTPWhat it meansWhat to do
VALIDATION_FAILED400The body did not match the schemaRead details.issues; each names a field path
INVALID_REQUEST400A well-formed request that cannot be honouredRead the message; it names the problem
INVALID_CURSOR400A cursor that was tampered with, truncated or came from another versionStart the listing again without a cursor
INVALID_FILTER400A filter naming something that cannot be filtered onCheck the parameter against the endpoint’s docs
UNSUPPORTED_SORT400A sort= value outside the closed listUse one of the documented sorts
UNAUTHENTICATED401No credential, or one that does not verifySend Authorization: Bearer …
INVALID_API_KEY401The key does not existCheck you copied the whole secret
API_KEY_EXPIRED401The key is past its expiryRotate it in the dashboard
API_KEY_REVOKED401The key was revokedMint another
SESSION_EXPIRED401The access token is past its expiryRefresh, then retry once
FORBIDDEN403Allowed to authenticate, not allowed to do thisRead the message
INSUFFICIENT_SCOPE403The API key lacks a required scopeUse a credential that has it
INSUFFICIENT_PERMISSION403The role or agent grant does not cover itAsk an owner, or grant the ability when minting
ENTITLEMENT_REQUIRED403The plan does not include thisdetails.capability names it; upgrade or ask
TENANT_SUSPENDED403The workspace is suspendedContact support
ORIGIN_NOT_ALLOWED403The browser origin is not on the project’s listAdd it in project settings
NOT_FOUND404It does not exist, or is not yours to seeSee the note below
CONFLICT409The state changed under youRe-read and decide again
SLUG_TAKEN409That slug is in use by a live resourcePick another, or let one be generated
IDEMPOTENCY_IN_FLIGHT409The same key is still being processedRetry after the Retry-After interval
PAYLOAD_TOO_LARGE413The body is over 1 MBSend less; media goes to object storage, not through the API
UNSUPPORTED_MEDIA_TYPE415The content type is not JSONSend content-type: application/json
UNPROCESSABLE422Understood, and still cannot be doneRead the message
IDEMPOTENCY_KEY_REUSED422The key was used with a different bodyUse a new key for a new request
RATE_LIMIT_EXCEEDED429Too many requests per secondBack off for Retry-After seconds
QUOTA_EXCEEDED429The period’s request allowance is spentWait for the period, or upgrade
INTERNAL_ERROR500OursRetry; if it persists, quote the requestId
SERVICE_UNAVAILABLE503At capacity, deliberately refusingRetry after Retry-After; it is short
IDEMPOTENCY_UNAVAILABLE503Idempotency cannot be guaranteed right nowRetry; 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.

A retry loop worth copyingts
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)
}