When GitHub gets no HTTP response from your webhook's URL, the delivery shows "We couldn't deliver this payload:" followed by a reason, the same text the REST API returns as the delivery's status. When a server did answer with an error, the delivery shows its status code and the response instead. Either way the reason tells you which hop failed. GitHub does not retry a failed delivery, so fix the cause and then redeliver.
- A
failed to connect to hoststatus means GitHub never got a TCP connection: the name does not resolve, or the port is closed or filtered. - A reason ending in
context deadline exceeded (Client.Timeout exceeded while awaiting headers)means your server took longer than 10 seconds. GitHub's docs call thistimed out. - A line containing
x509:points at your TLS certificate, and the words after it narrow the problem down. - An
Invalid HTTP Response: 404status (or 401, 405 and so on) means a server answered with an error. - A bare
502status came from the tunnel in front of the app in every case tested here, and the response body GitHub stored said which hop failed.
Every error below was reproduced on purpose on 29 October 2026 on a private test repository, mostly with one webhook per failure. The receiving end was the Node handler from the GitHub webhooks guide, which checks X-Hub-Signature-256 and answers 401 on a mismatch, reached through a LocalCan Public URL (also called a tunnel) from the LocalCan CLI 1.3.2. Hooks for the DNS, port and certificate failures pointed at hosts picked to fail.
The error strings quoted here are the deliveries' status from the REST API. The delivery's page in GitHub shows the same text after "We couldn't deliver this payload:", word for word, which I checked for the timeout, a self-signed certificate and failed to connect to host. An AI assistant helped draft this post. The commands and numbers come from my own runs.
Where do you see the full error?
In the repository, open Settings → Webhooks, click the webhook, then Recent deliveries, and click a delivery's GUID. The details show the request GitHub sent and "the response that GitHub received from your server", if one came back. When none came back, the delivery has only a Request tab, with a yellow box above the payload that reads "We couldn't deliver this payload:" and the reason. When a server answered, as with the 404 and the 502 in the test run, there is no yellow box, and the Response tab shows the status code and the headers and body GitHub stored. GitHub lists deliveries from the past 3 days (Viewing webhook deliveries↗).
The REST API returns the same list, which is faster when a hook has many failures:
gh api repos/OWNER/REPO/hooks/HOOK_ID/deliveries \
--jq '.[] | [.delivered_at, .event, .redelivery, .status_code, .status] | @tsv'
2026-10-29T07:46:13.674Z ping false 500 POST https://tiny-dolphin.localcan.dev/slow giving up after 1 attempt(s): Post "https://tiny-dolphin.localcan.dev/slow": context deadline exceeded (Client.Timeout exceeded while awaiting headers)
Every failure from the test run, by what was on the other end of the Payload URL:
| Payload URL pointed at | status | status_code |
|---|---|---|
| A name with no DNS record | failed to connect to host | 502 |
| A port that refuses connections | failed to connect to host | 502 |
| A port behind a firewall that drops packets | failed to connect to host | 502 |
| A handler that answers after 12 seconds | … context deadline exceeded (Client.Timeout exceeded while awaiting headers) | 500 |
| A self-signed certificate, an untrusted root, a missing intermediate | … x509: certificate signed by unknown authority | 500 |
| An expired certificate | … x509: certificate has expired or is not yet valid: … | 500 |
| A certificate for another hostname | … x509: certificate is valid for *.badssl.com, badssl.com, not wrong.host.badssl.com | 500 |
| The wrong path | Invalid HTTP Response: 404 | 404 |
| The wrong secret | Invalid HTTP Response: 401 | 401 |
An http:// URL that redirects to https:// | Invalid HTTP Response: 404 | 404 |
| A running Public URL with the app behind it stopped | 502 | 502 |
| A Public URL whose tunnel was stopped | 502 | 502 |
In the first seven rows GitHub got no HTTP response in time, so it stored no response headers or body, and the 500 or 502 is GitHub's own placeholder. In the last five, the status code and the stored response are what a server sent back.
failed to connect to host
GitHub could not open a connection. Its troubleshooting page gives two causes: the URL did not resolve to an IP address, or "network restrictions" kept GitHub from the host (Troubleshooting webhooks↗). In the test run, three different faults produced this same text:
- a hostname with no DNS record, after 0.05 seconds
- a port that refuses connections, after 0.08 seconds
- a port behind a firewall that drops packets, on three hosts, after 1.15 to 1.46 seconds
The refusing port was port 81 on scanme.nmap.org, a host the Nmap project runs for this kind of test↗, and it refused connections when tested from my machine. The delivery's duration separates the dropped port (over a second) from the other two, and dig in step 1 below separates a missing DNS record from a refused port. The same troubleshooting page lists a separate failed to connect to network error for a server that "refused the connection", but the refused port above came back as failed to connect to host.
If the failures come and go and a later redelivery works with no cause found, as in a 2026 GitHub Community thread where a fourth redelivery worked↗, check the Webhooks component on GitHub Status↗ before you check your own network.
To fix it, check from outside your network:
- Running
dig +short your.host.examplereturns an address. A name that only resolves on your LAN or VPN fails here. - The port is open to GitHub. Deliveries come from the ranges in the
hookslist ofGET /meta(gh api meta --jq '.hooks'), and a firewall or security group that allows only your own IP drops them. In the test run every delivery that reached the tunnel came from140.82.115.x. - The URL is not a private address. GitHub refuses
localhostwhen you save the hook, but a LAN IP or a.localname saves and then fails with this error, as the GitHub webhooks guide shows.
context deadline exceeded (timed out)
GitHub waits 10 seconds for a response (Troubleshooting webhooks↗). GitHub's docs name this error timed out. Older reports quote shorter wording, "timed out" in a 2025 GitHub Community report of a webhook failing intermittently↗ and "Service Timeout" in a 2018 Drone forum thread about pushes that never reached Drone↗. In the test run, a /slow route that answers 200 after 12 seconds got this line, after "We couldn't deliver this payload:" on the delivery's page and as the status in the API:
POST https://tiny-dolphin.localcan.dev/slow giving up after 1 attempt(s): Post "https://tiny-dolphin.localcan.dev/slow": context deadline exceeded (Client.Timeout exceeded while awaiting headers)
GitHub recorded a duration of 10. The traffic inspector logged the request as 499 (Client Closed Request) after 9,796 ms, and the handler's log (the two /slow lines) shows its 200 two seconds after that, sent to nobody:
2026-10-29T07:46:03.929Z POST /slow ping ca52e74a-d36c-11f1-8df8-986558856f6e
2026-10-29T07:46:15.930Z slow: answering 200 after 12000 ms
A handler that logs only its own responses sees a success here. The giving up after 1 attempt(s) part means GitHub made one attempt and will not make another (more under Redeliver after the fix). Verify the signature, put the work on a queue, and answer 2xx before you do anything slow.
x509 certificate errors (Peer certificate cannot be authenticated)
GitHub checks the certificate of an https:// Payload URL like any HTTPS client. Its docs and a 2017 Stack Overflow question about GitHub Enterprise and Jenkins over HTTPS↗ quote this error as Peer certificate cannot be authenticated with given CA certificates, but none of the five API statuses in the test run had that text. Each came back as tls: failed to verify certificate: x509: and then a reason. The test hosts were the public badssl.com↗ set:
| Certificate problem | Ends with |
|---|---|
| Self-signed | x509: certificate signed by unknown authority |
| Signed by an untrusted root | x509: certificate signed by unknown authority |
| Server sends no intermediate certificate | x509: certificate signed by unknown authority |
| Expired | x509: certificate has expired or is not yet valid: current time 2026-10-29T07:46:08Z is after 2015-04-12T23:59:59Z |
| Issued for another hostname | x509: certificate is valid for *.badssl.com, badssl.com, not wrong.host.badssl.com |
Three different faults share signed by unknown authority, so check the server yourself. The openssl s_client command tells them apart:
openssl s_client -connect your.host.example:443 -servername your.host.example \
-verify_hostname your.host.example </dev/null 2>&1 | grep -i "verify error"
## incomplete-chain.badssl.com
verify error:num=20:unable to get local issuer certificate
verify error:num=21:unable to verify the first certificate
## self-signed.badssl.com
verify error:num=18:self-signed certificate
## untrusted-root.badssl.com
verify error:num=19:self-signed certificate in certificate chain
## expired.badssl.com
verify error:num=10:certificate has expired
## wrong.host.badssl.com
verify error:num=62:hostname mismatch
Without -verify_hostname, openssl reports nothing for the wrong-hostname certificate, so keep the flag. For a missing intermediate, point the server at the full chain file from your certificate authority (with Let's Encrypt, fullchain.pem rather than cert.pem, per Certbot: Where are my certificates?↗). Renew an expired certificate, reissue one for the hook's exact hostname on a mismatch, and replace a self-signed or private-root certificate with one from a public certificate authority.
The webhook form also has an SSL verification option that can turn the check off (insecure_ssl: "1" in the API). With it, the TLS error went away and the self-signed and expired hosts answered 405. GitHub's API docs say "We strongly recommend not setting this to 1 as you are subject to man-in-the-middle and other attacks" (Repository webhooks↗). Fix the certificate instead, or for local development use a URL that already has a valid one. A LocalCan Public URL printed Verify return code: 0 (ok) for the same openssl command.
Invalid HTTP Response: 404, 401 and other codes
A server answered with a status outside 2xx. The delivery's page shows no yellow box, only the code on the Response tab, and the body stored there is usually the fastest clue:
- A
404withCannot POST /webhooksmeans the hook's path did not match the handler's route,/webhooksagainst/webhook. - A
401withsignature mismatchmeans the handler's HMAC check failed because the hook's secret andGITHUB_WEBHOOK_SECRETwere different strings. The GitHub webhooks guide covers the other ways a correct secret still fails, such as hashing parsed JSON instead of the raw body. - A
405means the server does not acceptPOSTon that path.
One 404 comes from a redirect. A hook saved with http:// in front of a server that redirects to https:// got Invalid HTTP Response: 404 and the response body Cannot GET /webhook. GitHub followed the 301, but as a GET with no body, so the handler never saw the payload. The inspector recorded the request GitHub sent after the redirect:
GET /webhook HTTP/1.1
Host: tiny-dolphin.localcan.dev
referer: http://tiny-dolphin.localcan.dev/webhook
user-agent: GitHub-Hookshot/51d2642
x-github-event: ping
x-hub-signature-256: sha256=c17c260b880f07f6037b7ece3c549043ee65078ee207365acdda7e33e7bdb0f8
[accept, connection, lc-ipcountry, x-forwarded-*, x-real-ip, x-hub-signature and the other x-github-* headers trimmed]
HTTP/1.1 404 Not Found
[response headers trimmed]
Save the Payload URL with https:// and the exact path your server answers on.
502 with no "Invalid HTTP Response" prefix
Every 502 in the test run came from the LocalCan Public URL in front of the handler. The delivery's page showed no "We couldn't deliver this payload" box, only a red 502 on the Response tab, and the response GitHub stored there said which of two things had happened.
When the tunnel was running but nothing listened on the local port, because the app had crashed or not started, the response was JSON with an X-Localcan-Error header:
gh api repos/OWNER/REPO/hooks/HOOK_ID/deliveries/DELIVERY_ID --jq '.response'
{"headers":{"Alt-Svc":"h3=\":443\"; ma=86400","Cache-Control":"no-store","Content-Length":"228","Content-Type":"application/json","Date":"Thu, 29 Oct 2026 07:49:57 GMT","Server":"LocalCan","X-Content-Type-Options":"nosniff","X-Frame-Options":"DENY","X-Localcan-Error":"LC1001"},"payload":"{\"error\":\"app_connection_refused\",\"code\":\"LC1001\",\"message\":\"LocalCan reached the computer behind this URL, but nothing is listening on localhost:3020.\",\"host\":\"tender-otter.localcan.dev\",\"target\":\"localhost:3020\",\"edge\":\"ams\"}\n"}
Start the app on that port.
When the tunnel had stopped, or the hook pointed at a name nothing serves, the body was an HTML page titled "Public URL not available". Start the tunnel, then check that the hook's URL is the one it prints.
A quick tunnel (localcan http 3020, see Quick tunnels) got a new name when it restarted (tiny-dolphin became tender-otter), and the hook kept sending to the old one. Update only the URL with the hook's config endpoint, which keeps the secret:
gh api -X PATCH repos/OWNER/REPO/hooks/HOOK_ID/config -f url=https://tender-otter.localcan.dev/webhook
The other update endpoint, PATCH /repos/OWNER/REPO/hooks/HOOK_ID with a config that leaves out secret, removes the secret (Repository webhooks↗, measured in the GitHub webhooks guide). To stop updating the hook at all, use a URL that keeps its name. On paid plans, a Public URL you add in the app stays reserved while it is paused (up to 7 days) or while LocalCan is closed (Public URLs), and a custom domain never changes. On the Free plan each Public URL lasts up to 60 minutes and the next session gets a new one, so update the hook with the config PATCH above each session (Public URLs).
Redeliver after the fix
"GitHub does not automatically redeliver failed deliveries" (Redelivering webhooks↗). In the test run the failed deliveries on the active hook did not come back by themselves in the 44 minutes I watched. You can redeliver anything from the past 3 days. In the UI, open Recent deliveries, click the delivery, then Redeliver. Through the API:
gh api -X POST repos/OWNER/REPO/hooks/HOOK_ID/deliveries/DELIVERY_ID/attempts
The DELIVERY_ID is the delivery's id field in the deliveries list.
With the handler started and the hook pointed at the new URL, redelivering the failed push and an older ping gave:
2026-10-29T07:50:18.566Z ping true 200 OK
2026-10-29T07:50:17.888Z push true 202 OK
2026-10-29T07:49:57.081Z push false 502 502
2026-10-29T07:48:08.766Z ping false 502 502
[older rows trimmed]
The redelivered ping reached tender-otter, so a redelivery goes to the hook's current URL, not the one that failed. The redelivered push was byte-identical to the original, with the same X-GitHub-Delivery and X-Hub-Signature-256, so your handler can deduplicate on X-GitHub-Delivery (Best practices for using webhooks↗).
For a hook with many failures, GitHub suggests a scheduled script that lists recent deliveries and redelivers every one whose status is not OK (Handling failed webhook deliveries↗). Shopify, by contrast, retries a failed delivery 8 times over 4 hours on its own (Shopify webhook not firing).
The traffic inspector also recorded the failed push, with its full body, while the app was down. LocalCan answered GitHub with the 502, and the request it could not forward stayed in the list:
$ localcan traffic ls --host tender-otter.localcan.dev
ID TIME METHOD STATUS DUR PATH HOST
3ae3f7d0 08:50:18 POST 200 17ms /webhook tender-otter.localcan.dev
b0fb7b7f 08:50:17 POST 202 16ms /webhook tender-otter.localcan.dev
376f73a5 08:49:56 POST 502 5ms /webhook tender-otter.localcan.dev
The inspector lists local time, an hour ahead of the UTC times in GitHub's list.
In the app, Replay on that request sends it again to the local handler. I sent 376f73a5 with the same daemon call the button makes, and the handler verified the signature and answered 202. The replay skips GitHub, so the 3-day limit does not apply to it.
Without the app, as on Linux, localcan traffic get <id> --format curl prints the captured request as a curl command aimed at the local URL (curl -X POST 'http://localhost:3020/webhook', see the traffic commands). Read the command before you run it, since it carries whatever the sender put in the request. Run on the failed push, it got the handler's ok, so the signature still verified.
The inspector keeps the most recent 250 exchanges in memory and clears them when the daemon restarts (traffic inspector), so export one you want to keep with localcan traffic get <id> --format curl.
Verify the fix
- Send a ping:
gh api -X POST repos/OWNER/REPO/hooks/HOOK_ID/pings, or Redeliver on any delivery. - List the deliveries again. The newest row should read
OKwith a 2xxstatus_code. - Check the same request in your server log or the inspector, answered well under 10 seconds.
- Redeliver every failed delivery from the past 3 days that your app still needs.
