Errors
Every error has a code, a readable message and a request id. Here is what each one means and how to fix it.
Every failure comes back in the same shape, so you only have to handle one:
{
"error": {
"code": "INSUFFICIENT_SCOPE",
"message": "This API key is not allowed to perform this action (records.create)",
"details": { "required": "records.create" },
"requestId": "8ce3d344-6260-4437-973e-54b82feb263b"
}
}codeis stable. Branch on this, never on the message.messageis for a human.detailsis there when the error has something specific to add, such as which scope was missing or which fields failed validation.requestIdalso comes back as theX-Request-Idheader. Log it — it is how you find one request later.
Database errors and stack traces are never returned. They are logged against the request id instead.
Handling them
async function baas(path, options) {
const res = await fetch(`${BASE}${path}`, options);
const body = await res.json().catch(() => null);
if (res.ok) return body;
const error = body?.error ?? { code: 'UNKNOWN', message: res.statusText };
console.error('API error', { code: error.code, requestId: error.requestId });
switch (error.code) {
case 'SCHEMA_VALIDATION_FAILED':
return showFieldErrors(error.details.issues); // map onto your form
case 'VERSION_CONFLICT':
return refetchAndRetry();
case 'RATE_LIMITED':
return waitAndRetry(Number(res.headers.get('retry-after') ?? 1));
default:
throw new Error(`${error.code}: ${error.message}`);
}
}The ones you will actually meet
| Code | Status | What it means | What to do |
|---|---|---|---|
VALIDATION_ERROR | 400 | The request body or query is malformed | Read details; it names the field |
INVALID_FILTER | 400 | The filter could not be parsed | Check the filter syntax; the message gives the position |
INVALID_JSON | 400 | The body was not valid JSON | Usually a missing Content-Type: application/json |
UNAUTHENTICATED | 401 | No usable credential | Send Authorization: Bearer <key> |
INVALID_API_KEY | 401 | The key is wrong, revoked or expired | Check for a stray space or newline; make a new key |
TOKEN_EXPIRED | 401 | A session token ran out | Sign in again |
INSUFFICIENT_SCOPE | 403 | The key lacks a permission | details.required names it; create a key with that scope |
FORBIDDEN | 403 | Your role does not allow it | Ask a workspace owner or admin |
COLLECTION_NOT_FOUND | 404 | Wrong id or slug — or not visible to you | Check the slug and workspace |
RECORD_NOT_FOUND | 404 | No such record, or it is in the trash | Add ?deleted=include to look in the trash |
VERSION_CONFLICT | 409 | Someone changed the record first | Re-read, re-apply, retry |
SLUG_TAKEN | 409 | That collection slug is in use | Pick another |
LIMIT_EXCEEDED | 409 | A limit was reached | Delete something, or split the work up |
SCHEMA_VALIDATION_FAILED | 422 | The record does not match the schema | details.issues lists each problem with a path |
SCHEMA_MIGRATION_BLOCKED | 422 | Existing records would become invalid | Fix them, or allow it explicitly |
PAYLOAD_TOO_LARGE | 413 | The body is over the size limit | See limits |
RATE_LIMITED | 429 | Too many requests | Wait for Retry-After seconds |
INTERNAL_ERROR | 500 | Something broke on our side | Retry; quote the requestId if it persists |
Why "not found" instead of "not allowed"
Anything you are not allowed to see returns 404, not 403. If it returned 403, that
would confirm the thing exists — and someone could discover other people's collections by
guessing. You get 403 only when you can see a resource but may not do that particular
thing to it.
Rate limiting
A 429 reply includes Retry-After in seconds. Every reply carries your current budget:
RateLimit-Limit: 1200
RateLimit-Remaining: 1198
RateLimit-Reset: 18See limits for the numbers.
Retrying safely
Retry 429 and 5xx; do not retry 4xx, since the same request will fail again.
When you retry something that creates data, send the same Idempotency-Key you used
the first time. The original response is replayed instead of creating a second record.
const key = crypto.randomUUID();
for (let attempt = 0; attempt < 3; attempt++) {
const res = await fetch(url, {
method: 'POST',
headers: { ...headers, 'Idempotency-Key': key },
body,
});
if (res.ok || res.status < 500) return res;
await new Promise((r) => setTimeout(r, 2 ** attempt * 500));
}Next steps
View this page as Markdown — handy for copying into an editor or an AI assistant.