Payment providers make one promise you can absolutely rely on: they will send you the same webhook more than once. Retries on timeouts, at-least-once delivery guarantees, manual replays from a dashboard — duplication isn't an edge case, it's the contract.
If your handler isn't idempotent, every duplicate is a potential double-charge, a double-provisioned subscription, or a corrupted ledger. When I integrated Lemon Squeezy billing for a SaaS platform with 8,000+ users, this was the first problem I designed for.
Step 1: Verify the signature before anything else
Never process a webhook you can't authenticate. Lemon Squeezy signs payloads with HMAC-SHA256:
import crypto from "node:crypto";
function verifySignature(rawBody: string, signature: string, secret: string) {
const digest = crypto
.createHmac("sha256", secret)
.update(rawBody)
.digest("hex");
return crypto.timingSafeEqual(
Buffer.from(digest, "hex"),
Buffer.from(signature, "hex")
);
}
Two details people get wrong:
- Use the raw request body, not the parsed JSON. Re-serializing the body changes whitespace and key order, and the signature won't match.
- Use
timingSafeEqual, not===. String comparison leaks timing information an attacker can exploit.
Step 2: Record the event ID inside a transaction
Every webhook event has a unique ID. Store it with a unique constraint, and let the database — not application logic — decide whether you've seen it before:
CREATE TABLE webhook_events (
id TEXT PRIMARY KEY, -- provider's event ID
event_type TEXT NOT NULL,
processed_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
await db.transaction(async (tx) => {
const inserted = await tx.query(
`INSERT INTO webhook_events (id, event_type)
VALUES ($1, $2)
ON CONFLICT (id) DO NOTHING
RETURNING id`,
[event.id, event.type]
);
if (inserted.rowCount === 0) {
return; // duplicate — already processed, exit quietly
}
await applyBusinessLogic(tx, event);
});
The critical property: the event-ID insert and the business logic commit atomically. If processing fails halfway, the transaction rolls back, the event ID is never recorded, and the provider's retry gets a clean second attempt.
Step 3: Return 200 fast, defer heavy work
Webhook endpoints should acknowledge quickly — providers time out slow handlers and retry, which creates more duplicates. Anything heavy goes to a BullMQ queue:
app.post("/webhooks/billing", async (req, res) => {
if (!verifySignature(req.rawBody, req.headers["x-signature"], secret)) {
return res.status(401).end();
}
await billingQueue.add("process-event", req.body, {
jobId: req.body.meta.event_id, // BullMQ dedupes on jobId too
});
res.status(200).end();
});
Note the jobId — BullMQ ignores jobs with an ID it has already seen, giving you a second layer of deduplication at the queue level.
The payoff
With this pattern in production, duplicate webhooks became a non-event. The logs show them arriving; the ledger shows them ignored. No double charges, no support tickets, no 2 a.m. incident calls.
Idempotency isn't extra polish on a billing system — it is the billing system.