# Delivery, retries and replay (/en/docs/webhooks/entrega)

## The delivery contract [#the-delivery-contract]

* Answer `2xx` within **10 seconds**. Anything else — a non-2xx status, a timeout, a TLS failure —
  counts as a failure. Do your work asynchronously: acknowledge first.
* **At-least-once delivery, with no guaranteed order.** Events can arrive twice and out of order.
  Deduplicate on `id` (`evt_…`) and ignore any event whose `data.object` is older than the one you
  already hold. `document.accepted` arriving before `document.sent` is normal, and it cannot be
  allowed to break you.

## The retry schedule [#the-retry-schedule]

Immediately, and then **30 s, 2 min, 10 min, 1 h, 3 h, 6 h, 12 h, 24 h** — 9 attempts across about 48
hours, with jitter.

The jitter is there so that an outage on your side does not produce a synchronised stampede when you
come back.

> **Trap — After 5 days of total failure, the endpoint disables itself**
>
> After **5 consecutive days** of total failure, the endpoint moves to `status: disabled`, we send an
> email to the account, and we emit `webhook_endpoint.disabled`.
>
> A disabled endpoint does not accumulate retries: the events stay in the log, but nobody is pushing
> them at you. Re-enabling it does not automatically resend what you missed — that is what replay is
> for.

## Inspect and retry by hand [#inspect-and-retry-by-hand]

```bash
# Qué intentamos entregar, y cómo fue
curl -G https://api.facturia.cl/v1/webhook_endpoints/whe_01K2RF5N8Q2W/deliveries \
  -H "Authorization: Bearer $FACTURIA_KEY" -d status=failed

# Reintentar una entrega puntual
curl -X POST https://api.facturia.cl/v1/webhook_endpoints/whe_01K2RF5N8Q2W/deliveries/whd_01K2RG1P4M7X01/retry \
  -H "Authorization: Bearer $FACTURIA_KEY"
```

## The event log [#the-event-log]

```bash
curl -G https://api.facturia.cl/v1/events \
  -H "Authorization: Bearer $FACTURIA_KEY" \
  -d "type=document.accepted" -d "created_at[gte]=2026-08-14T00:00:00Z"

curl https://api.facturia.cl/v1/events/evt_01K2RG1P4M7X \
  -H "Authorization: Bearer $FACTURIA_KEY"
```

### Polling instead of webhooks [#polling-instead-of-webhooks]

If you cannot expose an HTTPS endpoint — a personal agent, a laptop, anything behind NAT —
`GET /v1/events?after=` is the supported substitute, not a workaround:

```bash
curl -G https://api.facturia.cl/v1/events \
  -H "Authorization: Bearer $FACTURIA_KEY" \
  -d "after=evt_01K2RG1P4M7X" -d "limit=100"
```

`after` pages **forwards**: oldest first, strictly newer than the id you name, with `next_cursor`
advancing away from it. The loop is `after = next_cursor ?? after`, processing `data` in order; it
never re-reads a row and needs no set of seen ids. `after` accepts both an `evt_…` id (the last event
you processed) and a `cur_…` value from `next_cursor`, so your stored checkpoint and the token you
are handed are interchangeable. When `has_more` is `false`, `next_cursor` is `null` — keep the `id`
of the last event you processed and send that next time.

`cursor` is the opposite direction and the default: newest first, "older than this", which is what a
dashboard reading the ledger backwards wants. Sending both is `400` rather than a silent
precedence — they name positions in opposite orders, and honouring either would skip rows for half
the callers with nothing to tell them so.

> **12 months of retention, on purpose**
>
> Events are retained for **12 months** — deliberately longer than the usual 30-to-90-day webhook log,
> because in this domain the unit of work is a tax year.
>
> A discrepancy that surfaces while closing a period in March needs the previous July's events to still
> be there, and rebuilding a full F29 cycle — the monthly Chilean VAT return — out of the log is a
> supported flow, not an emergency.
>
> If your consumer was down for a week, replay instead of reconciling by hand.

## A well-behaved consumer [#a-well-behaved-consumer]

```js
const procesados = new Set(); // en producción, una tabla con índice único

export async function manejar(evento) {
  if (procesados.has(evento.id)) return; // al menos una vez

  switch (evento.type) {
    case 'document.accepted':
      await marcarAceptado(evento.data.object);
      break;
    case 'document.rejected':
    case 'document.reparo':
      await avisarAOperaciones(evento.data.object);
      break;
    case 'received_document.deadline_approaching':
      await avisarPlazo(evento.data.object);
      break;
    default:
      // Tipos de evento nuevos aparecen sin previo aviso: ignóralos, no lances.
      break;
  }

  procesados.add(evento.id);
}
```

That `default` branch is the only compatibility rule we ask of you. Inside `/v1` we evolve additively,
and a client that falls over on an unknown event type will fall over.
