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 withtimingSafeEqual, 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.
| Behaviour | GitHub's figure |
|---|---|
| Response timeout | 10 seconds |
| Automatic retries | None |
| Redelivery window | 3 days, from the Recent deliveries panel or the REST API |
| Payload cap | 25 MB; an event with a larger payload is not delivered at all |
| Deliveries list | per_page up to 100, cursor pagination via the Link header |
| Signatures | HMAC-SHA256 hex with sha256= prefix; legacy SHA-1 alongside |
| Delivery identity | X-GitHub-Delivery, a GUID shared by every delivery of the same event, redeliveries included |
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?
| Header | What GitHub puts in it |
|---|---|
X-GitHub-Hook-ID | The webhook's numeric id |
X-GitHub-Event | The event name, for example push or pull_request |
X-GitHub-Delivery | A GUID identifying the event; the deduplication key |
X-Hub-Signature-256 | sha256= plus the hex HMAC-SHA256 of the body; only when a secret is set |
X-Hub-Signature | The legacy SHA-1 HMAC; verify the SHA-256 header instead |
User-Agent | Always starts with GitHub-Hookshot/ |
X-GitHub-Hook-Installation-Target-Type | Where the hook lives: repository, organization, or app |
X-GitHub-Hook-Installation-Target-ID | The 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.
The contract
| Field | Value |
|---|---|
| Provider key | github |
| Header checked | X-Hub-Signature-256: sha256=<hex> |
| Scheme | HMAC-SHA256 over the raw request body, hex-encoded, compared in constant time. |
| Deduplicated on | X-GitHub-Delivery |
| Event type | X-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_…"}
- In the repository or organisation, open Settings → Webhooks → Add webhook.
- Paste the ingest URL as the payload URL and the source secret as the secret.
- Choose application/json as the content type.
- 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.