Create a test URL

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.

Create a test URL

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 payload field 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.