Payment Webhooks Failing

The essential playbook for implementing payment webhooks failing in your SaaS.

Use this page when payment events are not reaching your app, webhook signature checks fail, or subscription state does not update after checkout, renewals, or failed payments. The goal is to restore reliable event delivery, return fast 2xx responses, and make webhook handling idempotent.

Quick Fix / Quick Setup

bash
# 1) Verify the webhook endpoint is reachable from the public internet
curl -i https://yourapp.com/api/billing/webhook

# 2) Test locally or against staging with the Stripe CLI
stripe login
stripe listen --forward-to localhost:8000/api/billing/webhook
stripe trigger checkout.session.completed

# 3) Compare the signing secret in your environment with the one from the provider dashboard
printenv STRIPE_WEBHOOK_SECRET

# 4) Replay failed events from Stripe after fixing the handler
stripe events resend evt_123 --webhook-endpoint=we_123

Minimal FastAPI example: verify raw body, then return 200 fast.

python
from fastapi import FastAPI, Request, Header, HTTPException
import stripe, os

app = FastAPI()
stripe.api_key = os.environ["STRIPE_SECRET_KEY"]
endpoint_secret = os.environ["STRIPE_WEBHOOK_SECRET"]

@app.post("/api/billing/webhook")
async def stripe_webhook(
    request: Request,
    stripe_signature: str = Header(None, alias="stripe-signature")
):
    payload = await request.body()
    try:
        event = stripe.Webhook.construct_event(payload, stripe_signature, endpoint_secret)
    except ValueError:
        raise HTTPException(status_code=400, detail="Invalid payload")
    except stripe.error.SignatureVerificationError:
        raise HTTPException(status_code=400, detail="Invalid signature")

    # Process idempotently using event["id"] in your database/queue
    return {"received": True}

Most webhook failures come from one of five issues: wrong signing secret, parsing JSON before signature verification, wrong public URL/path, the handler taking too long and timing out, or returning non-2xx responses. Verify the raw request body and provider-specific signing flow first.

What’s happening

Payment providers send server-to-server event notifications to your webhook URL for checkout completion, subscription updates, invoice payment failures, refunds, and disputes.

If your app does not receive or successfully acknowledge these events, billing state drifts from reality:

  • users may stay locked out after payment
  • users may remain active after cancellation
  • upgrades may not apply after checkout
  • failed payments may not downgrade access

A webhook is considered failed when the provider cannot connect, your app returns 4xx/5xx, signature verification fails, the request times out, or your app processes the event incorrectly after receipt.

Webhook systems are retry-based. Temporary failures may self-heal, but non-idempotent handlers can create duplicate side effects when events are retried.

Step-by-step implementation

1. Check provider delivery logs first

Open the webhook delivery logs in Stripe or your payment provider dashboard. Inspect:

  • response status code
  • response body
  • configured endpoint URL
  • retry history
  • event type
  • event ID

Do not start with app code until you confirm whether the request is reaching your server.

2. Validate public reachability

Confirm the webhook URL resolves publicly over HTTPS and is not blocked.

bash
curl -I https://yourapp.com/api/billing/webhook
curl -i -X POST https://yourapp.com/api/billing/webhook
curl -s https://yourapp.com/health || true

Check for:

  • DNS issues
  • expired TLS certificate
  • WAF blocks
  • auth middleware
  • private/internal-only routes
  • Cloudflare or reverse proxy rules

A webhook endpoint must be reachable from the provider, not just from your browser session or VPN.

3. Confirm exact path and method

The configured route must accept POST on the exact path.

Common breakages:

  • /api/billing/webhook vs /api/billing/webhook/
  • /billing/webhook vs /api/billing/webhook
  • route exists in staging but not production
  • GET works, POST does not
  • 301/302 redirect from HTTP to HTTPS or slash rewrite

Example FastAPI route:

python
@app.post("/api/billing/webhook")
async def stripe_webhook(request: Request):
    ...

Example Express route:

js
app.post("/api/billing/webhook", express.raw({ type: "application/json" }), handler)

Do not rely on redirects. Configure the exact final HTTPS URL in the provider dashboard.

4. Verify reverse proxy routing

If you use Nginx, Traefik, Caddy, or an ingress controller, confirm the webhook path reaches the app unchanged.

Check Nginx config:

bash
grep -R "webhook" /etc/nginx/sites-enabled /etc/nginx/nginx.conf
sudo nginx -t
sudo tail -f /var/log/nginx/access.log /var/log/nginx/error.log

Example Nginx block:

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

    location /api/billing/webhook {
        proxy_pass http://127.0.0.1:8000;
        proxy_http_version 1.1;
        proxy_set_header Host $host;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }

    location / {
        proxy_pass http://127.0.0.1:8000;
    }
}

Avoid proxy rules that rewrite the path unexpectedly.

5. Verify signature handling with the raw request body

This is the most common application-level failure.

The provider signature must be verified against the raw request body exactly as received. If your framework parses JSON first, the body may change and signature verification will fail.

FastAPI

python
from fastapi import FastAPI, Request, Header, HTTPException
import stripe, os

app = FastAPI()
endpoint_secret = os.environ["STRIPE_WEBHOOK_SECRET"]

@app.post("/api/billing/webhook")
async def stripe_webhook(
    request: Request,
    stripe_signature: str = Header(None, alias="stripe-signature")
):
    payload = await request.body()

    try:
        event = stripe.Webhook.construct_event(payload, stripe_signature, endpoint_secret)
    except ValueError:
        raise HTTPException(status_code=400, detail="Invalid payload")
    except stripe.error.SignatureVerificationError:
        raise HTTPException(status_code=400, detail="Invalid signature")

    return {"received": True}

Flask

python
from flask import Flask, request, jsonify
import os, stripe

app = Flask(__name__)
endpoint_secret = os.environ["STRIPE_WEBHOOK_SECRET"]

@app.post("/api/billing/webhook")
def stripe_webhook():
    payload = request.get_data()
    sig_header = request.headers.get("Stripe-Signature")

    try:
        event = stripe.Webhook.construct_event(payload, sig_header, endpoint_secret)
    except ValueError:
        return jsonify({"error": "invalid payload"}), 400
    except stripe.error.SignatureVerificationError:
        return jsonify({"error": "invalid signature"}), 400

    return jsonify({"received": True}), 200

Django

python
import os, stripe
from django.http import JsonResponse, HttpResponseBadRequest
from django.views.decorators.csrf import csrf_exempt

endpoint_secret = os.environ["STRIPE_WEBHOOK_SECRET"]

@csrf_exempt
def stripe_webhook(request):
    payload = request.body
    sig_header = request.headers.get("Stripe-Signature")

    try:
        event = stripe.Webhook.construct_event(payload, sig_header, endpoint_secret)
    except ValueError:
        return HttpResponseBadRequest("Invalid payload")
    except stripe.error.SignatureVerificationError:
        return HttpResponseBadRequest("Invalid signature")

    return JsonResponse({"received": True})

Express / Node

Use express.raw() on this route. Do not let global express.json() parse the body first.

js
import express from "express";
import Stripe from "stripe";

const app = express();
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
const endpointSecret = process.env.STRIPE_WEBHOOK_SECRET;

app.post(
  "/api/billing/webhook",
  express.raw({ type: "application/json" }),
  (req, res) => {
    const sig = req.headers["stripe-signature"];

    try {
      const event = stripe.webhooks.constructEvent(req.body, sig, endpointSecret);
      return res.status(200).json({ received: true });
    } catch (err) {
      return res.status(400).send(`Webhook Error: ${err.message}`);
    }
  }
);

6. Compare secrets and environment mode

Test and production endpoints usually have different webhook secrets.

Also, Stripe CLI forwarding uses a different secret than your deployed production endpoint.

Check environment:

bash
printenv STRIPE_SECRET_KEY
printenv STRIPE_WEBHOOK_SECRET

Verify:

  • live webhook secret in production
  • test webhook secret in staging/dev
  • no stale secret in container image or secret manager
  • dashboard endpoint matches your deployed endpoint

7. Return fast and queue the real work

A webhook handler should validate, persist, enqueue, and return 200 quickly.

Do not block the request on:

  • sending email
  • provisioning resources
  • syncing CRM
  • slow subscription reconciliation
  • expensive DB joins
  • third-party API calls

Recommended pattern:

  1. verify signature
  2. store event ID + payload metadata
  3. enqueue background job
  4. return 200
  5. process business logic asynchronously

Example pseudo-flow:

python
event = verify_signature(raw_body, signature)
save_webhook_receipt(event["id"], event["type"])
enqueue_webhook_job(event["id"])
return 200

8. Make event processing idempotent

Providers retry events. Some events may be delivered more than once. Your system must safely ignore duplicates.

Use a durable store with a unique constraint on event ID.

Example SQL:

sql
CREATE TABLE processed_webhooks (
    event_id TEXT PRIMARY KEY,
    event_type TEXT NOT NULL,
    received_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

Example processing pattern:

python
def process_event(event):
    inserted = insert_processed_event(event["id"], event["type"])
    if not inserted:
        return "duplicate"

    # apply billing update once
    update_subscription_state(event)
    return "processed"

If your business logic writes invoices, grants credits, or creates accounts, apply idempotency at those write points too.

9. Log the right metadata

At minimum, log:

  • request path
  • response status
  • provider event ID
  • event type
  • customer ID
  • subscription ID
  • environment
  • signature verification result
  • processing result

Separate transport/auth failures from business-logic failures.

Bad:

text
webhook failed

Better:

text
webhook event_id=evt_123 type=invoice.payment_failed sig_verified=true queued=true status=200 env=production

10. Replay failed events

After the fix is deployed, replay failed events from the provider dashboard or CLI.

bash
stripe events resend evt_123 --webhook-endpoint=we_123

Also useful for local testing:

bash
stripe listen --forward-to localhost:8000/api/billing/webhook
stripe trigger checkout.session.completed

Replaying is required if your local billing state already drifted.

11. Add monitoring

Monitor:

  • repeated 4xx on webhook routes
  • repeated 5xx on webhook routes
  • timeouts
  • retry backlog
  • queue backlog
  • mismatch between provider subscription status and local records

Use error tracking and production diagnostics to capture failures:

Common causes

  • Wrong webhook signing secret for the current endpoint or environment
  • JSON/body parsing occurs before signature verification
  • Webhook URL is incorrect, not publicly reachable, or uses the wrong path
  • Auth middleware, CSRF protection, WAF, or IP restrictions block provider requests
  • Handler returns 4xx/5xx due to application exceptions
  • Handler is too slow and times out before returning 2xx
  • Trailing slash or redirect mismatch on the webhook route
  • Test events are being sent to a live environment or vice versa
  • Nginx, proxy, or load balancer routing is misconfigured
  • Duplicate event processing due to missing idempotency checks

Debugging tips

Use these commands during incident response:

bash
curl -i -X POST https://yourapp.com/api/billing/webhook
curl -I https://yourapp.com/api/billing/webhook
stripe listen --forward-to localhost:8000/api/billing/webhook
stripe trigger checkout.session.completed
stripe events resend evt_123 --webhook-endpoint=we_123
printenv STRIPE_WEBHOOK_SECRET
grep -R "webhook" /etc/nginx/sites-enabled /etc/nginx/nginx.conf
sudo nginx -t
sudo tail -f /var/log/nginx/access.log /var/log/nginx/error.log
docker logs -f your_app_container
journalctl -u your-app.service -f
curl -s https://yourapp.com/health || true

Additional checks:

  • Add a log line at the start of the webhook handler with request path, content length, and environment.
  • Log signature verification failures separately from downstream processing failures.
  • Temporarily reduce the handler to signature verification plus 200. If deliveries start working, the transport path is fine and the bug is in business logic.
  • Check whether your app redirects /api/billing/webhook to /api/billing/webhook/ or redirects HTTP to HTTPS.
  • Compare Nginx access logs with app logs for the same request.
  • If compliance allows it, store failed payloads safely for replay in non-production.
provider
DNS/HTTPS
reverse proxy
app route
signature verification
queue
billing update

Process Flow

Checklist

  • Webhook endpoint is publicly reachable over HTTPS
  • Configured URL exactly matches deployed route and method
  • Correct test/live webhook secret is loaded in the current environment
  • Signature verification uses the raw request body
  • Webhook route bypasses browser-specific auth/CSRF protections where appropriate
  • Handler returns 2xx quickly and defers heavy work to a queue
  • Event IDs are persisted and checked for idempotency
  • Provider delivery logs are monitored for repeated failures
  • Replay procedure for failed events is documented
  • Subscription and invoice events update local billing state consistently

Also review:

Related guides

FAQ

Why are webhooks failing only in production?

Production commonly differs in URL, HTTPS, proxy routing, secrets, and middleware. Check the live endpoint URL, live webhook secret, reverse proxy config, and whether the route is blocked by auth or CSRF rules.

What status code should a webhook handler return?

Return a 2xx status as soon as the event is validated and safely queued or recorded. Non-2xx responses trigger retries.

Can I process the same event more than once?

Yes. Providers retry on failures and sometimes deliver duplicates. Your handler must be idempotent by storing and checking event IDs.

Should I expose webhook endpoints without authentication?

Do not use user auth for provider webhooks. Keep the endpoint public, use HTTPS, and verify provider signatures.

How do I know if the issue is transport or business logic?

Temporarily reduce the handler to signature verification plus a 200 response. If deliveries succeed, the transport path is fine and the bug is in your processing logic.

Final takeaway

Fix webhook failures in this order:

  1. provider delivery logs
  2. public reachability
  3. exact path and method
  4. correct secret
  5. raw-body signature verification
  6. fast 2xx response
  7. idempotent processing

Once delivery is stable, add replay procedures and monitoring so transient failures do not silently break subscriptions, billing state, or access control.