Facturiadocs
Webhooks

Delivery, retries and replay

The jittered retry schedule, the auto-disable after 5 days, and why the event log lives for 12 months.

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

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

# 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

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

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:

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

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.

On this page