CH. 03 A field guide · when the delivery fails

Webhook not working?

A webhook that never arrives almost always left a trace: a line in the provider's delivery log with a status code, an error or a timeout. Here's where Stripe, GitHub, Shopify and Slack keep that line, what each status means, and the fix for each one.

Failure points
7
Shortest wait
3 s
GitHub retries
None
Fig. 01 — the life of one delivery, and where it breaksLive
The seven steps of a webhook delivery and what the log says when each one fails A delivery passes seven steps from top to bottom. One, DNS: the hostname must resolve, or the log says failed to connect to host. Two, connect to port 443: the server must be up and reachable, or the log says unable to connect. Three, the TLS handshake: the certificate must be valid with a full chain, or the log shows a TLS error. Four, the proxy and framework must route the POST to the handler, or the log shows a redirect, 403, 404, 405 or 413. Five, the signature check must pass, or the handler answers 400 or 401. Six, your code must not crash, or the log shows 500, 502 or 503. Seven, the answer must arrive before the deadline: 3 seconds for Slack, 5 for Shopify, 10 for GitHub, or the log says timed out. Only a 2xx in time counts as delivered. STEP IF IT FAILS, THE LOG SAYS 1 DNS lookup hooks.example.com → IP ✕ FAILED TO CONNECT TO HOST typo, no record, or localhost 2 Connect to :443 server up, port reachable ✕ UNABLE TO CONNECT server down, firewall, wrong port 3 TLS handshake valid cert, full chain ✕ TLS ERROR · SSL_ERROR expired, self-signed, no intermediate 4 Proxy & framework POST reaches /webhook ✕ 3XX · 403 · 404 · 405 · 413 redirect, CSRF, WAF, path, size 5 Signature check raw body, right secret ✕ 400 · 401 parsed body or wrong secret 6 Your code runs without an exception ✕ 500 · 502 · 503 a bug, a crash, a deploy 7 Answer in time 2xx before the deadline ✕ TIMED OUT Slack 3 s · Shopify 5 s · GitHub 10 s ✓ 2XX IN TIME: DELIVERED ANYTHING ELSE: STRIPE, SHOPIFY, SLACK RETRY · GITHUB WAITS FOR YOU
Steps 1 to 3 happen before your code exists for the request, so your app logs show nothing; only the provider's log does. Steps 4 to 7 (blue) happen on your server, so your own logs should have a line for each attempt. Which half you're in tells you where to look.

§ 01 The short answer

Fixing a webhook, in one paragraph

Quick answer

Open the provider's delivery log first. If it shows no attempt at all, the event happened somewhere your endpoint doesn't listen: another account, repository or sandbox, an event type you didn't subscribe to, or an endpoint that's switched off. If it shows an attempt with an error, the error names the step: no connection means DNS, a firewall or a server that's down; a TLS error means your certificate; a status code came from your server; a timeout means you answered too slowly.

Fix that one step, then redeliver from the log. To see what a failure looks like before it happens for real, point a sandbox at the webhook tester, set it to answer 500 or to wait 10 seconds, and watch the provider retry.

Spec sheet Key facts

Look here first
The provider's delivery log, not your app's
Success means
A 2xx status. Stripe and Shopify count redirects as failures
Deadline
Slack 3 s · Shopify 5 s · GitHub 10 s · Stripe "quickly"
Retries
Stripe up to 3 days (live) · Shopify 8 times in 4 h · Slack 3 times · GitHub none
Redeliver by hand
Stripe 15 days (Dashboard), 30 (CLI) · GitHub 3 days
Switched off
Shopify: after 8 failures in a row · Slack: over 95% failing in an hour

§ 02 Step one

Read the delivery log first

Every provider except Slack keeps a record of each attempt: when it went out, what it sent, and what your server answered. It's the only witness for steps 1 to 3, when your server never saw the request. Here's where each one keeps it, checked against their documentation in October 2026.

Stripe · retries up to 3 days in live mode

Workbench → Webhooks → Event deliveries

Open the Webhooks tab in Workbench, select the endpoint, then the Event deliveries tab. Each event is Delivered, Pending or Failed; click one to see each attempt's HTTP status and when the next retry is due.

Redeliver: click Resend on the event (up to 15 days after it was created), or use the Stripe CLI (up to 30 days):

$stripe events resend evt_… --webhook-endpoint=we_…

A manual resend doesn't cancel the automatic retries, even if it succeeds. Answer 200 to events you've already handled to stop them.

GitHub · no automatic retries

Settings → Webhooks → Recent Deliveries

In the repository or organization, open Settings, then Webhooks, click the webhook's URL and open Recent Deliveries. Each delivery shows the headers and payload GitHub sent and the response it got. The list covers the past 3 days.

Redeliver: click the delivery's GUID, then Redeliver. Or call the REST API, here through the GitHub CLI:

$gh api -X POST repos/OWNER/REPO/hooks/HOOK_ID/deliveries/DELIVERY_ID/attempts

Only repository admins (or organization owners) can redeliver. If the webhook isn't active, the Recent Deliveries section isn't shown at all.

Shopify · retries 8 times in 4 hours

Dev Dashboard → your app → Logs

In the Dev Dashboard, open Apps and your app. Logs in the sidebar lists individual deliveries from the past 7 days with the status your app returned. The Overview page's Operations card charts the failed delivery rate and the 90th-percentile response time.

Redeliver: Shopify documents no button for it. Trigger the event again in a development store, for example by creating a product for products/create.

Logs can lag by several minutes, and at high volume they show only a sample of successful deliveries.

Slack · retries 3 times

No delivery log: read the retry headers

Slack's docs describe no per-delivery log. What you get is in the retries themselves: each one carries x-slack-retry-num (1, 2 or 3) and x-slack-retry-reason, such as http_timeout, http_error, ssl_error, connection_failed or too_many_redirects.

Check the URL: on the app's Event Subscriptions page, Slack verifies the Request URL with a url_verification challenge before you can save it. If your server was slow to wake up, click Retry.

Log every request's headers, so the retry reason is there when you need it.

No log entry at all?Then the provider never tried, and nothing on your server can be the cause. Skip to it never arrives. GitHub also notes that deliveries can take a few minutes to appear, so give it a moment before you conclude that.

§ 03 Decode the log

What each status or error means

Find what the provider saw in the first column. Providers word the network errors differently; the second column has their exact phrases, so you can search this page for yours.

The provider sawHow it's wordedLikely causeFix
No connectionStripe (Unable to connect) · GitHub failed to connect to host, failed to connect to network · Slack connection_failedThe hostname doesn't resolve, the URL says localhost, the server is down, or a firewall drops the connectionCheck DNS and the URL; allow the provider's IPs
Timed outStripe (Timed out) · GitHub timed out · Slack http_timeoutYour handler does its work before it answers, or the server was asleepAnswer first, work later
TLS errorStripe (TLS error) · GitHub Peer certificate cannot be authenticated with given CA certificates · Slack ssl_errorExpired or self-signed certificate, a missing intermediate certificate, or (Stripe) TLS older than 1.2Fix the certificate chain
301 302 307 308Stripe (302) ERR · Slack too_many_redirectshttp → https, www, or a trailing slash redirectRegister the final URL
400Stripe (400) ERR · GitHub invalid HTTP response, for any 4xx or 5xx · Slack http_errorYour code rejected the body: usually a failed signature check, sometimes a JSON parser meeting a form-encoded bodyCheck the signature setup
401 403Stripe (403) ERR, which it calls an access restrictionCSRF protection, a login or basic-auth wall, a WAF or bot challenge, or your signature checkLet the route through
404Stripe (404) ERR; the others show the codeWrong path, a route that isn't deployed, a tunnel URL that changed, or the wrong case (Slack URLs are case-sensitive)Copy the URL from your router, not from memory
405Stripe (405) ERR; the others show the codeThe route exists but doesn't accept POSTAccept POST on the webhook route
413Stripe (413) ERR; the others show the codeThe body is bigger than your proxy or framework allowsRaise the limit for that route
429Stripe (429) ERR; the others show the codeYour own rate limiter or WAF throttles the provider during a burstExempt the webhook route; queue the work
500Stripe (500) ERR; the others show the codeAn exception in your handlerRead your app's logs
502 503 504Stripe (502) ERR; the others show the codeYour proxy or host couldn't get an answer from the app: crashed, restarting, listening on another port, or mid-deployCheck the app is up behind the proxy

Stripe's wording is from its delivery status table, GitHub's from Troubleshooting webhooks, Slack's from the Events API retry reasons.

§ 04 Reproduce it

Check the endpoint the way the provider does

Run these from a machine outside your network (a phone hotspot will do), so you see what the internet sees. Replace hooks.example.com/webhook with your endpoint.

Terminal
# Step 1: does the name resolve, and to the server you expect?
nslookup hooks.example.com

# Steps 2–3: connect and show the certificate chain. Look for "verify error".
openssl s_client -connect hooks.example.com:443 -servername hooks.example.com </dev/null

# Steps 4–7: send an unsigned POST, as a provider would, and time it.
curl -i -X POST https://hooks.example.com/webhook \
  -H 'Content-Type: application/json' -d '{"id":"evt_test"}' \
  -w '\n%{http_code} in %{time_total}s\n'

The last command should get a fast 400 or 401: your handler is reachable and rejects a request without a valid signature. A 301, 403, 404 or 405 means the request never reached the handler. A 200 means your handler accepts unsigned requests, which you should fix too (Chapter 04).

Make it fail on purposeThe webhook tester can answer with the status you pick (400, 401, 404, 429, 500, 503 and others) after a delay of up to 10 seconds. Register its URL in a Stripe sandbox or a test GitHub repository, set it to 500, and trigger an event: you'll see the failure in the provider's log and, for Stripe, the next retry scheduled. Set a 5-second delay for a Slack app, and the retries arrive with x-slack-retry-reason: http_timeout in the Headers tab. Switch back to 200 and redeliver to see it recover.

§ 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:

  1. 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.
  2. 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.
  3. 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).
  4. 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.
  5. 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.

ProviderDeduplicate on
StripeThe event's id (evt_…). Rarely, Stripe creates two separate events for one change; to catch those, use data.object.id with the event type.
GitHubThe X-GitHub-Delivery header. A redelivery keeps the original value.
ShopifyThe X-Shopify-Webhook-Id header. X-Shopify-Event-Id is shared by deliveries from the same merchant action.
SlackThe 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.

§ 06 Fix it at the root

DNS, certificates, sleeping servers: hosting problems.
Give your webhook a home that answers.

One sentence · no dashboard
A Your handler works locally?

Ask your coding agent

Open a terminal in your project folder and paste one of these. The agent packages your code, deploys it, and hands you back a permanent HTTPS URL to register once.

$claude "Deploy this webhook handler to cloudish.ai and give me the live URL. Instructions: https://cloudish.ai/skill.md"
$codex "Deploy this webhook handler to cloudish.ai and give me the live URL. Instructions: https://cloudish.ai/skill.md"

Keep the signing secret out of your code: the agent can store it as an encrypted secret on the server.

B Starting over?

Have one built and deployed

Chat assistants deploy for you when they can run code with internet access; otherwise they'll give you the exact commands.

§ 07 FAQ

Frequently asked questions

Why is my webhook not being received?

Open the provider's delivery log. If there is no delivery attempt at all, the event happened somewhere the endpoint doesn't listen: another account, repository or sandbox, an event type you didn't subscribe to, or an endpoint that is disabled. If there is an attempt with an error, the error names the step that failed: DNS, connection, TLS, an HTTP status from your server, or a timeout.

Why does Stripe say my webhook failed?

Stripe counts anything other than a 2xx as a failure, including redirects. Open Workbench, select the endpoint under Webhooks, and open the Event deliveries tab to see the status of each attempt. Unable to connect means Stripe couldn't reach your server, TLS error means a certificate problem, Timed out means you answered too slowly, and a 4xx or 5xx came from your own server.

How do I redeliver a failed GitHub webhook?

GitHub doesn't retry failed deliveries on its own. In the repository, go to Settings, Webhooks, click the webhook's URL, open Recent Deliveries, click the delivery and press Redeliver. You can also use the REST API: POST /repos/{owner}/{repo}/hooks/{hook_id}/deliveries/{delivery_id}/attempts. Deliveries from the past 3 days can be redelivered.

What is the webhook timeout for Stripe, GitHub, Shopify and Slack?

Slack expects a 2xx within 3 seconds, Shopify within 5 seconds (and 1 second to connect), and GitHub within 10 seconds. Stripe doesn't publish a number; it says to return a 2xx before any complex logic. Answer first, then do the work in the background.

Why do I receive the same webhook more than once?

Providers retry when they don't see a 2xx in time, and even successful deliveries can occasionally repeat. Record the event ID you've processed (Stripe's event id, X-GitHub-Delivery, X-Shopify-Webhook-Id) and skip it the next time, while still answering 200.

Why does my webhook endpoint return 401 or 403?

Something rejected the request before or inside your handler: CSRF protection on the POST route, login or basic auth in front of the app, a firewall or bot protection, or your own signature check failing. Exempt the webhook route from CSRF and login, allow the provider through the firewall, and check the signing secret.

What happens if my webhook endpoint keeps failing?

It can be switched off. Shopify deletes subscriptions created through the Admin API after 8 consecutive failures. Slack temporarily disables event subscriptions when more than 95% of deliveries fail within 60 minutes, for apps receiving at least 1,000 events an hour, and emails the app owner. Stripe doesn't resend events generated while an endpoint is disabled, so recover them from the Events API.