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 wherecodealone is ambiguous: on409duplication conflicts it pinpoints the duplicate key (e.g.subcode: "tax_id_already_exists"), and on402payment errors it pinpoints which gate rejected the call (e.g.subcode: "webhooks_addon_required"). Likecode, 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 inerrors[](see below).errors[]— optional, present on422validation errors. Lists all failed fields (see Multi-field validation errors).details— optional. Carriesexisting_resource_idon409duplication conflicts (see Duplication conflicts) andpayment_setup_urlon the402errors that need a payment method registered (see payment_required_error).doc_url— optional. Link to this guide with anchor to the specificcode(#{code}).request_id— unique request identifier (req_<ULID>). Always include it when contacting support. It is also returned in theX-Request-Idresponse 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 specificcode, e.g.https://docs.factuarea.com/errors/resource_already_exists. Thecodeis 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 oncodeinstead — 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
| type | HTTP | Description |
|---|---|---|
invalid_request_error | 400 or 422 | Malformed payload, missing/invalid parameters or business validation failure. |
authentication_error | 401 | The API key is missing, invalid, revoked, expired or the IP is not in the allowlist. |
payment_required_error | 402 | The 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_error | 403 | The key is valid but the scope doesn't cover the endpoint. |
permission_error | 403 | The company's plan does not grant access to the feature. |
not_found_error | 404 | The requested resource doesn't exist or doesn't belong to the key's company. |
conflict_error | 409 | Creation conflict, idempotency lock or duplicate resource (e.g. a tax_id already registered). |
idempotency_error | 409 | Reusing Idempotency-Key with a different payload. |
rate_limit_error | 429 | Exceeded the per-minute or monthly quota, or too many auth failures. |
api_error | 500 | Unexpected backend error. Retries may help; report to support with request_id. |
service_unavailable_error | 503 | Public 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_transition —
not 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 bothPOST /v1/invoices/{id}/paymentsandPOST /v1/purchase_invoices/{id}/payments.invalid_payment_date(param: "paid_on") — the payment date is outside the allowedissue_date … todaywindow (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_01JBVH7K9Y4N3CDQ2EHJB1AGSVWith the request_id we correlate logs, metrics and traces to
investigate quickly.
Retry strategy
4xxexcept429→ do not retry: the error is in the request. Fix and resend.429→ respect theRetry-Afterheader. 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.