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

Source: https://www.techinterview.org/post/3233468243/lld-payment-webhook/
Updated: 2026-04-17 · techinterview.org

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.

Payment webhook receiver and processing design is discussed in [Stripe system design interview questions](https://www.techinterview.org/post/3233460268/stripe-interview-guide-2026-process-bug-bash-round-and-payment-systems/).

Payment webhook and order processing design is covered in [Shopify system design interview preparation](https://www.techinterview.org/post/3233459924/shopify-interview/).

Payment webhook and financial event processing design is discussed in [Coinbase system design interview guide](https://www.techinterview.org/post/3233459920/coinbase-interview/).
