Payment Webhook Receiver Low-Level Design: Stripe, Verification, and Idempotency

A payment webhook receiver processes incoming HTTP callbacks from payment processors (Stripe, PayPal, Adyen) notifying your system of payment events: charges succeeded, subscriptions renewed, disputes opened. The design must be correct above all else — missed or double-processed payment events cause financial errors. The three requirements are: signature verification (reject forged webhooks), idempotent processing (handle duplicate deliveries safely), and reliable acknowledgment (return 200 quickly to prevent retries, process asynchronously).

Core Data Model

CREATE TABLE WebhookEvent (
    event_id        VARCHAR(100) PRIMARY KEY,  -- provider's event ID (e.g., Stripe evt_xxx)
    provider        VARCHAR(50) NOT NULL,       -- 'stripe', 'paypal', 'adyen'
    event_type      VARCHAR(100) NOT NULL,      -- 'payment_intent.succeeded'
    payload         JSONB NOT NULL,
    status          VARCHAR(20) NOT NULL DEFAULT 'pending',
    -- pending, processing, processed, failed
    received_at     TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    processed_at    TIMESTAMPTZ,
    attempt_count   INT NOT NULL DEFAULT 0,
    error_message   TEXT,
    idempotency_key VARCHAR(200) UNIQUE  -- derived key for business dedup
);

CREATE INDEX idx_webhook_status ON WebhookEvent(status, received_at)
    WHERE status IN ('pending', 'failed');

Receive, Verify, and Enqueue (Fast Path)

from fastapi import FastAPI, Request, HTTPException, BackgroundTasks
import hmac, hashlib, time

app = FastAPI()

STRIPE_WEBHOOK_SECRET = os.environ['STRIPE_WEBHOOK_SECRET']

def verify_stripe_signature(payload: bytes, sig_header: str, secret: str) -> bool:
    """Verify Stripe webhook signature (HMAC-SHA256)."""
    # Stripe sends: t=timestamp,v1=signature
    parts = dict(p.split('=', 1) for p in sig_header.split(','))
    timestamp = parts.get('t', '')
    signature = parts.get('v1', '')

    # Reject if timestamp is more than 5 minutes old (replay attack prevention)
    if abs(time.time() - int(timestamp)) > 300:
        return False

    # Compute expected signature: HMAC(timestamp + "." + payload)
    signed_payload = f"{timestamp}.".encode() + payload
    expected = hmac.new(secret.encode(), signed_payload, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, signature)

@app.post("/webhooks/stripe")
async def receive_stripe_webhook(request: Request, background_tasks: BackgroundTasks):
    payload = await request.body()
    sig_header = request.headers.get('stripe-signature', '')

    if not verify_stripe_signature(payload, sig_header, STRIPE_WEBHOOK_SECRET):
        raise HTTPException(status_code=400, detail="Invalid signature")

    event = json.loads(payload)
    event_id = event['id']

    # Fast path: persist and acknowledge immediately
    # Processing happens asynchronously
    db.execute("""
        INSERT INTO WebhookEvent (event_id, provider, event_type, payload)
        VALUES (%s, 'stripe', %s, %s)
        ON CONFLICT (event_id) DO NOTHING
    """, [event_id, event['type'], json.dumps(event)])

    # Enqueue for async processing (non-blocking)
    background_tasks.add_task(enqueue_webhook, event_id)

    # Must return 2xx quickly -- Stripe retries if no response within 30s
    return {"received": True}

Idempotent Event Processing

def process_webhook_event(event_id: str):
    with db.transaction():
        # Lock and check status
        event = db.fetchone("""
            SELECT * FROM WebhookEvent
            WHERE event_id = %s AND status = 'pending'
            FOR UPDATE SKIP LOCKED
        """, [event_id])
        if not event:
            return  # already processed or being processed by another worker

        db.execute("""
            UPDATE WebhookEvent
            SET status='processing', attempt_count=attempt_count+1
            WHERE event_id=%s
        """, [event_id])

    try:
        payload = event['payload']
        handler = WEBHOOK_HANDLERS.get(event['event_type'])
        if handler:
            handler(payload)

        db.execute("""
            UPDATE WebhookEvent SET status='processed', processed_at=NOW()
            WHERE event_id=%s
        """, [event_id])
    except Exception as e:
        db.execute("""
            UPDATE WebhookEvent SET status='failed', error_message=%s
            WHERE event_id=%s
        """, [str(e), event_id])

# Example handler: payment succeeded
def handle_payment_intent_succeeded(payload: dict):
    payment_intent_id = payload['data']['object']['id']
    amount = payload['data']['object']['amount_received']
    metadata = payload['data']['object']['metadata']
    order_id = metadata.get('order_id')

    db.execute("""
        UPDATE Order
        SET payment_status='paid', paid_at=NOW(), amount_paid=%s
        WHERE order_id=%s AND payment_status='pending'
    """, [amount / 100, order_id])
    # The WHERE payment_status='pending' guard prevents double-processing

Retry and Alerting

def retry_failed_webhooks():
    """Retry failed webhooks with backoff. Stripe will also retry from its side."""
    failed = db.fetchall("""
        SELECT event_id FROM WebhookEvent
        WHERE status='failed' AND attempt_count  NOW() - INTERVAL '48 hours'
        ORDER BY received_at ASC
        LIMIT 100
        FOR UPDATE SKIP LOCKED
    """)
    for row in failed:
        db.execute("""
            UPDATE WebhookEvent SET status='pending' WHERE event_id=%s
        """, [row['event_id']])
        enqueue_webhook(row['event_id'])

Key Interview Points

  • Verify the signature on every request — never trust incoming webhooks without HMAC verification. An unverified webhook endpoint is an unauthenticated write API for your payment state.
  • Return 200 within 30 seconds — most payment processors retry if no response is received quickly. Do the minimal work (persist, enqueue) synchronously; process asynchronously.
  • The event_id primary key (ON CONFLICT DO NOTHING) is your idempotency guard — Stripe can deliver the same event multiple times; you process it once.
  • Business-level idempotency in handlers (WHERE payment_status=’pending’) catches the case where the webhook is processed twice due to a race before the event_id guard kicks in.
  • Monitor webhook processing lag (received_at to processed_at). Alert if P95 lag exceeds 60 seconds — payment state should update near-immediately for good UX.
  • Replay missed webhooks: payment processors provide a dashboard to resend events. Your endpoint must be idempotent so replayed events are safe to process.

{“@context”:”https://schema.org”,”@type”:”FAQPage”,”mainEntity”:[{“@type”:”Question”,”name”:”Why must you verify webhook signatures and how does Stripe’s HMAC signature work?”,”acceptedAnswer”:{“@type”:”Answer”,”text”:”Without signature verification, any party can POST to your webhook endpoint with fabricated payment data: fake payment_intent.succeeded events to trigger order fulfillment without paying. Stripe signs every webhook using HMAC-SHA256: the signature is HMAC(secret, timestamp + "." + raw_body). The secret is set in your Stripe dashboard and stored in your application config. Verification: parse the stripe-signature header (t=timestamp,v1=signature), compute HMAC(secret, timestamp.body), compare with constant-time comparison (hmac.compare_digest — prevents timing attacks). Also verify the timestamp is within 5 minutes to prevent replay attacks (attacker captures a valid webhook and replays it later). Reject any request that fails either check with 400 Bad Request.”}},{“@type”:”Question”,”name”:”Why should webhook processing be asynchronous and what is the risk of synchronous processing?”,”acceptedAnswer”:{“@type”:”Answer”,”text”:”Payment processors retry webhook delivery if no 2xx response is received within a timeout (Stripe: 30 seconds). If your processing logic (updating orders, sending emails, calling inventory APIs) takes more than 30 seconds, Stripe times out and retries — causing duplicate processing. Even if processing is fast today, a database slowdown during an incident can push it over the timeout. The correct pattern: receive the webhook, verify the signature, persist the raw payload to WebhookEvent (takes ~5ms), return 200 immediately. Process asynchronously via a background job queue. If the job fails, your retry logic handles it — the payment processor does not need to retry. This decouples your processing reliability from Stripe’s delivery timeout.”}},{“@type”:”Question”,”name”:”How do you handle Stripe retrying the same webhook multiple times?”,”acceptedAnswer”:{“@type”:”Answer”,”text”:”Stripe retries webhooks up to 25 times over 72 hours when it receives non-2xx responses or network errors. Each delivery uses the same event ID (evt_xxx). Your idempotency guard: INSERT INTO WebhookEvent (event_id, …) ON CONFLICT (event_id) DO NOTHING. The first delivery inserts the row; subsequent deliveries find a conflict and do nothing. Return 200 for all deliveries including duplicates — if you return 4xx or 5xx for a duplicate, Stripe continues retrying. Business-level idempotency in the handler (UPDATE … WHERE payment_status=’pending’) provides a second layer: even if the inbox-level dedup is bypassed by a race condition, the business update is a no-op for already-processed payments.”}},{“@type”:”Question”,”name”:”How do you handle out-of-order webhook delivery?”,”acceptedAnswer”:{“@type”:”Answer”,”text”:”Payment processors do not guarantee event delivery order. A payment_intent.payment_failed event may arrive before payment_intent.created, or a charge.refunded may arrive before charge.succeeded. Handler design must be defensive: always load the current state from the database before applying the webhook, not rely on the expected sequence. Check: if the order is already in state "refunded", a payment_succeeded webhook should be logged but not change state. Use state machine transitions: each state change is only valid from specific prior states (e.g., can only move to "paid" from "pending_payment", not from "refunded"). Log unexpected transitions as warnings for investigation without crashing the handler.”}},{“@type”:”Question”,”name”:”How do you test webhook processing locally without a live payment processor?”,”acceptedAnswer”:{“@type”:”Answer”,”text”:”Stripe CLI provides local webhook forwarding: stripe listen –forward-to localhost:8080/webhooks/stripe. This tunnels events from your Stripe test account to your local server, including the correct signature header. For automated tests: (1) capture a real webhook payload from the Stripe dashboard (or use the Stripe CLI to trigger events: stripe trigger payment_intent.succeeded). (2) Sign the payload using your test webhook secret: generate the stripe-signature header manually in your test fixture. (3) POST to your endpoint with that header. This tests the full path: signature verification, DB insert, async processing, business logic. Testing with an unsigned raw payload misses the most common production failure mode (signature mismatch after a body transformation by a middleware).”}}]}

Payment webhook receiver and processing design is discussed in Stripe system design interview questions.

Payment webhook and order processing design is covered in Shopify system design interview preparation.

Payment webhook and financial event processing design is discussed in Coinbase system design interview guide.

Scroll to Top