The error envelope
The exact shape of an error, the nine types, what to do with each one and why the request_id matters.
An error is an HTTP status plus a single error object. There is no success envelope: successful
responses are the resource.
{
"error": {
"type": "invalid_request_error",
"code": "receptor_incomplete",
"message": "Receptor 76543212-K is missing `receptor.giro`, `receptor.address.street` and `receptor.address.comuna`, and tipo 33 cannot be filed without <GiroRecep>, <DirRecep>, <CmnaRecep>. Facturia looked: this empresa has emitted no earlier document to 76543212-K carrying them. …",
"hint": "`error.param` names exactly which field to send. Facturia fills a receptor’s razón social, giro, dirección and comuna from this empresa’s own earlier documents to the same RUT; when there is no earlier one, they have to arrive with the request. Better here than an SII rejection with the folio already spent.",
"retriable": false,
"param": "receptor.giro",
"doc_url": "https://docs.facturia.cl/errors/receptor_incomplete",
"request_id": "req_01K2R7Q9M4T2VB",
"empresa": "77928532-4"
}
}| Field | What it is |
|---|---|
type | The error's family. It decides what to do |
code | The specific error, and stable. Program against this |
message | Human-readable prose. Do not program against this |
hint | What to do about it, for this exact code |
retriable | Can the identical request work later, unchanged? |
param | The request field that caused the problem, where it applies |
doc_url | A link to this code's explanation |
request_id | The identifier of this exact call |
empresa | The RUT (Chile's taxpayer id) whose context it happened in |
existing_id | On conflicts, the id of the object that already exists |
details | Structured data for the case: the pending_action that is missing, the checkout_url of a 402 |
sii | Present on sii_error — the raw status from the SII (Chile's tax authority) |
hint and retriable come in the response
They are exactly the same hint and the same retriable that GET /v1/error_codes publishes for
that code, folded into the envelope so you do not have to make a second 75 KB call to look up what
to do. They are not written at the point where the error is thrown: they are looked up by code in
the same registry that serves the dictionary, so the response and the dictionary cannot contradict
each other.
They are missing only in the rare case of a code minted after your client shipped and not yet
present in your local copy of the dictionary. Treat that as "read message and doc_url", never as
a failure.
`hint` comes in English, on purpose
The error dictionary has always been English and it is the same object on the wire and in
GET /v1/error_codes; translating it would fork the registry by language, or make the response and
the dictionary say different things. Each code's Spanish lives on its page on this site — the one
doc_url points at — and in the dashboard.
The nine types
type | HTTP | Means | What to do |
|---|---|---|---|
invalid_request_error | 400 | Malformed or incomplete input | Fix the request. Retrying it as-is will not help |
invalid_request_error | 402 | Payment missing: subscription_required or plan_limit_reached | Send a person to error.details.checkout_url. See below |
authentication_error | 401 | Key absent, revoked or malformed | Check the credential |
permission_error | 403 | Valid key, not permitted: scope, empresa allowlist, mode | Check the scope or the empresa. error.message names the missing one |
not_found_error | 404 | No such object under this key | Check the id and the key's mode |
conflict_error | 409 | State conflict: idempotency reuse, a duplicate folio (the pre-authorized document number), an empresa that already exists | Look at existing_id |
unprocessable_error | 422 | Well-formed but breaks a domain rule: folios exhausted, acknowledgment window closed, an empresa that is not emitting yet | It is a business rule, not a formatting bug |
rate_limit_error | 429 | Slow down; look at Retry-After | Wait what the header says |
sii_error | 502 | The SII rejected or failed the upstream call. error.sii carries the raw status and message | See Codes |
api_error | 500 / 503 | Ours. Nothing was issued | Retry with backoff |
That is nine families and ten statuses: the 402 is not a tenth family, it is the one case where
an invalid_request_error goes out with an HTTP status other than 400, because "you have not paid"
is not a malformed request and deserved a status of its own.
The 402, in detail
{
"error": {
"type": "invalid_request_error",
"code": "subscription_required",
"message": "This live act needs an active subscription for this empresa, and it has none.",
"retriable": false,
"details": {
"empresa": "emp_01K2R7Q4XW9M3B8ZC5YHTVJD6N",
"empresa_rut": "77928532-4",
"empresa_name": "Connect SpA",
"account_plan": "starter",
"subscription_status": "canceled",
"grace_until": null,
"checkout_url": "https://app.facturia.cl/…"
}
}
}402 subscription_required has been genuinely enforced since 2026-08-24. The subscription is
per empresa ($19.990 + IVA, Chile's VAT, a month for every RUT that issues), so each act is
decided by the subscription of the empresa the request names: one lapsed RUT never stops another one
on the same account. Exactly five acts are blocked:
POST /v1/documentsPOST /v1/documents/{document_id}/credit_notePOST /v1/documents/{document_id}/sendPOST /v1/folios/requestsPOST /v1/empresas/{empresa_id}/onboarding/go_live
Reads, the commercial response to a received document (an 8-day legal deadline), tax filings (legal
deadlines) and all of test mode are never blocked. A subscription that lapses keeps emitting
through 7 days of grace before this starts; an account on plan: house passes for every one of its
empresas.
What is actionable is in error.details: checkout_url points at that empresa, so an agent that
gets a 402 does not have to improvise where to send its human to pay. Page:
subscription_required.
402 plan_limit_reached is published and nothing emits it today. There is one plan and it has
unlimited documents: there is no document quota that can run out. The code stays published so a
client that already knows how to handle a 402 has a stable definition to program against. Its page
says the same thing just as plainly:
plan_limit_reached.
The request_id
Always log it
request_id appears on every response, successful or not, in the Facturia-Request-Id header.
With it we can find the exact call. Without it, a support conversation begins by reconstructing what
happened.
A well-built HTTP client stores it next to the result, not only when something fails.
Handling errors in practice
async function llamar(path, init) {
const res = await fetch(`https://api.facturia.cl/v1${path}`, init);
const requestId = res.headers.get('Facturia-Request-Id');
if (res.ok) return { data: await res.json(), requestId };
const { error } = await res.json();
// `hint` viene en la respuesta: no hace falta ir a buscarlo al diccionario.
// Falta solo si el código es más nuevo que tu cliente, así que hay fallback.
log.warn({ requestId, code: error.code, quéHacer: error.hint ?? error.message });
// El único caso en que el estado importa más que el tipo: falta pagar, y
// `details.checkout_url` es lo que hay que ponerle delante a una persona.
if (res.status === 402) {
throw new PagoRequerido(error, error.details?.checkout_url, requestId);
}
// `retriable` responde «¿puede funcionar este MISMO request más rato?» para
// cualquier código, incluidos los que salieron después de este switch.
switch (error.type) {
case 'rate_limit_error':
// El header dice cuánto. No adivines.
return { retryAfter: Number(res.headers.get('Retry-After') ?? 1), requestId };
case 'api_error':
// Nada se emitió. Reintenta con la MISMA Idempotency-Key.
throw new Reintentable(error, requestId);
case 'sii_error':
// Puede ser transitorio. `error.sii` dice qué respondió el SII.
throw new Reintentable(error, requestId);
case 'invalid_request_error':
case 'unprocessable_error':
// Reintentar el mismo cuerpo va a fallar igual. Arréglalo o escálalo.
throw new NoReintentable(error, requestId);
default:
// Un tipo que no reconoces: `retriable` sigue siendo una respuesta.
throw error.retriable
? new Reintentable(error, requestId)
: new NoReintentable(error, requestId);
}
}Trap
A 4xx does not burn your Idempotency-Key; a 5xx is safe to retry
Requests that fail with a 4xx do not consume the idempotency key: fix the body and retry with the same key. 5xx failures are also safe to retry with the same key — that is exactly the situation it exists for.
What you must not do is generate a fresh key when retrying a timeout: that is where duplicate facturas (invoices) are born.
The machine-readable dictionary
curl https://api.facturia.cl/v1/error_codesNo authentication. It returns every Facturia code, its SII counterpart and whether retrying is worth it. Same content as Rejection codes, in a shape your build can consume.