Stripe’s Workbench says “Succeeded,” your server logs show nothing. GitHub’s “Recent Deliveries” is a column of red Xs. Shopify keeps retrying and then the subscription disappears. When webhook-driven logic breaks (grant access after payment, deploy on PR merge, sync an order), a broken webhook is a broken product.
Fastest fix: open the provider’s delivery log and read the HTTP status code of the last attempt. That one number tells you which of the seven causes below you have. A 200 means the provider reached you and the bug is inside your handler; a 404 means a wrong URL; a 5xx or Timed out means your code is throwing or too slow. Don’t guess from your own logs first — the provider already recorded exactly what happened.
A second time-sensitive detail that trips most people: response timeouts are short. Stripe expects a 2xx within seconds, and Shopify waits 5 seconds per attempt. If your handler does any real work synchronously (LLM call, email, slow query) before responding, you will time out and the provider will report a failure even though your code “worked.”
Common causes
Ordered by how often they’re the real culprit, highest first.
1. Endpoint returns non-2xx, retries exhausted
The provider sees a 4xx/5xx, retries with exponential back-off, then gives up. Stripe retries for up to 3 days in live mode (3 times over a few hours in a sandbox); Shopify retries 8 times over ~4 hours and then auto-removes the subscription if failures persist, so the webhook silently stops firing for good.
How to spot it: delivery log shows “Failed” with an HTTP status, or the subscription is simply gone from the dashboard.
2. Webhook URL typo
✅ https://api.yourdomain.com/webhooks/stripe
❌ https://api.yourdomain.com/webhook/stripe # missing the s
❌ https://yourdomain.com/webhooks/stripe # missing the api subdomain
How to spot it: copy the URL from the dashboard into a browser or curl. Does it land on your route, or 404?
3. Endpoint only reachable internally (localhost / private network)
You configured http://localhost:3000 in dev, but webhooks come from the public internet and never reach your laptop. Same problem with a private IP or a service bound to 127.0.0.1.
How to spot it: the URL contains localhost, 127.0.0.1, or a private range (10.x, 192.168.x, 172.16.x).
4. Handler too slow, judged a timeout
Stripe expects a 2xx within seconds, Shopify’s deadline is 5 seconds per attempt, and many other providers sit in the 5-30 second range. A handler that runs an LLM call, sends an email, or does a long query before responding will blow past that and be retried.
How to spot it: the delivery log status is Timed out, or your endpoint’s own timing shows the response took longer than the provider’s deadline.
5. Signature verification rejecting valid requests
The provider signs the request (Stripe-Signature, GitHub’s X-Hub-Signature-256, Shopify’s X-Shopify-Hmac-Sha256). Your endpoint computes its own HMAC and rejects on a mismatch. The classic cause is verifying against a JSON-parsed body instead of the raw bytes, or using a secret that doesn’t match the dashboard.
How to spot it: 400/401/403 in the delivery log, often with a body mentioning “signature.”
6. Firewall / CDN blocks the provider
Cloudflare, a WAF, or a bot-fight rule sees a non-browser user-agent like Stripe/1.0 (or an unfamiliar IP) and challenges or blocks it. The provider gets a 403 or a challenge page, never your app.
How to spot it: the CDN/WAF event log shows blocks or challenges for the provider’s IP or user-agent at the time of the attempt.
7. Handler throws an unhandled exception → 500
A code bug crashes the handler, the framework returns 500, the provider retries until it gives up.
How to spot it: your server log shows a stack trace lined up with the delivery timestamp.
Diagnosis: read the status code first
| Status in delivery log | What it means | Jump to |
|---|---|---|
200-299 | Provider reached you; bug is inside your handler | Step 7 (logging) |
404 | Wrong URL or route not deployed | Step 2 |
400/401/403 | Signature or auth rejected | Step 5 |
5xx | Handler threw an exception | Step 4 + Step 7 |
Timed out | Handler too slow | Step 4 |
| No attempts at all | Webhook disabled, or the event type isn’t subscribed | Step 1 |
Shortest path to fix
Step 1: Read the provider’s delivery log
This is the single most useful action. Every major provider records every attempt.
Stripe: Workbench → Webhooks → click the endpoint → "Event deliveries"
(older accounts: Developers → Webhooks → Recent events)
GitHub: Repo/Org Settings → Webhooks → click the hook → "Recent Deliveries"
Shopify: Notifications/webhook settings → view delivery attempts
(app webhooks: check your app's logs + the eventbridge/PubSub sink)
Linear: Settings → API → Webhooks → Deliveries
Slack: api.slack.com/apps → your app → Event Subscriptions
Each attempt shows the timestamp, HTTP status, response headers/body (first few KB), and the request payload. On GitHub and Stripe you also get a Redeliver button — replay the exact same payload after you ship a fix instead of waiting for a real event.
Step 2: curl the endpoint to test reachability
# Run from a PUBLIC network (mobile hotspot or a server), not corp wifi/VPN
curl -i -X POST 'https://api.yourdomain.com/webhooks/stripe' \
-H 'Content-Type: application/json' \
-d '{"test": true}'
# Expect a 2xx, or a specific 4xx, returned within a few seconds
curl fails or hangs → reachability/DNS/firewall problem (Steps 3, 6). curl lands on your route → the provider-side config is the issue (URL, secret, event subscription).
Step 3: Expose a dev endpoint with a stable tunnel
The ngrok free tier now gives every account one static dev domain, so you no longer have to re-paste a random URL into the dashboard on every restart. Find your assigned domain on the ngrok dashboard, then bind to it:
# Start your server
npm run dev # http://localhost:3000
# In another terminal, bind your assigned static domain
ngrok http --url=your-domain.ngrok-free.dev 3000
# Forwarding https://your-domain.ngrok-free.dev -> http://localhost:3000
# Put https://your-domain.ngrok-free.dev/webhooks/stripe in the provider config
For Stripe specifically, skip the dashboard round-trip entirely and use the Stripe CLI, which forwards real events and prints the signing secret to use locally:
stripe listen --forward-to localhost:3000/webhooks/stripe
# > Ready! Your webhook signing secret is whsec_... (set this as STRIPE_WEBHOOK_SECRET)
stripe trigger payment_intent.succeeded # fire a test event on demand
Step 4: Acknowledge immediately, process asynchronously
This is the fix for every timeout and most 5xx. Verify the signature, hand the work to a queue, and return 200 in well under a second. Stripe, Shopify, and GitHub all document this pattern as the recommended approach.
// ❌ Synchronous: real work runs before the response → provider timeout
app.post('/webhooks/stripe', async (req, res) => {
await processEvent(req.body); // could take 30 seconds
res.json({ received: true });
});
// ✅ Verify, enqueue, ack — all in milliseconds
app.post('/webhooks/stripe', async (req, res) => {
const valid = verifySignature(req.headers['stripe-signature'], req.rawBody);
if (!valid) return res.status(400).end(); // reject before enqueueing
await enqueue({ task: 'process-stripe-event', payload: req.body });
res.json({ received: true }); // < 100ms
});
The provider sees success; your worker processes at its own pace.
Step 5: Verify the signature correctly
Two rules cover almost every signature failure: use the raw request body (not re-serialized JSON), and make sure the secret matches the one shown in the dashboard for this endpoint.
// Stripe
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' }), // raw body is required to verify
(req, res) => {
let event;
try {
event = stripe.webhooks.constructEvent(
req.body, // raw Buffer, not parsed JSON
req.headers['stripe-signature']!,
endpointSecret
);
} catch (err) {
console.error('Signature verify failed:', err);
return res.status(400).send('Webhook signature verification failed');
}
res.json({ received: true });
// enqueue(event) for async processing
}
);
GitHub and Shopify follow the same shape with a manual HMAC compare. Common GitHub mistakes: using the SHA-1 X-Hub-Signature header instead of X-Hub-Signature-256, parsing the body before hashing, a mismatched secret, or Content-Type not set to application/json in the hook settings.
// GitHub: compare your HMAC-SHA256 against X-Hub-Signature-256
import crypto from 'crypto';
const expected = 'sha256=' + crypto
.createHmac('sha256', process.env.GH_WEBHOOK_SECRET!)
.update(req.rawBody) // raw bytes, not JSON.stringify(req.body)
.digest('hex');
const ok = crypto.timingSafeEqual(
Buffer.from(expected),
Buffer.from(req.headers['x-hub-signature-256'] as string)
);
Step 6: Add a CDN / WAF allow rule
If the delivery log shows 403 or a challenge, exempt the webhook path for the provider’s published IP ranges.
Cloudflare → Security → WAF → Custom Rules → Skip:
When URI Path equals /webhooks/stripe
And (IP Source Address is in [Stripe IP range])
Then Skip: All remaining custom rules, Bot Fight Mode
Stripe, GitHub, and Shopify publish their outbound IP ranges; allowlist those rather than disabling protection on the path entirely.
Step 7: Log every webhook on arrival
If the provider says 200 but you saw nothing, this is how you prove it. Log on the very first line, before any logic can throw:
app.post('/webhooks/stripe', (req, res) => {
console.log('webhook received', {
type: req.body?.type,
id: req.body?.id,
at: new Date().toISOString(),
});
// ...
});
Provider says delivered but no log line appears → the request never reached your code. The block is in front of your app: CDN, load balancer, routing, or a different deployment than the one the URL points at.
How to confirm it’s fixed
- In the provider dashboard, hit Redeliver on a recently failed attempt (Stripe Workbench and GitHub both have this), or run
stripe trigger payment_intent.succeeded. - The delivery log should now show a
200with your real response body. - Your server log should show the “webhook received” line from Step 7 within a second of the attempt.
- The downstream effect (access granted, deploy queued, order synced) should have actually happened — check the side effect, not just the status code.
All four green means the path is healthy end to end, not just returning 200 into a void.
Prevention
- Before going live, validate the URL with webhook.site so you know the path is publicly reachable.
- Always “verify, enqueue, ack” — anything over ~2 seconds of work in the handler is unsafe given how short provider deadlines are (Shopify is just 5 seconds per attempt).
- Verify the signature before enqueueing and before responding; never return
200first and verify later (that lets an attacker enqueue arbitrary payloads). - Make handlers idempotent — dedupe on the event id (
event.id, GitHub’sX-GitHub-Delivery, Shopify’sX-Shopify-Event-Id) so retries don’t double-process. - Monitor per endpoint: delivery success rate, handler latency, and
4xx/5xx/timeout counts. A slow climb in timeouts predicts an outage. - Keep the dashboard signing secret and your production env var in sync, and rotate them together.
- Watch for silent subscription removal (Shopify) after a run of failures — add an alert so you re-register before events are lost.
Related
FAQ
The provider dashboard says “Succeeded” / 200, but my code never ran. How is that possible?
The 200 was returned by something in front of your app — a CDN, load balancer, a catch-all route, or an old deployment still serving that URL. Add the Step 7 log line on the first line of the handler. No log line means the request was answered before it reached your code. Check that the dashboard URL points at the deployment you’re actually editing.
Why does my handler time out when the work only takes a few seconds?
Because the provider’s deadline is shorter than you think. Stripe expects a 2xx within seconds and Shopify waits 5 seconds per attempt; many others sit in the 5-30s range. Anything synchronous (an LLM call, an email, a slow join) eats that budget. Move the work to a queue and return 200 immediately (Step 4).
My signature verification keeps failing even though the secret is right. What’s wrong?
Almost always you’re hashing the wrong bytes. Sign the raw request body, not a re-serialized JSON.stringify(req.body) — any reordering or whitespace change breaks the HMAC. In Express, mount express.raw() on the webhook route so req.body is the original Buffer. Also confirm you’re reading X-Hub-Signature-256 (not the legacy SHA-1 header) for GitHub.
My Shopify webhook just stopped firing with no error. Where did it go? Shopify retries a failing endpoint 8 times over ~4 hours and, if failures persist across events, deletes the subscription. The hook is gone, not failing. Re-register it, fix the underlying error first, and add monitoring so you catch the failures before they hit the removal threshold.
How do I test a webhook against my local machine without deploying?
Use a tunnel. For Stripe, stripe listen --forward-to localhost:3000/webhooks/stripe forwards real events and prints the local signing secret. For everything else, run ngrok http --url=your-domain.ngrok-free.dev 3000 — the free tier now gives every account one static dev domain, so the public URL stays stable across restarts and you set it in the dashboard once.
Should I return an error status if signature verification fails?
Return 400, not 200. A 400 shows up in the delivery log so you can see the rejection, and it stops you from silently enqueueing unverified payloads. Don’t return 5xx for a bad signature — that triggers retries of a request you intend to refuse.
Tags: #Backend #Debug #Troubleshooting