Deduplicar entregas de webhooks
El mismo evento conserva su id en el sobre de empresa y en el de cuenta: cómo guardarlo y descartar repeticiones.
La entrega de webhooks es al menos una vez, no exactamente una. Si tu servidor responde con un error, o responde tarde o no responde, volvemos a enviar el evento, incluso cuando tu servidor ya había hecho el trabajo. Y si tienes a la vez un endpoint de empresa y un endpoint de cuenta que admiten el mismo evento de un NIF, cada uno recibe su propia copia. En todos los casos la copia lleva el mismo id, y eso es lo que permite a tu receptor distinguir una repetición de un evento nuevo.
Esta guía explica de dónde sale el id y qué lo mantiene igual, qué lo repite y qué no, cómo se ve un evento de factura en un endpoint de empresa y en uno de cuenta, en qué se distingue un evento account.* y una receta para tu receptor con el tiempo que debes guardar los ids.
Un evento, un id
Tres sitios llevan el identificador del evento, siempre con el mismo valor:
| Dónde | Qué lleva |
|---|---|
id en el cuerpo | El identificador del evento (UUID v7). |
Cabecera Factuarea-Event-Id | El mismo valor. |
Cabecera Idempotency-Key | El mismo valor otra vez, para que un sistema que ya deduplica con esta cabecera estándar pueda hacerlo sin leer el cuerpo. |
La Idempotency-Key de una entrega es una cabecera que te enviamos nosotros. No tiene relación con la Idempotency-Key que tú envías en tus propias escrituras a la API, que explica Idempotencia.
El id se asigna una sola vez, cuando se registra el evento, y nunca se vuelve a generar. Un evento de empresa conserva el id que tiene en el registro de eventos (GET /v1/companies/{company}/events) en cada sobre en el que viaja, el de empresa y el de cuenta. Un evento account.* tiene su propio id, que recibe cuando se registra el evento.
Cuatro valores parecen claves y no lo son:
Factuarea-Delivery-Ididentifica la entrega, que es un evento enviado a un endpoint, y no el evento. Dos endpoints tienen siempre dos distintos.Factuarea-Signaturelleva la marca de tiempo de cada intento, así que cambia en cada intento de la misma entrega.correlation_idenlaza los eventos causados por una misma operación, así que varios eventos pueden compartirlo.data.object.ides el id del recurso, como la factura, y una factura produce muchos eventos.
Dos eventos distintos sobre la misma factura, como invoice.created e invoice.sent, tienen dos ids distintos. Deduplicar por id nunca los fusiona.
Qué vuelve a enviar el mismo id
El id y Factuarea-Event-Id son iguales en todas las filas de esta tabla. Lo que cambia es la entrega.
| Qué pasa | El cuerpo | Factuarea-Delivery-Id |
|---|---|---|
| Un reintento automático de una entrega, en cualquier endpoint | El mismo | El mismo |
POST …/deliveries/{delivery}/replay en un endpoint de empresa | El mismo | Uno nuevo: un replay crea otra entrega |
POST …/deliveries/{delivery}/retry en un endpoint de cuenta | El mismo | El mismo: un retry rearma esa entrega |
| Otro endpoint suscrito al evento, de empresa o de cuenta | El mismo evento, en la versión de payload de ese endpoint, más account, account_relationship y company_id en un endpoint de cuenta | Uno distinto para cada endpoint |
Reintentamos cuando tu servidor responde 5xx, 408, 425 o 429, o no responde a tiempo. Cualquier 2xx cierra la entrega como delivered. Cualquier otro 4xx, y una dirección a la que nos negamos a llamar por motivos de seguridad, la cierran como failed_permanently sin reintento.
El calendario es el mismo en los endpoints de empresa y en los de cuenta: 8 intentos en total, con una espera de 1 minuto, 5 minutos, 30 minutos, 2 horas, 12 horas, 1 día y 3 días tras cada fallo, y cada espera varía hasta un 10 % en uno u otro sentido. Tras el octavo, la entrega queda failed_permanently.
Como mucho una entrega por endpoint y evento
En un endpoint de cuenta hay como mucho una entrega por cada evento: el par de endpoint y evento es único. Si un evento llega dos veces a nuestro motor de entregas, la segunda vez encuentra la entrega ya creada y no crea nada, y un retry manual rearma esa entrega en lugar de añadir otra.
En un endpoint de empresa la entrega se crea cuando se registra el evento, una por cada endpoint suscrito, y un replay crea otra cuando la pides, con el cuerpo guardado y el mismo id.
En cualquier caso, la garantía es por endpoint. No protege a un receptor al que se llega desde dos endpoints, y ahí el id es lo único que distingue una repetición de un evento nuevo.
El mismo evento en dos endpoints
Un evento de empresa sobre un NIF llega a los endpoints de empresa de ese NIF que se suscriben a su tipo. Si el NIF pertenece a una cuenta con endpoints de cuenta, llega también a los que lo admiten. Y cuando el NIF pertenece a una cuenta gestionada, llega como evento managed a los endpoints de la cuenta que la aprovisionó, cuando lo admiten. Es un solo evento, registrado una vez, así que todas las copias llevan el mismo id.
Una factura en los dos endpoints
Tu cuenta tiene un NIF. Tienes un endpoint de empresa en ese NIF y un endpoint de cuenta en la cuenta, y los dos se suscriben a invoice.sent. Se emite la factura, lo que la pasa de draft a sent, y salen dos entregas.
La del endpoint de empresa:
Factuarea-Signature: t=1790590562,v1=062bd80e4c4a80332fb8da16a7029a5306ce830f5eac8a2fe6753f7108242be8
Factuarea-Event-Id: 01a0e783-aae8-7c2d-9f15-2b7d4e6a8c31
Factuarea-Event-Type: invoice.sent
Factuarea-Delivery-Id: 01a0e783-abb0-7e19-a2c4-3d8f5b7a9e12
Idempotency-Key: 01a0e783-aae8-7c2d-9f15-2b7d4e6a8c31{
"id": "01a0e783-aae8-7c2d-9f15-2b7d4e6a8c31",
"type": "invoice.sent",
"api_version": "2026-09-23",
"created": 1790590561,
"livemode": true,
"test": false,
"correlation_id": "01a0e783-aa84-7b48-8c63-1a5e9d2f4b70",
"data": {
"type": "invoice.sent",
"object": {
"object": "invoice",
"id": "01931b3e-7c4a-7f2e-9a8b-3c5d6e7f8a42",
"number": "FAC-2026-00042",
"status": "sent",
"total": "1210.00",
"currency": "EUR"
}
}
}La del endpoint de cuenta:
Factuarea-Signature: t=1790590562,v1=686880414ba2f9fc4735066a9b1e26c0ce8caa15567938dce5d3e3a2e3908dcb
Factuarea-Event-Id: 01a0e783-aae8-7c2d-9f15-2b7d4e6a8c31
Factuarea-Event-Type: invoice.sent
Factuarea-Delivery-Id: 01a0e783-abe2-7a3f-b5d8-4e9a6c1b7d23
Idempotency-Key: 01a0e783-aae8-7c2d-9f15-2b7d4e6a8c31
Factuarea-Account-Relationship: own{
"id": "01a0e783-aae8-7c2d-9f15-2b7d4e6a8c31",
"type": "invoice.sent",
"api_version": "2026-09-23",
"created": 1790590561,
"livemode": true,
"test": false,
"correlation_id": "01a0e783-aa84-7b48-8c63-1a5e9d2f4b70",
"account": { "id": "01931b3e-7c4a-7f2e-9a8b-3c5d6e7f8a00" },
"account_relationship": "own",
"company_id": "01931b3e-7c4a-7f2e-9a8b-3c5d6e7f8a01",
"data": {
"type": "invoice.sent",
"object": {
"object": "invoice",
"id": "01931b3e-7c4a-7f2e-9a8b-3c5d6e7f8a42",
"number": "FAC-2026-00042",
"status": "sent",
"total": "1210.00",
"currency": "EUR"
}
}
}La factura de data.object está abreviada a seis de sus campos en los dos cuerpos. La real es la factura entera, y es la misma en los dos. Compara las dos entregas:
- Igual:
id,type,created,livemode,test,correlation_id,data,Factuarea-Event-Id,Factuarea-Event-TypeeIdempotency-Key. - Distinto:
Factuarea-Delivery-IdyFactuarea-Signature, porque cada endpoint tiene su propia entrega y su propio secret. La entrega de cuenta añadeaccount,account_relationshipycompany_idal cuerpo yFactuarea-Account-Relationshipa las cabeceras. - Puede diferir:
api_versiony la forma dedata, cuando los dos endpoints están fijados a versiones de payload distintas. Elidnunca.
Una entrega de prueba (test: true) que envías a un endpoint de empresa con test-event no se reenvía a los endpoints de cuenta. Y cada entrega de prueba, de empresa o de cuenta, tiene un id nuevo propio, así que enviar dos pruebas nunca parece una repetición.
Los eventos account.* no tienen gemelo de empresa
Los eventos account.* existen solo en los endpoints de cuenta. Su id se da cuando se registra el evento de cuenta y ningún sobre de empresa lo lleva, así que no hay nada con lo que compararlo en una entrega de empresa. La receta de abajo funciona con ellos sin cambios.
Dos entregas con el mismo id de un evento account.* vienen de un reintento, de un retry manual o de dos endpoints de cuenta tuyos suscritos al mismo tipo. Nunca vienen de un endpoint de empresa. En los eventos account.* que no tratan de un NIF, company_id es null.
Descarta repeticiones en tu receptor
Verifica primero la firma.
Un cuerpo que no has verificado no es un evento. Verifícalo como explica Webhooks, o con el verificador de webhooks de los SDKs, que devuelve el evento decodificado. Solo entonces mira el id.
Guarda el id y haz el trabajo en una sola transacción.
La tabla tiene una clave única sobre el id. Una repetición encuentra la clave y no hace nada. Si el trabajo falla, la transacción se deshace y el id no queda guardado, así que el siguiente reintento se procesa.
CREATE TABLE processed_webhook_events (
event_id CHAR(36) NOT NULL PRIMARY KEY,
received_at TIMESTAMP NOT NULL
);
CREATE INDEX processed_webhook_events_received_at ON processed_webhook_events (received_at);$payload = file_get_contents('php://input');
$header = $_SERVER['HTTP_FACTUAREA_SIGNATURE'] ?? '';
if (! verifyFactuareaSignature($payload, $header, getenv('FACTUAREA_WEBHOOK_SECRET'))) {
http_response_code(401);
exit;
}
$event = json_decode($payload, true);
$pdo->beginTransaction();
try {
$insert = $pdo->prepare(
'INSERT IGNORE INTO processed_webhook_events (event_id, received_at) VALUES (?, NOW())'
);
$insert->execute([$event['id']]);
if ($insert->rowCount() === 1) {
handleEvent($pdo, $event);
}
$pdo->commit();
} catch (Throwable $e) {
$pdo->rollBack();
http_response_code(500);
exit;
}
http_response_code(200);app.post('/factuarea/webhook', express.raw({ type: 'application/json' }), async (req, res) => {
const payload = req.body.toString('utf8');
if (!verifySignature(payload, req.header('Factuarea-Signature'), process.env.WHSEC)) {
return res.status(401).end();
}
const event = JSON.parse(payload);
const client = await pool.connect();
try {
await client.query('BEGIN');
const { rowCount } = await client.query(
'INSERT INTO processed_webhook_events (event_id, received_at) VALUES ($1, now()) ON CONFLICT (event_id) DO NOTHING',
[event.id]
);
if (rowCount === 1) {
await handleEvent(client, event);
}
await client.query('COMMIT');
res.status(200).end();
} catch (error) {
await client.query('ROLLBACK');
res.status(500).end();
} finally {
client.release();
}
});import json
from flask import request, abort
@app.post('/factuarea/webhook')
def webhook():
payload = request.get_data()
if not verify(payload, request.headers.get('Factuarea-Signature', ''), WHSEC):
abort(401)
event = json.loads(payload)
with conn, conn.cursor() as cur:
cur.execute(
'INSERT INTO processed_webhook_events (event_id, received_at) VALUES (%s, now()) '
'ON CONFLICT (event_id) DO NOTHING',
(event['id'],),
)
if cur.rowcount == 1:
handle_event(cur, event)
return '', 200El insert que no hace nada cuando la clave existe es INSERT IGNORE en MySQL y ON CONFLICT DO NOTHING en PostgreSQL y SQLite. Comprobar primero e insertar después no basta: las dos copias de un evento salen de nuestro lado con pocos instantes de diferencia y pueden llegar a tu handler a la vez, y solo la clave única resuelve esa carrera.
Responde 2xx a una repetición.
Una repetición no es un error. Responde 200 sin volver a hacer el trabajo. Un 4xx distinto de 408, 425 o 429 cierra la entrega como failed_permanently y no la volvemos a intentar.
Borra los ids más antiguos que tu ventana.
Basta un trabajo diario. La ventana es lo que calcula la sección siguiente.
DELETE FROM processed_webhook_events WHERE received_at < ?;El parámetro es el momento situado tantos días atrás como dura tu ventana.
Si varios sistemas tuyos consumen los eventos, cada uno guarda su propia tabla: el id pertenece al evento, no al receptor.
Con una clave fact_test_ no verás ninguna repetición. Un endpoint de cuenta del entorno test registra sus entregas y no las envía nunca: quedan pending y las lees con GET …/deliveries. Probar cuentas gestionadas explica ese camino.
Cuánto tiempo guardar los ids
Un id puede volver mientras se pueda enviar todavía una entrega de su evento. Tres cosas pueden enviarlo:
Qué puede volver a enviar el id | Hasta cuándo |
|---|---|
| Los reintentos automáticos | Unos 5 días y 2 horas después del primer intento |
Un replay de una entrega de empresa | Mientras el evento esté guardado: 30 días después del evento |
Un retry de una entrega de cuenta | Mientras la entrega esté guardada: 90 días después de crearse |
La primera fila sale del calendario de arriba. Las esperas suman 4 días y 14 horas, y la variación del 10 % en cada espera lo estira hasta 5 días y 2 horas como máximo. Solo se puede volver a enviar a mano, con replay o retry, una entrega que acabó en failed o failed_permanently.
Guarda los ids al menos 7 días. Cubre todos los reintentos automáticos con margen. Si tú o tu equipo reenviáis entregas a mano, guárdalos 90 días, el plazo más largo de los tres. Un id son 36 caracteres, así que guardarlos 90 días sale barato.
El historial de cada endpoint te ayuda a contrastar tu tabla con la nuestra: cada entrega de …/deliveries en un endpoint de empresa y de …/deliveries en un endpoint de cuenta lleva un event_id, que es el id de su evento.
Relacionado
endpoints de empresa, la firma y su verificación, los reintentos y la rotación del secret.
el endpoint que recibe los eventos de tu cuenta y de sus NIF, y el catálogo account.*.
el registro de eventos y el catálogo de tipos de evento de empresa.
la Idempotency-Key que envías en tus escrituras a la API.