Webhook Retries and Idempotency

BackendWebhooksReliabilityAPI
Share on LinkedIn Share on X Share on Reddit Share on HN Share on Bluesky

A payment webhook fired three times because our endpoint returned 200 before the database transaction committed. The first attempt timed out at the gateway, Stripe retried, and we processed the same payment_intent.succeeded event twice — double-shipping an order. Fixing the response timing and adding idempotency checks on event ID took one day and prevented every duplicate since.

Delivery architecture

Event occurs → Queue → Delivery worker → HTTP POST → Receiver
                ↑                              |
                └── Retry scheduler ←── 4xx/5xx/timeout
                         |
                    Dead letter queue (max retries exceeded)

Never deliver webhooks synchronously from the event handler. Queue events and deliver asynchronously.

Retry schedule

Exponential backoff with jitter:

RETRY_SCHEDULE = [
    60,       # 1 minute
    300,      # 5 minutes
    1800,     # 30 minutes
    7200,     # 2 hours
    36000,    # 10 hours
    86400,    # 24 hours
]

def schedule_retry(delivery, attempt):
    if attempt >= len(RETRY_SCHEDULE):
        move_to_dead_letter(delivery)
        return

    base_delay = RETRY_SCHEDULE[attempt]
    jitter = random.uniform(0, base_delay * 0.1)
    delay = base_delay + jitter

    delivery.retry_at = now() + timedelta(seconds=delay)
    delivery.attempt = attempt + 1
    queue.enqueue(delivery, delay=delay)
Attempt Delay Cumulative
1 1 min 1 min
2 5 min 6 min
3 30 min 36 min
4 2 hours ~2.5 hours
5 10 hours ~12.5 hours
6 24 hours ~36.5 hours

Sender-side idempotency

Each event gets a unique, immutable ID:

{
  "id": "evt_20260524_001",
  "type": "payment.completed",
  "created_at": "2026-05-24T10:30:00Z",
  "data": {
    "payment_id": "pay_123",
    "amount": 4999,
    "currency": "usd"
  }
}

Store delivery attempts per event ID:

CREATE TABLE webhook_deliveries (
  id          UUID PRIMARY KEY,
  event_id    TEXT NOT NULL,
  endpoint_id UUID NOT NULL,
  attempt     INT DEFAULT 1,
  status      TEXT DEFAULT 'pending',
  response_code INT,
  retry_at    TIMESTAMPTZ,
  created_at  TIMESTAMPTZ DEFAULT now(),
  UNIQUE(event_id, endpoint_id, attempt)
);

Receiver-side deduplication

@app.post("/webhooks/payments")
async def handle_payment_webhook(request: Request):
    payload = await request.json()
    event_id = payload["id"]

    # Check if already processed
    if await redis.exists(f"webhook:processed:{event_id}"):
        return Response(status_code=200)

    # Verify signature first (before any processing)
    verify_signature(request.headers, await request.body())

    # Process the event
    await process_payment_event(payload)

    # Mark as processed (TTL covers retry window)
    await redis.set(f"webhook:processed:{event_id}", "1", ex=86400 * 3)

    return Response(status_code=200)

Return 200 for duplicates — tell the sender to stop retrying.

Idempotent processing

Make handlers safe to replay:

async def process_payment_event(payload):
    payment_id = payload["data"]["payment_id"]

    async with db.transaction():
        existing = await db.orders.find_by_payment_id(payment_id)
        if existing:
            return existing  # already processed

        order = await db.orders.create(
            payment_id=payment_id,
            amount=payload["data"]["amount"],
            status="paid",
        )
        await fulfillment.queue(order.id)
        return order

Use database unique constraints as a safety net:

ALTER TABLE orders ADD CONSTRAINT unique_payment_id UNIQUE (payment_id);

Dead letter queue

After max retries, move to DLQ for manual investigation:

def move_to_dead_letter(delivery):
    db.dead_letter_queue.insert({
        "event_id": delivery.event_id,
        "endpoint": delivery.endpoint_url,
        "payload": delivery.payload,
        "attempts": delivery.attempt,
        "last_error": delivery.last_error,
        "failed_at": now(),
    })
    alert_ops(f"Webhook delivery failed after {delivery.attempt} attempts")

Provide a dashboard for replaying DLQ events after fixing receiver issues.

Monitoring

Track these metrics:

Alert when any endpoint's success rate drops below 95% over a one-hour window.

Endpoint health scoring

Track success rate per endpoint over rolling windows. Automatically disable endpoints below 50% success for one hour — stop wasting retries on permanently broken URLs. Notify the endpoint owner with the failure reason and last successful delivery timestamp.

Payload size limits

Cap webhook payload size at 256KB. Larger events should include a URL to fetch full data. Receivers timeout on large payloads; retries compound the problem.

Resources

Operational checklist (1)

Before promoting Webhooks Retry Idempotency changes, confirm observability dashboards cover error rate and p75 latency for affected routes, rollback is documented in the pull request, and a staging drill reproduced the last known failure mode.

Field validation (2)

Re-baseline Webhooks Retry Idempotency after browser upgrades or CDN configuration changes. Mobile share above seventy percent shifts median device class — optimizations tuned on desktop lab profiles may not transfer.

Coordination (3)

Align with platform and backend owners on cache TTL, deploy windows, and API contracts when Webhooks Retry Idempotency touches shared infrastructure — single-layer wins often disappear when another tier invalidates caches.

Operational checklist (4)

Before promoting Webhooks Retry Idempotency changes, confirm observability dashboards cover error rate and p75 latency for affected routes, rollback is documented in the pull request, and a staging drill reproduced the last known failure mode.

Field validation (5)

Re-baseline Webhooks Retry Idempotency after browser upgrades or CDN configuration changes. Mobile share above seventy percent shifts median device class — optimizations tuned on desktop lab profiles may not transfer.

Coordination (6)

Align with platform and backend owners on cache TTL, deploy windows, and API contracts when Webhooks Retry Idempotency touches shared infrastructure — single-layer wins often disappear when another tier invalidates caches.

Operational checklist (7)

Before promoting Webhooks Retry Idempotency changes, confirm observability dashboards cover error rate and p75 latency for affected routes, rollback is documented in the pull request, and a staging drill reproduced the last known failure mode.

Capacity and cost notes for webhooks retry idempotency

Estimate QPS, payload size, cardinality, and downstream saturation. Functionally correct webhooks retry idempotency changes still cause outages through pool exhaustion, crawl waste, or CPU amplification.

Check Expected for webhooks retry idempotency
Happy path Pass
Injected fault Controlled degradation
After rollback Prior stable behavior

Concrete probe 1: inject the failure mode you fear for webhooks retry idempotency in staging, confirm the alarm fires, and confirm users see a controlled fallback. Record the result in the change ticket so the next on-call is not guessing.

Reviewer checklist for webhooks retry idempotency

Ask what happens when the dependency is slow, when authz is skipped on batch jobs, and when clients retry. Those three questions catch most webhooks retry idempotency regressions before production.

Concrete probe 2: inject the failure mode you fear for webhooks retry idempotency in staging, confirm the alarm fires, and confirm users see a controlled fallback. Record the result in the change ticket so the next on-call is not guessing.

Incident patterns around webhooks retry idempotency

Most incidents involving webhooks retry idempotency start as a silent drift: a secondary path skips the control, a retry amplifies load, or a config default from a tutorial ships to production. Write the failure story before the happy path.

Check Expected for webhooks retry idempotency
Happy path Pass
Injected fault Controlled degradation
After rollback Prior stable behavior

Concrete probe 3: inject the failure mode you fear for webhooks retry idempotency in staging, confirm the alarm fires, and confirm users see a controlled fallback. Record the result in the change ticket so the next on-call is not guessing.

Invariants to enforce for webhooks retry idempotency

Name three invariants that must hold after every deploy of webhooks retry idempotency. Encode at least one in an automated test that fails when the invariant is disabled. Reviewers should reject PRs that only cover the primary UI path.

Concrete probe 4: inject the failure mode you fear for webhooks retry idempotency in staging, confirm the alarm fires, and confirm users see a controlled fallback. Record the result in the change ticket so the next on-call is not guessing.

Telemetry and ownership for webhooks retry idempotency

Pair a leading operational signal with a lagging user or risk outcome. Page on burn related to webhooks retry idempotency, not vanity counters. Keep a named owner and a dashboard link in the service catalog entry.

Check Expected for webhooks retry idempotency
Happy path Pass
Injected fault Controlled degradation
After rollback Prior stable behavior

Concrete probe 5: inject the failure mode you fear for webhooks retry idempotency in staging, confirm the alarm fires, and confirm users see a controlled fallback. Record the result in the change ticket so the next on-call is not guessing.

Rollout sequence for webhooks retry idempotency

Prefer flags, weighted routes, or dual-running configs. Rehearse rollback once in staging. The on-call note for webhooks retry idempotency should include the revert command and the expected user-visible effect within five minutes.

Concrete probe 6: inject the failure mode you fear for webhooks retry idempotency in staging, confirm the alarm fires, and confirm users see a controlled fallback. Record the result in the change ticket so the next on-call is not guessing.

Cross-team contracts for webhooks retry idempotency

Document producers, consumers, timeouts, and idempotency keys. Silent schema or policy changes are how webhooks retry idempotency breaks without a clear owner in the incident channel.

Check Expected for webhooks retry idempotency
Happy path Pass
Injected fault Controlled degradation
After rollback Prior stable behavior

Concrete probe 7: inject the failure mode you fear for webhooks retry idempotency in staging, confirm the alarm fires, and confirm users see a controlled fallback. Record the result in the change ticket so the next on-call is not guessing.

Frequently asked questions

Why do webhooks need retry logic?

Webhook receivers go down, return 503, hit rate limits, or take too long to respond. Without retries, events are lost permanently. At-least-once delivery requires retrying failed deliveries until the receiver acknowledges success with a 2xx response. Most webhook providers retry over hours or days with increasing intervals.

How does idempotency prevent duplicate webhook processing?

Retries mean the same event may arrive multiple times. Idempotency keys — usually the event ID — let receivers detect and skip duplicates. The sender includes a unique event ID in every payload. The receiver stores processed IDs and rejects or ignores events it has already handled. Without this, a retried payment webhook could charge a customer twice.

What HTTP status codes should webhook receivers return?

Return 2xx (200, 201, 204) to acknowledge successful processing and stop retries. Return 4xx (except 429) for permanent failures the sender should not retry — invalid payload, unknown event type. Return 429 or 5xx for temporary failures that should trigger retry. Never return 2xx before processing completes — the sender will not retry after a success response.

Hiring a senior Android / Flutter engineer?

I architect and ship production mobile software — Kotlin, Jetpack Compose, Flutter — for robotics, EV infrastructure, fintech, and real-time systems. Open to remote roles in Europe and the US.

Get in touch →