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
2xxwithin 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 whosedata.objectis older than the one you already hold.document.acceptedarriving beforedocument.sentis 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.