§ 05 The fixes
Every common cause, and what to do
01
It never arrives at all
No entry in the delivery log · nothing in your server logs
The provider didn't try, or tried an address that isn't yours. Go down this list:
- The event happened somewhere else. In another repository, organization or Stripe account than the one the webhook belongs to. Stripe sandboxes are isolated from live mode and from each other: register the endpoint in the environment where the event happens. Test and live endpoints also have different signing secrets.
- You didn't subscribe to that event type. Stripe and GitHub send only the types you selected; Slack sends only the events listed under Event Subscriptions, and only for workspaces where the app is installed with the needed scopes.
- The endpoint is switched off. GitHub's Active box is unchecked, a Stripe destination is disabled, or a Shopify subscription was deleted after repeated failures (see fix 10).
- The URL is wrong. A typo, an old tunnel URL,
http where you need https, or localhost. GitHub refuses localhost and 127.0.0.1 outright; for others it points at their own servers. To receive webhooks on your laptop, see Chapter 02.
- The provider is having a bad day. Check its status page (githubstatus.com, status.stripe.com).
02
Timeouts: you answered too late
Timed out · http_timeout · the work finished, but the provider says it failed
Each provider waits a fixed time for your answer. Slack allows 3 seconds, Shopify 5 seconds for the whole request (and 1 second to connect), GitHub 10 seconds. Stripe doesn't publish a number; it says to return a 2xx before any complex logic. Your work may have succeeded, but the provider marks the delivery failed and, except for GitHub, sends it again.
Answer first, then work. Verify the signature, store the event or put it on a queue, return 200, and process it afterwards. Sending email, calling another API or waiting on a slow database query inside the request is what usually blows the deadline. A host that sleeps when idle can also miss it on the first request after a quiet period.
03
The same event arrives twice
Two orders, two emails, two rows for one payment
Retries after a timeout are the usual reason, but providers also say a webhook can occasionally be delivered more than once even when everything worked. Make the handler idempotent: record each event's ID when you process it, and if you see the ID again, skip the work and still answer 200.
| Provider | Deduplicate on |
| Stripe | The event's id (evt_…). Rarely, Stripe creates two separate events for one change; to catch those, use data.object.id with the event type. |
| GitHub | The X-GitHub-Delivery header. A redelivery keeps the original value. |
| Shopify | The X-Shopify-Webhook-Id header. X-Shopify-Event-Id is shared by deliveries from the same merchant action. |
| Slack | The event_id in the payload; retries also carry x-slack-retry-num. |
04
Events arrive out of order
invoice.paid before invoice.created · an update for a record you don't have yet
Neither Stripe nor GitHub guarantees order, and a retried event can land after newer ones. Don't make one event depend on another having arrived first. When one shows up early, fetch the current state of the object from the provider's API instead of trusting the sequence. Stripe also warns that its created timestamp is in whole seconds, so it can't order events reliably either.
05
Your server redirects the webhook
(302) ERR · 301 · 308 · too_many_redirects
To a browser, a redirect is routine. To Stripe and Shopify, any 3xx is a failed delivery; Slack follows up to two redirects before it gives up. The usual culprits are an http → https redirect, example.com → www.example.com, and a framework adding or removing a trailing slash. Register the exact final URL. To see where a redirect points:
Terminal
# Prints the status and, for a redirect, where it points
curl -s -o /dev/null -X POST https://hooks.example.com/webhook \
-w '%{http_code} %{redirect_url}\n'
If it prints a 3xx and a URL, register that URL and run it again until you get a 2xx or 4xx.
06
Something blocks the POST before your handler
403 Forbidden · CSRF verification failed · InvalidAuthenticityToken · 401 Unauthorized
The provider has no session cookie, CSRF token or password. Anything that expects one turns it away:
- CSRF protection in Rails, Django, Laravel and similar frameworks. Exempt only the webhook route (Stripe's docs show Rails'
protect_from_forgery except: :webhook) and rely on the signature instead.
- A login wall: basic auth on a staging site, an auth middleware applied to every route, or a hosting platform's preview protection.
- A WAF or bot protection that challenges requests without a browser. Add a rule that lets the webhook path through, ideally limited to the provider's IP ranges.
07
A firewall or IP allowlist drops the provider
Unable to connect · failed to connect to network · connection refused
If your server only accepts known addresses, the provider's must be on the list, and kept current. Two providers publish them:
- Stripe lists webhook source addresses on its IP addresses page, also as text and JSON for importing into a firewall. It announces changes seven days ahead on its API announce mailing list.
- GitHub returns the current ranges in the
hooks key of GET https://api.github.com/meta, and says they change from time to time, so refresh your list periodically rather than copying it once.
An allowlist is a second layer, not the check that matters. Verify signatures either way, and for providers that don't publish ranges, the signature is your only check.
08
The certificate doesn't check out
(TLS error) ERR · Peer certificate cannot be authenticated with given CA certificates · ssl_error
Providers verify your certificate before they send anything, and a delivery fails if the check does. The usual causes:
- A self-signed certificate. Fine on your laptop, rejected by Stripe, GitHub, Shopify and Slack. Use a certificate from a public CA, for example a free one from Let's Encrypt.
- An incomplete chain. The server sends its own certificate but not the intermediate. Configure the full-chain file.
- An expired certificate, often because automatic renewal stopped working.
- Old TLS. Stripe supports only TLS 1.2 and 1.3.
Test with openssl s_client from § 04 and look for verify error, or run the SSL Labs server test, which both Stripe and GitHub suggest. Don't switch off GitHub's SSL verification to make the error go away; GitHub recommends leaving it on.
09
The body is too large
413 Request Entity Too Large · 413 Payload Too Large
Some webhooks are big: a GitHub push with many commits, an order with hundreds of line items. Limits sit at every layer, and the smallest one wins. Express's express.json() and express.raw() default to 100kb; nginx's client_max_body_size defaults to 1m. Raise the limit on the webhook route only:
server.js
app.post("/webhook", express.raw({ type: "application/json", limit: "5mb" }), handler);
On the sending side, GitHub caps payloads at 25 MB and doesn't deliver larger ones at all, so a giant push may never produce a delivery.
10
The provider switched your endpoint off
It was failing for a while, and now nothing arrives
Providers stop sending to endpoints that fail too often. What each one does:
- Shopify deletes a subscription after 8 consecutive failures if it was created through the Admin API, and sends warnings to the app's emergency developer email first. A deleted subscription gets nothing until you create it again.
- Slack temporarily disables an app's event subscriptions when more than 95% of deliveries fail within 60 minutes, and emails the app's owner. Apps receiving fewer than 1,000 events an hour are exempt. Re-enable delivery in the app's settings.
- Stripe: once a destination is disabled, pending retries stop, and Stripe won't resend events generated while it was off. After you re-enable it, find what you missed with the List Events API and
delivery_success=false (it covers the last 30 days).
- GitHub doesn't retry at all, so a long outage means redelivering from Recent Deliveries within 3 days, or reconciling through the API after that.
Make sure the warning emails reach someone who reads them.
11
Your server answers 500, 502 or 503
500 Internal Server Error · 502 Bad Gateway · 503 Service Unavailable
A 500 is an exception in your code, and your app's logs have the stack trace. Watch for the event that always crashes the handler, an unexpected type or a missing field: every retry fails the same way. Log the event ID, catch errors per event, and fix the bug before redelivering.
A 502 or 503 usually comes from the proxy or host in front of your app: the app crashed, is restarting during a deploy, or listens on a different port than the proxy expects. If it happens during deploys, the retries will cover it, except on GitHub.
12
The signature check fails
No signatures found matching the expected signature for payload · 400 · 401
The request reached your handler, and your own code rejected it. Almost always it's one of these: the framework parsed the body before you verified it, so the bytes changed; the signing secret is from another endpoint, a sandbox, or the Stripe CLI; or a proxy changed the body or headers on the way. On GitHub, if no secret is configured, there's no signature header to check. Also check that a GitHub webhook's content type is what your parser expects: application/x-www-form-urlencoded wraps the JSON in a payload form field.
The full walkthrough, provider by provider, is in Chapter 04: Verify webhook signatures. To check a captured request against your secret, use the tester's Signature tab.