Skip to content

Webhook Idempotency — Complete Guide

DodaTech Updated 2026-06-28 7 min read

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

Do all webhook providers include idempotency keys?

Most major providers do: Stripe, GitHub, Svix. Some smaller providers do not. If the provider does not include an idempotency key, use a combination of event type, timestamp, and data hash.

How long should idempotency keys be stored?

At least as long as the provider's retry window. Typical windows are 24 hours to 7 days. Storing for 30 days is safe and covers most recovery scenarios.

What happens to idempotency when the consumer database is restored from backup?

Restoring from backup loses idempotency records created after the backup. All webhooks from that period may be re-processed. Schedule idempotency table backups with the main database.

Can idempotency be skipped for non-critical webhooks?

Yes. For low-value webhooks like email notifications or analytics, duplicates may be acceptable. For payments, orders, and account changes, idempotency is mandatory.

How do idempotency and exactly-once delivery relate?

Exactly-once delivery is impossible in distributed systems. Idempotency combined with at-least-once delivery achieves effectively-once processing. The provider delivers at least once. The consumer deduplicates.

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