HookGet Start free

GitHub webhook signatures and redelivery

GitHub signs a webhook with HMAC-SHA256 over the raw body, sent as sha256=<hex> in X-Hub-Signature-256 when the hook has a secret. Verify it with a constant-time comparison before parsing. GitHub gives your server ten seconds to respond and never retries on its own: a failed delivery stays failed until you redeliver it, from the UI or the deliveries API, within three days.

In short

  • X-Hub-Signature-256: sha256=<hex> is HMAC-SHA256 over the raw body with the hook secret; compare with timingSafeEqual, ignore the SHA-1 header.
  • 10-second timeout, zero automatic retries, 3-day redelivery window. The retry loop is a cron job against POST .../deliveries/{id}/attempts.
  • Deduplicate on X-GitHub-Delivery: a redelivery carries the same GUID.
  • Verification: the provider’s own HMAC signature over the raw body.
  • Deduplication: X-GitHub-Delivery.
  • Once verified, the event joins your catalogue with the same retries, log and replay as anything you publish yourself.

How do I verify X-Hub-Signature-256 in Node?

When a webhook has a secret, GitHub sends two signatures of the raw body: HMAC-SHA256 as sha256=<hex> in X-Hub-Signature-256, and the older HMAC-SHA1 in X-Hub-Signature, kept only for existing integrations. Verify the SHA-256 one and ignore the other. GitHub's documentation is explicit that the comparison must not be ==; use timingSafeEqual on equal-length buffers, and treat the body as UTF-8 bytes, because payloads contain unicode.

import { createHmac, timingSafeEqual } from 'node:crypto';

/** GitHub: "sha256=" + hex HMAC-SHA256 over the raw body, in X-Hub-Signature-256. */
export function verifyGitHub(secret, header, rawBody) {
  const expected = 'sha256=' + createHmac('sha256', secret).update(rawBody).digest('hex');
  const a = Buffer.from(expected, 'utf8');
  const b = Buffer.from(String(header ?? ''), 'utf8');
  return a.length === b.length && timingSafeEqual(a, b); // never ==, never startsWith
}

If the header is missing entirely, the webhook has no secret configured. GitHub only signs when one is set, and a handler that treats "no header" as "no signature to check" has turned verification off for everyone.

Does GitHub retry a failed webhook delivery?

No. GitHub gives your server ten seconds; if it is down or slower than that, the delivery is recorded as failed and GitHub does not redeliver it on its own. That is the whole difference between GitHub and Stripe or Shopify: the retry loop is yours to run, and it has a deadline, because deliveries can only be redelivered for three days.

BehaviourGitHub's figure
Response timeout10 seconds
Automatic retriesNone
Redelivery window3 days, from the Recent deliveries panel or the REST API
Payload cap25 MB; an event with a larger payload is not delivered at all
Deliveries listper_page up to 100, cursor pagination via the Link header
SignaturesHMAC-SHA256 hex with sha256= prefix; legacy SHA-1 alongside
Delivery identityX-GitHub-Delivery, a GUID shared by every delivery of the same event, redeliveries included
The retry loop you run for GitHub webhooks: GitHub delivers once with a 10-second timeout, records a failure and does not retry; your cron job lists failed deliveries and posts to the attempts endpoint within 3 days; the redelivery carries the same GUID Flow diagram. GitHub delivers a webhook with a 10-second timeout. Your endpoint verifies the X-Hub-Signature-256 header, inserts the delivery keyed on the X-GitHub-Delivery GUID and answers 202; a 2xx ends in Delivered. Anything else is recorded as failed and GitHub does not retry. A cron job of yours lists the hook's deliveries and posts to the attempts endpoint for each failed one, within 3 days. The redelivery arrives with the same GUID, so a repeat becomes one refused insert. GitHub delivers once, 10 s to answer Your endpoint verify sha256=, insert on GUID, 202 Delivered Recorded as failed GitHub does not retry Your cron job GET .../deliveries POST X-Hub-Signature-256 2xx non-2xx or timeout POST .../deliveries/{id}/attempts, within 3 days the redelivery carries the same X-GitHub-Delivery GUID: a repeat is one refused insert non-2xx, or slower than 10 s
The retry loop is yours: GitHub delivers once with a 10-second timeout and records a failure without retrying. A cron job lists the hook’s deliveries and posts to the attempts endpoint within the 3-day window; the redelivery arrives with the same X-GitHub-Delivery GUID, so a handler keyed on it refuses the repeat.
import express from 'express';
import { verifyGitHub } from './verify-github.mjs';

const app = express();

// Ten seconds to answer, and no second chance from GitHub's side.
app.post('/webhooks/github', express.raw({ type: 'application/json' }), async (req, res) => {
  if (!verifyGitHub(process.env.GITHUB_WEBHOOK_SECRET, req.get('x-hub-signature-256'), req.body)) {
    return res.status(401).send('signature mismatch');
  }

  // CREATE TABLE github_deliveries (
  //   guid text PRIMARY KEY, event text NOT NULL, hook_id text, payload jsonb NOT NULL,
  //   received_at timestamptz NOT NULL DEFAULT now(), processed_at timestamptz);
  const { rowCount } = await db.query(
    `INSERT INTO github_deliveries (guid, event, hook_id, payload)
     VALUES ($1, $2, $3, $4) ON CONFLICT (guid) DO NOTHING`,
    [req.get('x-github-delivery'), req.get('x-github-event'), req.get('x-github-hook-id'), JSON.parse(req.body)],
  );

  res.status(202).json({ stored: rowCount === 1 }); // a redelivery repeats the GUID: rowCount 0
});

How do I redeliver failed GitHub webhooks automatically?

List the hook's deliveries, pick the ones without a 2xx, and post to the attempts endpoint for each. The same three calls exist for organisation hooks under /orgs/{org}/hooks/{hook_id}/deliveries and for a GitHub App under /app/hook/deliveries. Because a redelivery carries the original GUID, the handler above will insert nothing on the second arrival if the first one actually succeeded and only the response was lost.

// redeliver-failed.mjs: GitHub does not retry, so this does. Run it from cron.
// Token needs the repository webhooks permission (repo scope on a classic token).
const { GITHUB_TOKEN, OWNER, REPO, HOOK_ID } = process.env;
const api = `https://api.github.com/repos/${OWNER}/${REPO}/hooks/${HOOK_ID}/deliveries`;
const headers = {
  authorization: `Bearer ${GITHUB_TOKEN}`,
  accept: 'application/vnd.github+json',
  'x-github-api-version': '2022-11-28',
};

const res = await fetch(`${api}?per_page=100`, { headers });
if (!res.ok) throw new Error(`list deliveries: ${res.status}`);
const deliveries = await res.json();

// One event GUID may have several deliveries. If any of them succeeded, the
// event is done; redelivering it again would only exercise your idempotency.
const ok = (d) => d.status_code >= 200 && d.status_code < 300;
const done = new Set(deliveries.filter(ok).map((d) => d.guid));
const failed = deliveries.filter((d) => !ok(d) && !done.has(d.guid));

for (const d of failed) {
  const r = await fetch(`${api}/${d.id}/attempts`, { method: 'POST', headers });
  console.log(`${d.guid} ${d.event}${d.action ? '.' + d.action : ''}: was ${d.status_code}, redeliver -> ${r.status}`);
  done.add(d.guid);
}

Which headers does GitHub send with a webhook?

HeaderWhat GitHub puts in it
X-GitHub-Hook-IDThe webhook's numeric id
X-GitHub-EventThe event name, for example push or pull_request
X-GitHub-DeliveryA GUID identifying the event; the deduplication key
X-Hub-Signature-256sha256= plus the hex HMAC-SHA256 of the body; only when a secret is set
X-Hub-SignatureThe legacy SHA-1 HMAC; verify the SHA-256 header instead
User-AgentAlways starts with GitHub-Hookshot/
X-GitHub-Hook-Installation-Target-TypeWhere the hook lives: repository, organization, or app
X-GitHub-Hook-Installation-Target-IDThe id of that repository, organisation or app

How do I deduplicate a GitHub redelivery?

On X-GitHub-Delivery. The deliveries API describes the GUID as the identifier of the event, shared by all deliveries for it, and a redelivery is listed with the same GUID and redelivery: true. Make it the primary key of the table you store deliveries in, and a redelivery of something you already processed costs one refused insert. Choose application/json as the content type when creating the hook: the form-encoded alternative wraps the JSON in a payload= field, and the signature covers that raw form body, which is a second thing to get wrong.

Receive GitHub webhooks through a verified HookGet source

Receiving GitHub webhooks through HookGet

Everything above is what the receiving end has to do by hand. A HookGet source does the verification and the deduplication before the event exists, and the event then gets the retry schedule, dead-letter queue and per-attempt log described on the rest of this site.

How an inbound webhook is verified before it enters A provider sends a signed request. HookGet verifies the signature or token and deduplicates on the provider's delivery id before the event exists. A request that fails verification is refused with a 401 and never enters the pipeline. A verified event flows into the same pipeline as any other: retries, timeline, replay. The provider GitHub, Stripe, 13 more Verified at the door signature or token · dedupe The same pipeline retries · timeline · replay Refused bad signature → 401, nothing enters
Verification happens before the event exists, so a forged request is refused at the door — it is never stored, never retried, never seen again. Redeliveries are deduplicated on the provider’s own delivery id.

The contract

FieldValue
Provider keygithub
Header checkedX-Hub-Signature-256: sha256=<hex>
SchemeHMAC-SHA256 over the raw request body, hex-encoded, compared in constant time.
Deduplicated onX-GitHub-Delivery
Event typeX-GitHub-Event becomes github.push, github.pull_request, and so on.

Setting it up

# 1. create a verified source; the secret is shown once
curl -X POST https://api.hookget.com/v1/sources \
  -H "authorization: Bearer $HOOKGET_KEY" \
  -H "content-type: application/json" \
  -d '{"provider":"github","name":"Production"}'

# {"id":"src_…","secret":"whsec_…","ingest_path":"/ingest/src_…"}
  1. In the repository or organisation, open Settings → Webhooks → Add webhook.
  2. Paste the ingest URL as the payload URL and the source secret as the secret.
  3. Choose application/json as the content type.
  4. Select the events you want. HookGet namespaces whatever arrives.

Worth knowing. GitHub redelivers on your request and after failures. The delivery id is the dedupe key, so a redelivery becomes the same event rather than a second one.

What you get after verification

  • The event appears in your log with a namespaced type, so GitHub traffic never collides with your own.
  • It fans out to your destinations with the same retry schedule and dead-letter behaviour as any other event.
  • The source secret rotates with an overlap window, so you can update GitHub at your own pace.
  • Each inbound source is rate limited on its own, so a busy provider cannot exhaust your publish budget.

Questions

Does GitHub retry failed webhook deliveries?

No. If your server is down or takes longer than ten seconds, GitHub records the delivery as failed and does not redeliver it automatically. You can redeliver from the webhook's Recent deliveries panel or through the REST API for up to three days.

What is the difference between X-Hub-Signature and X-Hub-Signature-256?

Both are HMACs of the raw body with the hook secret. X-Hub-Signature uses SHA-1 and exists for older integrations; X-Hub-Signature-256 uses SHA-256 and is the one GitHub recommends. Verify the SHA-256 header and ignore the other.

Why is my GitHub webhook signature failing?

Either the hook has no secret, so no signature header is sent; the secret on your side has a stray newline or space; the body was parsed and re-serialised before hashing; or the hook was created with the form-encoded content type, so the signed body is payload=... rather than the JSON you expected.

How do I redeliver a GitHub webhook through the API?

List deliveries with GET /repos/{owner}/{repo}/hooks/{hook_id}/deliveries, then POST .../deliveries/{delivery_id}/attempts for each failed one. Organisation hooks use /orgs/{org}/hooks/{hook_id}/deliveries and a GitHub App uses /app/hook/deliveries. The window is three days.

Can I deduplicate GitHub redeliveries on X-GitHub-Delivery?

Yes. The GUID identifies the event and is shared by all of its deliveries, and the deliveries API marks a redelivery with the same GUID and redelivery: true. Store it as a primary key and a repeat becomes one refused insert.

How do I verify a GitHub webhook?

HMAC-SHA256 over the raw request body, hex-encoded, compared in constant time. With HookGet you do not implement it: the source is created with a secret, and every request is checked against X-Hub-Signature-256: sha256=<hex> before the event exists.

What happens if GitHub sends the same event twice?

Redeliveries are deduplicated on X-GitHub-Delivery, namespaced per source, so a repeat becomes the same event rather than a second one.

What if verification fails?

The request is refused with 401 and nothing enters the pipeline. The rejection is logged on our side as a signal worth watching; the caller learns nothing beyond the status.

Start delivering webhooks today

Point your webhooks at HookGet and watch the first delivery arrive, signed, in under a minute.

Create a free account Try the free webhook tester

10,000 deliveries a month free, no credit card. The free tier blocks rather than bills, so trying it cannot produce an invoice.