Why Stripe Webhooks Matter

Stripe webhooks are how your application learns about events that happen outside your control: successful payments, failed charges, subscription renewals, disputed transactions, and refunds. Without webhooks, you're blind to what's happening in your billing system.

This guide covers everything you need to handle Stripe webhooks correctly in production — from initial setup to advanced patterns that prevent data loss.

Setting Up Your Webhook Endpoint

A Stripe webhook endpoint is a POST route on your server that receives event payloads. Here's a minimal but production-ready implementation:

import stripe
from fastapi import FastAPI, Request, HTTPException

app = FastAPI()

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

    try:
        event = stripe.Webhook.construct_event(
            payload, sig_header, WEBHOOK_SECRET
        )
    except ValueError:
        raise HTTPException(400, "Invalid payload")
    except stripe.error.SignatureVerificationError:
        raise HTTPException(400, "Invalid signature")

    # Route by event type
    handlers = {
        "checkout.session.completed": handle_checkout,
        "invoice.payment_succeeded": handle_payment,
        "invoice.payment_failed": handle_failed_payment,
        "customer.subscription.deleted": handle_cancellation,
    }

    handler = handlers.get(event["type"])
    if handler:
        await handler(event["data"]["object"])

    return {"status": "ok"}

Signature Verification — Non-Negotiable

Never process a webhook without verifying the signature. Without verification, anyone can POST fake events to your endpoint — creating fake subscriptions, granting unauthorized access, or manipulating your data.

Stripe signs every webhook with a timestamp and HMAC-SHA256 signature using your webhook secret. The construct_event method handles verification automatically. Never skip this step, even in development.

The Critical Event Types

checkout.session.completed

Fires when a customer completes Stripe Checkout. This is where you provision access — create the account, upgrade the plan, generate API keys, whatever your product needs.

async def handle_checkout(session):
    customer_email = session["customer_details"]["email"]
    price_id = session["line_items"]["data"][0]["price"]["id"]
    plan = PRICE_TO_PLAN.get(price_id, "starter")
    await upgrade_user(customer_email, plan)

invoice.payment_succeeded

Fires on every successful payment — including subscription renewals. Use this to extend subscription periods, generate invoices/receipts, and update billing records.

invoice.payment_failed

Fires when a payment attempt fails. Stripe retries automatically (Smart Retries), but you should notify the customer and optionally grace their access for a few days.

customer.subscription.deleted

Fires when a subscription is fully cancelled (after any trial or billing period ends). This is when you downgrade or revoke access — not when the customer clicks "cancel."

Idempotency: Handle Duplicates

Stripe delivers webhooks "at least once." Network issues, timeouts, or retries mean you might receive the same event multiple times. Your handler must be idempotent:

async def handle_payment(invoice):
    event_id = invoice["id"]

    # Check if already processed
    if await db.event_exists(event_id):
        return  # Skip duplicate

    # Process the payment
    await process_payment(invoice)

    # Mark as processed
    await db.record_event(event_id)

Store processed event IDs in your database and check before processing. This is the single most important pattern for webhook reliability.

Return 200 Fast, Process Later

Stripe expects a 2xx response within 20 seconds. If your webhook handler takes longer (sending emails, generating PDFs, calling external APIs), Stripe considers it failed and retries — potentially causing duplicate processing.

# ❌ Slow — blocks the response
@app.post("/webhooks/stripe")
async def webhook(request: Request):
    event = verify_and_parse(request)
    await send_welcome_email(event)      # 2 seconds
    await generate_invoice_pdf(event)     # 3 seconds
    await update_crm(event)              # 1 second
    return {"ok": True}

# ✅ Fast — queue for background processing
@app.post("/webhooks/stripe")
async def webhook(request: Request):
    event = verify_and_parse(request)
    await queue.enqueue("process_webhook", event)
    return {"ok": True}  # Responds in milliseconds

Testing Webhooks in Development

Testing webhooks locally is one of the most frustrating parts of Stripe integration. Stripe can't reach your localhost. Traditional options:

  • Stripe CLIstripe listen --forward-to localhost:8000/webhooks/stripe. Works but requires the CLI installed and running.
  • ngrok — expose your local port. Works but adds latency and another tool to manage.
  • Webhook debugging tools — capture real webhooks, inspect payloads, replay to your local server.

WebhookVault takes the third approach: create an endpoint, point Stripe's test webhooks at it, inspect every payload in detail, then replay specific events to your local dev server. You get a full history of every webhook Stripe sends, which is invaluable for debugging.

Production Checklist

  1. Signature verification — enabled, using construct_event
  2. Idempotency — event IDs stored and checked before processing
  3. Fast responses — return 200 immediately, process async
  4. Error handling — catch and log errors, don't crash the endpoint
  5. Monitoring — alert on consecutive webhook failures
  6. Failed event retry — check Stripe dashboard for failed deliveries weekly
  7. Endpoint URL in environment variable — different for staging vs. production
  8. Event type filtering — only subscribe to events you handle in the Stripe dashboard

Common Pitfalls

  • Using test mode webhook secret with live mode events — test and live use different secrets. Make sure they match your environment.
  • Hardcoding webhook secret in code — use environment variables. If it's in git, rotate it immediately.
  • Processing customer.subscription.updated for cancellations — this fires on ANY subscription change. Use customer.subscription.deleted for actual cancellations.
  • Assuming event ordering — webhooks can arrive out of order. A payment_succeeded might arrive before checkout.session.completed. Design your handlers to work in any order.

Try WebhookVault free

Everything you need to handle Stripe webhooks correctly — signature verification, event types, idempotency, error handling, and testing with real payloads. Get started with our free tier — no credit card required.

Get started free →