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?

  1. Open view.webhooks.page. You get a bin URL like https://<bin>.webhooks.page/ that accepts any method on any path. No account needed.
  2. 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.
  3. Trigger the event: stripe trigger payment_intent.succeeded, the ping GitHub sends when you save a webhook, a product created in a Shopify dev store, or the provider's test button.
  4. 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 push event 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 localhost and 127.0.0.1 outright, and Twilio can't reach 10.x.x.x or 192.168.x.x. For development, see testing webhooks locally and testing without ngrok.
  • DNS resolves from outside. Run dig +short api.example.com from 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.com and look for Verify 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 /meta and 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 /api prefix, 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's client_max_body_size to 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 with csurf 403 (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 listen prints 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

ProviderResponse timeoutRetriesAfter repeated failuresDelivery logSignature header
StripeNot published; answer 2xx before slow workLive mode: up to 3 days, exponential backoff. Sandbox: 3 times over a few hoursRetries stop; none go to a disabled or deleted endpoint. Manual resend: Dashboard up to 15 days, CLI up to 30Workbench > Webhooks > your endpoint > Event deliveriesStripe-Signature
GitHub10 secondsNone; redeliver by hand or via the REST APIThe delivery stays failedWebhook settings > Recent Deliveries (3 days, request and response)X-Hub-Signature-256
Shopify1 second to connect, 5 seconds total8 retries over 4 hoursAfter 8 consecutive failures an Admin API subscription is deleted; warning emails go to the app's emergency developer addressDev Dashboard > Apps > your app > Logs (7 days)X-Shopify-Hmac-Sha256 (base64)
Slack (Events API)3 seconds3: 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 hoursMore than 95% failing within 60 minutes temporarily disables event subscriptions (apps under 1,000 events an hour are exempt); the owner gets an emailNone documented; read x-slack-retry-reason on the retryX-Slack-Signature with X-Slack-Request-Timestamp
Twilio15 seconds total by default (5 second TCP connect); hard 15-second cap on voice call requests1, only on a failed TCP connect or TLS handshake; tune with #rc= and #rp= URL overridesPer request: error logged (such as 11200 or 11205), then your fallback URL if set; no automatic disabling documentedConsole: Monitor > Debugger (30 days); Request Inspector on each callX-Twilio-Signature
Discord (interactions)3 seconds for the initial responseNone documented; the interaction token is invalid after 3 secondsRoutine 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 DMNone documentedX-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 symptomLikely causeFix
No attempt in the provider's logEvent not subscribed, wrong mode or account, endpoint disabled or deleted, Slack Socket Mode onSubscribe where the event fires, check the mode, re-enable or recreate the endpoint
Connection failedDNS doesn't resolve publicly, firewall, private IP, server downMake the host reachable from outside your network on port 443
TLS or certificate errorSelf-signed or expired certificate, missing intermediate, TLS below 1.2Publicly trusted certificate, full chain, test with openssl s_client
301, 302, 307, 308http-to-https, www or trailing-slash redirectRegister the exact final URL
400Signature checked on a parsed body or with the wrong secret, JSON parse errorVerify the raw bytes with the secret for that environment
401Login middleware, basic auth, preview deployment protection, or your own signature checkExempt the route from login auth; rely on the signature
403WAF or bot challenge, IP allowlist, Django or csurf CSRF checkSkip rule for the path, current provider IPs, CSRF exemption
404Wrong path or prefix, route not deployed, wrong environmentCompare the registered path with your router
405Route accepts GET only, or static hostingAccept POST on the webhook route
413Body size limit in the framework or proxyRaise the limit for the webhook route
419 or 422CSRF check: Laravel answers 419, Rails 422Exempt the webhook route from CSRF
500, 502, 503, 504Handler exception, app down behind the proxy, proxy or platform timeoutRead the app logs; store the event, answer 2xx, then process
Timed outSlow work before the responseAnswer within the provider's budget, queue the rest
2xx, but nothing happenedSwallowed exception, unhandled event type, duplicate skipped, work killed after the responseLog event ID and type on arrival; never return 200 from a silent catch
Slack won't verify the Request URLHandler doesn't echo the challengeReturn the challenge value on url_verification
Discord won't save the Interactions Endpoint URLPING not answered with {"type":1}, or bad signatures not rejectedVerify 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.