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": "La factura debe tener al menos una línea",
"instance": "/v1/invoices/abc-123",
"errors": [],
"success": false,
"error": {
"code": "INVOICE_NO_LINES",
"message": "La factura debe tener al menos una línea",
"details": {}
},
"meta": {
"timestamp": "2026-05-21T10:00:00Z",
"request_id": "4bf92f3577b34da6a3ce929d0e0e4736"
}
}| Section | What it is |
|---|---|
type, title, detail, instance, errors[] | 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, detail = error.message, errors[] mirrors 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
Pass Accept-Language to receive detail / error.message in es (default), en, or ca. 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_sk_test_..." \
-H "Accept-Language: en" \
https://app.beel.es/api/v1/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, past due, or cancelled | Direct the user to billing |
| 403 Forbidden | Authenticated but not allowed: scope missing, account locked, email unverified, feature gated | Surface; retrying changes nothing |
| 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, idempotency key in flight, or concurrent write | Depends on the code — see retry rules |
| 422 Unprocessable Entity | Validation failed (field-level, NIF, fiscal rules). errors[] / error.details carry the per-field breakdown | Fix the payload, retry with a new Idempotency-Key |
| 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 | Retry with the same Idempotency-Key, exponential backoff |
| 502 / 503 | Upstream provider (VeriFactu, Stripe) unavailable — usually short-lived | Retry with backoff, same 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 | Yes — exponential backoff (1s, 2s, 4s…), escalate after 3–5 attempts with meta.request_id | Same key — if the original succeeded, you get the cached success; a new key risks duplicates |
409 IDEMPOTENCY_KEY_PROCESSING | Yes — wait 1–5 seconds; the in-flight result will be returned | Same key |
409 DUPLICATE_RESOURCE family | No — the entity already exists; GET it by its natural key instead | — |
422 validation | Only after fixing the payload | New key — the 422 is cached against the old key; replaying it (even with a corrected body) returns the cached 422 |
Other 4xx | No — fix the request first; retrying fails identically | — |
The 422 footgun. A 422 means the request was received and the validation result was cached against your key. Retrying a corrected body with the same key returns the cached 422, not the success you expect. Fix the payload, then generate a new key. Full details in Idempotency.
One nuance on 429: request-rate 429s are emitted by the edge filter with a minimal body, while monthly-quota 429s (QUOTA_EXCEEDED) come from the application with the full envelope. Rely on the status and headers, not the body shape — 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 | HTTP | TL;DR |
|---|---|---|
VALIDATION_ERROR | 422 | Request shape or a fiscal rule failed — see the field-level breakdown. Fix and retry with a new key. |
BUSINESS_RULE_VIOLATION | 400 | Operation invalid in the resource's current state (issuing a non-draft, deleting an issued invoice). Not retryable — surface the message. |
NIF_NOT_IN_CENSUS | 422 | 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. |
INVALID_API_KEY | 401 | Key unknown, revoked, or for the wrong environment. Re-issue. |
RATE_LIMIT_EXCEEDED | 429 | Request window exhausted. Sleep Retry-After, retry with the same key. |
NOT_FOUND | 404 | Doesn't exist or not visible to your key. Don't garbage-collect the ID — verify. |
IDEMPOTENCY_KEY_PROCESSING | 409 | Same key already in flight. Wait 1–5 s, retry with the same key. |
INTERNAL_ERROR | 500 | Server-side hiccup. Retry with the same 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.
- 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 three ways —
DUPLICATE_RESOURCE/CLIENT_DUPLICATE/PRODUCT_DUPLICATE(fetch the existing entity),IDEMPOTENCY_KEY_PROCESSING(wait + same key),CONCURRENT_MODIFICATION(refetch, reapply, new key). - 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.
Field-level errors
When validation fails (422), both contracts carry the per-field breakdown:
{
"type": "https://docs.beel.es/errors/VALIDATION_ERROR",
"title": "VALIDATION_ERROR",
"detail": "Los datos proporcionados no son válidos.",
"instance": "/v1/invoices",
"errors": [
{ "field": "/recipient", "code": "FIELD_REQUIRED" },
{ "field": "/lines/0/quantity", "code": "LINE_INVALID_QUANTITY" }
],
"success": false,
"error": {
"code": "VALIDATION_ERROR",
"message": "Los datos proporcionados no son válidos.",
"details": {
"recipient": "El campo 'recipient' no puede estar vacío",
"lines[0].quantity": "La cantidad no puede ser cero ni nula"
}
},
"meta": { "timestamp": "…", "request_id": "…" }
}errors[] uses RFC 9457 conventions (JSON-pointer-style paths, per-field codes); error.details mirrors the same information as a path → localized message map. Wire errors[].field to your form fields and you get inline validation for free.
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, INVALID_API_KEY, FORBIDDEN, EMAIL_NOT_VERIFIED |
BeeLNotFoundError | 404 | NOT_FOUND, RESOURCE_NOT_FOUND, CLIENT_NOT_FOUND |
BeeLConflictError | 409 | DUPLICATE_RESOURCE, 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 BeeLApiError && err.statusCode >= 500) {
// retry with SAME idempotencyKey, exponential backoff
} 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/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/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/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/invoices/abc-123 \
| jq -r '.type // empty')
[ -n "$URL" ] && open "$URL"