Designing Idempotent Payment Webhook Processors in Node.js & Redis
Payment gateways and banks deliver status callbacks at least once, not exactly once. Retries, network timeouts and out-of-order delivery mean the same event can reach your server several times, and a consumer that is not idempotent will credit a wallet twice or flip a settled transaction back to pending. This article walks through the pattern we use to make webhook processing safe.
Why webhooks arrive more than once
Most providers retry a callback until they receive a 2xx response. If your handler is slow, crashes after committing but before responding, or sits behind a load balancer that times out, the provider will send the same event again. Providers also do not guarantee ordering: a SUCCESS callback can arrive before the earlier PENDING one.
Treat every callback as untrusted, repeatable input. Correctness has to come from your own data model, not from assumptions about the sender.
Verify the signature on the raw body
Before doing anything else, verify the HMAC signature the provider sends. Compute it over the raw request bytes, not over re-serialised JSON, and compare with a constant-time function so the check cannot be used as a timing oracle.
import crypto from "node:crypto";
export function isValidSignature(rawBody, signature, secret) {
const expected = crypto
.createHmac("sha256", secret)
.update(rawBody)
.digest("hex");
const a = Buffer.from(expected);
const b = Buffer.from(signature || "");
return a.length === b.length && crypto.timingSafeEqual(a, b);
}Use an idempotency key and let the database decide
Every event carries a stable identifier (an event id, or the gateway reference plus status). Store it in a table with a unique constraint and insert it in the same database transaction that applies the business change. If the insert fails with a unique violation, the event was already processed and you can acknowledge it and stop.
CREATE TABLE webhook_events (
event_id text PRIMARY KEY,
received_at timestamptz NOT NULL DEFAULT now()
);
-- inside the same transaction that updates the payment:
INSERT INTO webhook_events (event_id) VALUES ($1)
ON CONFLICT (event_id) DO NOTHING;
-- 0 rows inserted => duplicate, skip the business logicThe unique constraint is the source of truth because it is atomic and survives restarts. Redis is useful as a fast first filter in front of it, not as a replacement for it.
Use Redis as a fast duplicate filter and lock
A short-lived Redis key lets you reject obvious duplicates cheaply and stops two workers from processing the same event at the same moment. SET with NX and an expiry is atomic.
const acquired = await redis.set(`wh:${eventId}`, "1", "EX", 300, "NX");
if (!acquired) {
// another worker is handling this event, or it was just handled
return res.status(200).end();
}
try {
await processEventInTransaction(event); // writes webhook_events row too
} catch (err) {
await redis.del(`wh:${eventId}`); // allow the provider's retry to succeed
throw err;
}If Redis is unavailable, fall through to the database constraint. The system gets slower, never wrong.
Acknowledge fast, process asynchronously
- Verify the signature, persist the raw event, and return 200 within a second or two.
- Push the event onto a queue and process it in a worker, so slow downstream calls never trigger provider retries.
- Return a non-2xx status only for genuinely transient failures you want retried.
Handle out-of-order and conflicting status updates
Model the payment as a state machine with a defined precedence: for example PENDING < FAILED < SUCCESS, with refunds as a separate branch. A callback may only move a payment forward. A late PENDING after SUCCESS is recorded and ignored rather than applied.
Finally, run a scheduled reconciliation job that asks the provider for the status of any payment that has been pending longer than expected. Webhooks are an optimisation; reconciliation is what guarantees you never lose a result.
Checklist
- Signature verified on the raw body with a constant-time compare.
- Unique event id enforced by the database, written in the same transaction as the change.
- Redis NX key as a fast filter, with the database as the final authority.
- Fast acknowledgement, with processing moved to a queue.
- Forward-only status transitions plus periodic reconciliation.