How to handle duplicate webhook events — three proven strategies with code examples.
Last updated March 2026. Code examples in Node.js, applicable to any language.
If you process webhooks long enough, you will receive duplicates. It's not a matter of if, but when. Here's why:
200 response, but a network hiccup prevented the provider from receiving it. The provider sees a timeout and retries.payment_intent.succeeded event processed twice means a customer gets charged twice, or receives two confirmation emails. A duplicate customer.subscription.deleted processed twice could trigger two cancellation workflows. At 10,000 events/day with a 0.1% duplicate rate, that's 10 duplicate events per day that could cause real damage.
The simplest and most common approach: store the event ID before processing, and check for it on every delivery. If the ID already exists, skip processing and return 200.
How it works:
id field, GitHub's X-GitHub-Delivery header).200 immediately — the event was already processed.const Redis = require('ioredis');
const redis = new Redis();
async function handleWebhook(req, res) {
const event = JSON.parse(req.body);
const eventId = event.id; // e.g., "evt_1Nq..."
// Try to set the key — NX means "only if it doesn't exist"
// EX 259200 = 72 hour TTL (covers Stripe's 3-day retry window)
const isNew = await redis.set(
`webhook:${eventId}`,
'processing',
'EX', 259200,
'NX'
);
if (!isNew) {
// Already processed — skip, but return 200 so provider stops retrying
console.log(`Duplicate event ${eventId}, skipping`);
return res.status(200).json({ received: true, duplicate: true });
}
try {
// Process the event (charge customer, send email, etc.)
await processEvent(event);
// Mark as completed
await redis.set(`webhook:${eventId}`, 'completed', 'EX', 259200);
} catch (err) {
// Processing failed — delete the key so retries can try again
await redis.del(`webhook:${eventId}`);
return res.status(500).json({ error: 'Processing failed' });
}
res.status(200).json({ received: true });
}
Key detail: Use Redis SET ... NX (set-if-not-exists) for atomic check-and-set. This prevents a race condition where two duplicate deliveries arrive at the same millisecond and both pass the "does it exist?" check.
If you're already writing webhook data to a database, let the database enforce uniqueness. Use INSERT ... ON CONFLICT DO NOTHING (PostgreSQL) or INSERT IGNORE (MySQL) to silently skip duplicates.
-- Create the table with a unique constraint on event_id
CREATE TABLE webhook_events (
id SERIAL PRIMARY KEY,
event_id TEXT NOT NULL UNIQUE,
event_type TEXT NOT NULL,
payload JSONB NOT NULL,
processed_at TIMESTAMPTZ DEFAULT NOW()
);
-- Insert — silently does nothing if event_id already exists
INSERT INTO webhook_events (event_id, event_type, payload)
VALUES ($1, $2, $3)
ON CONFLICT (event_id) DO NOTHING
RETURNING id;
async function handleWebhook(req, res) {
const event = JSON.parse(req.body);
const result = await db.query(
`INSERT INTO webhook_events (event_id, event_type, payload)
VALUES ($1, $2, $3)
ON CONFLICT (event_id) DO NOTHING
RETURNING id`,
[event.id, event.type, event]
);
if (result.rows.length === 0) {
// Conflict — event already processed
return res.status(200).json({ received: true, duplicate: true });
}
// New event — process it
await processEvent(event);
res.status(200).json({ received: true });
}
Advantage: No separate deduplication store needed — your database does double duty. The unique constraint is enforced at the database level, so it's safe even with concurrent requests across multiple application instances.
For complex workflows where the same entity goes through multiple states (e.g., order: pending → paid → shipped → delivered), use a state machine pattern. Only process a webhook event if the current state allows the transition.
const VALID_TRANSITIONS = {
'pending': ['paid', 'cancelled'],
'paid': ['shipped', 'refunded'],
'shipped': ['delivered', 'returned'],
'delivered': ['returned'],
'cancelled': [],
'refunded': [],
'returned': [],
};
async function handleOrderWebhook(event) {
const orderId = event.data.object.metadata.order_id;
const newState = mapEventToState(event.type);
// Atomic update — only transitions if current state allows it
const result = await db.query(
`UPDATE orders
SET status = $1, updated_at = NOW()
WHERE id = $2
AND status = ANY($3)
RETURNING id, status`,
[newState, orderId, VALID_TRANSITIONS_TO[newState]]
);
if (result.rows.length === 0) {
// State doesn't allow this transition — likely a duplicate or out-of-order event
console.log(`Order ${orderId}: transition to ${newState} not allowed from current state`);
return; // Still return 200 to the webhook provider
}
console.log(`Order ${orderId}: transitioned to ${newState}`);
}
When to use: State machines are ideal when events represent state transitions and the order matters. A duplicate payment_intent.succeeded event won't re-process because the order is already in paid state, and paid → paid is not a valid transition.
In production, most teams combine two or more strategies for defense in depth:
When you replay a failed webhook event in SDNBros, it tells you whether the replay is safe — whether the payload and idempotency key match the original delivery. This prevents accidental re-processing when investigating production issues.
SDNBros generates a normalized fingerprint for each webhook event — a hash of the event type, key fields, and payload structure. You can use this fingerprint in your own deduplication logic, especially for providers that don't include a stable event ID.
Use the SDNBros Sandbox to test your idempotency implementation. Replay the same event multiple times against your endpoint and verify that side effects only happen once. The sandbox shows exactly what your endpoint returned for each delivery.
Test your webhook handler with duplicate events in the SDNBros Sandbox. Replay events, inspect responses, and verify your idempotency logic works.
Open Sandbox Testing ChecklistRelated guides: What is a Webhook? · Webhook vs API · Webhook Security · Retry Strategies · Testing Checklist
More: What is a Webhook? | Webhook Security Guide | Testing Checklist | Retry Strategies | Compare Tools