---
title: Errors
description: Every error has a code, a readable message and a request id. Here is what each one means and how to fix it.
group: Reference
order: 1
keywords: [api errors, error codes, http status, troubleshooting, 401, 403, 409]
---

# Errors

Every failure comes back in the same shape, so you only have to handle one:

```json
{
  "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"
  }
}
```

- **`code`** is stable. Branch on this, never on the message.
- **`message`** is for a human.
- **`details`** is there when the error has something specific to add, such as which
  scope was missing or which fields failed validation.
- **`requestId`** also comes back as the `X-Request-Id` header. 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

```js
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](/docs/queries#filtering); 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](/docs/limits) 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](/docs/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:

```http
RateLimit-Limit: 1200
RateLimit-Remaining: 1198
RateLimit-Reset: 18
```

See [limits](/docs/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.

```js
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

- [Limits](/docs/limits) — the numbers behind `413`, `429` and `LIMIT_EXCEEDED`.
- [API keys](/docs/api-keys) — fixing `401` and `403`.
