Handling Webhooks Correctly

The essential playbook for implementing handling webhooks correctly in your SaaS.

Webhook handling is where many Stripe and payment integrations fail in production. The goal is simple: accept the raw request, verify the provider signature, store the event idempotently, process it safely, and return a fast 2xx response. This page outlines a production-safe webhook flow for small SaaS apps.

Quick Fix / Quick Setup

python
# FastAPI example: verify Stripe webhook using raw body
from fastapi import FastAPI, Request, HTTPException
from fastapi.responses import JSONResponse
import os
import stripe

app = FastAPI()
stripe.api_key = os.environ["STRIPE_SECRET_KEY"]
WEBHOOK_SECRET = os.environ["STRIPE_WEBHOOK_SECRET"]
processed_events = set()  # replace with database table in production

@app.post('/webhooks/stripe')
async def 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(status_code=400, detail='Invalid payload')
    except stripe.error.SignatureVerificationError:
        raise HTTPException(status_code=400, detail='Invalid signature')

    event_id = event['id']
    if event_id in processed_events:
        return JSONResponse({'status': 'duplicate_ignored'}, status_code=200)

    processed_events.add(event_id)

    if event['type'] == 'checkout.session.completed':
        session = event['data']['object']
        # lookup user/account from metadata and activate plan
        # enqueue background job if heavy work is needed
        pass
    elif event['type'] == 'invoice.payment_failed':
        # mark subscription as past_due / notify user
        pass

    return JSONResponse({'status': 'ok'}, status_code=200)

Use the raw request body, not parsed JSON, for signature verification. In production, replace in-memory deduplication with a database table keyed by provider event ID, and push heavy processing to a queue.

Quick setup

  • Create a dedicated webhook endpoint such as /webhooks/stripe.
  • Expose it over HTTPS with a stable public URL.
  • Store provider secrets in environment variables, not in code.
  • Verify the webhook signature using the raw request body.
  • Persist each event ID before processing to enforce idempotency.
  • Return HTTP 200 quickly after validation and persistence.
  • Process heavy business logic asynchronously with a background worker.

What’s happening

Payment providers send server-to-server event notifications when subscriptions, invoices, checkouts, and payment intents change state.

Important delivery rules:

  • Webhook delivery is at-least-once, so duplicate events are normal.
  • Events can arrive out of order.
  • Your billing state should come from webhook-confirmed events, not browser redirects.
  • If your endpoint is slow or returns errors, the provider retries and your state can drift.

For Stripe-backed SaaS apps, webhooks commonly drive:

  • checkout completion
  • subscription creation and updates
  • invoice paid / failed transitions
  • cancellation state
  • entitlement changes

Step-by-step implementation

  1. Create a single webhook route per provider, for example /webhooks/stripe.
  2. Exempt the route from CSRF middleware if your framework applies browser protections to all POST requests.
  3. Read the raw body exactly as received before JSON parsing or body mutation.
  4. Verify the provider signature with the endpoint secret for that exact environment.
  5. Reject invalid signatures with 400.
  6. Parse the event type and event ID only after verification succeeds.
  7. Insert the event ID into a webhook_events table with a unique constraint.
  8. If the insert fails due to duplicate key, return 200 and stop.
  9. Store the full payload, event type, provider object IDs, and received timestamp.
  10. Map external objects to your internal account using metadata, customer ID, subscription ID, or checkout session references.
  11. Update local billing state from webhook events, not only from success redirects.
  12. Make handlers idempotent. Setting plan=pro twice must remain safe.
  13. Offload slow tasks like email, PDF generation, account provisioning, and third-party sync to a queue.
  14. Return 2xx as soon as validation and persistence complete.
  15. Build a replay command or admin action for failed or historical events.
  16. Test locally with the provider CLI and validate in staging before production rollout.

Example production flow

text
provider -> webhook endpoint -> verify signature -> insert event row
         -> enqueue job -> return 200
worker   -> process event idempotently -> update subscription state
Stripe
webhook endpoint
database
worker
billing state update

sequence diagram showing Stripe -> webhook endpoint -> database -> worker -> billing state update.

Example PostgreSQL table

sql
CREATE TABLE webhook_events (
  id BIGSERIAL PRIMARY KEY,
  provider TEXT NOT NULL,
  event_id TEXT NOT NULL,
  event_type TEXT NOT NULL,
  status TEXT NOT NULL DEFAULT 'received',
  payload_json JSONB NOT NULL,
  object_id TEXT,
  account_id BIGINT,
  error_message TEXT,
  received_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  processed_at TIMESTAMPTZ
);

CREATE UNIQUE INDEX webhook_events_provider_event_id_uniq
ON webhook_events(provider, event_id);

Example event insert pattern

sql
INSERT INTO webhook_events (
  provider, event_id, event_type, status, payload_json, object_id, account_id
) VALUES (
  'stripe', $1, $2, 'received', $3::jsonb, $4, $5
)
ON CONFLICT (provider, event_id) DO NOTHING;

If the insert affects 0 rows, treat the event as already recorded and return 200.

Minimal worker-safe processing pattern

python
def process_stripe_event(event: dict):
    event_type = event["type"]
    obj = event["data"]["object"]

    if event_type == "checkout.session.completed":
        account_id = obj["metadata"]["account_id"]
        # safe idempotent write
        activate_account_plan(account_id, "pro")

    elif event_type == "customer.subscription.deleted":
        sub_id = obj["id"]
        mark_subscription_canceled(sub_id)

    elif event_type == "invoice.payment_failed":
        customer_id = obj["customer"]
        mark_account_past_due(customer_id)

Common causes

  • Using parsed JSON instead of the raw request body for signature verification.
  • Wrong webhook secret loaded for test vs live environment.
  • Webhook route blocked by auth, CSRF, WAF, or IP restrictions.
  • Returning 3xx, 4xx, or 5xx instead of a fast 2xx response.
  • Doing long-running work inside the request handler causing timeouts.
  • No idempotency check, causing duplicate subscription updates or duplicate emails.
  • Relying on client-side checkout success instead of webhook events.
  • Out-of-order events causing invalid state transitions.
  • Reverse proxy or middleware modifying request bodies or headers.
  • Not persisting provider object IDs, making reconciliation difficult.

Debugging tips

Use provider tooling first, then inspect your edge and app logs.

Stripe CLI

bash
stripe listen --forward-to localhost:8000/webhooks/stripe
stripe trigger checkout.session.completed
stripe events list

Basic endpoint checks

bash
curl -i -X POST https://yourapp.com/webhooks/stripe
curl -I https://yourapp.com/webhooks/stripe

Nginx and app logs

bash
grep -i webhook /var/log/nginx/access.log
grep -i webhook /var/log/nginx/error.log
journalctl -u gunicorn -n 200 --no-pager
docker logs <app_container> --tail 200

Database inspection

bash
psql "$DATABASE_URL" -c "SELECT provider,event_id,event_type,status,received_at,processed_at FROM webhook_events ORDER BY received_at DESC LIMIT 20;"

What to check when signature verification fails

  • Confirm the route reads the raw body before parsing.
  • Confirm the Stripe-Signature header reaches the app.
  • Confirm the endpoint secret matches the exact configured endpoint.
  • Confirm test mode secrets are not used in live mode.
  • Confirm middleware is not decompressing, parsing, or rewriting the body.

What to check when events are missing

  • Verify the public URL is reachable from the provider.
  • Check provider dashboard delivery logs.
  • Check whether your endpoint returns redirects.
  • Check firewall, WAF, or reverse proxy rules.
  • Confirm the route is not protected by auth middleware.

Example Nginx reverse proxy

nginx
server {
    listen 443 ssl;
    server_name yourapp.com;

    location /webhooks/stripe {
        proxy_pass http://127.0.0.1:8000;
        proxy_set_header Host $host;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    }
}

Do not add middleware that rewrites request bodies on the webhook path.

Checklist

  • Webhook endpoint is public over HTTPS.
  • Raw body signature verification is implemented.
  • Correct secret is loaded for the current environment.
  • Route is excluded from CSRF protection if required.
  • Event IDs are stored with a unique constraint.
  • Handlers are idempotent.
  • Heavy work is queued asynchronously.
  • Relevant provider IDs are persisted locally.
  • Failed events can be replayed safely.
  • Logs and alerts exist for repeated failures.

Cross-check before go-live:

Checklist

Related guides

FAQ

Should webhook events update my subscription database directly?

Yes. After verification and idempotency checks, webhooks should drive authoritative billing state changes in your app.

What status code should I return for duplicate events?

Return 200. The event was already handled or recorded, so no retry is needed.

Can I verify a webhook after parsing JSON?

Usually no. Most providers sign the raw payload bytes, so parsing and re-serializing can break verification.

How do I handle events that arrive before related objects exist locally?

Store the event, mark it pending or failed, and retry processing after syncing the missing customer, subscription, or account mapping.

What is the minimum safe production setup?

HTTPS endpoint, raw-body signature verification, unique event ID storage, fast 2xx responses, async processing, and alerting on repeated failures.

Should I process the webhook synchronously?

No. Verify, persist, enqueue, and return 200 quickly.

Can I rely on the success redirect after checkout?

No. Users can close the tab and redirects can fail. Webhooks should activate access.

Why am I seeing duplicate events?

Providers retry on timeout or failure. At-least-once delivery is normal.

Why does signature verification fail behind a proxy?

The body may be altered by middleware or parsed before verification.

Should I support replay?

Yes. Replaying failed events is required for recovery and reconciliation.

Final takeaway

Reliable webhook handling is mostly about correctness, not complexity: verify raw payloads, dedupe by event ID, persist first, process idempotently, and monitor failures. If this path is stable, your billing state will stay aligned with the payment provider.