NewStripe: a buyer with a NIF always gets a standard invoice, and the simplified threshold is deprecated
BeeL
Get startedMulti-NIFVeriFactuRulesStripeAPI referenceChangelog

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"
  }
}
SectionWhat it is
type, title, detail, instanceStandard RFC 9457 Problem Details fields. Prefer these for new integrations.
success, error.{code,message,details}, metaLegacy 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-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:

StatusMeaningDefault action
400 Bad RequestMalformed 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 UnauthorizedMissing, malformed, expired, or revoked API keyRe-authenticate; do not retry
402 Payment RequiredSubscription required, inactive, unpaid (payment retries exhausted), or cancelledDirect the user to billing
403 ForbiddenAuthenticated 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 priceSurface; retrying changes nothing. For CONTACT_SALES_REQUIRED, show error.details.salesUrl
404 Not FoundResource 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 ConflictDuplicate resource (e.g. CLIENT_DUPLICATE, INVOICE_DUPLICATE_EXTERNAL_REFERENCE), idempotency key in flight or reused with another body, or concurrent writeDepends on the code — see retry rules
422 Unprocessable EntityValidation failed (field-level, NIF, fiscal rules). error.code names the rule; error.details, when present, carries the per-field breakdownFix the payload and retry — a 4xx is not stored, so the key is free again
429 Too Many RequestsRate limit or per-feature quota exceededSleep Retry-After seconds, then retry with the same key
500 Internal Server ErrorUnhandled server-side failure — the work may or may not have completedStored under your Idempotency-Key: the same key replays this 5xx. Check whether the work happened, then retry with a new key
502 / 503Upstream provider (VeriFactu, Stripe) unavailable — usually short-livedSame 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:

OutcomeRetry?Idempotency-Key
429 rate limit / quotaYes — sleep Retry-After seconds first, then exponential backoff with jitterSame key — the request was rejected before any work happened
5xx server errorOnly after checking whether the work happened — see Idempotency. Escalate persistent failures with meta.request_idNew key — a 5xx is stored, so the same key replays it
409 IDEMPOTENCY_KEY_PROCESSINGYes — wait Retry-After seconds (2); once the original finishes, the same key returns its stored resultSame 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_MISMATCHNo — the key is already bound to a different body or pathNew key — or resend the original body to get the stored response
409 CONCURRENT_MODIFICATIONYes, 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 jobSame key — the 409 released it. A new key if you rebuild the body from the current state
422 validationOnly after fixing the payloadSame key — a 4xx is not stored, so the key is released and the corrected body re-executes
Other 4xxNo — 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.

CodeTL;DR
422 VALIDATION_ERRORRequest 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_VIOLATIONGeneric "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_CENSUSNIF 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 UNAUTHORIZEDKey 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_EXCEEDEDRequest window exhausted. Sleep Retry-After, retry with the same key.
404 NOT_FOUNDDoesn'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_PROCESSINGSame key already in flight. Wait the seconds in Retry-After, then retry with the same key.
409 IDEMPOTENCY_KEY_MISMATCHThe key already stored a different request (other body or other path). Use a new key.
409 CONCURRENT_MODIFICATIONAnother request changed the same resource first; nothing was applied. Retry — see below.
500 INTERNAL_ERRORServer-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 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/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_AVAILABLE and 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 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.

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-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 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 classHTTPTypical codes
BeeLAuthError401, 403UNAUTHORIZED, FORBIDDEN, INSUFFICIENT_SCOPE
BeeLNotFoundError404NOT_FOUND, INVOICE_NOT_FOUND, CLIENT_NOT_FOUND
BeeLConflictError409CLIENT_DUPLICATE, IDEMPOTENCY_KEY_PROCESSING, CONCURRENT_MODIFICATION
BeeLValidationError422VALIDATION_ERROR and the invoice validation family — err.details exposes the field map
BeeLRateLimitError429RATE_LIMIT_EXCEEDED, QUOTA_EXCEEDED — exposes err.retryAfterSeconds
BeeLApiErroreverything elseINTERNAL_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=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:
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.