Webhook not received? How to find where it's getting lost
Point the sender at a free bin (view.webhooks.page, no signup). Nothing arrives: fix the sender (event subscription, test vs live mode, disabled endpoint). It arrives: check your side (redirects, auth, CSRF or WAF blocks, TLS, signatures, slow acks).
A webhook that never shows up died at one of four layers: the sender never sent it, the network refused it, your HTTP stack rejected it, or your handler failed it. One test tells you which half to look at; logs on both sides narrow it down.
The 60-second isolation test
Take your server out of the picture: can the provider deliver to any public URL?
- Open view.webhooks.page. You get a bin URL like
https://<bin>.webhooks.page/that accepts any method on any path. No account needed. - Add it at the provider as an endpoint for the same events as your real one. Stripe, GitHub and Shopify allow several endpoints, so add it next to yours.
- Trigger the event:
stripe trigger payment_intent.succeeded, thepingGitHub sends when you save a webhook, a product created in a Shopify dev store, or the provider's test button. - Watch the viewer. Requests appear within about a second, with method, path, query, headers and body.
It arrived in the bin
The sender works; the break is between the internet and your code. Read the status code the provider's delivery log recorded for your real endpoint and find it in the symptom table. If the provider's attempts never reach your access logs, the request dies before your app: DNS, TLS, a firewall, a WAF or the load balancer.
It never arrived
The problem is on the sender side: the event isn't subscribed, it fired in another mode, account or repository, the endpoint was disabled or removed, or the event never happened. No attempt in the delivery log means the provider never tried.
Split the difference with curl
Save the captured body as payload.json and send it to your endpoint from a machine outside your network:
curl -i -X POST https://api.example.com/webhooks/stripe \
-H "Content-Type: application/json" \
--data-binary @payload.json
-i prints the status line and headers; without -L, a redirect shows up as a 301 or 302 instead of being followed. The request has no valid signature, so a 400 or 401 from your signature check is good news: it reached your code. A 404, a 405, a redirect or an HTML challenge page means it didn't.
Caveats: Discord interactions verify the URL when you save it, with a signed PING that must be answered with {"type":1}. A bin answers 200 {"ok":true,"delivery_id":"..."}, so Discord won't accept it, but the PING still lands in the bin and shows you what Discord sends. Slack's url_verification challenge is fine: the bin echoes it. For Twilio, use a spare number: the bin returns JSON, not TwiML, so expect a Debugger error for that message or call. Bins take bodies up to 100KB and 100 requests a day.
Checklist by layer
1. Sender side
- Subscribed to the exact event, in the right place. Repository vs organization webhook on GitHub, the right store on Shopify. GitHub sends no
pushevent when more than three tags are pushed at once. Slack only sends events your OAuth scopes cover, from conversations the app is part of; reinstall the app after adding scopes. - Right mode. A Stripe sandbox endpoint never sees live events, and each endpoint has its own
whsec_secret. Events from connected accounts need a Connect endpoint. - Delivered where you think. With Slack's Socket Mode on, events travel over a WebSocket, not to your Request URL. Discord sends interactions over the Gateway or over HTTP, never both. A Twilio number in a Messaging Service that uses its own webhook ignores the number's webhook URL.
- Endpoint still active. Shopify deletes Admin API subscriptions after 8 consecutive failures, Slack temporarily disables event subscriptions when more than 95% of deliveries fail within 60 minutes, Discord removes an interactions URL that fails its security checks, and Stripe sends no retries to a disabled endpoint.
- Delayed, not lost. GitHub deliveries can take a few minutes and get throttled in surges; Shopify's delivery log lags several minutes.
2. Network, DNS and TLS
- Public HTTPS only. GitHub refuses
localhostand127.0.0.1outright, and Twilio can't reach10.x.x.xor192.168.x.x. For development, see testing webhooks locally and testing without ngrok. - DNS resolves from outside. Run
dig +short api.example.comfrom a machine off your VPN. Split-horizon DNS and brand-new records are the usual suspects. - A certificate chain senders trust. Self-signed certificates are rejected (GitHub and Twilio say so), and Stripe requires TLS 1.2 or newer. The classic trap is a missing intermediate certificate: browsers often paper over it, server-side HTTP clients don't. Run
openssl s_client -connect api.example.com:443 -servername api.example.comand look forVerify return code: 0 (ok). - Nothing in front of your app blocking it. Firewalls, WAF rules and bot protection (a Cloudflare challenge page, for example) answer the sender with HTML, usually a 403 or 503, and your app never sees the request. Rate limiters answer 429. Add a skip rule for the webhook path.
- IP allowlists kept current. GitHub publishes its ranges at
GET /metaand changes them occasionally, Stripe lists webhook IPs at docs.stripe.com/ips, and Twilio publishes no fixed range for webhooks. Signature verification is the sturdier check.
3. HTTP layer
- Redirects. An http-to-https, www or trailing-slash redirect (Django's
APPEND_SLASH) can turn the POST into a GET. Stripe and Shopify count any 3xx as a failure, Slack follows up to two redirects, and Twilio follows them but drops the original parameters. Register the exact final URL. - 404 and 405. Compare the path in the registered URL with your router: a missing
/apiprefix, a route that never deployed, the wrong environment. A 405 means the route only accepts GET or the URL points at static hosting; webhooks arrive as POST (Twilio can be set to GET). - 413: body too large.
express.json()defaults to a 100kb limit and nginx'sclient_max_body_sizeto 1m. Raise it for the webhook route. - 401 or 403 from auth. Login middleware, basic auth on staging and preview-deployment protection all reject the sender. Exempt the webhook route; the signature is its authentication.
- CSRF protection. Frameworks reject POSTs without a CSRF token: Rails answers 422 (
skip_forgery_protection only: :webhook), Django 403 (@csrf_exempt), Laravel 419 (add the path to the CSRF exceptions), Express withcsurf403 (register the webhook route before the middleware).
4. Your handler
- Signature checked against the wrong bytes. Providers sign the raw body; if JSON middleware parsed and re-serialized it first, the HMAC won't match. Verify first, parse after:
express.raw({ type: "application/json" })on the webhook route,await request.text()in a Next.js route handler. A proxy that rewrites the body breaks it too. - Wrong secret or clock. Test and live secrets differ,
stripe listenprints its own, and a rotated Shopify client secret can take up to an hour to be used for signing. Stripe's libraries and Slack's recommended check reject timestamps more than 5 minutes off. Twilio signs the full public URL, so a proxy that changes the scheme or host breaks validation. Check a captured delivery by hand with the HMAC signature verifier. - Non-2xx for events you ignore. Answer 200 to event types you don't handle; a 400 counts as a failed delivery and triggers retries.
- Slow acknowledgements. 3 seconds for Slack and Discord, 5 for Shopify, 10 for GitHub, 15 for Twilio, and serverless cold starts count. Store the event, answer 2xx, then process it.
- Work killed after the response. Serverless platforms can freeze code that runs after the response. Use a queue or the platform's
waitUntil(Cloudflare Workers, Vercel). - Exceptions swallowed. A silent catch-all that returns 200 reports success for nothing. Log the event ID and type the moment a request arrives.
Provider cheat sheet: timeouts, retries, logs
| Provider | Response timeout | Retries | After repeated failures | Delivery log | Signature header |
|---|---|---|---|---|---|
| Stripe | Not published; answer 2xx before slow work | Live mode: up to 3 days, exponential backoff. Sandbox: 3 times over a few hours | Retries stop; none go to a disabled or deleted endpoint. Manual resend: Dashboard up to 15 days, CLI up to 30 | Workbench > Webhooks > your endpoint > Event deliveries | Stripe-Signature |
| GitHub | 10 seconds | None; redeliver by hand or via the REST API | The delivery stays failed | Webhook settings > Recent Deliveries (3 days, request and response) | X-Hub-Signature-256 |
| Shopify | 1 second to connect, 5 seconds total | 8 retries over 4 hours | After 8 consecutive failures an Admin API subscription is deleted; warning emails go to the app's emergency developer address | Dev Dashboard > Apps > your app > Logs (7 days) | X-Shopify-Hmac-Sha256 (base64) |
| Slack (Events API) | 3 seconds | 3: almost at once, after 1 minute, after 5 minutes, with x-slack-retry-num and x-slack-retry-reason. Delayed Events adds hourly retries for 24 hours | More than 95% failing within 60 minutes temporarily disables event subscriptions (apps under 1,000 events an hour are exempt); the owner gets an email | None documented; read x-slack-retry-reason on the retry | X-Slack-Signature with X-Slack-Request-Timestamp |
| Twilio | 15 seconds total by default (5 second TCP connect); hard 15-second cap on voice call requests | 1, only on a failed TCP connect or TLS handshake; tune with #rc= and #rp= URL overrides | Per request: error logged (such as 11200 or 11205), then your fallback URL if set; no automatic disabling documented | Console: Monitor > Debugger (30 days); Request Inspector on each call | X-Twilio-Signature |
| Discord (interactions) | 3 seconds for the initial response | None documented; the interaction token is invalid after 3 seconds | Routine checks send deliberately invalid signatures; if you don't answer them with 401, the interactions URL is removed and you get an email and System DM | None documented | X-Signature-Ed25519 with X-Signature-Timestamp |
Sources: Stripe webhooks; GitHub best practices, failed deliveries, viewing deliveries; Shopify verify deliveries, troubleshoot; Slack Events API, verifying requests; Twilio connection overrides, error 11205, Debugger, security; Discord receiving and responding, overview.
Symptom table: status codes and fixes
| Status or symptom | Likely cause | Fix |
|---|---|---|
| No attempt in the provider's log | Event not subscribed, wrong mode or account, endpoint disabled or deleted, Slack Socket Mode on | Subscribe where the event fires, check the mode, re-enable or recreate the endpoint |
| Connection failed | DNS doesn't resolve publicly, firewall, private IP, server down | Make the host reachable from outside your network on port 443 |
| TLS or certificate error | Self-signed or expired certificate, missing intermediate, TLS below 1.2 | Publicly trusted certificate, full chain, test with openssl s_client |
| 301, 302, 307, 308 | http-to-https, www or trailing-slash redirect | Register the exact final URL |
| 400 | Signature checked on a parsed body or with the wrong secret, JSON parse error | Verify the raw bytes with the secret for that environment |
| 401 | Login middleware, basic auth, preview deployment protection, or your own signature check | Exempt the route from login auth; rely on the signature |
| 403 | WAF or bot challenge, IP allowlist, Django or csurf CSRF check | Skip rule for the path, current provider IPs, CSRF exemption |
| 404 | Wrong path or prefix, route not deployed, wrong environment | Compare the registered path with your router |
| 405 | Route accepts GET only, or static hosting | Accept POST on the webhook route |
| 413 | Body size limit in the framework or proxy | Raise the limit for the webhook route |
| 419 or 422 | CSRF check: Laravel answers 419, Rails 422 | Exempt the webhook route from CSRF |
| 500, 502, 503, 504 | Handler exception, app down behind the proxy, proxy or platform timeout | Read the app logs; store the event, answer 2xx, then process |
| Timed out | Slow work before the response | Answer within the provider's budget, queue the rest |
| 2xx, but nothing happened | Swallowed exception, unhandled event type, duplicate skipped, work killed after the response | Log event ID and type on arrival; never return 200 from a silent catch |
| Slack won't verify the Request URL | Handler doesn't echo the challenge | Return the challenge value on url_verification |
| Discord won't save the Interactions Endpoint URL | PING not answered with {"type":1}, or bad signatures not rejected | Verify Ed25519, answer PING, return 401 on bad signatures |
FAQ
Why does my webhook work locally but not in production?
Locally, a provider CLI or tunnel skips your production edge and brings its own secret. In production the request meets an http-to-https redirect, a WAF or bot challenge, login middleware, CSRF protection, body-size limits, and a different signing secret (test vs live, CLI vs dashboard). Send a test event to a bin to see the real request, replay it against production with curl -i, and read the status code.
Why does Stripe say my endpoint returned 400?
Your server sent it, usually from the signature check: Stripe's own sample handlers return 400 when verification throws. Common reasons: JSON middleware parsed the body first, the whsec_ secret belongs to another endpoint or mode (stripe listen prints its own), or the timestamp is outside the 5-minute tolerance. Open the event under Event deliveries in Workbench to see each attempt's status code, then check the payload in the HMAC signature verifier.
How do I see the exact request a provider sent?
Point the provider at a free bin at view.webhooks.page: the viewer shows method, path, query, headers and body. Signature headers like Stripe-Signature come through; Authorization, cookie and other secret-looking headers are redacted. Scripts can list requests with GET https://view.webhooks.page/api/bins/{bin}/deliveries and fetch one with headers and body at /api/bins/{bin}/deliveries/{id}, sending an x-view-key header. GitHub's Recent Deliveries also keeps headers, payload and your response for 3 days.
Why is my GitHub webhook not triggering?
Open the webhook's Recent Deliveries. No entry means GitHub never sent it: wrong event, wrong repository or organization, an inactive webhook, or a documented limit. An entry with an error means GitHub tried, and the reason (failed to connect, timed out after 10 seconds, invalid HTTP response, certificate problem) names the layer. GitHub never retries on its own, so redeliver after the fix.
Why did my Shopify webhook stop working?
Shopify retries a failed delivery 8 times over 4 hours. After 8 consecutive failures it deletes the subscription if it was created through the Admin API, and warning emails go to the app's emergency developer address. A deleted subscription receives nothing until you create it again: fix the endpoint, recreate the subscription, and backfill the gap from the Admin API. Subscriptions declared in shopify.app.toml don't need re-subscribing.
Why am I not receiving Slack events?
Check in order: Socket Mode is off, the Request URL passed url_verification, you're subscribed to the event and your scopes cover it, the app is in the conversation, you reinstalled after adding scopes, and event subscriptions weren't disabled for failing more than 95% of deliveries within an hour. Answer within 3 seconds, or Slack retries with x-slack-retry-reason: http_timeout.
Is a webhook lost if my server was down?
Depends on the sender. Stripe retries for up to 3 days in live mode, Shopify 8 times over 4 hours, Slack 3 times over a few minutes (24 hours with Delayed Events), Twilio once on connection failures before trying your fallback URL, and GitHub not at all. After an outage, redeliver or reconcile from the provider's API (Stripe's List Events API filters on delivery_success=false).
Last updated
Try it in 10 seconds.
Create a free bin at view.webhooks.page — no signup, no install. Point your sender at the URL and watch requests land live. Bins expire after 48 hours.