Test GitHub webhooks
A GitHub webhook can live on a repository, an organisation or a GitHub App. Paste a temporary URL as its Payload URL and set a secret: GitHub sends a ping event straight away, so you see a signed delivery before any real event happens.
Point your GitHub webhook at it and see a real event.
What GitHub sends
Deliveries are POST requests. The Content type you pick decides the body: application/json sends the payload as the body, and application/x-www-form-urlencoded sends it as a form field named payload. Choose JSON unless you have a reason not to. Payloads are capped at 25 MB, and GitHub drops larger events, such as very large pushes, without delivering them.
The ping event, trimmed:
{ "zen": "Keep it logically awesome.", "hook_id": 0, "hook": { "type": "Repository", "id": 0, "active": true, "events": ["push"] }}
The event name is in a header, not in the body, so route on the header.
Headers
| Header | Example value | Meaning |
|---|---|---|
| X-GitHub-Event | ping | The event that triggered the delivery, such as push or issues. |
| X-GitHub-Delivery | 72d3162e-cc78-11e3-81ab-4c9367dc0958 | A GUID for this delivery. |
| X-GitHub-Hook-ID | 292430182 | The webhook's ID. |
| X-GitHub-Hook-Installation-Target-Type | repository | Where the webhook was created. |
| X-GitHub-Hook-Installation-Target-ID | 79929171 | The ID of that repository or organisation. |
| X-Hub-Signature-256 | sha256=d57c68ca…aa23c | HMAC-SHA256 of the body as hex, sent only when a secret is set. |
| X-Hub-Signature | sha1=7d38cdd6…c5f6 | The legacy HMAC-SHA1 version. Ignore it. |
| User-Agent | GitHub-Hookshot/044aadd | Always starts with GitHub-Hookshot/. |
Verify the signature
Compute an HMAC-SHA256 of the request body with the webhook's secret, hex-encode it, put sha256= in front, and compare it with X-Hub-Signature-256 in constant time. GitHub publishes a test vector for checking your code: with the secret It's a Secret to Everybody and the payload Hello, World!, the header is sha256=757107ea0eb2509fc211221cce984b8a37570b6d7586c22c46f4379c8b043e17.
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
)
// validGitHubSignature checks X-Hub-Signature-256 against the raw body.
func validGitHubSignature(body []byte, header, secret string) bool {
mac := hmac.New(sha256.New, []byte(secret))
mac.Write(body)
expected := "sha256=" + hex.EncodeToString(mac.Sum(nil))
return hmac.Equal([]byte(expected), []byte(header))
}
import { createHmac, timingSafeEqual } from "node:crypto";
// body must be the raw request bytes (a Buffer from express.raw), not parsed JSON.
export function validGitHubSignature(body, header, secret) {
const expected = Buffer.from("sha256=" + createHmac("sha256", secret).update(body).digest("hex"));
const received = Buffer.from(header ?? "");
return expected.length === received.length && timingSafeEqual(expected, received);
}
Respond quickly
Answer with a 2xx as soon as the delivery is stored, and do the work afterwards. That includes the first ping: answer it rather than treating an unknown event as an error.
Common mistakes
- Checking
X-Hub-Signature. That is the SHA-1 header, kept only for old integrations. - Comparing with
==. GitHub's docs say never to use a plain equality operator. - Hashing the parsed payload. The HMAC covers the body bytes as sent; re-serialised JSON, or the decoded
payloadfield of a form delivery, will not match. - Creating the webhook without a secret. Then GitHub sends no signature header at all.
Try it with a real event
Create a test URL, add it as a webhook in the repository settings with a secret, and read the ping delivery. To check its signature by hand, use the signature verifier with the GitHub preset.