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.

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