Building Idempotent Webhook Handlers in Node.js
You reach for this the first time a payment provider double-fires a webhook in production and you discover a customer was charged twice — or a subscription was suspended and reinstated in a flickering loop. Duplicate deliveries are not an edge case; they are the contract. A 200 that takes too long, a deploy that drops a connection, or the provider’s own retry policy all produce repeats of the same invoice.payment_succeeded. Building idempotent webhook handlers in Node.js is about making the second, third, and fiftieth delivery into safe no-ops. This is the concrete Express implementation of the idempotent webhook consumer pattern; for the broader pipeline it plugs into, see Webhook Processing & Backend State Management.
Trade-offs
The core decision is where the idempotency key lives. The options differ sharply on durability, latency, and auditability.
| Store | Claim latency | Durability | Audit trail | Survives cache flush | Best role |
|---|---|---|---|---|---|
Postgres ON CONFLICT |
1–5 ms | ACID, permanent | Yes (queryable) | Yes | Source of truth for billing |
Redis SET NX + TTL |
0.2–1 ms | In-memory, evictable | No | No | Fast pre-filter only |
| Redis + Postgres (layered) | 0.2 ms hit / 5 ms miss | Redis cache, PG truth | Yes (PG) | Yes (PG) | High-throughput + correctness |
| In-process map | <0.1 ms | None (lost on restart) | No | No | Never, for billing |
For financial events the answer is Postgres as the authority, optionally fronted by Redis. The database survives restarts and cache flushes and gives you a queryable audit trail — both non-negotiable for PCI-DSS and SOC 2. The detailed reasoning lives in Redis vs Postgres for Webhook Idempotency Keys.
Step-by-Step Implementation
1. Verify the signature on the raw body
Use express.raw() so the bytes you HMAC are exactly what the provider signed — parsing first and re-serializing changes the bytes and breaks verification. Compare with a timing-safe equality check.
const express = require('express');
const crypto = require('crypto');
const { Pool } = require('pg');
const app = express();
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
app.use('/webhooks/billing', express.raw({ type: 'application/json' }));
function verifySignature(rawBody, signature) {
const expected = crypto.createHmac('sha256', process.env.WEBHOOK_SECRET)
.update(rawBody)
.digest('hex');
const a = Buffer.from(expected);
const b = Buffer.from(signature || '');
return a.length === b.length && crypto.timingSafeEqual(a, b); // ✅ constant-time
}
2. Define the idempotency key and the store
The provider event ID is the key. Back it with a unique-constrained table that doubles as the audit log.
CREATE TABLE webhook_events (
id VARCHAR(255) PRIMARY KEY, -- provider event id = idempotency key
type VARCHAR(64) NOT NULL,
status VARCHAR(16) NOT NULL DEFAULT 'processing',
received_at TIMESTAMPTZ NOT NULL DEFAULT now(),
processed_at TIMESTAMPTZ
);
3. Claim the key atomically and process in one transaction
INSERT ... ON CONFLICT DO NOTHING makes the claim the lock. If the insert affects zero rows the event is a duplicate, so acknowledge 200 and stop. Otherwise process state and write the outbox row in the same transaction.
async function webhookMiddleware(req, res, next) {
const signature = req.headers['x-webhook-signature'];
if (!verifySignature(req.body, signature)) {
return res.status(401).json({ error: 'Invalid signature' }); // ✗ reject forgery
}
const event = JSON.parse(req.body);
const idempotencyKey = event.id;
const client = await pool.connect();
try {
await client.query('BEGIN');
const claim = await client.query(
`INSERT INTO webhook_events (id, type, status)
VALUES ($1, $2, 'processing')
ON CONFLICT (id) DO NOTHING`,
[idempotencyKey, event.type]
);
if (claim.rowCount === 0) { // ⚠️ duplicate delivery
await client.query('ROLLBACK');
return res.set('X-Webhook-Status', 'duplicate').status(200).json({ received: true });
}
req.event = event;
req.dbClient = client; // hand the open tx to the route handler
req.commit = async () => {
await client.query(
`UPDATE webhook_events SET status = 'done', processed_at = now() WHERE id = $1`,
[idempotencyKey]
);
await client.query('COMMIT');
};
next();
} catch (err) {
await client.query('ROLLBACK');
client.release();
res.status(500).json({ error: 'Idempotency check failed' });
}
}
app.post('/webhooks/billing', webhookMiddleware, async (req, res) => {
const { event, dbClient } = req;
try {
// domain mutation + outbox row, all inside the SAME transaction
await dbClient.query(
`UPDATE subscriptions SET state = $1 WHERE subscription_id = $2`,
[mapState(event), event.data.subscription_id]
);
await dbClient.query(
`INSERT INTO billing_outbox (aggregate_id, event_type, payload) VALUES ($1, $2, $3)`,
[event.data.subscription_id, event.type, event.data]
);
await req.commit(); // ✅ atomic commit
res.status(200).json({ processed: true });
} catch (err) {
await dbClient.query('ROLLBACK'); // ✗ key not retained
res.status(500).json({ error: 'Processing failed' });
} finally {
dbClient.release();
}
});
The critical property: the idempotency key is only durably retained when the business logic commits. If processing throws, the rollback removes the claim so the provider’s retry can succeed cleanly.
Verification & Testing
Assert exactly-once under concurrency by firing the same event ID through two simultaneous requests and checking the row count:
-- After a concurrent double-delivery, expect exactly one row, status 'done'.
SELECT count(*) FROM webhook_events WHERE id = 'evt_1Mq... '; -- expect 1
SELECT count(*) FROM billing_outbox WHERE payload->>'event_id' = 'evt_1Mq... '; -- expect 1
In an integration test, post a payload with a tampered body and assert 401 (signature forgery test). Post the same valid event twice sequentially and assert the second returns 200 with X-Webhook-Status: duplicate and that no second outbox row appears. Simulate a processing failure (throw inside the route) and assert the webhook_events row is absent afterward, proving a retry can still proceed.
Gotchas & Production Pitfalls
- Parsing before verifying: if you let
express.json()run first, you HMAC re-serialized bytes that no longer match the provider’s signature and every webhook fails401. Mountexpress.raw()on the webhook route only. - Keys stuck in
processing: an unhandled promise rejection between claim and commit can leave a row inprocessingforever, swallowing the event. Always wrap claim plus logic in one transaction and run a sweep that deletesprocessingrows older than the provider’s max retry window. - Trusting Redis TTL alone: provider clock skew or a mid-cycle eviction lets a duplicate slip past a Redis-only check. Keep Postgres as the source of truth and treat Redis as a pre-filter.
- Connection-pool exhaustion under retry storms: a provider outage produces a flood of retries that drains the pool. Add ingress rate-limiting and a circuit breaker so the handler sheds load instead of hanging.
- Deadlocks on high-concurrency upserts: concurrent claims for adjacent keys can deadlock. Retry the transaction with exponential backoff on Postgres error code
40P01rather than returning500to the provider.