Webhook Reliability Engineering

Prerequisite: API Design

Your payment processor sends a payment.completed webhook. Your handler credits the customer’s account. The processor’s retry logic fires because your 200 response arrived 50ms after its 30-second timeout. The webhook arrives again. Your handler credits the customer’s account again. The customer now has double their money, and your ledger is wrong.

Webhooks are the standard mechanism for asynchronous event delivery in financial systems. Stripe sends roughly 4 billion webhook events per month. Every payment processor, banking API, and identity verification provider uses them. The delivery model is fundamentally unreliable — HTTP over the public internet, fire-and-forget semantics, no transaction boundary between sender and receiver — and you are building financial logic on top of it.

Delivery Guarantees: At-Least-Once or Nothing

No webhook provider guarantees exactly-once delivery. The delivery model is at-least-once: the provider sends the event, waits for a 2xx response, and retries if it doesn’t get one. Duplicates are an expected operating condition, not an edge case.

Provider Retry attempts Retry window Timeout
Stripe Up to 16 72 hours 20 seconds
Plaid Up to 7 24 hours 15 seconds
Modern Treasury Up to 20 7 days 30 seconds
Adyen Up to 10 24 hours 30 seconds

Duplicates arrive for three reasons. First, your handler succeeded but the response was lost — network failure, load balancer timeout, TCP reset. The provider retries because it never saw the 2xx. Second, the provider’s delivery infrastructure has its own retry semantics — message queues, worker restarts, partition rebalancing — that can re-enqueue an event that was already delivered. Third, the provider intentionally sends the same logical event multiple times: a payment status change might generate both a payment.updated and a payment.completed event that carry overlapping information.

A webhook handler that is not idempotent will eventually create or destroy money. The duplicate is not a bug in the provider. It is a design property of the delivery model.

Idempotent Receivers

Every webhook event carries a unique identifier. Stripe uses evt_1MqLkZ2eZvKYlo2C0NXIMR8O. Plaid uses wh_1234abcd. Your handler must record this identifier before processing the event and reject any event it has already seen.

BEGIN;

INSERT INTO webhook_events (event_id, provider, received_at, status)
VALUES ($1, $2, NOW(), 'processing')
ON CONFLICT (event_id) DO NOTHING;

-- If the insert affected 0 rows, this is a duplicate
-- RETURNING or checking affected rows tells you whether to proceed

-- ... process the event ...

UPDATE webhook_events SET status = 'completed' WHERE event_id = $1;
COMMIT;

The idempotency check and the business logic must share a transaction boundary. If you check for duplicates, process the event, and then record the event ID in separate transactions, a crash between processing and recording produces a state where the event was processed but not recorded — the next retry will process it again.

For systems where the webhook handler writes to a different datastore than the idempotency table (e.g., the handler calls an external API), use a two-phase approach: record the event as processing before doing anything, execute the side effect, then mark it completed. On retry, check the status — if it’s processing, the previous attempt may have crashed mid-execution and you need recovery logic, not a simple skip.

Strategy Duplicate protection Crash safety Complexity
Event ID in same transaction Full Full Low
Event ID in separate table, same DB Full Partial (gap between check and record) Low
Event ID in Redis + business in Postgres Partial (Redis eviction) Low Medium
Idempotency key forwarded to downstream API Full (if downstream is idempotent) Full Medium

Ordering Is Not Guaranteed

Webhook events for the same resource can arrive out of order. A payment.updated event sent at T=1 and a payment.completed event sent at T=2 may arrive at T=2 and T=1 respectively. The provider’s delivery infrastructure processes events through distributed workers with independent retry queues. Ordering is best-effort at best.

This breaks any handler that applies state transitions sequentially. If your handler processes payment.completed (setting status to completed) and then processes payment.updated (setting status to pending), the final state is wrong.

Two approaches handle this.

Timestamp-based last-write-wins. Each webhook carries a timestamp or a sequence number from the provider. Your handler only applies the update if the incoming event is newer than the last event processed for that resource.

UPDATE payments
SET status = $2, updated_at = $3
WHERE id = $1 AND updated_at < $3;

If the update affects zero rows, the event is stale — a newer event was already processed.

Fetch-on-receive. Treat the webhook as a notification, not as data. When you receive a payment.updated event, ignore the payload and fetch the current state from the provider’s API. The API always returns the latest state, regardless of what order events arrived. This adds one API call per webhook but eliminates ordering concerns entirely.

See API Design. Stripe explicitly recommends the fetch-on-receive pattern in their documentation. The webhook tells you something changed. The API tells you what the current state is.

Signature Verification

Every webhook provider signs events with HMAC-SHA256 or a similar scheme. The provider includes a signature header computed from the raw request body and a shared secret. Your handler must verify the signature before processing the event.

import hmac
import hashlib

def verify_stripe_signature(payload: bytes, signature: str, secret: str) -> bool:
    timestamp, sig = parse_signature_header(signature)

    signed_payload = f"{timestamp}.{payload.decode()}"
    expected = hmac.new(
        secret.encode(), signed_payload.encode(), hashlib.sha256
    ).hexdigest()

    if not hmac.compare_digest(expected, sig):
        return False

    # Reject events older than 5 minutes to prevent replay attacks
    if abs(time.time() - int(timestamp)) > 300:
        return False

    return True

Skipping signature verification exposes you to two attacks. First, an attacker sends a forged payment.completed event with a valid-looking structure. Your handler credits the customer without a real payment. Second, a replay attack: an attacker captures a legitimate webhook and resends it. Timestamp validation in the signature check prevents this — Stripe includes a timestamp in the signed payload, and you reject events older than 5 minutes.

Provider Signature scheme Header Replay protection
Stripe HMAC-SHA256 with timestamp Stripe-Signature Timestamp in signed payload
Plaid HMAC-SHA256 Plaid-Verification JWT with expiry
Modern Treasury HMAC-SHA256 X-Signature Timestamp header
Adyen HMAC-SHA256 X-HMAC-Signature Nonce in payload

Parse the raw request body, not a deserialized-and-reserialized version. JSON serialization is not round-trip stable — field ordering, whitespace, and Unicode escaping can change between deserialization and reserialization, producing a different byte sequence and a failed signature check.

Retry Logic and Backoff

When your handler returns a non-2xx status, the provider retries with exponential backoff. Stripe’s schedule: 5 seconds, 5 minutes, 30 minutes, 2 hours, 5 hours, 10 hours, then roughly every 12 hours up to 72 hours.

Your handler should return the correct status code to signal its intent.

Status code Meaning Provider behavior
200–299 Processed successfully No retry
400 Bad request (malformed payload) Some providers stop retrying
401/403 Auth failure (bad signature) Stop retrying
429 Rate limited Retry with longer backoff
500–599 Server error Retry with backoff
Timeout No response within window Retry with backoff

Return 200 as early as possible. Accept the event, enqueue it for processing, and return 200 before doing any business logic. If your handler performs database writes, API calls, or ledger updates synchronously before responding, you risk hitting the provider’s timeout window. A handler that takes 25 seconds to credit an account and then returns 200 at second 25 will be retried by a provider with a 20-second timeout — your system processed the event, but the provider thinks it failed.

POST /webhooks/stripe → 200 (within 2 seconds)
  └─ Enqueue to internal processing queue
       └─ Worker: validate, deduplicate, process, record

The internal queue gives you control over retry semantics, concurrency, and error handling. The webhook endpoint becomes a fast ingestion layer.

Dead Letter Queues

Events that fail processing after your internal retry budget belong in a dead letter queue, not in an infinite retry loop. A webhook event that fails because a dependent service is down will succeed when the service recovers. A webhook event that fails because the payload references a customer that does not exist in your system will fail forever.

Classify failures before deciding what to retry.

Failure type Example Action
Transient Database connection timeout Retry with backoff
Dependency Downstream API returns 503 Retry with longer backoff
Data integrity Referenced customer not found Dead letter queue
Logic error Unhandled event type Dead letter queue
Poison message Malformed JSON, invalid schema Dead letter queue, alert

Dead letter queue events require a resolution workflow: manual review, automated reprocessing after a fix, or explicit discard with an audit record. For financial events, discarding without review is not an option — a dead-lettered payment.completed event might represent real money that your system has not yet recorded.

Monitor the dead letter queue depth. A growing DLQ means your system is falling behind on event processing. Set alerts at thresholds: 10 events triggers investigation, 100 events triggers incident response, 1,000 events means something structural is broken.

When Webhooks Carry Financial Events

The failure modes above are inconveniences in a social media notification system. In a financial system, they create money, lose money, or produce audit findings.

Double-credit. A duplicate payment.completed event processed twice credits the customer twice. The money in your ledger exceeds the money in your bank account.

Missed event. A payment.failed event that your system never processes leaves a customer’s account in a pending state indefinitely. Depending on your flow, the customer either cannot use their funds or believes they were charged when they were not.

Stale state. An out-of-order refund.created event processed after a refund.completed event resets the refund status to created. The customer contacts support because their refund appears stuck.

Forged event. A payment.completed event with a valid structure but no signature verification credits a customer with no corresponding bank movement. The reconciliation break surfaces at end-of-day — or end-of-month, if your reconciliation cadence is that slow.

Every webhook handler in a financial system must be idempotent, must verify signatures, should treat webhooks as notifications rather than authoritative data, and must have a dead letter path with human review. The webhook is a trigger. The provider’s API is the source of truth. Your ledger is the system of record. The webhook connects them, and the connection is unreliable by design.