Architecting Idempotent Webhook Pipelines
Most CRM and payment providers guarantee at-least-once webhook delivery, not exactly-once. If your system assumes every webhook arrives exactly one time, you will eventually double-book an appointment, double-charge a client, or double-notify an agent. This is the architectural trade-off at the center of every serious automation build: correctness under retries costs a little extra state, but the alternative costs trust.
The trade-off
There are three common strategies, in increasing order of robustness and cost:
- Do nothing — cheapest, breaks in production the first time a provider retries after a slow 200.
- Timestamp/window de-duplication — cheap, but fragile around clock skew and burst retries.
- Idempotency-key ledger — a small persisted table keyed on the provider’s event ID, checked before any side effect runs.
For anything touching money, scheduling, or client communication, option 3 is the only defensible choice.
Implementation: the idempotency-key middleware
import type { Request, Response, NextFunction } from 'express';
import { Pool } from 'pg';
interface WebhookEvent {
id: string;
type: string;
payload: unknown;
}
export function idempotencyGuard(pool: Pool) {
return async (req: Request, res: Response, next: NextFunction) => {
const event = req.body as WebhookEvent;
const { rowCount } = await pool.query(
`INSERT INTO webhook_events (event_id, event_type, received_at)
VALUES ($1, $2, now())
ON CONFLICT (event_id) DO NOTHING`,
[event.id, event.type],
);
if (rowCount === 0) {
// Already processed this exact event ID — ack without re-running side effects.
res.status(200).json({ status: 'duplicate_ignored' });
return;
}
next();
};
}
The ON CONFLICT DO NOTHING clause is doing the real work here: the uniqueness constraint on event_id turns a race between two concurrent retries into a no-op for the loser, with no explicit locking required.
Queueing the guarded events
Once an event passes the guard, it’s handed to a durable queue rather than processed inline — so a downstream failure (CRM API timeout, rate limit) doesn’t force the webhook endpoint itself to block or fail:
# consumer-config.yaml
queue:
name: webhook-events
visibility_timeout_seconds: 30
max_receive_count: 5
dead_letter_queue: webhook-events-dlq
consumer:
concurrency: 10
backoff:
strategy: exponential
base_ms: 500
max_ms: 30000
Verifying the fix
Replaying a captured webhook payload against a local instance confirms the guard behaves correctly under a simulated retry:
# First delivery — should process normally
curl -X POST http://localhost:8787/webhooks/crm \
-H "Content-Type: application/json" \
-d '{"id":"evt_9f8a1c","type":"lead.created","payload":{}}'
# Simulated retry of the same event — should be ignored, not reprocessed
curl -X POST http://localhost:8787/webhooks/crm \
-H "Content-Type: application/json" \
-d '{"id":"evt_9f8a1c","type":"lead.created","payload":{}}'
If the second call returns duplicate_ignored and no second side effect fires, the ledger is doing its job. This same pattern — a small keyed table in front of anything with a side effect — is the backbone of every reliable automation pipeline we ship, regardless of which CRM or messaging provider sits on the other end.