Deduplicar lliuraments de webhooks
El mateix esdeveniment conserva el seu id al sobre d'empresa i al de compte: com desar-lo i descartar repeticions.
El lliurament de webhooks és almenys una vegada, no exactament una. Si el teu servidor respon amb un error, o respon tard o no respon, tornem a enviar l'esdeveniment, fins i tot quan el teu servidor ja havia fet la feina. I si tens alhora un endpoint d'empresa i un endpoint de compte que admeten el mateix esdeveniment d'un NIF, cadascun rep la seva pròpia còpia. En tots els casos la còpia porta el mateix id, i això és el que permet al teu receptor distingir una repetició d'un esdeveniment nou.
Aquesta guia explica d'on surt l'id i què el manté igual, què el repeteix i què no, com es veu un esdeveniment de factura en un endpoint d'empresa i en un de compte, en què es distingeix un esdeveniment account.* i una recepta per al teu receptor amb el temps que has de desar els ids.
Un esdeveniment, un id
Tres llocs porten l'identificador de l'esdeveniment, sempre amb el mateix valor:
| On | Què porta |
|---|---|
id al cos | L'identificador de l'esdeveniment (UUID v7). |
Capçalera Factuarea-Event-Id | El mateix valor. |
Capçalera Idempotency-Key | El mateix valor una altra vegada, perquè un sistema que ja deduplica amb aquesta capçalera estàndard ho pugui fer sense llegir el cos. |
La Idempotency-Key d'un lliurament és una capçalera que t'enviem nosaltres. No té relació amb la Idempotency-Key que tu envies en les teves pròpies escriptures a l'API, que explica Idempotència.
L'id s'assigna una sola vegada, quan es registra l'esdeveniment, i mai no es torna a generar. Un esdeveniment d'empresa conserva l'id que té al registre d'esdeveniments (GET /v1/companies/{company}/events) a cada sobre en què viatja, el d'empresa i el de compte. Un esdeveniment account.* té el seu propi id, que rep quan es registra l'esdeveniment.
Quatre valors semblen claus i no ho són:
Factuarea-Delivery-Ididentifica el lliurament, que és un esdeveniment enviat a un endpoint, i no l'esdeveniment. Dos endpoints en tenen sempre dos de diferents.Factuarea-Signatureporta la marca de temps de cada intent, de manera que canvia a cada intent del mateix lliurament.correlation_idenllaça els esdeveniments causats per una mateixa operació, de manera que diversos esdeveniments el poden compartir.data.object.idés l'id del recurs, com la factura, i una factura produeix molts esdeveniments.
Dos esdeveniments diferents sobre la mateixa factura, com invoice.created i invoice.sent, tenen dos ids diferents. Deduplicar per id mai no els fusiona.
Què torna a enviar el mateix id
L'id i Factuarea-Event-Id són iguals a totes les files d'aquesta taula. El que canvia és el lliurament.
| Què passa | El cos | Factuarea-Delivery-Id |
|---|---|---|
| Un reintent automàtic d'un lliurament, en qualsevol endpoint | El mateix | El mateix |
POST …/deliveries/{delivery}/replay en un endpoint d'empresa | El mateix | Un de nou: un replay crea un altre lliurament |
POST …/deliveries/{delivery}/retry en un endpoint de compte | El mateix | El mateix: un retry rearma aquest lliurament |
| Un altre endpoint subscrit a l'esdeveniment, d'empresa o de compte | El mateix esdeveniment, en la versió de payload d'aquest endpoint, més account, account_relationship i company_id en un endpoint de compte | Un de diferent per a cada endpoint |
Reintentem quan el teu servidor respon 5xx, 408, 425 o 429, o no respon a temps. Qualsevol 2xx tanca el lliurament com a delivered. Qualsevol altre 4xx, i una adreça a la qual ens neguem a cridar per motius de seguretat, el tanquen com a failed_permanently sense reintent.
El calendari és el mateix als endpoints d'empresa i als de compte: 8 intents en total, amb una espera d'1 minut, 5 minuts, 30 minuts, 2 hores, 12 hores, 1 dia i 3 dies després de cada error, i cada espera varia fins a un 10 % en un sentit o en l'altre. Després del vuitè, el lliurament queda failed_permanently.
Com a màxim un lliurament per endpoint i esdeveniment
En un endpoint de compte hi ha com a màxim un lliurament per a cada esdeveniment: el parell d'endpoint i esdeveniment és únic. Si un esdeveniment arriba dues vegades al nostre motor de lliuraments, la segona vegada troba el lliurament ja creat i no en crea cap, i un retry manual rearma aquest lliurament en lloc d'afegir-ne un altre.
En un endpoint d'empresa el lliurament es crea quan es registra l'esdeveniment, un per a cada endpoint subscrit, i un replay en crea un altre quan el demanes, amb el cos desat i el mateix id.
En qualsevol cas, la garantia és per endpoint. No protegeix un receptor al qual s'arriba des de dos endpoints, i allà l'id és l'única cosa que distingeix una repetició d'un esdeveniment nou.
El mateix esdeveniment en dos endpoints
Un esdeveniment d'empresa sobre un NIF arriba als endpoints d'empresa d'aquest NIF que se subscriuen al seu tipus. Si el NIF pertany a un compte amb endpoints de compte, arriba també als que l'admeten. I quan el NIF pertany a un compte gestionat, arriba com a esdeveniment managed als endpoints del compte que el va aprovisionar, quan l'admeten. És un sol esdeveniment, registrat una vegada, de manera que totes les còpies porten el mateix id.
Una factura als dos endpoints
El teu compte té un NIF. Tens un endpoint d'empresa en aquest NIF i un endpoint de compte al compte, i tots dos se subscriuen a invoice.sent. S'emet la factura, cosa que la passa de draft a sent, i surten dos lliuraments.
El de l'endpoint d'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"
}
}
}El de l'endpoint de compte:
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à abreujada a sis dels seus camps als dos cossos. La real és la factura sencera, i és la mateixa als dos. Compara els dos lliuraments:
- Igual:
id,type,created,livemode,test,correlation_id,data,Factuarea-Event-Id,Factuarea-Event-TypeiIdempotency-Key. - Diferent:
Factuarea-Delivery-IdiFactuarea-Signature, perquè cada endpoint té el seu propi lliurament i el seu propi secret. El lliurament de compte afegeixaccount,account_relationshipicompany_idal cos iFactuarea-Account-Relationshipa les capçaleres. - Pot diferir:
api_versioni la forma dedata, quan els dos endpoints estan fixats a versions de payload diferents. L'idmai.
Un lliurament de prova (test: true) que envies a un endpoint d'empresa amb test-event no es reenvia als endpoints de compte. I cada lliurament de prova, d'empresa o de compte, té un id nou propi, de manera que enviar dues proves mai no sembla una repetició.
Els esdeveniments account.* no tenen bessó d'empresa
Els esdeveniments account.* existeixen només als endpoints de compte. El seu id es dona quan es registra l'esdeveniment de compte i cap sobre d'empresa el porta, de manera que no hi ha res amb què comparar-lo en un lliurament d'empresa. La recepta d'aquí sota funciona amb ells sense canvis.
Dos lliuraments amb el mateix id d'un esdeveniment account.* vénen d'un reintent, d'un retry manual o de dos endpoints de compte teus subscrits al mateix tipus. Mai no vénen d'un endpoint d'empresa. Als esdeveniments account.* que no tracten d'un NIF, company_id és null.
Descarta repeticions al teu receptor
Verifica primer la signatura.
Un cos que no has verificat no és un esdeveniment. Verifica'l com explica Webhooks, o amb el verificador de webhooks dels SDKs, que retorna l'esdeveniment decodificat. Només llavors mira l'id.
Desa l'id i fes la feina en una sola transacció.
La taula té una clau única sobre l'id. Una repetició troba la clau i no fa res. Si la feina falla, la transacció es desfà i l'id no queda desat, de manera que el reintent següent es processa.
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 '', 200L'insert que no fa res quan la clau existeix és INSERT IGNORE a MySQL i ON CONFLICT DO NOTHING a PostgreSQL i SQLite. Comprovar primer i inserir després no n'hi ha prou: les dues còpies d'un esdeveniment surten del nostre costat amb pocs instants de diferència i poden arribar al teu handler alhora, i només la clau única resol aquesta cursa.
Respon 2xx a una repetició.
Una repetició no és un error. Respon 200 sense tornar a fer la feina. Un 4xx diferent de 408, 425 o 429 tanca el lliurament com a failed_permanently i no el tornem a intentar.
Esborra els ids més antics que la teva finestra.
N'hi ha prou amb una tasca diària. La finestra és el que calcula la secció següent.
DELETE FROM processed_webhook_events WHERE received_at < ?;El paràmetre és el moment situat tants dies enrere com dura la teva finestra.
Si diversos sistemes teus consumeixen els esdeveniments, cadascun desa la seva pròpia taula: l'id pertany a l'esdeveniment, no al receptor.
Amb una clau fact_test_ no veuràs cap repetició. Un endpoint de compte de l'entorn test registra els seus lliuraments i no els envia mai: queden pending i els llegeixes amb GET …/deliveries. Provar comptes gestionats explica aquest camí.
Quant de temps desar els ids
Un id pot tornar mentre es pugui enviar encara un lliurament del seu esdeveniment. Tres coses el poden enviar:
Què pot tornar a enviar l'id | Fins quan |
|---|---|
| Els reintents automàtics | Uns 5 dies i 2 hores després del primer intent |
Un replay d'un lliurament d'empresa | Mentre l'esdeveniment estigui desat: 30 dies després de l'esdeveniment |
Un retry d'un lliurament de compte | Mentre el lliurament estigui desat: 90 dies després de crear-se |
La primera fila surt del calendari d'aquí dalt. Les esperes sumen 4 dies i 14 hores, i la variació del 10 % a cada espera ho allarga fins a 5 dies i 2 hores com a màxim. Només es pot tornar a enviar a mà, amb replay o retry, un lliurament que va acabar en failed o failed_permanently.
Desa els ids almenys 7 dies. Cobreix tots els reintents automàtics amb marge. Si tu o el teu equip reenvieu lliuraments a mà, desa'ls 90 dies, el termini més llarg dels tres. Un id són 36 caràcters, de manera que desar-los 90 dies surt barat.
L'historial de cada endpoint t'ajuda a contrastar la teva taula amb la nostra: cada lliurament de …/deliveries en un endpoint d'empresa i de …/deliveries en un endpoint de compte porta un event_id, que és l'id del seu esdeveniment.
Relacionat
endpoints d'empresa, la signatura i la seva verificació, els reintents i la rotació del secret.
l'endpoint que rep els esdeveniments del teu compte i dels seus NIF, i el catàleg account.*.
el registre d'esdeveniments i el catàleg de tipus d'esdeveniment d'empresa.
la Idempotency-Key que envies en les teves escriptures a l'API.