Webhook Retry & Timeout Strategies

Reliable subscription billing depends on deterministic event delivery, and that determinism has to survive network partitions, provider outages, and your own deploy windows. When a payment gateway or tax provider stalls, your retry mechanism is the only thing standing between a transient blip and a corrupted ledger. This page sits under Webhook Processing & Backend State Management and details the timeout configurations, backoff algorithms, and reconciliation patterns that keep revenue accurate under load. Retry logic that is engineered rather than guessed prevents revenue leakage, preserves PCI-DSS audit trails, and stops false-positive failures from kicking customers into dunning they never earned.

The distinction worth internalizing before anything else is that a webhook retry is not a business decision — it is a delivery-layer contract between you and the provider. The provider commits to redelivering an event until it observes a 2xx, and you commit to making the effect of that event idempotent so redelivery is free. Every design choice on this page flows from that contract. If you break the idempotency half, the provider’s perfectly reasonable retries turn into double invoice_id postings; if you break the acknowledgment half by doing slow work before returning 200, the provider’s read timeout fires and you get retries you never needed. Most billing incidents traced back to webhooks are not exotic — they are one of those two halves quietly violated under load, then amplified by a retry curve that assumed the happy path. Getting the timeout budgets and backoff schedule right is what keeps a five-second provider hiccup from cascading into a reconciliation job that runs all weekend.

It also helps to be precise about whose retries you are engineering. There are two independent retry loops in a billing system and they must never be conflated. The first is the provider’s outbound loop — Stripe, Adyen, or a tax engine redelivering to your endpoint on their schedule, which you do not control and can only influence by returning the right status codes. The second is your internal loop — your worker re-attempting a downstream mutation (a ledger write, a provisioning call, a second-hop webhook to a fulfillment service) after a transient failure that the provider never sees. The two loops have different failure domains, different idempotency keys, and different ceilings, and the single most common architectural mistake is letting the provider’s redelivery double as your internal retry. When that happens, a slow database means the provider hammers your endpoint, each hit re-runs partial work, and the amplification is multiplicative rather than additive.

Prerequisites

A retry pipeline is only as safe as the guarantees around it: without idempotency, retries double-charge; without a durable queue, they vanish on restart; without a DLQ, exhausted events disappear silently. Each prerequisite closes one of those holes. The stack shows them before the checklist.

Retry pipeline prerequisites A signed endpoint, an idempotency store, a durable queue runtime, a dead-letter queue, and correlated structured logging underpin safe retries. Safe retry pipeline Signed endpoint HMAC first Idempotency no double-charge Durable queue survives restart Dead-letter operator surface Correlated logs event + sub id
Five prerequisites, each closing one hole — dedup, durability, and a DLQ are the load-bearing three.

A subtle point about the idempotency store: it must be checked and written inside the same transaction as the ledger mutation, not as a separate cache lookup that races the write. If you check Redis, see nothing, then start a slow ledger transaction, a redelivery that lands during that window will also see nothing in Redis and proceed in parallel. The store that actually protects you is the one whose uniqueness is enforced by the same commit that posts the money — typically a unique(event_id) constraint on the ledger-entry table or an inserts-only processed_events table joined into the same BEGIN. Redis in front of that is a latency optimization, not the source of truth. Treat any design where the dedup check and the mutation can commit independently as unprotected, because under retry pressure the two-phase gap is exactly where duplicates are born.

The durable queue prerequisite carries a requirement people skip: the enqueue must happen before you return 200, and it must be the only slow thing on the acknowledgment path. If your handler writes the raw event to Postgres, publishes to SQS, and updates a metrics counter all before acking, then the provider’s read timeout is racing three dependencies instead of one. Persist the raw body to a single durable store, ack, and let the worker do everything else. That single write is the entire promise you make to the provider — you have the event, you will process it, go away. Everything downstream of that write is your problem to retry internally, invisible to the provider’s loop.

One more prerequisite that rarely makes checklists but belongs on this one: a monotonic, per-endpoint clock skew budget. Signature timestamps are validated against your server clock, and if your workers drift more than the replay window (300 seconds here) you will reject legitimate events as stale or accept genuinely old replays. Pin NTP on every node that terminates webhooks and alarm on drift over about 30 seconds, well inside the replay window, so a slowly desyncing clock surfaces as a monitoring page rather than a wave of spurious 400s during a provider’s busy hour.

Architecture & Data Flow

The retry pipeline decouples three responsibilities that naive handlers conflate: HTTP acknowledgment, processing, and scheduling. The endpoint verifies the signature, persists the raw event, and returns 200 fast so the provider never times out. A worker then pulls events, applies them inside a transaction, and on failure schedules the next attempt with backoff. A circuit breaker watches the provider’s health and short-circuits the loop during a wide outage, parking traffic in a fallback queue until recovery.

Webhook retry pipeline A signed event is acknowledged, queued, and processed by a worker that retries with backoff, trips a circuit breaker on provider outages, and dead-letters exhausted events. Gateway webhook Verify + ack return 200 Durable queue Retry worker + backoff Circuit breaker Ledger + idempotency Dead-letter queue
Acknowledge fast, retry with backoff behind a circuit breaker, and dead-letter what the retry ceiling cannot recover.

The flow is: inputs (signed gateway events) → processing (verify, enqueue, retry with backoff while the breaker is closed, mutate the ledger idempotently) → outputs (settled ledger entries, or DLQ records when attempts are exhausted). Critical lifecycle events — cancellations, hard declines — get a high-priority lane with a tighter schedule so they never wait behind a bulk reconciliation backlog.

Why acknowledgment and processing must be separate hops

The reason the endpoint and the worker are different processes is that they answer to different deadlines. The endpoint answers to the provider’s read timeout, which for most gateways sits between 10 and 30 seconds and is not negotiable per-request. The worker answers to your own SLA for how quickly a subscription_id must reach past_due after a failed charge, which might be minutes. If you collapse the two, every slow ledger write borrows against the provider’s timeout budget, and the moment a downstream dependency — the tax engine, a foreign-key check, a lock on a hot customer_id row — adds latency, the provider times out and redelivers. Now you are processing the same event twice concurrently, and the second copy started because the first was slow, which is precisely the condition under which your idempotency guarantees are most stressed. Separating the hops converts that failure mode from a correctness problem into a throughput problem: the queue simply gets deeper, and depth is something you can watch and drain, whereas concurrent double-processing is something you can only detect after the ledger is already wrong.

State machine of a single event

It helps to model each event as a small state machine rather than a function call. An event moves through receivedqueuedprocessing → (processed | retry_scheduled | dead_lettered), and the only legal transitions are forward. The retry count and the next-attempt timestamp live on this record, not in the provider’s headers, so your schedule is authoritative and survives a provider that redelivers on its own cadence. Storing state explicitly also gives operators a queryable answer to “what is happening to event_id = evt_charge_failed_88f2”: a single row shows how many attempts have burned, when the next fires, and whether the breaker for that provider is currently open. Without that record, the same question requires correlating log lines across three services under time pressure during an incident, which is when you can least afford the archaeology. The record is cheap — a few columns keyed by event_id — and it is the difference between a retry pipeline you can reason about and one you merely hope is working.

Implementation Walkthrough

The five steps build the pipeline from the edge inward: verify and ack fast, schedule retries with jittered backoff, tune timeouts per endpoint class, wrap providers in a breaker, then commit state and acknowledgment atomically. The backoff curve below is the heart of it — delay grows exponentially, jitter decorrelates workers, and a ceiling caps the tail.

Backoff schedule Retry delay grows exponentially from about five minutes to a twenty-four-hour ceiling, with randomized jitter added to each attempt to break synchronized retries. delay attempt 5m 1h 6h 24h cap + jitter each attempt
Exponential growth to a 24-hour ceiling, jittered per attempt so recovering providers are not thundered.

1. Verify, deduplicate, and acknowledge

Reject forged or stale payloads first, then short-circuit on anything already processed. Returning 200 for a known event_id is correct: the provider has done its job, and you have done yours.

import hmac, hashlib, time

REPLAY_WINDOW_SECONDS = 300  # reject signatures older than 5 minutes

def verify_and_ingest(event_id: str, raw_body: bytes, signature: str,
                      timestamp: int, signing_secret: bytes) -> int:
    if abs(time.time() - timestamp) > REPLAY_WINDOW_SECONDS:
        return 400  # ✗ stale signature, possible replay

    signed = f"{timestamp}.".encode() + raw_body
    expected = hmac.new(signing_secret, signed, hashlib.sha256).hexdigest()
    if not hmac.compare_digest(expected, signature):
        return 401  # ✗ bad signature

    if idempotency_store.exists(event_id):
        return 200  # ✅ already processed, acknowledge without reprocessing

    enqueue(event_id, raw_body)  # hand off to durable queue, then ack
    return 200

The verify_and_ingest function above is deliberately doing the minimum. Notice what it does not do: it does not parse the JSON body beyond what HMAC needs, it does not look up the customer_id, and it does not touch the ledger. That restraint is the point. Signature verification runs on the raw bytes because any deserialization-then-reserialization risks changing the byte sequence the HMAC was computed over — a reordered key or a normalized number silently invalidates an otherwise valid signature. Validate the timestamp first because it is the cheapest rejection and it caps how long a captured-then-replayed request stays useful to an attacker to the replay window. Only after both pass do you consult the dedup store, and only after that do you enqueue. Each stage is ordered cheapest-and-most-security-critical first, so a flood of forged requests is rejected before it can cost you a queue write.

2. Schedule retries with exponential backoff and jitter

Backoff spreads load; jitter breaks the synchronized thundering herd that forms when a provider recovers and every worker retries on the same boundary. Cap attempts and the per-attempt delay so the schedule aligns with gateway SLAs (Stripe, for example, retries on its own multi-day curve).

The jitter strategy shown — adding a uniform random value between zero and one base delay on top of the exponential term — is deliberately the “full-ish jitter” variant rather than pure exponential or pure random. Pure BASE_DELAY * 2**attempt is the worst possible choice under a correlated outage: every event that failed at the same instant retries at the same instant, so a provider that comes back at second 3600 is hit by the entire hour’s backlog in one spike, which knocks it back down. Adding unbounded jitter fixes the correlation but smears the tail unpredictably. The bounded additive jitter here keeps the schedule roughly predictable for operators reading dashboards while still decorrelating the fleet enough that no two workers land on the same millisecond. If your fleet is large — hundreds of workers draining one provider — consider clamping the low end of the jitter above zero as well, because a jitter range that includes near-zero still lets a meaningful fraction of workers cluster at the exponential boundary.

The MAX_RETRIES = 5 ceiling deserves scrutiny against real gateway behavior. Five internal attempts over a curve that reaches a 24-hour cap spans several days, which for a failed renewal charge is usually the right envelope — it covers a customer’s card being temporarily over-limit and then topped up. But the ceiling interacts with the provider’s own retry curve, and you do not want them multiplying. If Stripe is independently redelivering invoice.payment_failed for three days and your worker is also retrying the downstream provisioning for three days, a single logical failure can generate dozens of processing attempts. Anchor your ceiling to a business event, not a count: stop internal retries once the subscription has definitively transitioned to past_due and dunning has taken ownership, because past that point the retry is chasing state the dunning subsystem now controls.

Choosing the base delay against provider timeouts

The BASE_DELAY of 60 seconds is not arbitrary — it should be at least an order of magnitude larger than the provider’s typical recovery time for a transient blip, so your first retry does not land inside the same outage that caused the first failure. If a gateway’s median transient error clears in two to three seconds, a 60-second first retry wastes a little time on the fast-recovery case but costs almost nothing, whereas a five-second first retry would frequently re-hit an outage that has not cleared. The asymmetry favors patience: a retry that fires too early consumes an attempt from your ceiling and adds load to a struggling provider, while a retry that fires slightly late only delays recovery by seconds a customer will never notice. For payment authorization specifically, err longer, because issuers apply their own velocity checks and a burst of rapid re-authorizations against the same customer_id card can trip fraud heuristics that decline a card which would otherwise have approved.

import random

BASE_DELAY = 60          # seconds
MAX_DELAY = 86_400       # 24 hours
MAX_RETRIES = 5

def next_delay(attempt: int) -> int:
    jitter = random.uniform(0, BASE_DELAY)        # decorrelate workers
    return int(min(BASE_DELAY * (2 ** attempt) + jitter, MAX_DELAY))

def process(event_id: str, payload: dict) -> int:
    attempt = get_retry_count(event_id)
    if attempt >= MAX_RETRIES:
        route_to_dlq(event_id, payload)           # ⚠️ exhausted, hand to operators
        return 200
    try:
        apply_ledger_mutation(event_id, payload)  # idempotent, transactional
        idempotency_store.mark_processed(event_id)  # ✅ success path
        return 200
    except TransientError:
        schedule_retry(event_id, next_delay(attempt))  # ⚠️ retry path
        return 503

3. Tune timeouts per endpoint class

Connection, read, and write timeouts need independent budgets. A synchronous decline check should fail fast; an async tax recalculation can wait. A 504/524 means infrastructure latency, not a declined card — never let it trigger dunning.

The three-way split between connect, read, and write timeouts matters because they fail for different reasons and want different responses. A connect timeout usually means the provider is unreachable — DNS, a dead load balancer, a network partition — and it is almost always safe to retry immediately against a different connection, because no request was ever delivered. A read timeout is the dangerous one: the request was delivered, the provider may have processed it, and you simply did not hear the answer in time. Retrying a read timeout on a non-idempotent operation is how you double-charge, which is exactly why the whole pipeline insists on an idempotency_key on the outbound authorization call itself, so that a retried read-timeout replays the provider’s cached result rather than charging the card twice. A write timeout, where you could not even finish sending the request body, is generally as safe to retry as a connect timeout. Encoding these three cases as distinct exception types in your client, rather than catching a single Timeout, is what lets the worker make the right retry-or-not decision instead of guessing.

The two endpoint classes in the config — a two-second-connect synchronous decline check versus a thirty-second-read async tax calculation — illustrate why one global timeout is malpractice. If you set a single 30-second budget, your synchronous checkout path inherits a 30-second worst case and a customer stares at a spinner while a tax service that should have failed at second three keeps you waiting. If you set a single two-second budget, your async tax recalculation gets killed mid-flight and you retry a computation the provider was about to finish. Budget per operation according to what the caller can tolerate: a human waiting on a checkout button gets aggressive, fail-fast timeouts and a graceful degradation path; a background reconciliation for subscription_id renewals gets patient timeouts because nobody is watching and a slower-but-completed answer beats a fast failure. The idle: 30s connection-pool setting is separate again — it governs how long a keep-alive socket to the provider survives between requests, and setting it below the provider’s own idle timeout avoids the race where you send on a socket the provider has already half-closed.

timeouts:
  synchronous_decline_check:
    connect: 2s
    read: 5s
    write: 5s
  async_tax_calculation:
    connect: 3s
    read: 30s
    write: 10s
  idle: 30s

4. Wrap providers in a circuit breaker

The breaker counts consecutive 5xx responses and opens after a threshold, parking work in a fallback queue. A half-open probe lets a trickle through before fully closing.

The breaker exists to solve a problem backoff alone cannot: during a sustained provider outage, retrying every event on its own backoff curve still means thousands of independent requests keep hammering a provider that is already down, wasting your worker capacity on calls that cannot succeed and slowing the provider’s own recovery. The breaker collapses all of that into a single question asked once — is this provider healthy? — and when the answer is no, it fails fast for every event without spending a socket. The failure_threshold: 5 counts consecutive failures rather than a rate, which is a deliberate choice: a rate-based threshold (“open at 50% errors”) can flap when traffic is low, because two failures out of three requests trips it, whereas consecutive-count semantics require a genuine sustained failure. For a high-volume provider you may prefer a sliding-window error-rate breaker instead, but for the moderate webhook volumes typical of billing, consecutive-count is simpler to reason about and less prone to flapping.

The half-open state is where breakers are most often implemented wrong. When reset_timeout expires the breaker must admit a strictly limited number of probe requests — half_open_max_requests: 3 here — and it must not admit the next probe until the previous one resolves. A common bug is letting all queued work rush the half-open gate at once: the timeout fires, a thousand parked events all see a half-open breaker, all fire simultaneously, and either they overwhelm a provider that had barely recovered or they collectively decide health based on a stampede rather than a controlled probe. The correct implementation gates half-open admission behind a counter or a token so exactly three requests test the water; if they succeed the breaker closes and normal flow resumes, and if any fail the breaker re-opens for another full reset_timeout. Note also that the breaker should key on the provider, not the endpoint — a Stripe outage should not trip the breaker guarding your tax vendor, so maintain one breaker per upstream dependency and let each fail independently.

One integration detail ties the breaker back to timeouts: a 504 or 524 and a slow-but-eventually-200 both consume a timeout budget, but only the former should count toward the breaker’s failure tally. If you count slow successes as failures you will open the breaker during a provider’s merely-degraded period and convert a slowdown into a self-inflicted outage. Count toward the failure threshold only responses that are actually unrecoverable within the attempt — connection refusals, 5xx, and timeouts — and treat a slow 200 as a latency signal that feeds a separate alarm, not the breaker.

circuit_breaker:
  failure_threshold: 5         # consecutive 5xx before opening
  reset_timeout: 120s          # stay open this long before half-open
  half_open_max_requests: 3    # probes allowed while half-open
  fallback_queue: "payment_retry_dlq"

5. Commit state and the acknowledgment atomically

Use a transactional outbox so the domain write and the downstream publish share one commit. A separate relay drains the outbox, giving you at-least-once delivery without dual-write races. For the cross-service propagation that follows, see Database Sync & Consistency Patterns.

BEGIN;
  UPDATE subscriptions
     SET status = 'past_due', updated_at = now()
   WHERE subscription_id = $1;

  INSERT INTO event_outbox (outbox_id, event_id, aggregate_id, payload, status)
  VALUES (gen_random_uuid(), $2, $1, $3, 'pending');
COMMIT;

-- Relay worker drains the outbox without lock contention
SELECT outbox_id, event_id, payload
  FROM event_outbox
 WHERE status = 'pending'
 ORDER BY created_at
 LIMIT 100
   FOR UPDATE SKIP LOCKED;
-- publish to broker, then UPDATE status = 'sent'

Edge Cases & Failure Modes

The retry-specific failures cluster around three confusions: a retry mistaken for a new charge, a timeout mistaken for a decline, and an out-of-order event mistaken for a retry. Each has a distinct fix. The map sorts them.

Retry failure confusions Duplicate retries need a DB constraint, a 504 timeout must not trigger dunning, and out-of-order dunning events need buffering by sequence. Retry = charge? overlapping windows duplicate attempts → DB unique key Timeout = decline? 504 / 524 infra premature dunning → treat as retryable Order = retry? stale created_at premature suspend → buffer by sequence
Three confusions, three fixes — a constraint, a retryable classification, and a sequence buffer.
Failure scenario Symptom Mitigation
Overlapping retry windows Duplicate charge attempts Idempotency key enforced at a DB unique constraint before mutation
Proration webhook storm Tax recalculation loops in one cycle Deduplicate by event_type + created_at before invoking the tax engine
Provider rate-limit exhaustion Ledger desync, 429 floods Circuit breaker opens; fallback queue drains at a throttled rate
Timeout misread as decline Premature dunning, churn Treat 504/524 as retryable infra latency, not a hard decline
Out-of-order dunning events Premature suspension Buffer until predecessor arrives — see the sibling page below
Abrupt connection drop mid-write Partial state commit Single-transaction mutation; outbox makes the publish atomic with state

Performance & Scale

The scale challenge is the renewal-day burst: tens of thousands of events fanning into minutes. The defenses are batch pulls with SKIP LOCKED, a partitioned idempotency table, an indexed outbox scan, and a bounded backoff so backlog cannot outlive the incident. The diagram shows the four levers.

Retry scale levers Batch pulls with SKIP LOCKED, partition the idempotency table, index the outbox scan, and bound the backoff to prevent runaway backlog. Batch pull 100 rows SKIP LOCKED Partition idempotency by customer Index outbox (status, created) cheap relay scan Bound backoff cap in-flight no runaway backlog
Four levers absorb the renewal burst without workers blocking each other or the backlog outliving the incident.

At 100k subscriptions a renewal day can fan out tens of thousands of events into a few minutes. Pull events in batches of 100 with FOR UPDATE SKIP LOCKED so workers never block one another. Partition the idempotency table (or shard the Redis key space) by customer_id to keep lookups O(1) and index event_outbox (status, created_at) for the relay scan. Keep retry delays bounded — an unbounded backoff curve quietly accumulates a backlog that outlives the incident. Cap concurrent in-flight retries per provider so a recovering gateway is not re-hammered the instant the breaker half-opens.

SKIP LOCKED is the workhorse here and it is worth understanding what it buys you. Without it, two workers issuing SELECT ... FOR UPDATE LIMIT 100 against the same pending-events table serialize: the second worker blocks on the rows the first has locked, and your horizontal scaling evaporates because adding workers just adds contention. SKIP LOCKED tells Postgres to silently pass over already-locked rows and grab the next unlocked batch, so N workers partition the backlog among themselves with no coordination and no lock waits. The cost is that ordering is best-effort — a worker may process event 200 before event 150 because 150 was locked when it looked — which is exactly why event ordering must be enforced by sequence buffering downstream rather than by queue draining order. Do not try to make SKIP LOCKED also give you ordering; the two goals are in tension and the queue should optimize purely for throughput while a separate sequencing layer handles causality.

The renewal-day burst is a scheduling problem, not a capacity problem

The instinct when a renewal day melts the pipeline is to add workers, but raw worker count rarely helps because the constraint is almost never your CPU — it is a downstream dependency with its own limits. The gateway rate-limits you, the tax engine has a concurrency cap, and your own database has a finite connection pool. Doubling workers against any of those just moves the queue from your side to theirs, and against a rate-limited provider it actively hurts by burning your quota on 429s. The durable answer is admission control: cap concurrent in-flight requests per downstream dependency at or just below that dependency’s known limit, let the queue absorb the burst as depth, and drain at the sustainable rate. A renewal burst of 40,000 events against a provider that sustains 200 requests per second is not an emergency — it is a backlog that drains in a few minutes, and depth is a metric you can watch calmly rather than an outage you fight. This is why the bounded backoff matters at scale: it keeps the total in-flight population finite, so the backlog is a queue you are draining rather than an ever-growing set of scheduled futures with no ceiling.

Backpressure and the cost of unbounded queues

A durable queue is not infinite in practice even when it is nominally unbounded, and pretending otherwise hides a failure mode. If events arrive faster than they drain for long enough, memory or disk fills, the queue’s own latency climbs, and eventually the enqueue on your acknowledgment path — the one thing that must stay fast — slows down, and now the provider’s read timeout fires and you are back to concurrent redelivery. Design an explicit backpressure signal: when queue depth crosses a high-water mark, shed or defer the lowest-value traffic first. Bulk proration recalculations and analytics-driven events can wait; a charge.failed for an active subscription_id cannot. The high-priority lane mentioned in the architecture section is the mechanism — critical lifecycle events bypass the general queue precisely so that a flood of low-value events can never starve them. Measuring depth per lane, and alarming on the critical lane long before the bulk lane, is what turns “the queue is backing up” from a 3 a.m. surprise into a graph someone watched trend for an hour.

Testing Strategy

Determinism is the whole game — a mock clock makes backoff schedules assertable without real waits. Around it: a replay test for idempotency, a forged-signature test, and a 504-storm test that proves the breaker opens and half-opens on schedule. The panel lists them.

Retry test suite Mock-clock backoff assertions, replay idempotency, forged-signature rejection, and a 504 storm that opens the breaker after the failure threshold. Mock clock backoff schedule assertable Replay same event id one entry Forgery flipped byte 401 504 storm breaker opens then half-opens
Four tests anchored on a mock clock — the breaker test asserts open-then-half-open on exact thresholds.

Determinism is the whole game. Inject a mock clock so backoff schedules are assertable without real waits. Replay the same event_id twice and assert a single ledger entry. Forge a signature with a flipped byte and assert 401. Simulate a 504 storm and assert the breaker opens after exactly failure_threshold consecutive failures, then half-opens after reset_timeout. Fuzz delivery order and duplicates against staging, and run a daily reconciliation query that diffs gateway settlements against internal ledger entries to catch anything that slipped the real-time path.

def test_duplicate_event_is_idempotent(clock, ledger):
    process("evt_inv_paid_001", payload)
    process("evt_inv_paid_001", payload)  # redelivery
    assert ledger.count(event_id="evt_inv_paid_001") == 1  # ✅ exactly once

Frequently Asked Questions

What is the optimal retry window for failed subscription payment webhooks? A 72-hour window with exponential backoff (roughly 5m, 1h, 6h, 24h) balances recovery odds against provider SLA limits. Critical lifecycle events such as cancellations should bypass the standard queue and use a tighter high-priority schedule with circuit-breaker fallback.

How do I prevent duplicate ledger entries during aggressive retries? Enforce the idempotency key at the database constraint level, not just in application logic. Verify the signature, check the store, and if the event_id already exists, return 200 immediately. Pairing this with an idempotent webhook handler gives you exactly-once effects across redeliveries.

Should webhook timeouts trigger immediate dunning emails? No. A 504 or 524 indicates infrastructure latency, not a payment failure. Dunning should only fire after a definitive decline code or after the configured retry window is exhausted. Premature dunning increases involuntary churn and breaches fair-billing expectations.

How is a retry different from an out-of-order delivery? A retry reuses the same event_id and created_at; out-of-order delivery introduces a new event whose created_at predates the last one you processed. The first is a deduplication problem, the second a sequencing problem.

What HTTP status should my endpoint return to make the provider stop retrying? Return 200 (or any 2xx) once you have durably persisted the raw event, even if downstream processing has not run yet — the 2xx tells the provider its delivery contract is satisfied. Return 4xx only for events you will never be able to process, such as a failed signature or a malformed body, because most providers treat 4xx as permanent and stop retrying; returning 4xx for a transient problem discards an event you actually wanted. Return 5xx (or let the request time out) only when you genuinely could not persist the event and want the provider to redeliver. The trap is returning 200 after a crash in your own async processing: the provider is now satisfied and will never redeliver, so your internal retry loop and DLQ are the only safety net left, which is why they are prerequisites rather than nice-to-haves.

Should I retry a webhook that returned a 409 Conflict from my downstream service? Usually no, and understanding why sharpens the whole model. A 409 from your own downstream typically means the state you are trying to write already exists — the invoice_id was already marked paid, the subscription_id already transitioned. That is not a failure to retry; it is idempotency working, and the correct action is to treat it as success and acknowledge. Blindly retrying 409s burns your attempt ceiling on an operation that will keep conflicting, and worse, it can mask a genuine ordering bug where a later event is losing to an earlier one that should have been superseded. Inspect the conflict, and if it means “already done,” record success rather than scheduling another attempt.

How long should events live in the dead-letter queue before I purge them? Keep them at least as long as your reconciliation and dispute windows, which for card payments means tens of days, not hours. A dead-lettered charge.failed that nobody replayed is a customer who may not be in the billing state you think they are, and the DLQ record is your evidence trail when finance or a chargeback investigation asks what happened to a given customer_id. Purge on a schedule tied to the longest downstream obligation — dispute deadlines, tax filing periods, audit retention — and make replay from the DLQ idempotent by construction, so an operator draining a month-old backlog cannot double-apply an effect the real-time path already handled.

Does retrying webhooks risk sending duplicate customer emails or provisioning calls? Yes, and this is the most common way idempotency-at-the-ledger still leaks a visible bug. Making the ledger write idempotent protects money, but the side effects triggered by that write — a receipt email, a provisioning API call, a Slack alert — often live outside the transaction and fire once per processing attempt rather than once per event. The fix is to gate every externally visible side effect on the same idempotency record that guards the ledger, or better, to emit side effects through the outbox so they inherit exactly-once semantics from the relay. A customer who gets three “your payment failed” emails from one failed invoice_id will not care that your ledger was perfectly consistent.