# Errors

The LedgerBee API uses conventional HTTP status codes and returns every error
as a JSON envelope. The status code tells you whether retrying can help; the
machine-readable `code` tells you exactly what went wrong.

## The error envelope

Every error body carries at least `statusCode`, `timestamp`, `path`, and
`message`. Most errors also include `code` and `details`:

```json
{
  "statusCode": 400,
  "timestamp": "2026-06-10T12:34:56.789Z",
  "path": "/v1/customers",
  "message": "Bad Request Exception",
  "code": "SHARED_VALIDATION_FAILED",
  "details": {
    "validationErrors": [
      { "property": "email", "constraints": { "isEmail": "email must be an email" } }
    ]
  }
}
```

| Field | Always present | Description |
|---|---|---|
| `statusCode` | Yes | The HTTP status, repeated in the body. |
| `timestamp` | Yes | ISO 8601 time the error was produced. |
| `path` | Yes | The request path, relative to the `/api` mount (e.g. `/v1/customers`). |
| `message` | Yes | Human-readable summary. For logging only — its wording may change. |
| `code` | No | Stable machine-readable identifier (e.g. `UNAUTHORIZED`, `LICENSE.REQUIRED`). Branch on this, never on `message`. |
| `details` | No | Context for the specific `code` — failing fields, required scopes, the missing license key. |

## Validation errors (400)

Request bodies are validated strictly: unknown fields are rejected, not
ignored. A failed validation returns `code: "SHARED_VALIDATION_FAILED"` with
one `details.validationErrors` entry per failing field:

- `property` — dot-separated path to the field, including array indices
  (e.g. `lines.0.quantity`)
- `constraints` — map of failed rule name to a human-readable explanation

## Status codes

| Status | When |
|---|---|
| `400 Bad Request` | Validation failed (above), or the request violates a business rule — the `code` says which. |
| `401 Unauthorized` | Missing, invalid, expired, or IP-restricted API key / OAuth token. The body has `code: "UNAUTHORIZED"` and `message: "Invalid API key"`. See [Authentication](/guides/authentication). |
| `403 Forbidden` | The credential is valid but lacks a required scope (`code: "INSUFFICIENT_PERMISSIONS"`, with `details.requiredScopes`), or your company lacks a required license (`code: "LICENSE.REQUIRED"`, with `details.licenseKey`). See [Authentication](/guides/authentication) for scopes. |
| `404 Not Found` | No such route, or the referenced resource doesn't exist in your company. |
| `413 Payload Too Large` | The request body exceeds the size limit — `message` states the received size and the limit. |
| `429 Too Many Requests` | You hit a rate limit. Back off and retry — see [Rate limits](/guides/rate-limits). |
| `500 Internal Server Error` | Something failed on our side. `message` is a generic `Internal Server Error` — retry with backoff and contact support if it persists. |

Requests authenticated with an OAuth bearer token also receive a standard
`WWW-Authenticate` challenge header on 401/403
(`error="invalid_token"` vs `error="insufficient_scope"`), so OAuth and MCP
clients know whether to refresh the token or re-consent.

## Request IDs

Every response, success or error, carries an `X-Request-ID` header with a
UUID unique to that request. The same ID is stamped on our server-side traces.
Include it in support requests so we can locate the exact request in our logs.

```bash
curl -i https://api.ledgerbee.com/api/v1/company \
  -H "x-api-key: YOUR_API_KEY"

# HTTP/1.1 200 OK
# X-Request-ID: 7f9c2ba4-1c3d-4e5f-8a9b-0c1d2e3f4a5b
```

## Error codes are stable

`code` values such as `SHARED_VALIDATION_FAILED`, `UNAUTHORIZED`, and
`LICENSE.REQUIRED` are stable identifiers, safe to branch on. They are never
renamed or repurposed; `message` text may be reworded at any time. New codes
may be added over time, so treat any unrecognized `code` as a generic failure
for its status code.
