# The error envelope (/en/docs/errores)

An error is an HTTP status plus a single `error` object. There is no success envelope: successful
responses **are** the resource.

```json
{
  "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 [#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 [#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](/en/docs/errores/codigos)                                 |
| `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 [#el-402]

```json
{
  "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/documents`
* `POST /v1/documents/{document_id}/credit_note`
* `POST /v1/documents/{document_id}/send`
* `POST /v1/folios/requests`
* `POST /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&#x60;: **`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`](/en/docs/errores/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`](/en/docs/errores/plan_limit_reached).

## The `request_id` [#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 [#handling-errors-in-practice]

```js
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 [#the-machine-readable-dictionary]

```bash
curl https://api.facturia.cl/v1/error_codes
```

No authentication. It returns every Facturia code, its SII counterpart and whether retrying is worth
it. Same content as [Rejection codes](/en/docs/errores/codigos), in a shape your build can consume.
