Skip to content

Webhook Idempotency Keys

DodaTech 6 min read

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

  1. What is an idempotency key and how is it used?
  2. How does idempotency prevent duplicate charges?
  3. What should you store in the idempotency cache?
  4. How long should idempotency keys be valid?
  5. 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

Can I use the webhook event ID as the idempotency key?

Yes, the event ID is the natural idempotency key. It is unique per event and consistent across retries.

How long should idempotency keys be stored?

Store keys for at least the maximum retry window (24 hours). Some systems store for 30-90 days for audit purposes.

What happens if two different events have the same key?

The second event is rejected as a duplicate. Ensure keys are generated uniquely to prevent collisions.

Should idempotency apply to all webhook types?

Yes, all webhook types should be idempotent. You never know when a retry might cause duplicate processing.

How do I handle idempotency in a distributed system?

Use a distributed cache (Redis) or database with strong consistency guarantees for the idempotency store.

What is the performance impact of idempotency checking?

The overhead is minimal (a cache lookup). The cost of duplicate processing is much higher than the idempotency check.

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