Deduplicate webhook deliveries
The same event keeps the same id in the company and the account envelope: how to store it and skip repeats.
Webhook delivery is at least once, not exactly once. If your server answers with an error, or answers too late or not at all, we send the event again, even when your server had already done the work. And if you hold both a company endpoint and an account endpoint that admit the same event of a tax ID, each of them gets its own copy. In every case the copy carries the same id, and that is what lets your receiver tell a repeat from a new event.
This guide explains where the id comes from and what keeps it the same, what repeats it and what does not, how one invoice event looks at a company endpoint and at an account endpoint, how an account.* event differs, and a recipe for your receiver with the time you should keep the ids.
One event, one id
Three places carry the identifier of the event, always with the same value:
| Where | What it carries |
|---|---|
id in the body | The identifier of the event (UUID v7). |
Factuarea-Event-Id header | The same value. |
Idempotency-Key header | The same value again, so that a stack that already deduplicates on this standard header can do it without reading the body. |
The Idempotency-Key of a delivery is a header that we send to you. It has nothing to do with the Idempotency-Key that you send on your own writes to the API, which Idempotency explains.
The id is assigned once, when the event is recorded, and it is never generated again. A company event keeps the id it has in the event log (GET /v1/companies/{company}/events) in every envelope it travels in, the company one and the account one. An account.* event has its own id, given when the event is recorded.
Four values look like keys and are not:
Factuarea-Delivery-Ididentifies the delivery, which is one event sent to one endpoint, and not the event. Two endpoints always have two different ones.Factuarea-Signaturecarries the timestamp of each attempt, so it changes on every attempt of the same delivery.correlation_idlinks the events caused by one operation, so several events can share it.data.object.idis the id of the resource, such as the invoice, and one invoice produces many events.
Two different events about the same invoice, such as invoice.created and invoice.sent, have two different ids. Deduplicating on id never merges them.
What sends the same id again
The id and Factuarea-Event-Id are the same in every row of this table. What changes is the delivery.
| What happens | The body | Factuarea-Delivery-Id |
|---|---|---|
| An automatic retry of a delivery, on any endpoint | The same | The same |
POST …/deliveries/{delivery}/replay on a company endpoint | The same | A new one: a replay creates another delivery |
POST …/deliveries/{delivery}/retry on an account endpoint | The same | The same: a retry re-arms that delivery |
| Another endpoint subscribed to the event, company or account | The same event, in the payload version of that endpoint, plus account, account_relationship and company_id on an account endpoint | A different one for each endpoint |
We retry when your server answers 5xx, 408, 425 or 429, or does not answer in time. Any 2xx closes the delivery as delivered. Any other 4xx, and an address that we refuse to call for security reasons, close it as failed_permanently with no retry.
The calendar is the same on company and on account endpoints: 8 attempts in all, with a wait of 1 minute, 5 minutes, 30 minutes, 2 hours, 12 hours, 1 day and 3 days after each failure, each wait varied up to 10 % either way. After the eighth the delivery is failed_permanently.
At most one delivery per endpoint and event
On an account endpoint there is at most one delivery for each event: the pair of endpoint and event is unique. If an event reaches our delivery engine twice, the second time finds the delivery already there and creates nothing, and a manual retry re-arms that delivery instead of adding one.
On a company endpoint the delivery is created when the event is recorded, one for each subscribed endpoint, and a replay creates another one when you ask for it, with the stored body and the same id.
Either way, the guarantee is per endpoint. It does not protect a receiver that is reached from two endpoints, and there the id is the only thing that tells a repeat from a new event.
The same event at two endpoints
A company event about a tax ID goes to the company endpoints of that tax ID that subscribe to its type. If the tax ID belongs to an account with account endpoints, it also goes to the ones that admit it. And when the tax ID belongs to a managed account, it goes as a managed event to the endpoints of the account that provisioned it, when they admit it. It is one event, recorded once, so every copy carries the same id.
An invoice at both endpoints
Your account holds a tax ID. You have a company endpoint on that tax ID and an account endpoint on the account, and both subscribe to invoice.sent. The invoice is issued, which moves it from draft to sent, and two deliveries leave.
The one for the company endpoint:
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"
}
}
}The one for the account endpoint:
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"
}
}
}The invoice under data.object is shortened to six of its fields in both bodies. The real one is the whole invoice, and it is the same in both. Compare the two deliveries:
- The same:
id,type,created,livemode,test,correlation_id,data,Factuarea-Event-Id,Factuarea-Event-TypeandIdempotency-Key. - Different:
Factuarea-Delivery-IdandFactuarea-Signature, because each endpoint has its own delivery and its own secret. The account delivery addsaccount,account_relationshipandcompany_idto the body andFactuarea-Account-Relationshipto the headers. - It can differ:
api_versionand the shape ofdata, when the two endpoints are pinned to different payload versions. Theidnever does.
A test delivery (test: true) that you send to a company endpoint with test-event is not forwarded to account endpoints. And every test delivery, company or account, has a new id of its own, so sending two tests never looks like a repeat.
account.* events have no company twin
The account.* events exist only on account endpoints. Their id is given when the account event is recorded and no company envelope carries it, so there is nothing to match against a company delivery. The recipe below works on them without a change.
Two deliveries with the same id of an account.* event come from a retry, from a manual retry or from two account endpoints of yours that subscribe to the same type. They never come from a company endpoint. In the account.* events that are not about a tax ID, company_id is null.
Skip repeats in your receiver
Verify the signature first.
A body that you have not verified is not an event. Verify it as Webhooks explains, or with the webhook verifier of the SDKs, which returns the decoded event. Only then look at the id.
Record the id and do the work in one transaction.
The table has a unique key on the id. A repeat finds the key and does nothing. If the work fails, the transaction rolls back and the id is not recorded, so the next retry is processed.
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 '', 200The insert that does nothing when the key exists is INSERT IGNORE in MySQL and ON CONFLICT DO NOTHING in PostgreSQL and SQLite. Checking first and inserting afterwards is not enough: the two copies of one event leave our side within moments of each other and can reach your handler at the same time, and only the unique key settles that race.
Answer 2xx to a repeat.
A repeat is not an error. Answer 200 without doing the work again. A 4xx other than 408, 425 or 429 closes the delivery as failed_permanently and we do not try it again.
Delete the ids that are older than your window.
A daily job is enough. The window is what the next section works out.
DELETE FROM processed_webhook_events WHERE received_at < ?;The parameter is the moment that is as many days ago as your window.
If several systems of yours consume the events, each one keeps its own table: the id belongs to the event, not to the receiver.
With a fact_test_ key you will not see a repeat. An account endpoint of the test environment records its deliveries and never sends them: they stay pending and you read them with GET …/deliveries. Test managed accounts walks through that path.
How long to keep the ids
An id can come back for as long as a delivery of its event can still be sent. Three things can send it:
What can send the id again | Until when |
|---|---|
| The automatic retries | About 5 days and 2 hours after the first attempt |
A replay of a company delivery | While the event is stored: 30 days after the event |
A retry of an account delivery | While the delivery is stored: 90 days after it was created |
The first row comes from the calendar above. The waits add up to 4 days and 14 hours, and the 10 % variation on each wait stretches it to 5 days and 2 hours at most. Only a delivery that ended failed or failed_permanently can be sent again by hand, with replay or retry.
Keep the ids for at least 7 days. That covers every automatic retry with room to spare. If you or your team re-send deliveries by hand, keep them 90 days, the longest of the three. An id is 36 characters, so keeping them for 90 days is cheap.
The history of each endpoint helps you check your table against ours: every delivery of …/deliveries on a company endpoint and of …/deliveries on an account endpoint carries an event_id, which is the id of its event.
Related
company endpoints, the signature and its verification, retries and secret rotation.
the endpoint that receives the events of your account and of its tax IDs, and the account.* catalog.
the event log and the catalog of company event types.
the Idempotency-Key that you send on your writes to the API.