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.
Looking for a specific code? Jump to the error reference and pick a category, or paste the type URI from any error response into your browser.
Response shape
Every error response carries two contracts at once (additive, non-breaking):
{
"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 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.
curl -H "Authorization: Bearer $BEEL_API_KEY" \
-H "Accept-Language: en" \
https://app.beel.es/api/v1/companies/{company_id}/invoices/abc-123Status 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, 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, 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. Escalate persistent failures with meta.request_id | New key — a 5xx is stored, so the same key replays it |
409 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, PRODUCT_DUPLICATE, SERIES_CODE_DUPLICATED, INVOICE_DUPLICATE_EXTERNAL_REFERENCE) | No — the entity already exists; GET it by its natural key instead | — |
409 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 | 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. A request-rate 429 does not carry the error envelope described above; see Rate limits.
The codes you'll actually hit
These cover the vast majority of integration failures. Everything else lives in the error reference.
| Code | TL;DR |
|---|---|
422 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 | Generic "not allowed in the resource's current state". Most state errors carry their own code instead (ONLY_DRAFT_EMITTABLE, STATUS_NOT_DELETABLE…, see Invoice lifecycle). Not retryable — surface the message. |
422 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 | 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 | Request window exhausted. Sleep Retry-After, retry with the same key. |
404 NOT_FOUND | Doesn't exist or not visible to your key. Many resources answer with their own code (INVOICE_NOT_FOUND, CLIENT_NOT_FOUND…). Don't garbage-collect the ID — verify. |
409 IDEMPOTENCY_KEY_PROCESSING | Same key already in flight. Wait the seconds in Retry-After, then retry with the same key. |
409 IDEMPOTENCY_KEY_MISMATCH | The key already stored a different request (other body or other path). Use a new key. |
409 CONCURRENT_MODIFICATION | Another request changed the same resource first; nothing was applied. Retry — see below. |
500 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_levelyou hold over a managed account caps the key regardless of its scopes.ACCOUNT_NOT_ACCESSIBLEmeans 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/PRODUCT_DUPLICATE/SERIES_CODE_DUPLICATED/INVOICE_DUPLICATE_EXTERNAL_REFERENCE(fetch the existing entity — for the last one,GET /v1/companies/{company_id}/invoices?external_ref=…),IDEMPOTENCY_KEY_PROCESSING(wait + same key),IDEMPOTENCY_KEY_MISMATCH(the key is bound to another request — new key),CONCURRENT_MODIFICATION(retry, see below). 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_AVAILABLEandQUOTA_EXCEEDEDall populatedetails.action(subscribe,update_payment,reactivate_subscription…) so your UI can route the user without parsing messages.403CONTACT_SALES_REQUIREDis the exception: it carriesdetails.reasonanddetails.salesUrlinstead ofdetails.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.
Concurrent modification
409 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-Matchheader 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
409is a4xx, so it released yourIdempotency-Key: resending the same body with the same key runs it again. If you want to rebuild the body from the current state first (GETthe 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
409fromPOST …/issuemeans another request is issuing that same invoice, and no number was consumed twice. FromPOST …/voidit 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 with one entry per field in error.details:
{
"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, NEGATIVE_TOTAL_REQUIRES_RECTIFICATIVE, 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.
Consuming errors programmatically
With the Node.js SDK (@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 |
BeeLNotFoundError | 404 | NOT_FOUND, INVOICE_NOT_FOUND, CLIENT_NOT_FOUND |
BeeLConflictError | 409 | CLIENT_DUPLICATE, IDEMPOTENCY_KEY_PROCESSING, CONCURRENT_MODIFICATION |
BeeLValidationError | 422 | VALIDATION_ERROR and the invoice validation family — err.details exposes the field map |
BeeLRateLimitError | 429 | RATE_LIMIT_EXCEEDED, QUOTA_EXCEEDED — exposes err.retryAfterSeconds |
BeeLApiError | everything else | INTERNAL_ERROR, BUSINESS_RULE_VIOLATION, network failures |
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:
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,
});
}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,
)# 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"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.
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"{
"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=truekeeps requests that answered400or above. Narrow further withhttp_status,method,path_contains(a resource segment such as/invoices, not a template with{placeholders}),api_key_id, andfrom/to— the window defaults to the last 30 days.request_idis the join key. It is the same value as theX-Request-Idheader andmeta.request_idof the error you received. Log it with every failure, and you can open the exact request later:
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 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 and Get an API request log detail.