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
# 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_123Minimal FastAPI example: verify raw body, then return 200 fast.
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.
curl -I https://yourapp.com/api/billing/webhook
curl -i -X POST https://yourapp.com/api/billing/webhook
curl -s https://yourapp.com/health || trueCheck 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/webhookvs/api/billing/webhook//billing/webhookvs/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:
@app.post("/api/billing/webhook")
async def stripe_webhook(request: Request):
...Example Express route:
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:
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.logExample Nginx block:
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
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
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}), 200Django
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.
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:
printenv STRIPE_SECRET_KEY
printenv STRIPE_WEBHOOK_SECRETVerify:
- 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:
- verify signature
- store event ID + payload metadata
- enqueue background job
- return
200 - process business logic asynchronously
Example pseudo-flow:
event = verify_signature(raw_body, signature)
save_webhook_receipt(event["id"], event["type"])
enqueue_webhook_job(event["id"])
return 2008. 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:
CREATE TABLE processed_webhooks (
event_id TEXT PRIMARY KEY,
event_type TEXT NOT NULL,
received_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);Example processing pattern:
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:
webhook failedBetter:
webhook event_id=evt_123 type=invoice.payment_failed sig_verified=true queued=true status=200 env=production10. Replay failed events
After the fix is deployed, replay failed events from the provider dashboard or CLI.
stripe events resend evt_123 --webhook-endpoint=we_123Also useful for local testing:
stripe listen --forward-to localhost:8000/api/billing/webhook
stripe trigger checkout.session.completedReplaying 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:
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 || trueAdditional 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/webhookto/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.
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
- Error Tracking with Sentry
- Debugging Production Issues
- Common Auth Bugs and Fixes
- SaaS Production Checklist
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:
- provider delivery logs
- public reachability
- exact path and method
- correct secret
- raw-body signature verification
- fast
2xxresponse - idempotent processing
Once delivery is stable, add replay procedures and monitoring so transient failures do not silently break subscriptions, billing state, or access control.