Prevent duplicate webhook processing: signatures, idempotency, and durable retries
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.
- Sender: Send event evt-A
- Database: The receiver updates the order and commits
- Receiver API: Return 200, but the response never reaches the sender
- Sender: Retry the same evt-A
- 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.
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_200Names 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.
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.
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.
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)
commitThis 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?
| Reproduce | Expected result |
|---|---|
| Invalid signature, tampered body, or wrong account | Reject acceptance and business updates |
| Two simultaneous deliveries of one event | One inbox row and no duplicate business effect |
| Disconnect after persistence but before 200 reaches the sender | A retry can continue through the existing pending entry |
| Stop after the order update but before recording done | The shared DB transaction rolls back and can be retried |
| Amount or currency mismatch, or a concurrent order-detail change | Do not incorrectly mark paid; commit quarantine or retry a transient conflict |
| Deliver an old notification after a newer state | No forbidden backwards transition |
| Stop after external success but before local persistence | Reconcile 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?
- Does a valid signature remove the need for deduplication? Legitimate retries have valid signatures too. Authentication and duplicate-effect protection serve different purposes.
- Is a short Redis lock sufficient? Expiry, restarts, and later retries remain. Use durable records and business constraints.
- Does 200 mean business completion? In this design it means durable acceptance. Track worker state and monitoring for completion.
Primary sources on signatures, retries, and database guarantees
- Stripe Docs — Webhook delivery, duplicate events, ordering, and signatures
- Stripe Docs — Resolve signature verification errors
- GitHub Docs — Validating webhook deliveries
- Stripe API — Idempotent requests, including API v1 retention and parameter checks
- PostgreSQL 18 — INSERT / ON CONFLICT
- PostgreSQL 18 — UPDATE / RETURNING
- PostgreSQL 18 — Read Committed isolation