Factuarea API

Error handling

Normalized error envelope, type and code catalog with stable anchors, and retry strategy.

Every error response from the public API uses a consistent JSON envelope. The HTTP status indicates the general category; the type field disambiguates and the code field points to the specific cause.

Envelope

{
  "error": {
    "type": "invalid_request_error",
    "code": "parameter_invalid",
    "message": "El campo client_id es obligatorio.",
    "param": "client_id",
    "request_id": "req_01HKQS5N8VR7QXJ9K3T6BWPMZA",
    "doc_url": "https://docs.factuarea.com/guides/errors#parameter_invalid"
  }
}

Fields:

  • type — general error category. Stable and enumerated (list below).
  • code — specific cause. Stable and enumerated.
  • subcode — optional. Present where code alone is ambiguous: on 409 duplication conflicts it pinpoints the duplicate key (e.g. subcode: "tax_id_already_exists"), and on 402 payment errors it pinpoints which gate rejected the call (e.g. subcode: "webhooks_addon_required"). Like code, it is stable and invariant across languages and API versions.
  • message — human text in Spanish. Not guaranteed stable across versions; useful for logging and display.
  • param — optional, present in validation errors. Points to the first offending field. On multi-field validation errors the full set lives in errors[] (see below).
  • errors[] — optional, present on 422 validation errors. Lists all failed fields (see Multi-field validation errors).
  • details — optional. Carries existing_resource_id on 409 duplication conflicts (see Duplication conflicts) and payment_setup_url on the 402 errors that need a payment method registered (see payment_required_error).
  • doc_url — optional. Link to this guide with anchor to the specific code (#{code}).
  • request_id — unique request identifier (req_<ULID>). Always include it when contacting support. It is also returned in the X-Request-Id response header.

The error object always carries type, code and message; the remaining fields are present when relevant.

Multi-field validation errors

A 422 validation error reports every failed field, not just the first. The flat param/message still mirror the first field (for backward compatibility), and errors[] carries one item per failed field — so you fix all of them in a single round-trip instead of one request per field.

{
  "error": {
    "type": "invalid_request_error",
    "code": "invalid_param_value",
    "message": "El campo client_id es obligatorio.",
    "param": "client_id",
    "errors": [
      { "param": "client_id", "code": "parameter_missing", "message": "El campo client_id es obligatorio." },
      { "param": "issue_date", "code": "parameter_invalid_format", "message": "El formato de la fecha no es válido.", "expected_format": "YYYY-MM-DD" },
      { "param": "status", "code": "parameter_invalid_enum", "message": "El valor no es válido.", "allowed_values": ["draft", "sent", "paid"] }
    ],
    "request_id": "req_01HKQS5N8VR7QXJ9K3T6BWPMZA",
    "doc_url": "https://docs.factuarea.com/guides/errors#invalid_param_value"
  }
}

Each item of errors[] carries:

  • param — the failed field name.
  • code — a stable machine-readable code derived from the failed validation rule (e.g. parameter_missing, parameter_invalid_format, parameter_invalid_enum, parameter_invalid_integer).
  • message — human-readable description of the field error.
  • expected_format — optional. Present only on format errors; the expected pattern (e.g. YYYY-MM-DD, uuid, email, url).
  • allowed_values — optional. Present only on enum errors; the list of legal values.

errors[] is purely additive — integrations that only read param, code and message keep working unchanged.

Duplication conflicts

A 409 duplication conflict (code: resource_already_exists with a subcode of tax_id_already_exists, external_id_already_exists or sku_already_exists) returns the id of the pre-existing resource in details.existing_resource_id. Resolve it with a single GET instead of an extra find_by_* lookup.

{
  "error": {
    "type": "conflict_error",
    "code": "resource_already_exists",
    "subcode": "tax_id_already_exists",
    "message": "Ya existe un cliente con ese NIF.",
    "details": { "existing_resource_id": "0193e2a1-7c4e-7b3a-9f21-2d6c8e5a1b40" },
    "request_id": "req_01HKQS5NKW1C6W9T4G5HAIBZVM",
    "doc_url": "https://docs.factuarea.com/guides/errors#resource_already_exists"
  }
}

A GET /v1/clients/0193e2a1-7c4e-7b3a-9f21-2d6c8e5a1b40 returns the existing resource (200). The same applies on PUT when an external_id already belongs to another resource of the company.

Problem Details (RFC 9457)

Send Accept: application/problem+json to receive the same error as an RFC 9457 Problem Details document with Content-Type: application/problem+json. With Accept: application/json, Accept: */* or no Accept header you get the flat envelope above.

{
  "type": "https://docs.factuarea.com/errors/resource_already_exists",
  "title": "Resource already exists",
  "status": 409,
  "detail": "Ya existe un cliente con ese NIF.",
  "instance": "/v1/clients",
  "code": "resource_already_exists",
  "subcode": "tax_id_already_exists",
  "details": { "existing_resource_id": "0193e2a1-7c4e-7b3a-9f21-2d6c8e5a1b40" },
  "request_id": "req_01HKQS5NKW1C6W9T4G5HAIBZVM"
}
  • type — the documentation page of that specific code, e.g. https://docs.factuarea.com/errors/resource_already_exists. The code is the only variable segment, so you can build and compare the URI yourself. It used to be a single URI shared by every problem; if you match on it, match on code instead — that one never moves.
  • title — a short human summary of the problem type.
  • status — the HTTP status code.
  • detail — the human-readable message.
  • instance — the path of the affected resource.

The problem+json variant does not drop any extension data: code, subcode, param, errors[], details, doc_url and request_id are preserved as RFC 9457 extension members.

Localized messages

The message (and the problem+json detail) is localized via the Accept-Language header for the codes in the stable catalog. Supported languages are es, en and ca, with fallback to es when the header is absent or requests an unsupported language. The code and subcode are invariant across languages — always branch on code, never on message.

Accept-Language: en        → message in English
Accept-Language: ca-ES     → message in Catalan
(absent / Accept-Language: de) → message in Spanish (fallback)

Dynamic messages emitted by domain exceptions stay in Spanish; only the stable catalog messages are localized.

Error types

typeHTTPDescription
invalid_request_error400 or 422Malformed payload, missing/invalid parameters or business validation failure.
authentication_error401The API key is missing, invalid, revoked, expired or the IP is not in the allowlist.
payment_required_error402The operation charges money and the payment could not go through: no payment method on file, the charge was declined, or it needs a subscription or add-on that isn't contracted.
authorization_error403The key is valid but the scope doesn't cover the endpoint.
permission_error403The company's plan does not grant access to the feature.
not_found_error404The requested resource doesn't exist or doesn't belong to the key's company.
conflict_error409Creation conflict, idempotency lock or duplicate resource (e.g. a tax_id already registered).
idempotency_error409Reusing Idempotency-Key with a different payload.
rate_limit_error429Exceeded the per-minute or monthly quota, or too many auth failures.
api_error500Unexpected backend error. Retries may help; report to support with request_id.
service_unavailable_error503Public API disabled via kill-switch, or downstream outage (Stripe, mailer).

402 and 403 are not interchangeable. A 402 (payment_required_error) means the operation is available to you and the only thing between you and it is money: register a payment method, fix the declined charge, or contract the plan or add-on it bills to. A 403 means access itself is denied — either the key lacks the scope (authorization_error) or the company's plan doesn't include the feature (permission_error) — and no payment retry changes that. The pair addon_required (402, the add-on isn't contracted) and addon_not_active (403, no key reaches a feature that isn't subscribed) is the one worth reading twice.

Business rule violations (invalid status transition, an action not allowed in the current document state) respond 422 with type: invalid_request_error and code: invalid_status_transitionnot 409. 409 conflict_error is reserved for duplicate creation, idempotency conflicts and concurrency locks.

Code catalog

The anchor of each H3 heading matches exactly the value of the envelope code field. The doc_url returned by the API resolves to the specific section. The list below covers the codes you will encounter in practice; the live OpenAPI reference documents the exact codes per endpoint.

For the complete reference of every error code grouped by bounded context, with its HTTP status and type, see All error codes.

invalid_request_error

parameter_invalid

A request parameter is missing or invalid. param points to the offending field (e.g. client_id, lines[0].quantity).

parameter_invalid_format

A value's format is incorrect for its semantics (regex, length, encoding, a malformed UUID, an out-of-format date).

parameter_invalid_range

A numeric or date value is outside the allowed range (e.g. limit outside 1..100).

parameter_invalid_cursor

The starting_after / ending_before cursor is not a valid resource id. See Pagination.

parameter_unknown

The body contains an undocumented field (on strict endpoints).

invalid_param_format

Format constraint failed on a typed field — e.g. the Idempotency-Key header or the Factuarea-Version header is malformed.

invalid_param_value

The value does not meet a constraint (enum, format, semantic rule).

invalid_period

The requested reporting period is invalid (e.g. a quarter/year that does not exist).

invalid_status_transition

The requested transition is forbidden by the document state machine (e.g. sending an invoice that is not in a sendable state). Business rule violations like this are 422, not 409.

invoice_already_paid

mark-paid on an already-paid invoice.

quote_already_accepted

Action that conflicts with a quote already accepted.

business_rule_violation

A domain invariant blocked the operation. The subcode pinpoints the rule and param the offending field. Used by the payment ledger (Recording payments):

  • payment_exceeds_pending_amount (param: "amount") — the payment amount is greater than the invoice's pending balance. Applies to both POST /v1/invoices/{id}/payments and POST /v1/purchase_invoices/{id}/payments.
  • invalid_payment_date (param: "paid_on") — the payment date is outside the allowed issue_date … today window (purchase invoices).
  • purchase_invoice_not_payable (param: "status") — the purchase invoice is cancelled and no longer accepts payments.
{
  "error": {
    "type": "invalid_request_error",
    "code": "business_rule_violation",
    "subcode": "payment_exceeds_pending_amount",
    "message": "El importe del pago (1.500,00 €) supera el importe pendiente de la factura (710,00 €).",
    "param": "amount",
    "doc_url": "https://docs.factuarea.com/guides/errors#business_rule_violation",
    "request_id": "req_..."
  }
}

unsupported_format

The requested export/report format is not supported.

insufficient_data_for_report

Not enough data to generate the requested tax report.

signature_payload_too_large

The delivery-note signature image exceeds the maximum size.

authentication_error

missing_api_key

No authentication header present (Authorization: Bearer or X-API-Key).

invalid_api_key

The key does not exist or the secret doesn't match the stored hash.

api_key_revoked

The key was revoked. Create a new one in the dashboard.

too_many_auth_failures

Repeated authentication failures from your client have been throttled. Back off and verify your credentials.

payment_required_error

Every 402 comes from an operation that bills something at the moment you call it — a managed-company seat, an employee seat, or an add-on. None of them is retryable as-is: resolve the payment first, then repeat the same request.

Version note. Five of these codes shipped before this category existed and were served as invalid_request_error. They carry payment_required_error from Factuarea-Version: 2026-09-01 onwards: payment_method_required, seat_charge_failed, gestoria_plan_required, employee_seat_payment_method_required and employee_seat_charge_failed. Requests on an earlier version keep the old type unchanged. error.code, error.subcode and the 402 status are identical on every version — branch on code and you never have to think about this.

payment_method_required

POST /v1/companies and the activation endpoints charge a seat immediately, and the accounting firm operates in live mode with no payment method on file. The response carries details.payment_setup_url: open it, register a card and repeat the call.

{
  "error": {
    "type": "payment_required_error",
    "code": "payment_method_required",
    "message": "La gestoría no tiene un método de pago configurado: configúralo para añadir la empresa.",
    "details": { "payment_setup_url": "https://billing.stripe.com/p/session/live_YWNjdF8xS2ZHM0RLb0h4RXBGV3lY" },
    "request_id": "req_01HKQS5NPAYMENTMETHODREQ01",
    "doc_url": "https://docs.factuarea.com/guides/errors#payment_method_required"
  }
}

seat_charge_failed

The pro-rated charge for the managed-company seat was declined — card refused, authentication required, or the payment provider was unreachable. The company is not created when the seat isn't paid. Fix the payment method in the billing portal and retry.

gestoria_plan_required

The accounting firm has no active paid subscription, so there is no subscription to charge the seat to. Subscribe to a plan (or resume the cancelled one) before adding managed companies.

employee_seat_payment_method_required

Creating or reactivating an employee charges a seat immediately, and the company operates in live mode with no payment method on file. Same remedy as payment_method_required, and the response also carries details.payment_setup_url.

employee_seat_charge_failed

The pro-rated charge for the employee seat was declined. The employee is not activated when the seat isn't paid. Fix the payment method and retry; check with your bank if the card keeps being declined.

addon_required

The operation belongs to an add-on the company hasn't contracted — e.g. POST /v1/webhook_endpoints requires the Developer API add-on, whose free tier allows zero endpoints (subcode: webhooks_addon_required). Contract the add-on and repeat the call. Unlike addon_not_active (403), what's missing here is the subscription, not the scope.

authorization_error

insufficient_scope

The key lacks the scope required by the endpoint. See the catalog at Authentication › Scopes.

permission_error

feature_not_available_in_plan

The current plan does not include the required module (e.g. recurring_invoices).

addon_not_active

The company has no active Factuarea plan that includes public API access — for example, the 10-day trial expired or the subscription lapsed outside its grace period. Subscribe to or renew a plan to keep using the API.

not_found_error

resource_not_found

The resource doesn't exist or doesn't belong to your company.

tax_report_not_found

The requested tax report does not exist.

conflict_error

resource_already_exists

Attempt to create a duplicate (e.g. a tax_id already registered). The subcode (e.g. tax_id_already_exists) pinpoints the duplicate key.

resource_conflict

The operation conflicts with the current state of the resource (e.g. a concurrent modification).

max_api_keys_exceeded

The company has reached its maximum number of active API keys.

idempotency_error

idempotency_key_reused

Same Idempotency-Key, different request body. Use a new key. See Idempotency.

rate_limit_error

rate_limit_exceeded

You exceeded the per-minute or monthly quota of your tier. The Retry-After header indicates the seconds to wait. See Rate limits.

api_error

internal_error

Unexpected error. Already captured on our side, but share request_id with support.

service_unavailable_error

service_unavailable

The public API is temporarily unavailable — globally disabled via kill-switch, in a maintenance window, or a downstream dependency (database, mailer, Stripe) is unhealthy. Retry after a short back-off.

Typed errors with the official SDK

The TypeScript and PHP SDKs map this envelope to a typed exception hierarchy, so you branch on a class (and read code, type, param, request_id) instead of parsing JSON. Your API key is never included in any exception.

import {
  FactuareaError,
  ValidationError,
  RateLimitError,
} from "@factuarea/sdk";

try {
  await factuarea.invoices.create(body);
} catch (error) {
  if (error instanceof ValidationError) {
    console.error(error.fields);     // { client_id: ["obligatorio"], … }
  } else if (error instanceof RateLimitError) {
    console.error(error.retryAfter); // seconds to wait
  } else if (error instanceof FactuareaError) {
    console.error(error.code, error.type, error.requestId);
  }
}

The hierarchy also exports AuthenticationError, NotFoundError, ConflictError, ServerError and ConnectionError.

use Factuarea\Sdk\Models\Errors\ErrorThrowable;

try {
    $factuarea->invoices->publicApiV1InvoicesCreate($body);
} catch (ErrorThrowable $e) {
    $error = $e->container->error;
    echo $error->type->value;  // e.g. "invalid_request_error"
    echo $error->code;         // e.g. "parameter_invalid"
    echo $error->param;        // e.g. "client_id"
    echo $error->requestId;    // quote this to support
}

See SDKs › Handling errors for the full hierarchy. The retry policy below is applied automatically by both SDKs.

request_id and support

Every response includes a request_id. Attach it to any ticket or request to support@factuarea.com:

Subject: 422 on POST /v1/invoices — request_id req_01JBVH7K9Y4N3CDQ2EHJB1AGSV

With the request_id we correlate logs, metrics and traces to investigate quickly.

Retry strategy

  • 4xx except 429do not retry: the error is in the request. Fix and resend.
  • 429 → respect the Retry-After header. Implement exponential back-off with jitter.
  • 5xx → exponential back-off (2^n * 100ms) with jitter, max 5 attempts.

Stripe publishes a canonical pattern that also applies here: stripe.com/docs/error-handling.

On this page