Webhook Idempotency Keys
title: "Webhook Idempotency Keys" description: "Learn how to implement idempotency keys for webhook processing to safely handle duplicate deliveries and prevent data corruption." weight: 17 date: 2026-06-28 lastmod: 2026-06-28 tags: ["apis", "webhooks"]
Idempotency ensures that processing a webhook multiple times produces the same result as processing it once. Since webhooks are delivered with at-least-once semantics, idempotency is critical for data integrity.
## What You'll Learn
- What idempotency means for webhooks
- Implementing idempotency keys
- Deduplication strategies
- Side effect management
- Idempotency key lifecycle
## Why It Matters
Without idempotency, a single network glitch that causes a webhook retry can result in duplicate charges, double processing, or inconsistent data. Idempotency prevents these costly errors.
## Real-World Use
A payment gateway sends webhooks for each transaction. If a merchant's server responds slowly and triggers a retry, the duplicate webhook contains the same idempotency key. The merchant's server recognizes the key and skips processing, preventing double charges.
## Flow Chart
```mermaid
flowchart LR
A[Webhook Received] --> B{Idempotency Key Exists?}
B -->|No| C[Process Event]
B -->|Yes| D{Already Processed?}
D -->|Yes| E[Return Cached Response]
D -->|No| C
C --> F[Store Key + Response]
F --> G[Return 200 OK]
E --> G
Code Examples
Example 1: Idempotency Key Implementation
const express = require('express');
const app = express();
app.use(express.json());
// In-memory idempotency store (use Redis in production)
const idempotencyStore = new Map();
const IDEMPOTENCY_TTL = 24 * 60 * 60 * 1000; // 24 hours
// Cleanup expired keys
setInterval(() => {
const now = Date.now();
for (const [key, value] of idempotencyStore) {
if (now > value.expiresAt) {
idempotencyStore.delete(key);
}
}
}, 3600000);
app.post('/webhook', (req, res) => {
const idempotencyKey = req.headers['x-idempotency-key']
|| req.body?.id;
if (!idempotencyKey) {
return res.status(400).json({
error: 'Missing idempotency key'
});
}
// Check if already processed
const existing = idempotencyStore.get(idempotencyKey);
if (existing) {
console.log(`Duplicate webhook detected: ${idempotencyKey}`);
return res.status(200).json(existing.response);
}
// Process webhook
try {
const result = processWebhook(req.body);
// Store result with TTL
idempotencyStore.set(idempotencyKey, {
response: result,
processedAt: new Date().toISOString(),
expiresAt: Date.now() + IDEMPOTENCY_TTL,
});
res.status(200).json(result);
} catch (error) {
// On error, remove key so retry can re-process
idempotencyStore.delete(idempotencyKey);
res.status(500).json({ error: error.message });
}
});
// Provider sends idempotency key
async function sendWebhookWithIdempotency(url, event) {
const response = await axios.post(url, event.payload, {
headers: {
'Content-Type': 'application/json',
'X-Idempotency-Key': event.id,
'X-Event-Type': event.type,
},
timeout: 15000,
});
return response;
}
Expected output: Consumer uses idempotency key to detect and skip duplicate webhook processing, returning the cached response.
Example 2: Database-Backed Idempotency
-- Database schema for idempotency
CREATE TABLE webhook_idempotency (
idempotency_key VARCHAR(255) PRIMARY KEY,
response JSONB NOT NULL,
status_code INTEGER NOT NULL,
processed_at TIMESTAMP WITH TIME ZONE NOT NULL,
expires_at TIMESTAMP WITH TIME ZONE NOT NULL
);
CREATE INDEX idx_idempotency_expires
ON webhook_idempotency (expires_at);
// Database-backed idempotency
const { Pool } = require('pg');
const pool = new Pool();
async function processWithIdempotency(key, processor) {
const client = await pool.connect();
try {
// Check if already processed
const existing = await client.query(
`SELECT response, status_code
FROM webhook_idempotency
WHERE idempotency_key = $1
AND expires_at > NOW()`,
[key]
);
if (existing.rows.length > 0) {
const row = existing.rows[0];
return {
status: row.status_code,
body: row.response,
deduplicated: true,
};
}
// Process webhook
const result = await processor();
// Store result
await client.query(
`INSERT INTO webhook_idempotency
(idempotency_key, response, status_code, processed_at, expires_at)
VALUES ($1, $2, $3, NOW(), NOW() + INTERVAL '24 hours')
ON CONFLICT (idempotency_key) DO NOTHING`,
[key, JSON.stringify(result.body), result.status]
);
return { ...result, deduplicated: false };
} finally {
client.release();
}
}
// Usage
app.post('/webhook', async (req, res) => {
const key = req.headers['x-idempotency-key'];
const result = await processWithIdempotency(key, async () => {
// Actual webhook processing
const data = await processEvent(req.body);
return { status: 200, body: { status: 'ok', data } };
});
res.status(result.status).json(result.body);
});
Expected output: Database-backed idempotency with automatic cleanup of expired keys.
Example 3: Provider-Side Idempotency Key Generation
// Provider generates idempotency keys per event
class WebhookEvent {
constructor(type, data) {
this.id = generateUniqueId();
this.type = type;
this.data = data;
this.createdAt = new Date().toISOString();
}
}
function generateUniqueId() {
// ULID-style ID that is unique and sortable
const timestamp = Date.now().toString(36);
const random = Math.random().toString(36).substring(2, 10);
const suffix = Math.random().toString(36).substring(2, 6);
return `wh_${timestamp}${random}${suffix}`;
}
// Delivery with guaranteed unique ID
async function deliverWebhook(webhook, event) {
const payload = {
id: event.id,
type: event.type,
created_at: event.createdAt,
data: event.data,
};
const headers = {
'Content-Type': 'application/json',
'X-Idempotency-Key': event.id,
'X-Event-ID': event.id,
'X-Event-Type': event.type,
'X-Signature-256': signPayload(payload),
};
const maxAttempts = 5;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
const response = await axios.post(webhook.url, payload, {
headers: { ...headers, 'X-Delivery-Attempt': attempt },
timeout: 15000,
});
if (response.status >= 200 && response.status < 300) {
return { success: true, eventId: event.id };
}
} catch (error) {
if (attempt < maxAttempts) {
await sleep(Math.pow(2, attempt) * 1000);
}
}
}
return { success: false, eventId: event.id };
}
Expected output: Provider generates unique, persistent IDs for each event, enabling consumer-side deduplication.
Common Mistakes
| Mistake | Explanation |
|---|---|
| Using timestamps as idempotency keys | Timestamps can repeat; use UUIDs or ULIDs for uniqueness |
| Not storing error responses | If processing fails, delete the key so retries can re-process |
| Forever-growing idempotency store | Set TTL on stored keys and clean up expired entries |
| Different keys for the same logical event | The idempotency key must be the same across all delivery attempts |
| Bypassing idempotency for side effects | All operations with side effects must go through idempotency check |
Practice Questions
- What is an idempotency key and how is it used?
- How does idempotency prevent duplicate charges?
- What should you store in the idempotency cache?
- How long should idempotency keys be valid?
- What happens when processing fails partway through?
Challenge
Implement a complete idempotent webhook processing system. Use Redis for the idempotency store, support configurable TTL, handle partial failures (store key on success, delete on failure), and include a cleanup mechanism for expired keys.
FAQ
Mini Project
Build an idempotent webhook consumer with Redis-backed deduplication. Include a webhook testing tool that sends duplicate events with the same idempotency key and verifies they are processed only once.
What's Next
Learn about webhook ordering guarantees
Built by the developers of DodaTech
Doda Browser, DodaZIP & Durga Antivirus Pro