Stripe dashboard says “Delivery succeeded,” your server logs show nothing. GitHub’s “Recent Deliveries” is full of red Xs. Shopify keeps retrying. When webhook-dependent business logic breaks (grant access after payment, deploy on PR merge), broken webhooks = broken product.
Triage core: check the provider side first — did they say it was delivered, what status did they get back. Provider dashboards almost always have full delivery logs. Then verify your endpoint is actually reachable.
Common causes
Ordered by hit rate, highest first.
1. Endpoint returns non-2xx, provider retries exhausted
Provider sees 4xx/5xx and retries with exponential back-off a few times (Stripe retries up to 3 days), then gives up. What you see is “the last failure.”
How to spot it: Provider dashboard shows “failed delivery” with an HTTP status.
2. Webhook URL typo
✅ https://api.yourdomain.com/webhooks/stripe
❌ https://api.yourdomain.com/webhook/stripe # missing s
❌ https://yourdomain.com/webhooks/stripe # missing api subdomain
How to spot it: Copy URL from dashboard into browser or curl — does it land?
3. Endpoint only reachable internally (localhost / private network)
You configured http://localhost:3000 in dev, but webhooks come from the public internet — never reaches your laptop.
How to spot it: URL contains localhost / 127.0.0.1 / private IP (10.x, 192.168.x).
4. Endpoint slow, judged timeout
Webhook providers have a response timeout (Stripe 30s, many providers 5-10s). If your handler synchronously runs LLM / email / long query, it times out → retry.
How to spot it: Endpoint processing time > 5s and provider reports timeout.
5. Signature verification blocking
Provider sends a signed header (Stripe’s stripe-signature, GitHub’s x-hub-signature-256); your endpoint rejects with 401 on verification failure.
How to spot it: 401/403 with body containing “signature mismatch.”
6. Firewall / CDN blocks unusual user-agent
Cloudflare / WAF sees Stripe/1.0 non-browser UA and rejects as bot.
How to spot it: Cloudflare logs show blocks for provider IP / UA.
7. Handler throws unhandled exception → server 500
Code bug crashes handler → framework returns 500 → provider retries.
How to spot it: Server log shows stack trace.
Shortest path to fix
Step 1: Read provider delivery logs
Stripe: Developers → Webhooks → click endpoint → "Recent events"
GitHub: Repo Settings → Webhooks → click → "Recent Deliveries"
Shopify: Settings → Notifications → Webhooks → view attempts
Linear: Settings → API → Webhooks → Deliveries
Slack: api.slack.com/apps → Event Subscriptions → Recent
Each attempt shows:
- Timestamp
- HTTP status code (200 / 404 / 500 / …)
- Response body (first few KB)
- Request body (payload)
Status → symptom mapping:
| Status | Means |
|---|---|
| 200-299 | Delivered fine, your endpoint received → internal handler issue |
| 404 | URL wrong or route not deployed |
| 401/403 | Signature / auth rejected |
| 500 | Endpoint threw exception |
| Timeout | Endpoint too slow |
| No attempts | Webhook config disabled / event not subscribed |
Step 2: curl test endpoint reachability
# From a public network (mobile hotspot / server), not corp wifi
curl -i -X POST 'https://api.yourdomain.com/webhooks/stripe' \
-H 'Content-Type: application/json' \
-d '{"test": true}'
# Should return 2xx or a specific 4xx within 5s
curl fails → endpoint reachability issue. curl works → provider config issue.
Step 3: Dev endpoints via ngrok
# Start server
npm run dev # http://localhost:3000
# Another terminal
ngrok http 3000
# Forwarding https://abc123.ngrok.io -> http://localhost:3000
# Put https://abc123.ngrok.io/webhooks/stripe into provider config
# Webhooks now hit your local code
Or use Stripe CLI:
stripe listen --forward-to localhost:3000/webhooks/stripe
# Simulates delivery to local
Step 4: Refactor handler to “ack now, process async”
// ❌ Synchronous (slow → timeout)
app.post('/webhooks/stripe', async (req, res) => {
await processEvent(req.body); // 30 seconds
res.json({ received: true });
});
// ✅ Immediate ack + async processing
app.post('/webhooks/stripe', async (req, res) => {
// 1. Verify signature
const valid = verifySignature(req.headers['stripe-signature'], req.rawBody);
if (!valid) return res.status(401).end();
// 2. Enqueue for background processing
await enqueue({ task: 'process-stripe-event', payload: req.body });
// 3. 200 immediately (< 1 second)
res.json({ received: true });
});
Provider sees success. You process at leisure.
Step 5: Signature verification done right
// Stripe example
import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_KEY!);
const endpointSecret = process.env.STRIPE_WEBHOOK_SECRET!;
app.post('/webhooks/stripe',
express.raw({ type: 'application/json' }), // need raw body to verify
async (req, res) => {
let event;
try {
event = stripe.webhooks.constructEvent(
req.body,
req.headers['stripe-signature']!,
endpointSecret
);
} catch (err) {
console.error('Signature verify failed:', err);
return res.status(400).send(`Webhook Error`);
}
// Process event ...
res.json({ received: true });
}
);
Note: must use the raw body (don’t JSON-parse before verifying), and endpointSecret must match the value in the provider dashboard.
Step 6: CDN / WAF allow rule
Cloudflare:
WAF → Custom Rules → Skip:
URI Path equals /webhooks/stripe
AND IP in [Stripe IP range]
Stripe / GitHub / Shopify publish their IP ranges — allowlist them.
Step 7: Endpoint should log every webhook
First line of every handler:
app.post('/webhooks/stripe', async (req, res) => {
console.log('webhook received:', {
event_type: req.body.type,
id: req.body.id,
timestamp: Date.now(),
});
// ...
});
No log + provider says delivered → never reached your server. Check CDN / routing.
Prevention
- When configuring a webhook, first validate URL reachability with webhook.site
- Handler template is always “ack immediately + process async”; over 2s isn’t safe
- Verify signature before enqueueing, both before the response; don’t return 200 first then verify (lets attackers enqueue arbitrary payloads)
- Make handlers idempotent (dedup by event_id) so provider retries don’t double-process
- Add per-endpoint metrics: delivery success rate, processing time, 429 / 500 counts
- Use ngrok or the provider’s own CLI (Stripe CLI / GitHub webhook tester) for dev
- Webhook secret in provider dashboard must match prod env; rotate together
- For multiple services on the same secret, use one endpoint with internal routing (reduces config sprawl)
Related
Tags: #Backend #Debug #Troubleshooting