GitHub Webhook "We couldn't deliver this payload": Each Error and Its Fix
Published on October 29, 2026   •   13 min read

GitHub Webhook "We couldn't deliver this payload": Each Error and Its Fix

Jarek CeborskiJarek Ceborski

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 host status 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 this timed out.
  • A line containing x509: points at your TLS certificate, and the words after it narrow the problem down.
  • An Invalid HTTP Response: 404 status (or 401, 405 and so on) means a server answered with an error.
  • A bare 502 status 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:

Bash
gh api repos/OWNER/REPO/hooks/HOOK_ID/deliveries \
  --jq '.[] | [.delivered_at, .event, .redelivery, .status_code, .status] | @tsv'
Text
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 atstatusstatus_code
A name with no DNS recordfailed to connect to host502
A port that refuses connectionsfailed to connect to host502
A port behind a firewall that drops packetsfailed to connect to host502
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 authority500
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.com500
The wrong pathInvalid HTTP Response: 404404
The wrong secretInvalid HTTP Response: 401401
An http:// URL that redirects to https://Invalid HTTP Response: 404404
A running Public URL with the app behind it stopped502502
A Public URL whose tunnel was stopped502502

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:

  1. Running dig +short your.host.example returns an address. A name that only resolves on your LAN or VPN fails here.
  2. The port is open to GitHub. Deliveries come from the ranges in the hooks list of GET /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 from 140.82.115.x.
  3. The URL is not a private address. GitHub refuses localhost when you save the hook, but a LAN IP or a .local name 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:

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

Text
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 problemEnds with
Self-signedx509: certificate signed by unknown authority
Signed by an untrusted rootx509: certificate signed by unknown authority
Server sends no intermediate certificatex509: certificate signed by unknown authority
Expiredx509: 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 hostnamex509: 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:

Bash
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"
Text
## 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 404 with Cannot POST /webhooks means the hook's path did not match the handler's route, /webhooks against /webhook.
  • A 401 with signature mismatch means the handler's HMAC check failed because the hook's secret and GITHUB_WEBHOOK_SECRET were 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 405 means the server does not accept POST on 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:

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

Bash
gh api repos/OWNER/REPO/hooks/HOOK_ID/deliveries/DELIVERY_ID --jq '.response'
Text
{"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:

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

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

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

Text
$ 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

  1. Send a ping: gh api -X POST repos/OWNER/REPO/hooks/HOOK_ID/pings, or Redeliver on any delivery.
  2. List the deliveries again. The newest row should read OK with a 2xx status_code.
  3. Check the same request in your server log or the inspector, answered well under 10 seconds.
  4. Redeliver every failed delivery from the past 3 days that your app still needs.

More posts