Webhook Idempotency — Complete Guide
In this tutorial, you will learn about Webhook Idempotency. We cover key concepts, practical examples, and best practices to help you master this topic.
Learn webhook idempotency: design idempotent webhook handlers, use idempotency keys, handle duplicate deliveries safely, deduplicate webhook processing, and prevent side effects from retries.
What You Learn
You will learn how to make webhook processing idempotent, use idempotency keys to detect duplicates, implement deduplication in different storage backends, and design APIs that can safely Process the same webhook multiple times.
Why It Matters
Webhook retries are essential for reliability but cause duplicate deliveries. Without idempotency, duplicate Webhooks charge customers twice, create duplicate orders, send duplicate emails, and corrupt data. Idempotent processing prevents all these issues.
Real-World Use
DodaTech's billing system processes 50000 payment webhooks daily. The idempotency layer prevents duplicate charges during retries. Before idempotency was implemented, 12 customers per month were double-charged due to webhook retries. After implementation, zero duplicates.
Idempotency Key Pattern
// Provider includes unique ID
const webhookPayload = {
id: 'wh_unique_123', // Idempotency key
event: 'payment.succeeded',
data: { amount: 5000, customer: 'cus_123' },
};
// Consumer checks if already processed
const express = require('express');
const app = express();
const processedIds = new Set(); // In production: Redis or database
app.post('/webhooks/payment', express.json(), (req, res) => {
const webhookId = req.body.id;
// Check idempotency
if (processedIds.has(webhookId)) {
console.log(`Duplicate webhook ${webhookId}, skipping`);
return res.status(200).send('Already processed');
}
// Mark as processing
processedIds.add(webhookId);
try {
// Process the webhook
handlePayment(req.body.data);
res.status(200).send('OK');
} catch (err) {
// On failure, remove from processed set to allow retry
processedIds.delete(webhookId);
res.status(500).send('Processing failed');
}
});
Expected output: First delivery processes the webhook and adds the ID to the processed set. Duplicate delivery detects the ID and returns 200 without processing. Retries after failure re-process because the ID is removed on error.
Idempotency with Redis
const redis = require('redis');
class IdempotencyStore {
constructor(redisUrl) {
this.client = redis.createClient({ url: redisUrl });
this.ttlSeconds = 86400; // Keep for 24 hours
}
async isProcessed(webhookId) {
const result = await this.client.get(`wh:idemp:${webhookId}`);
return result !== null;
}
async markProcessed(webhookId) {
await this.client.set(
`wh:idemp:${webhookId}`,
'1',
{ EX: this.ttlSeconds, NX: true }
);
}
async markFailed(webhookId) {
await this.client.del(`wh:idemp:${webhookId}`);
}
async processIfNotDuplicate(webhookId, processor) {
// Atomic check-and-set
const acquired = await this.client.set(
`wh:idemp:${webhookId}`,
'processing',
{ EX: 300, NX: true } // 5 minute processing lock
);
if (!acquired) {
const status = await this.client.get(`wh:idemp:${webhookId}`);
if (status === '1') {
return { status: 'duplicate', action: 'skipped' };
}
if (status === 'processing') {
return { status: 'in_progress', action: 'wait' };
}
}
try {
await processor();
await this.client.set(
`wh:idemp:${webhookId}`,
'1',
{ EX: this.ttlSeconds }
);
return { status: 'success', action: 'processed' };
} catch (err) {
await this.client.del(`wh:idemp:${webhookId}`);
throw err;
}
}
}
Expected output: Redis provides atomic check-and-set with TTL. Concurrent duplicate webhooks are detected. Failed processing releases the lock. Successfully processed webhooks are marked for 24 hours.
Database-Backed Idempotency
-- PostgreSQL idempotency table
CREATE TABLE webhook_idempotency (
webhook_id VARCHAR(255) PRIMARY KEY,
status VARCHAR(20) NOT NULL DEFAULT 'processing',
processed_at TIMESTAMP,
response_code INTEGER,
error_message TEXT,
created_at TIMESTAMP DEFAULT NOW()
);
CREATE INDEX idx_webhook_idempotency_status
ON webhook_idempotency(status);
-- Consumer logic with database
async function processWithDbIdempotency(webhookId, processor) {
const { rows } = await db.query(
`INSERT INTO webhook_idempotency (webhook_id, status)
VALUES ($1, 'processing')
ON CONFLICT (webhook_id) DO NOTHING
RETURNING status`,
[webhookId]
);
if (rows.length === 0) {
// Already exists
const { rows: existing } = await db.query(
'SELECT status FROM webhook_idempotency WHERE webhook_id = $1',
[webhookId]
);
return { duplicate: true, status: existing[0].status };
}
try {
await processor();
await db.query(
`UPDATE webhook_idempotency
SET status = 'completed', processed_at = NOW()
WHERE webhook_id = $1`,
[webhookId]
);
return { duplicate: false, status: 'completed' };
} catch (err) {
await db.query(
`UPDATE webhook_idempotency
SET status = 'failed', error_message = $1
WHERE webhook_id = $2`,
[err.message, webhookId]
);
throw err;
}
}
Expected output: Database INSERT ON CONFLICT DO NOTHING provides atomic idempotency check. First attempt inserts and processes. Duplicate attempts find existing row and skip. Failed attempts update status for retry.
Idempotency in Practice
// Complete idempotent webhook handler
class IdempotentWebhookHandler {
constructor(options = {}) {
this.store = options.store; // Redis, DB, or in-memory
this.processor = options.processor;
}
async handle(req, res) {
const webhookId = req.body.id || req.headers['x-webhook-id'];
const idempotencyKey = req.headers['idempotency-key'];
const key = webhookId || idempotencyKey;
if (!key) {
return res.status(400).send('Missing idempotency key');
}
try {
const result = await this.store.processIfNotDuplicate(
key,
() => this.processor(req.body)
);
if (result.status === 'duplicate') {
return res.status(200).json({
status: 'duplicate',
message: 'Already processed',
});
}
res.status(200).json({ status: 'accepted' });
} catch (err) {
console.error(`Webhook ${key} processing failed:`, err);
res.status(500).send('Processing error');
}
}
}
// Usage
const handler = new IdempotentWebhookHandler({
store: new RedisIdempotencyStore('redis://localhost:6379'),
processor: async (payload) => {
// Business logic
await chargeCustomer(payload.data.customerId, payload.data.amount);
await sendReceipt(payload.data.customerId);
},
});
app.post('/webhooks/stripe', (req, res) => handler.handle(req, res));
Expected output: Idempotent handler checks the store, processes if new, returns duplicate status if already processed. Processor errors are caught and logged. The store handles TTL and cleanup.
Common Mistakes
1. Relying on Database Unique Constraints Only
Unique constraints prevent duplicate inserts but may not prevent duplicate processing if the first insert succeeds and then processing fails. Use two-phase: mark processing, process, mark complete.
2. Not Expiring Idempotency Records
Idempotency records accumulate forever. Set a TTL of 24 hours to 7 days. Old webhooks that are retried beyond this window are rare and safe to re-process.
3. Using In-Memory Storage for Production
In-memory sets disappear on server restart. After restart, all previous idempotency is lost, and retried webhooks are processed again. Use Redis or a database for production.
4. Not Handling Concurrent Duplicates
Two identical webhooks arriving simultaneously can both pass the idempotency check. Use atomic operations (SET NX, INSERT ON CONFLICT) to ensure only one processes.
5. Removing Idempotency Key on Success
If you delete the idempotency key after successful processing, retries will process again. Keep the key for the full TTL period. Use a status field to indicate success.
Practice Questions
1. What is idempotency in webhook processing?
An idempotent operation produces the same result regardless of how many times it is executed. Processing the same webhook twice has no additional side effects beyond processing it once.
2. Why is idempotency important for payment webhooks?
Without idempotency, a retried payment.succeeded webhook charges the customer twice. Idempotency ensures that duplicate webhooks do not cause duplicate charges.
3. How does Redis help with idempotency?
Redis SET NX provides atomic check-and-set. If the key does not exist, it is created and the operation proceeds. If it exists, the operation is a duplicate. TTL automatically expires old records.
4. What TTL should idempotency records have?
Match the provider's retry window. Most providers retry for 24 hours. Set TTL to 24-48 hours. Longer TTLs are safer but consume more storage.
Challenge
Build an idempotency middleware for Express that: extracts webhook ID from body or header, checks Redis for duplicates, marks processing state, processes the handler, marks complete state, handles errors with rollback, and returns appropriate status codes for duplicate and in-progress webhooks.
FAQ
Mini Project: Idempotency Dashboard
Build a dashboard showing idempotency statistics: total webhooks processed, duplicate rate by provider, average processing time per webhook, idempotency storage usage, and failed processing attempts with retry status.
What's Next
Now that you understand idempotency, learn about Webhook Ordering and handling out-of-order delivery.
Built by the developers of DodaTech
Doda Browser, DodaZIP & Durga Antivirus Pro