# Handling errors

Error response shape, status codes, retry rules with idempotency semantics, and how to consume BeeL. errors programmatically.

Every BeeL. endpoint reports failures with an HTTP status for the broad outcome and a JSON body for the specifics. If you only remember one thing: **switch on the error `code`, log `meta.request_id`, and never parse the human message** — it's localized and rewritten freely.

<Callout type="info">
Looking for a specific code? Jump to the **[error reference](/errors)** and pick a category, or paste the `type` URI from any error response into your browser.
</Callout>

## Response shape

Every error response carries **two contracts at once** (additive, non-breaking):

```json
{
  "type": "https://docs.beel.es/errors/INVOICE_NO_LINES",
  "title": "INVOICE_NO_LINES",
  "detail": "The invoice must have at least one line",
  "instance": "/api/v1/companies/{company_id}/invoices/abc-123",
  "success": false,
  "error": {
    "code": "INVOICE_NO_LINES",
    "message": "The invoice must have at least one line",
    "details": {}
  },
  "meta": {
    "timestamp": "2026-05-21T10:00:00Z",
    "request_id": "4bf92f3577b34da6a3ce929d0e0e4736"
  }
}
```

| Section | What it is |
| --- | --- |
| `type`, `title`, `detail`, `instance` | Standard [RFC 9457 Problem Details](https://datatracker.ietf.org/doc/html/rfc9457) fields. **Prefer these for new integrations.** |
| `success`, `error.{code,message,details}`, `meta` | Legacy envelope, kept for existing clients. Will be deprecated (via `Deprecation`/`Sunset` headers) once the migration completes. |

The fields pair up: `title` = `error.code` and `detail` = `error.message`. The specifics of an error — the fields that failed, the blockers, the limits — travel only in `error.details`. The `type` URI is a clickable deep link to that error's reference page. `meta.request_id` matches the `X-Request-Id` response header — quote it in support tickets.

### Localisation

Messages are in English by default. Send `Accept-Language` to negotiate another language: `es`, `en` and `ca` are supported, and anything else gets the default. Only `detail` / `error.message` (and the per-field messages in `error.details`) change; the codes themselves are language-neutral — **branch on `code`, never on the message**: the same `code` produces different text per locale.

```bash
curl -H "Authorization: Bearer $BEEL_API_KEY" \
     -H "Accept-Language: en" \
     https://app.beel.es/api/v1/companies/{company_id}/invoices/abc-123
```

## Status codes

The status says what the protocol thinks happened; the `code` says what BeeL. thinks happened. Use the status to pick a coarse strategy and the `code` for the precise one:

| Status | Meaning | Default action |
|---|---|---|
| **400** Bad Request | Malformed request, invalid JSON, or a business rule violation (operation not valid in the resource’s current state) | Surface — fix only if you know what to change; do not retry blindly |
| **401** Unauthorized | Missing, malformed, expired, or revoked API key | Re-authenticate; do not retry |
| **402** Payment Required | Subscription required, inactive, unpaid (payment retries exhausted), or cancelled | Direct the user to billing |
| **403** Forbidden | Authenticated but not allowed: scope missing, account locked, email unverified, feature gated — or, with [`CONTACT_SALES_REQUIRED`](/errors/CONTACT_SALES_REQUIRED), more than one NIF on the API plan without an agreed price | Surface; retrying changes nothing. For `CONTACT_SALES_REQUIRED`, show `error.details.salesUrl` |
| **404** Not Found | Resource doesn’t exist **or is not visible to your API key** (different tenant, sandbox vs live) | Surface; don’t auto-delete the ID from your records |
| **409** Conflict | Duplicate resource (e.g. [`CLIENT_DUPLICATE`](/errors/CLIENT_DUPLICATE), [`INVOICE_DUPLICATE_EXTERNAL_REFERENCE`](/errors/INVOICE_DUPLICATE_EXTERNAL_REFERENCE)), idempotency key in flight or reused with another body, or concurrent write | Depends on the code — see retry rules |
| **422** Unprocessable Entity | Validation failed (field-level, NIF, fiscal rules). `error.code` names the rule; `error.details`, when present, carries the per-field breakdown | Fix the payload and retry — a `4xx` is not stored, so the key is free again |
| **429** Too Many Requests | Rate limit or per-feature quota exceeded | Sleep `Retry-After` seconds, then retry with the **same** key |
| **500** Internal Server Error | Unhandled server-side failure — the work may or may not have completed | Stored under your `Idempotency-Key`: the same key replays this `5xx`. Check whether the work happened, then retry with a **new** key |
| **502 / 503** | Upstream provider (VeriFactu, Stripe) unavailable — usually short-lived | Same as `500`: check first, then retry with backoff and a **new** key |

## Retries and idempotency

The single most expensive mistake in error handling is getting the `Idempotency-Key` semantics wrong on retry — it either creates duplicate invoices or replays a cached failure:

| Outcome | Retry? | Idempotency-Key |
|---|---|---|
| `429` rate limit / quota | Yes — sleep `Retry-After` seconds first, then exponential backoff with jitter | **Same key** — the request was rejected before any work happened |
| `5xx` server error | Only after checking whether the work happened — see [Idempotency](/guides/idempotency#server-errors-5xx). Escalate persistent failures with `meta.request_id` | **New key** — a `5xx` is stored, so the same key replays it |
| `409` [`IDEMPOTENCY_KEY_PROCESSING`](/errors/IDEMPOTENCY_KEY_PROCESSING) | Yes — wait `Retry-After` seconds (2); once the original finishes, the same key returns its stored result | **Same key** |
| `409` duplicates ([`CLIENT_DUPLICATE`](/errors/CLIENT_DUPLICATE), [`PRODUCT_DUPLICATE`](/errors/PRODUCT_DUPLICATE), [`SERIES_CODE_DUPLICATED`](/errors/SERIES_CODE_DUPLICATED), [`INVOICE_DUPLICATE_EXTERNAL_REFERENCE`](/errors/INVOICE_DUPLICATE_EXTERNAL_REFERENCE)) | No — the entity already exists; `GET` it by its natural key instead | — |
| `409` [`IDEMPOTENCY_KEY_MISMATCH`](/errors/IDEMPOTENCY_KEY_MISMATCH) | No — the key is already bound to a different body or path | **New key** — or resend the original body to get the stored response |
| `409` [`CONCURRENT_MODIFICATION`](/errors/CONCURRENT_MODIFICATION) | Yes, after a short jittered delay — another request wrote the same resource first and nothing of yours was applied. On `issue` and `void`, read the invoice instead: the other request did the job | **Same key** — the `409` released it. A **new** key if you rebuild the body from the current state |
| `422` validation | Only after fixing the payload | **Same key** — a `4xx` is not stored, so the key is released and the corrected body re-executes |
| Other `4xx` | No — fix the request first; retrying fails identically | — |

What the key stores for each outcome — and why a `5xx` is not a free retry — is in [Idempotency](/guides/idempotency#error-handling). A request-rate `429` does not carry the error envelope described above; see [Rate limits](/guides/rate-limits#response-headers).

## The codes you'll actually hit

These cover the vast majority of integration failures. Everything else lives in the [error reference](/errors).

| Code | TL;DR |
|---|---|
| `422` [`VALIDATION_ERROR`](/errors/VALIDATION_ERROR) | Request shape or a fiscal rule failed — see the field-level breakdown. Fix the payload and retry; the `422` released the key, so the same one works. |
| `400` [`BUSINESS_RULE_VIOLATION`](/errors/BUSINESS_RULE_VIOLATION) | Generic "not allowed in the resource's current state". Most state errors carry their own code instead ([`ONLY_DRAFT_EMITTABLE`](/errors/ONLY_DRAFT_EMITTABLE), [`STATUS_NOT_DELETABLE`](/errors/STATUS_NOT_DELETABLE)…, see [Invoice lifecycle](/guides/invoice-lifecycle)). **Not retryable** — surface the message. |
| `422` [`NIF_NOT_IN_CENSUS`](/errors/NIF_NOT_IN_CENSUS) | NIF format and check digit OK, but AEAT doesn't recognise the (NIF, fiscal name) pair. Verify with the recipient — don't loop, each call counts against your AEAT quota. |
| `401` [`UNAUTHORIZED`](/errors/UNAUTHORIZED) | Key missing, malformed, unknown or revoked — the answer is the same for all of them, on purpose. Check the key and the `Authorization: Bearer` header; don't retry. |
| `429` [`RATE_LIMIT_EXCEEDED`](/errors/RATE_LIMIT_EXCEEDED) | Request window exhausted. Sleep `Retry-After`, retry with the **same** key. |
| `404` [`NOT_FOUND`](/errors/NOT_FOUND) | Doesn't exist *or* not visible to your key. Many resources answer with their own code ([`INVOICE_NOT_FOUND`](/errors/INVOICE_NOT_FOUND), [`CLIENT_NOT_FOUND`](/errors/CLIENT_NOT_FOUND)…). Don't garbage-collect the ID — verify. |
| `409` [`IDEMPOTENCY_KEY_PROCESSING`](/errors/IDEMPOTENCY_KEY_PROCESSING) | Same key already in flight. Wait the seconds in `Retry-After`, then retry with the **same** key. |
| `409` [`IDEMPOTENCY_KEY_MISMATCH`](/errors/IDEMPOTENCY_KEY_MISMATCH) | The key already stored a *different* request (other body or other path). Use a **new** key. |
| `409` [`CONCURRENT_MODIFICATION`](/errors/CONCURRENT_MODIFICATION) | Another request changed the same resource first; nothing was applied. Retry — see [below](#concurrent-modification). |
| `500` [`INTERNAL_ERROR`](/errors/INTERNAL_ERROR) | Server-side failure. The response is stored under your `Idempotency-Key`, so check whether the operation happened, then retry with a **new** key. Escalate with `meta.request_id` if it persists. |

### Distinctions worth internalizing

- **401 vs 403** — 401 means the key itself is the problem (re-issue it); 403 means the key is fine but the action isn't allowed (scope, plan, locked account). Neither is fixed by retrying.
- **A 403 on an account you manage may be the access level, not the scope** — the `access_level` you hold over a managed account caps the key regardless of its scopes. [`ACCOUNT_NOT_ACCESSIBLE`](/errors/ACCOUNT_NOT_ACCESSIBLE) means you do not reach that account at all.
- **404 is intentionally ambiguous** — BeeL. never confirms the existence of resources your key can't see, to avoid leaking other tenants' data. A 404 for an ID you wrote down may mean wrong environment (sandbox vs live) or wrong key scope.
- **The 409 family splits four ways** — duplicates such as [`CLIENT_DUPLICATE`](/errors/CLIENT_DUPLICATE)/`PRODUCT_DUPLICATE`/`SERIES_CODE_DUPLICATED`/[`INVOICE_DUPLICATE_EXTERNAL_REFERENCE`](/errors/INVOICE_DUPLICATE_EXTERNAL_REFERENCE) (fetch the existing entity — for the last one, `GET /v1/companies/{company_id}/invoices?external_ref=…`), [`IDEMPOTENCY_KEY_PROCESSING`](/errors/IDEMPOTENCY_KEY_PROCESSING) (wait + same key), [`IDEMPOTENCY_KEY_MISMATCH`](/errors/IDEMPOTENCY_KEY_MISMATCH) (the key is bound to another request — **new** key), [`CONCURRENT_MODIFICATION`](/errors/CONCURRENT_MODIFICATION) (retry, see [below](#concurrent-modification)). Deleting a customer that has invoices also lands here: it can't be deleted, deactivate it instead.
- **402/403/429 plan errors are one family** — `SUBSCRIPTION_*`, [`FEATURE_NOT_AVAILABLE`](/errors/FEATURE_NOT_AVAILABLE) and [`QUOTA_EXCEEDED`](/errors/QUOTA_EXCEEDED) all populate `details.action` (`subscribe`, `update_payment`, `reactivate_subscription`…) so your UI can route the user without parsing messages. `403` [`CONTACT_SALES_REQUIRED`](/errors/CONTACT_SALES_REQUIRED) is the exception: it carries `details.reason` and `details.salesUrl` instead of `details.action` — a second NIF, or going Live with several, on the API plan needs a price agreed with sales. See [More than one NIF on the API plan](/multi-nif/companies#more-than-one-nif-on-the-api-plan).

## Concurrent modification

`409` [`CONCURRENT_MODIFICATION`](/errors/CONCURRENT_MODIFICATION) means another request wrote the same resource between the moment yours read it and the moment it saved. Yours was rolled back entirely — nothing was half-applied — so it is **safe to retry**.

- **There is no version to send.** The API takes no `If-Match` header and no version field: the conflict is detected on the server, between two writes that overlapped. You cannot cause it with a stale copy you hold, and you cannot prevent it by sending one.
- **Retry the same request.** A `409` is a `4xx`, so it released your `Idempotency-Key`: resending the same body with the same key runs it again. If you want to rebuild the body from the current state first (`GET` the resource, reapply your change), send it under a **new** key.
- **Back off a little.** Overlaps come from your own concurrent calls — two workers updating the same invoice, a retry racing its original. A short, jittered delay before the retry is enough; if it keeps happening, serialise the writes to that resource on your side.
- **On issue and void it is already an answer.** A `409` from `POST …/issue` means another request is issuing that same invoice, and no number was consumed twice. From `POST …/void` it means the invoice was voided exactly once. Read the invoice instead of retrying.

## Field-level errors

When the request body breaks the contract — a required field missing, a value of the wrong type or out of range — you get `422` [`VALIDATION_ERROR`](/errors/VALIDATION_ERROR) with one entry per field in `error.details`:

```json
{
  "type": "https://docs.beel.es/errors/VALIDATION_ERROR",
  "title": "VALIDATION_ERROR",
  "detail": "The provided data is not valid.",
  "instance": "/api/v1/companies/{company_id}/invoices",
  "success": false,
  "error": {
    "code": "VALIDATION_ERROR",
    "message": "The provided data is not valid.",
    "details": {
      "recipient": "The field 'recipient' cannot be empty",
      "lines[0].quantity": "The field 'lines[0].quantity' cannot be empty"
    }
  },
  "meta": { "timestamp": "…", "request_id": "…" }
}
```

The keys are the request's own field names, in snake_case, with `[n]` for array positions; the values are localized messages. Map the keys onto your form fields and you get inline validation for free.

A value the API cannot read at all — an unknown enum value, or a path or query parameter of the wrong type (a NIF where a `company_id` UUID goes) — names the one offending field instead: `error.details` carries `field`, `invalid_value`, and `allowed_values` or `expected_format`. A wrong path or query parameter answers `400` rather than `422`.

A fiscal rule broken by valid-looking data answers with its own code instead ([`LINE_INVALID_QUANTITY`](/errors/LINE_INVALID_QUANTITY), [`NEGATIVE_TOTAL_REQUIRES_RECTIFICATIVE`](/errors/NEGATIVE_TOTAL_REQUIRES_RECTIFICATIVE), [`EMISSION_NOT_READY`](/errors/EMISSION_NOT_READY)…), and its `error.details` carries what that rule needs — the offending `field`, the `blockers`, the limits. Check the code's page in the [error reference](/errors).

## Consuming errors programmatically

With the [Node.js SDK](/sdks/node) (`@beel_es/sdk`), you don't parse anything — typed exception classes inherit from `BeeLApiError` and expose `statusCode`, `code`, `requestId`, and `details`:

| SDK class | HTTP | Typical codes |
|---|---|---|
| `BeeLAuthError` | 401, 403 | `UNAUTHORIZED`, `FORBIDDEN`, [`INSUFFICIENT_SCOPE`](/errors/INSUFFICIENT_SCOPE) |
| `BeeLNotFoundError` | 404 | `NOT_FOUND`, [`INVOICE_NOT_FOUND`](/errors/INVOICE_NOT_FOUND), [`CLIENT_NOT_FOUND`](/errors/CLIENT_NOT_FOUND) |
| `BeeLConflictError` | 409 | [`CLIENT_DUPLICATE`](/errors/CLIENT_DUPLICATE), [`IDEMPOTENCY_KEY_PROCESSING`](/errors/IDEMPOTENCY_KEY_PROCESSING), [`CONCURRENT_MODIFICATION`](/errors/CONCURRENT_MODIFICATION) |
| `BeeLValidationError` | 422 | [`VALIDATION_ERROR`](/errors/VALIDATION_ERROR) and the invoice validation family — `err.details` exposes the field map |
| `BeeLRateLimitError` | 429 | [`RATE_LIMIT_EXCEEDED`](/errors/RATE_LIMIT_EXCEEDED), [`QUOTA_EXCEEDED`](/errors/QUOTA_EXCEEDED) — exposes `err.retryAfterSeconds` |
| `BeeLApiError` | everything else | `INTERNAL_ERROR`, [`BUSINESS_RULE_VIOLATION`](/errors/BUSINESS_RULE_VIOLATION), network failures |

```ts
import { BeeLApiError, BeeLValidationError, BeeLRateLimitError, BeeLConflictError } from '@beel_es/sdk';

try {
  await beel.invoices.create(payload, { idempotencyKey });
} catch (err) {
  if (err instanceof BeeLValidationError) {
    for (const [field, message] of Object.entries(err.details ?? {})) {
      form.setFieldError(field, message); // inline, per-field
    }
  } else if (err instanceof BeeLRateLimitError) {
    await sleep(err.retryAfterSeconds * 1000); // then retry, SAME idempotencyKey
  } else if (err instanceof BeeLConflictError && err.code === 'IDEMPOTENCY_KEY_PROCESSING') {
    await sleep(2000); // then retry, SAME idempotencyKey
  } else if (err instanceof BeeLConflictError && err.code === 'CONCURRENT_MODIFICATION') {
    // nothing was applied — retry the same call (the 409 released the key)
  } else if (err instanceof BeeLApiError && err.statusCode >= 500) {
    // the 5xx is stored under idempotencyKey: check whether the invoice exists
    // (e.g. by external_ref) and, if it does not, retry with a NEW key
  } else {
    throw err; // surface err.message, log err.requestId for support
  }
}
```

Without the SDK, raw consumption looks like this:

<Tabs items={["TypeScript", "Python", "Bash"]}>
  <Tab value="TypeScript">

```ts
const res = await fetch(`https://app.beel.es/api/v1/companies/{company_id}/invoices/${id}`, {
  headers: { Authorization: `Bearer ${API_KEY}` },
});

if (!res.ok) {
  const problem = await res.json();
  // Branch on `code`, show `detail` to the user, deep-link docs via `type`.
  throw Object.assign(new Error(problem.detail), {
    code: problem.error.code,
    docsUrl: problem.type,
    traceId: problem.meta.request_id,
    status: res.status,
  });
}
```

  </Tab>
  <Tab value="Python">

```python
resp = requests.get(
    f"https://app.beel.es/api/v1/companies/{company_id}/invoices/{invoice_id}",
    headers={"Authorization": f"Bearer {api_key}"},
)
if not resp.ok:
    problem = resp.json()
    raise BeelApiError(
        code=problem["error"]["code"],
        message=problem["detail"],
        docs_url=problem["type"],
        trace_id=problem["meta"]["request_id"],
        status=resp.status_code,
    )
```

  </Tab>
  <Tab value="Bash">

```bash
# Extract just the code for shell scripting:
curl -s -H "Authorization: Bearer $BEEL_API_KEY" \
     https://app.beel.es/api/v1/companies/{company_id}/invoices/abc-123 \
     | jq -r '.error.code // empty'

# Open the docs page for whatever error you receive:
URL=$(curl -s -H "Authorization: Bearer $BEEL_API_KEY" \
     https://app.beel.es/api/v1/companies/{company_id}/invoices/abc-123 \
     | jq -r '.type // empty')
[ -n "$URL" ] && open "$URL"
```

  </Tab>
</Tabs>

## Debugging with request logs

Every call you make with an API key is logged, with its request and response bodies (truncated when large). When an integration fails in production, you don't need to reproduce it: list your recent errors and open the one you are after.

```bash
curl "https://app.beel.es/api/v1/accounts/{account_id}/request-logs?only_errors=true&path_contains=/invoices&limit=20" \
  -H "Authorization: Bearer $BEEL_API_KEY"
```

```json
{
  "success": true,
  "data": {
    "request_logs": [
      {
        "request_id": "4bf92f3577b34da6a3ce929d0e0e4736",
        "timestamp": "2026-09-24T10:00:00Z",
        "http_method": "POST",
        "http_path": "/v1/companies/9f1c2d3e-4a5b-6c7d-8e9f-0a1b2c3d4e5f/invoices",
        "http_status": 422,
        "error_code": "LINE_INVALID_QUANTITY",
        "duration_ms": 84
      }
    ],
    "pagination": { "next_cursor": null, "prev_cursor": null, "has_next": false, "has_previous": false }
  }
}
```

- **`only_errors=true`** keeps requests that answered `400` or above. Narrow further with `http_status`, `method`, `path_contains` (a resource segment such as `/invoices`, not a template with `{placeholders}`), `api_key_id`, and `from`/`to` — the window defaults to the last 30 days.
- **`request_id` is the join key.** It is the same value as the `X-Request-Id` header and `meta.request_id` of the error you received. Log it with every failure, and you can open the exact request later:

```bash
curl "https://app.beel.es/api/v1/accounts/{account_id}/request-logs/4bf92f3577b34da6a3ce929d0e0e4736?timestamp=2026-09-24T10:00:00Z" \
  -H "Authorization: Bearer $BEEL_API_KEY"
```

The detail adds the `request_body`, `response_body`, `query_string` and the headers. Passing the `timestamp` from the list lets it find requests older than the recent window. A `404` [`REQUEST_LOG_NOT_FOUND`](/errors/REQUEST_LOG_NOT_FOUND) means the request was made by someone else, in the other environment, or never reached the API.

Logs need the `logs:read` scope. They cover the requests of **all your API keys** in the environment of the key you call with — a sandbox key sees sandbox traffic, a live key live traffic — and never the requests of other users of the account. Since the detail shows full bodies, grant `logs:read` only to keys that need it. Reference: [List API request logs](/request-logs/listAccountRequestLogs) and [Get an API request log detail](/request-logs/getAccountRequestLog).

## Related

<Related>

- [Error catalog](/errors) — every code organised by domain — start here for an unfamiliar production error
- [Browse all codes](/errors/all) — flat alphabetical list. Copy as constants or scan at a glance.
- [Rate limits](/guides/rate-limits) — windows, headers, and how to back off
- [Idempotency](/guides/idempotency) — key generation, lifetime, and the retry semantics in depth

</Related>

---

Full OpenAPI spec: https://docs.beel.es/api/openapi