DebuggingAugust 6, 20268 min read

GitHub Webhook Not Received: You Get One Attempt and Three Days

GitHub does not retry failed webhook deliveries. Not once. The delivery record survives for three days and then it is gone too, which makes this the only major provider where a bad deploy costs you events permanently.

EVERY OTHER PROVIDERdeliveryfailsretryretryretrythe network gets several chances to be flakyGITHUBdeliveryfailsnothingno second attempt3 days to notice and redeliver by hand,then the delivery record is gone too
Every other major sender treats a failure as temporary. GitHub treats it as final, and gives you a short window to notice.

Your CI didn't run. The Slack notification never posted. You check GitHub's Recent Deliveries tab and the event is right there with a red mark next to it, timestamped during the ninety seconds your app was restarting.

Now here is the part that catches people out. GitHub will not send it again. Not in five minutes, not in an hour, not ever. The documentation says it in one sentence with no hedging: "GitHub does not automatically redeliver failed deliveries."

If you came to GitHub webhooks from Stripe or Shopify, this is the opposite of what you have been trained to expect.

TL;DR

  • GitHub gives your endpoint 10 seconds to return a 2XX
  • There is no automatic retry. A failed delivery is a lost event unless a human intervenes
  • Delivery records survive 3 days. After that you cannot even see what you missed
  • X-GitHub-Delivery is stable across manual redelivery, so it is the correct idempotency key
  • Because there is no safety net, the fix has to be "never fail in the first place," which means persisting before you process

What everyone else does, and what GitHub does

Stripe retries with exponential backoff for three days. Shopify retries eight times over four hours. Slack retries three times. Paddle retries sixty times in three days. Every one of those senders assumes your endpoint will sometimes be unavailable and designs around it.

GitHub sends the request. If it does not get a 2XX within 10 seconds, it records the failure and moves on.

EVERY OTHER PROVIDERdeliveryfailsretryretryretrythe network gets several chances to be flakyGITHUBdeliveryfailsnothingno second attempt3 days to notice and redeliver by hand,then the delivery record is gone too

That is a legitimate design decision rather than an oversight. GitHub delivers an enormous volume of events, most of which are informational, and a retry queue at that scale is expensive. But it shifts the entire burden of reliability onto you, and it does so quietly, because nothing in the setup flow tells you that this provider behaves differently from the others.

The three-day window is the real constraint

Failed deliveries are visible in Settings → Webhooks → Recent Deliveries, and you can click Redeliver on any of them. There is a REST API for the same thing, for repository, organization, and GitHub App webhooks.

The limit: "You can redeliver webhook deliveries that occurred in the past 3 days."

Three days sounds generous until you map it onto how teams actually notice this. A webhook fails during a Friday evening deploy. Nobody looks at the deliveries tab, because nothing alerted. On Monday morning someone asks why a PR never got labelled. By Tuesday the delivery record has aged out and you cannot even enumerate what was lost, let alone replay it.

Compare that to Stripe, where events remain pullable from the Events API for 30 days whether or not delivery ever succeeded. With GitHub, the delivery record is the copy. When it expires, the only remaining source of truth is the underlying repository state, and you are reconstructing "which pushes happened between 18:40 and 18:42" from the commit log.

Why the delivery failed

Work down this list in order.

The endpoint took longer than 10 seconds

"Your server should respond with a 2XX response within 10 seconds of receiving a webhook delivery."

A push event on a busy monorepo carries a large payload, and a handler that parses it, walks the commit list, and calls back into the GitHub API to fetch file contents will cross ten seconds without doing anything obviously wrong. GitHub's own advice is to queue: "you may want to set up a queue to process webhook payloads asynchronously."

The secret does not match

If a webhook has a secret configured, GitHub signs with X-Hub-Signature-256 and your verification has to run over the raw body. Re-serializing the JSON before hashing produces different bytes and the check fails, which returns a 4xx, which counts as a failed delivery with no retry. The per-provider details are in Verifying Stripe, GitHub, and Shopify Webhook Signatures.

Something in front of the app rejected it

A WAF rule, a bot filter, or auth middleware that expects a session cookie. Webhook traffic is anonymous POST traffic from a datacenter IP range, which is exactly the shape most protection rules are tuned to block. The response GitHub records will be a 401, 403, or 429 rather than anything from your code.

The event type is not subscribed

The delivery does not appear at all, rather than appearing and failing. Check the webhook's event list, and remember that changing a repository's default branch does not update webhooks configured against the old name.

It is a GitHub App and the installation lost access

App webhooks stop for repositories the installation was removed from. The deliveries tab shows nothing, which looks identical to "the trigger never fired."

The idempotency key

X-GitHub-Delivery is a UUID for the delivery. Two properties make it the right key:

It is stable across manual redelivery. GitHub's guidance is explicit that "redelivered webhooks retain the original header value," so when you click Redeliver after a bad deploy, your handler sees the same ID it would have seen the first time and can recognise the event as one it already processed.

It is per-delivery rather than per-event, which is what you want. Two genuinely separate pushes produce two IDs even if the payloads are similar.

export async function POST(req: Request) {
  const raw = await req.text();
  if (!verifyGitHubSignature(raw, req.headers.get("x-hub-signature-256"))) {
    return new Response("unauthorized", { status: 401 });
  }

  const deliveryId = req.headers.get("x-github-delivery");
  const { rowCount } = await db.query(
    `INSERT INTO processed_events (event_id, provider)
     VALUES ($1, 'github') ON CONFLICT (event_id) DO NOTHING`,
    [deliveryId],
  );
  if (rowCount === 0) return new Response(null, { status: 200 });

  await queue.publish({ deliveryId, event: req.headers.get("x-github-event"), body: raw });
  return new Response(null, { status: 200 });
}

Return 200 on the duplicate, not an error. The wider set of patterns, including what to do when the marker write and the side effect can't share a transaction, is in You Received the Same Webhook Twice.

Designing for a sender that does not retry

With every other provider you can be a little sloppy about availability, because the retry schedule absorbs it. Here the design constraint is different: the only delivery you are guaranteed is the first one, so the first one has to succeed.

That means the handler does the minimum possible work before returning 200. Verify the signature, write the raw body somewhere durable, return. Everything else, including the parts most likely to be slow or throw, happens after the response and can retry on its own schedule without GitHub watching.

It also means you need to know about failures within the three-day window rather than whenever someone notices. GitHub will not tell you. There is no failure email, no disabled-endpoint notification, nothing equivalent to Shopify's warning or Stripe's shutoff notice. If the only thing that would surface a gap is a human opening the deliveries tab, you do not have monitoring, you have luck.

A useful check that costs nothing: alert when a repository you expect regular events from goes quiet for longer than its normal gap. That catches both "our endpoint is failing" and "the webhook got deleted during a settings cleanup," which is a surprisingly common way for this to end.

Where AnyHook fits

The structural problem is that GitHub's only copy of the event is a delivery record with a three-day fuse, and your only copy is whatever your handler managed to write before it fell over.

Point the webhook at in.anyhook.net/you/github and the copy moves. AnyHook returns 200 to GitHub in under 50ms from an edge worker whose only job is to verify and persist, so the 10-second budget is never in play and the delivery never enters the failed state that GitHub does not recover from.

From there:

  • Delivery to your own endpoint is retried on exponential backoff, which is the safety net GitHub does not provide
  • The event is stored with full headers and body, encrypted at rest, on your plan's retention rather than GitHub's 3 days
  • Replay works over a time range, so the Friday deploy window is a selection and a click rather than a reconstruction from commit history
  • Failure alerts fire at 1, 5, and 20 consecutive failures, which is the signal GitHub does not send

Your handler still needs to be idempotent, because manual redelivery and replay both exist. Nothing removes that.

Takeaway

Every debugging habit you built against Stripe transfers badly here. There is no retry to wait for, no dead letter queue to inspect, and no thirty-day API to backfill from. There is one attempt, a three-day record of whether it worked, and then silence.

Return 200 fast on something you have written down, dedupe on X-GitHub-Delivery, and put an alert on unexpected quiet. If a failed delivery is currently unrecoverable in your setup, that is not a GitHub quirk you are working around, it is the design you have accepted.

All postsAugust 6, 2026 · 8 min

Stop losing webhooks.

Change one URL. Get retries, event log, and one-click replay.