Your Webhook Handler Will Be Called Twice and You Should Plan For It
At-least-once delivery means duplicates are normal operation, not an edge case. Here is how to build a handler that survives them.
The short answer
Every webhook provider worth using guarantees at-least-once delivery, not exactly-once. Your handler will receive duplicates.
Three requirements for a handler that survives:
- Verify the signature before doing anything else
- Deduplicate on the provider's event id, stored with a unique constraint
- Respond 200 fast, then process asynchronously
app.post("/webhooks/stripe", async (req, res) => {
const event = verifySignature(req); // 1
const inserted = await claimEvent(event.id); // 2
res.status(200).send(); // 3
if (inserted) await queue.push(event);
});
Why duplicates happen
Not because the provider is broken. Because the alternative is worse.
The provider sends your webhook and waits for a 200. If it does not get one, it retries. Now consider: your handler processed the event successfully and then the response was lost, or your server timed out after doing the work, or a load balancer returned 502 while your handler completed fine.
In every case the provider cannot distinguish "you did not receive it" from "you received it and I did not hear back". Retrying risks a duplicate. Not retrying risks a lost event. Providers choose duplicates, because a lost payment notification is worse than a repeated one.
You will also get duplicates from your own retries, from a deploy that restarts mid-processing, and from a queue with at-least-once semantics behind the handler. The webhook is only the first place this happens.
Deduplicate with a database constraint, not a check
The wrong version, which is extremely common:
const seen = await db.events.findOne({ id: event.id });
if (seen) return;
await db.events.insert({ id: event.id });
await processEvent(event);
That is a read-then-write race. Two simultaneous deliveries both find nothing, both insert, both process. The window is small and it is exactly the window a retry storm lands in.
Let the database decide:
async function claimEvent(eventId, type) {
const res = await db.query(
`INSERT INTO webhook_events (id, type, received_at)
VALUES ($1, $2, now())
ON CONFLICT (id) DO NOTHING
RETURNING id`,
[eventId, type]
);
return res.rowCount === 1; // true = first time, false = duplicate
}
ON CONFLICT DO NOTHING with RETURNING gives you an atomic claim. Exactly one caller gets true, no matter how many arrive at once. This is the same pattern that fixes check-then-act races generally, and it is worth internalising because it comes up constantly.
Note that the claim and the processing are separate. If processing fails after claiming, the retry will be treated as a duplicate and skipped, which is wrong. Track state:
CREATE TABLE webhook_events (
id text PRIMARY KEY,
type text NOT NULL,
status text NOT NULL DEFAULT 'pending',
attempts int NOT NULL DEFAULT 0,
received_at timestamptz NOT NULL DEFAULT now(),
completed_at timestamptz
);
Claim sets pending. Successful processing sets completed. A retry for a pending event that is older than some threshold can be reprocessed, because the previous attempt evidently did not finish.
Verify the signature first
Before parsing, before logging, before anything.
import crypto from "node:crypto";
function verify(rawBody, header, secret) {
const expected = crypto
.createHmac("sha256", secret)
.update(rawBody)
.digest("hex");
const a = Buffer.from(expected);
const b = Buffer.from(header);
if (a.length !== b.length) return false;
return crypto.timingSafeEqual(a, b);
}
Three details that are frequently wrong.
Use the raw body. Signatures are computed over the exact bytes. If your framework parses JSON before you see it, re-serialising produces different bytes and verification fails. In Express you need the raw body specifically for the webhook route:
app.post("/webhooks/stripe",
express.raw({ type: "application/json" }),
handler
);
Mounting a global express.json() before your webhook route is the single most common cause of signature verification failing for no apparent reason.
Use a timing safe comparison. expected === received leaks information through timing. timingSafeEqual does not. This matters less than people think for HMAC and it costs nothing to do correctly.
Check the timestamp. Most providers include one in the signature header. Reject anything older than a few minutes, or a captured request can be replayed indefinitely.
Respond fast, process later
Providers have short timeouts, typically 5 to 30 seconds, and they count a slow response as a failure.
If your handler does real work inline, a slow database or a downstream API turns into a retry, which turns into a duplicate, which arrives while the first one is still running.
app.post("/webhooks/provider", raw, async (req, res) => {
let event;
try {
event = verify(req.body, req.get("X-Signature"), SECRET);
} catch {
return res.status(400).send("invalid signature");
}
const isNew = await claimEvent(event.id, event.type);
res.status(200).send(); // acknowledge immediately
if (isNew) {
queue.enqueue({ eventId: event.id, payload: event })
.catch(err => logger.error({ err, eventId: event.id }, "enqueue failed"));
}
});
The handler does two fast things: verify and claim. Everything else happens in a worker with its own retry policy, its own timeout, and its own error handling.
Return 200 even for events you do not care about. A 404 or 400 for an unrecognised event type causes the provider to retry it forever, and some providers disable an endpoint after enough failures.
Return non-2xx only when you want a retry. That is the signal's only meaning. Signature invalid is a 400 with no retry desired, which most providers respect. A transient internal failure before you claimed the event is a 500, because you do want the retry.
Ordering is not guaranteed
Even without duplicates, events can arrive out of order. subscription.updated can land before subscription.created.
Do not assume sequence. Two approaches:
Use the event's own timestamp and ignore anything older than what you have already applied for that object:
UPDATE subscriptions
SET status = $2, updated_at = $3
WHERE id = $1 AND updated_at < $3;
That makes the write idempotent and order-insensitive in one line.
Or fetch current state instead of trusting the payload. Treat the webhook as a notification that something changed, then call the provider's API for the authoritative current state. Slower, and much more robust, and it also handles the case where you missed an event entirely.
For anything financial I would default to the second.
Testing it
The failure paths here never run in normal development, which is why they break in production.
Send the same event twice in your test suite and assert the side effect happened once:
test("duplicate delivery is idempotent", async () => {
const event = fixtures.paymentSucceeded();
await post("/webhooks/provider", event);
await post("/webhooks/provider", event);
const charges = await db.charges.count({ eventId: event.id });
expect(charges).toBe(1);
});
Send them concurrently, which is the case the naive implementation fails:
await Promise.all([
post("/webhooks/provider", event),
post("/webhooks/provider", event),
]);
expect(await db.charges.count({ eventId: event.id })).toBe(1);
Send out of order. Created after updated. Assert the state is correct.
Send with a bad signature. Assert 400 and no side effects.
Four tests, and they cover the things that actually go wrong. Most webhook handlers I have reviewed have none of them, because the happy path is easy to test and the rest requires thinking about the protocol.
Operating them
Log every event with its id, including the duplicates you skip. When a customer says a payment did not apply, you need to know whether the event arrived.
Alert on the pending backlog. Events claimed but never completed mean your worker is failing silently. This is the absence-of-expected-events pattern and it is the one nobody sets up.
Keep a replay path. A worker that can reprocess a stored event by id turns an incident into a five minute fix. Storing the full payload at claim time makes this possible and costs almost nothing.
Expire old rows. The dedupe table grows forever otherwise. Ninety days is usually plenty, matching the longest retry window any provider uses.