All posts

Prevent duplicate webhook processing: signatures, idempotency, and durable retries

· 10 min read

Duplicate deliveries, lost work after a 200 response, and out-of-order events require different safeguards. Use Stripe as an example to connect raw-body verification, a durable inbox, database constraints, conditional updates, and external API idempotency.

Why do webhooks arrive twice? Assume responses can be lost and events retried

A webhook notifies an HTTP endpoint about an external event. The sender may retry when it misses your response, even if you already completed the work. Manual and automatic retries can also overlap. Stripe documents duplicates and the absence of guaranteed delivery order, so do not assume exactly one delivery in occurrence order.

Illustrative example: successful processing and a successfully received response are separate outcomes
  1. Sender: Send event evt-A
  2. Database: The receiver updates the order and commits
  3. Receiver API: Return 200, but the response never reaches the sender
  4. Sender: Retry the same evt-A
  5. Receiver API: Prevent duplicate effects and acknowledge acceptance

This article uses a fictional order service receiving a Stripe payment-success notification and updating a local order. SQL targets PostgreSQL 18; processing snippets are pseudocode, not runnable SDK examples. Provider APIs and event formats differ.

Verify signatures over the raw body before JSON parsing

For Stripe, pass the unmodified body bytes, Stripe-Signature header, and endpoint signing secret to the official SDK verifier. Parsing and reserializing JSON can change whitespace or escapes and break verification. Also bound request size, and do not change business data for an unverified notification.

Where the protocol signs a timestamp, validate freshness according to that provider’s rules. Do not assume GitHub’s body HMAC includes Stripe-style signed timestamps. If comparing HMACs yourself, use a constant-time comparison. Disabling verification to avoid signature errors is not a valid fix.

When to return 200: after durable acceptance as pending work

Performing expensive business work inside the receiving request can delay responses and encourage retries. Here, the endpoint verifies the event, commits it as pending in an inbox table, then returns 200. A worker polls that table. An in-memory task or a started background function is not durable acceptance.

Receiver pseudocode: verification, persistence, then acknowledgement
raw = read_body_with_size_limit(request)
event = verify_with_provider_sdk(raw, request.headers, endpoint_secret)
scope = resolve_trusted_source_scope(endpoint_config, event)

transaction:
    insert_pending_event_if_absent(scope, event)
commit

return HTTP_200

Names such as verify_with_provider_sdk are explanatory placeholders. Do not return a success response when persistence fails; leave a path for retries to recover. An uncertain commit outcome must also be safe to retry with the same event ID. Define how verified but intentionally irrelevant event types are ignored.

Enforce deduplication with a unique constraint, not SELECT alone

Concurrent receivers can both conclude that an event is absent. The following example uses a primary key over source scope and event ID. Derive source_scope from verified provider, account, and mode information so it represents the event-ID namespace.

Minimal durable acceptance: prevent two committed rows for the same key
CREATE TABLE webhook_inbox (
    source_scope text NOT NULL,
    event_id text NOT NULL,
    event_type text NOT NULL,
    payload jsonb NOT NULL,
    status text NOT NULL DEFAULT 'pending'
        CHECK (status IN ('pending', 'done', 'quarantined')),
    received_at timestamptz NOT NULL DEFAULT now(),
    PRIMARY KEY (source_scope, event_id)
);

INSERT INTO webhook_inbox
    (source_scope, event_id, event_type, payload)
VALUES ($1, $2, $3, $4)
ON CONFLICT (source_scope, event_id) DO NOTHING
RETURNING event_id;

Zero inserted rows means no new inbox entry was created. A duplicate can receive 200 even when the existing row is pending, provided the worker can recover that pending work. Do not confuse acceptance deduplication with business completion. Production systems also track attempts, next-attempt time, and errors. Retain only necessary payload data and define access and retention policies.

Worker idempotency: commit the business update and completion record together

Deduplicating receipts does not handle worker crashes and retries. When the business update stays entirely in one database, lock a pending event row, update the order, and mark the event done in the same transaction. A crash before commit can roll both changes back.

Conditional update for an order still awaiting payment
UPDATE orders
SET status = 'paid'
WHERE tenant_id = $1
  AND id = $2
  AND provider_payment_id = $3
  AND total_minor = $4
  AND currency = $5
  AND status = 'awaiting_payment'
RETURNING id;

Validate that the event permits awaiting_payment to paid, and bind the verified payment amount and currency to $4 and $5. Checking them in UPDATE also closes the gap if order details change after initial validation. Under PostgreSQL Read Committed, a conflicting update waits and rechecks its predicate against the updated row. Zero affected rows requires classification: completion must match the same payment, amount, and currency to count as an accepted duplicate.

Database-only worker pseudocode: do not hide failures by marking them done
transaction:
    event = lock_one_pending_event_skip_locked()
    if event is absent:
        commit_and_stop()

    order = lock_and_validate_mapped_order(event)
    changed = transition_awaiting_payment_to_paid(order)
    if changed == 0:
        outcome = inspect_current_order_and_payment(order)
        if outcome is a permanent_inconsistency:
            mark_event_quarantined(event)
            commit_and_stop()
        if outcome is not an accepted_duplicate:
            rollback_and_retry_later()
            stop()

    mark_event_done(event)
commit

This minimal example locks both the inbox entry and mapped order while completing short database-only work. Commit a quarantined state in the same transaction for permanent inconsistencies; roll back transient failures for retry. Record retry delay and attempts in a separate transaction, with backoff and limits. Avoid long external API waits while holding locks. Moving expensive work outside them requires additional ownership and recovery design, such as leases.

Different event IDs can still repeat a business effect or arrive out of order

A retry of one event differs from separate events representing the same business outcome. Event-ID deduplication covers the former. Protect business operations too, for example with one shipment operation per order. Conversely, do not deduplicate every event by object ID alone when legitimate repeated updates to that object are possible.

An old notification arriving after payment completion must not blindly overwrite the order’s newer state. Define permitted transitions and query the provider’s current state when needed. Event creation time alone is not a complete ordering or uniqueness mechanism. Model refunds and other distinct business transitions explicitly.

External effects such as email and refunds are outside a local DB transaction

If an external call succeeds and the process crashes before recording done locally, retrying can repeat the effect. Recording done first instead risks losing work before the call. Persist a stable business-operation ID and reuse its idempotency key where the external endpoint supports it. For uncertain outcomes, query or reconcile using the provider’s operation ID.

Stripe API v1 retains results for a key subject to conditions including retention and matching parameters. Other endpoints, including API v2, need their own contract. The notification sender’s retry window and an external API’s idempotency retention are different limits. Account for manual replay after inbox deduplication records expire, and retain appropriate business constraints.

Test crash boundaries: can retries recover without repeating effects?

ReproduceExpected result
Invalid signature, tampered body, or wrong accountReject acceptance and business updates
Two simultaneous deliveries of one eventOne inbox row and no duplicate business effect
Disconnect after persistence but before 200 reaches the senderA retry can continue through the existing pending entry
Stop after the order update but before recording doneThe shared DB transaction rolls back and can be retried
Amount or currency mismatch, or a concurrent order-detail changeDo not incorrectly mark paid; commit quarantine or retry a transient conflict
Deliver an old notification after a newer stateNo forbidden backwards transition
Stop after external success but before local persistenceReconcile through the same business key and outcome lookup

A high 2xx rate does not establish business completion. Monitor oldest pending-event age, retries, quarantined events, and acceptance-to-completion latency. Correlate event and business IDs without logging signing secrets or unnecessary personal data.

FAQ: are a signature, Redis lock, or 2xx response enough?

Primary sources on signatures, retries, and database guarantees

Tips this post digs into