Idempotent event handling
You will sometimes receive the same event more than once. This usually isn't a problem, but it can be if your handler charges an account twice or sends the same email twice.
The fix is simple: dedupe using event.id, and make your handler safe to run
more than once.
Where duplicates come from
There are three common reasons.
Your 2xx response didn't reach us. Your handler processed the event, but
the response was lost. We retry it because we don't know the event was already
handled. This is the most common case.
Someone pressed replay. Cockpit can replay an old delivery. It creates a new
delivery for the same event, with the same payload and id. This is useful when
you're fixing a bug in your handler, so dedupe needs to use the event id, not
the delivery.
Two endpoints listen for the same event. Each endpoint gets its own delivery. If both endpoints point to the same service, your service will see the event twice.
Deduplicating
Every event has a unique id:
{
"id": "evt_01M480J2CTN4T8D9E8D2TBN1KC",
"type": "cardholder.created",
"created_at": "2026-10-06T07:06:30Z"
}
Store the id before doing the work:
CREATE TABLE processed_events (
event_id TEXT PRIMARY KEY,
processed_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
async function handle(event) {
const { rowCount } = await db.query(
'INSERT INTO processed_events (event_id) VALUES ($1) ON CONFLICT DO NOTHING',
[event.id],
);
if (rowCount === 0) return; // already handled
await applyEvent(event);
}
The insert acts as the lock. If two copies of the same event arrive at the same
time, only one insert succeeds. The other gets ON CONFLICT and stops.
Don't use these as the key:
- The object id. A card can have
card.created,card.activated, andcard.frozen. They all have the samecard_id, but they're different events. - A hash of the payload. Two different events can have the same payload. For example, two identical updates can still be two separate events.
- The delivery id. A replay creates a new delivery for the same event. You want to treat it as a duplicate.
Insert first, or work first?
Inserting the event id first has a downside. If the handler crashes halfway through, the event is already marked as handled. A retry will be ignored, even though the work wasn't finished.
If the work is a database change you control, put both the insert and the work in the same transaction:
await db.transaction(async (tx) => {
const { rowCount } = await tx.query(
'INSERT INTO processed_events (event_id) VALUES ($1) ON CONFLICT DO NOTHING',
[event.id],
);
if (rowCount === 0) return;
await applyEvent(tx, event);
});
If the handler crashes, both changes are rolled back. The retry can then run the event again.
This doesn't help with things the database can't roll back, like:
- charging a card
- sending an email
- calling another API
For those, put the event on a durable queue as part of the transaction. Then let the worker handle idempotency for the external operation, using whatever idempotency key that service supports.
Make the work idempotent too
Dedupe is useful, but don't rely on it completely. The table could be cleared, truncated during a migration, or a replay could happen much later.
Your handler should still be safe if the same event runs twice.
Usually, that means writing the state from the event instead of applying a change:
// Safe to run twice.
await db.query(
'UPDATE cards SET status = $1 WHERE id = $2',
[event.data.object.status, event.data.object.id],
);
// Not safe to run twice.
await db.query(
'UPDATE accounts SET balance = balance - $1 WHERE id = $2',
[event.data.object.amount, accountId],
);
data.object contains the full object, not just the changes. So for most
events, you can just save the state it reports. Running the same event again
won't change anything.
For things that really need to accumulate, store the event id with the row and add a unique constraint:
CREATE TABLE ledger_entries (
id BIGSERIAL PRIMARY KEY,
event_id TEXT NOT NULL UNIQUE,
amount BIGINT NOT NULL
);
If the same event tries to create the row again, the unique constraint catches it.
Combining this with ordering
Deduplication and ordering are two different problems.
A duplicate event should be dropped. An old event should also be dropped if it
would overwrite newer state. That old event won't be in processed_events
because you haven't seen it before.
Handle both:
async function handle(event) {
if (await alreadyProcessed(event.id)) return; // duplicate
if (await staleFor(event)) return; // out of order
await applyEvent(event);
}
The second check uses created_at from the event. See retries, backoff and
ordering.
Not the same as Idempotency-Key
The API's Idempotency-Key header is different.
That one prevents a request you send from being applied twice when you retry it.
Event deduplication prevents an event we send you from being applied twice.
They solve different problems and don't share any state. Having one doesn't give you the other.
How long to keep the ids
Automatic retries stop after eight attempts within 45 minutes, so keeping ids for 24 hours is enough to cover those retries.
Replays are different. Someone might replay an event from last month while debugging. If you only keep ids for one day, that event will be processed again.
For most systems, it's easiest to keep the ids indefinitely. One small row per event is usually cheap. You can clean them up later if the table gets too large.
Check it works
Pick a cardholder event in Cockpit's delivery list and replay it.
Your endpoint should return 200, and nothing in your database should change:
- no new row
- no updated timestamp
- no second email
If something changes, your handler isn't fully idempotent yet. The replay should help you find where.
Common mistakes
| Mistake | What happens |
|---|---|
| Checking for the id, then inserting it after the work | Two copies can both pass the check |
| Deduplicating on the object id | Real events for the same object get dropped |
| Deduplicating per delivery instead of per event | Replay gets processed again |
Relying only on dedupe with balance = balance - x | Clearing the dedupe table can cause a double debit |
Returning 4xx for a duplicate | The event gets retried. Return 200 instead |
Next
- Retries, backoff and ordering — where duplicates and out-of-order events come from.
- Testing locally — replay an event against your laptop.
- Card issuing events — every event type and its payload.