How to Test GitHub Webhooks

Configure a repository webhook, read the headers GitHub sends, verify X-Hub-Signature-256 correctly and redeliver events while you debug.

Updated:

GitHub webhooks notify your service about pushes, pull requests, issues, releases and more. Before you write the handler, it helps to see a real delivery: which headers GitHub sets, what the body looks like for each content type, and how the signature is built. This guide shows how to capture deliveries on a public URL, verify them and forward them to your local code.

Step 1: Create a capture URL

Open Webhook Inspector and create a URL such as:

https://webhook.voronyuk.com/abcd23efgh/github/webhook

It accepts any method, sub-path and query. By default it replies 200 with {"ok":true,"request_id":N}, so GitHub records each delivery as successful.

Step 2: Add the webhook to your repository

  1. In the repository, open Settings → Webhooks and click Add webhook.
  2. Payload URL: paste the capture URL.
  3. Content type: choose application/json or application/x-www-form-urlencoded (see below).
  4. Secret: enter a long random string. You'll use it to verify signatures.
  5. Choose which events trigger the webhook: just the push event, everything, or individual events.
  6. Keep Active checked and click Add webhook.

GitHub immediately sends a ping event. It should appear in the inspector within seconds.

Step 3: Know the headers

Header Meaning
X-GitHub-Event Event name, e.g. push, pull_request, ping
X-GitHub-Delivery A unique GUID for this delivery
X-GitHub-Hook-ID The ID of the webhook that sent it
X-Hub-Signature-256 sha256= followed by the HMAC-SHA256 hex digest of the raw body
X-Hub-Signature Legacy SHA-1 signature, kept for compatibility
User-Agent Starts with GitHub-Hookshot/

Route on X-GitHub-Event, and use X-GitHub-Delivery to detect duplicates.

The ping event

The first delivery is ping. Its body includes a zen string, the hook_id and the hook configuration. Your handler should return 2xx for it, even if it ignores the event.

JSON vs form-encoded

With application/json, the body is the JSON payload itself. With application/x-www-form-urlencoded, the JSON is sent as the value of a payload form field. The inspector decodes form data, so you'll see the payload field and its JSON content either way. JSON is usually simpler to handle.

In both cases, the signature covers the raw bytes of the body as sent, not the decoded JSON.

Step 4: Verify X-Hub-Signature-256

GitHub computes an HMAC-SHA256 of the raw request body with your webhook secret and sends it as sha256=<hex>. Compute the same value and compare in constant time.

Node.js

const crypto = require('crypto');
const express = require('express');

const SECRET = process.env.GITHUB_WEBHOOK_SECRET;
const app = express();

function isValid(rawBody, header) {
  if (!header || !header.startsWith('sha256=')) return false;
  const expected =
    'sha256=' + crypto.createHmac('sha256', SECRET).update(rawBody).digest('hex');
  const a = Buffer.from(expected);
  const b = Buffer.from(header);
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

app.post('/webhook', express.raw({ type: '*/*' }), (req, res) => {
  if (!isValid(req.body, req.get('X-Hub-Signature-256'))) {
    return res.status(401).send('Bad signature');
  }
  const event = req.get('X-GitHub-Event');
  const delivery = req.get('X-GitHub-Delivery');
  res.status(202).send('Accepted'); // respond first
  console.log(event, delivery);     // then process (ideally in a queue)
});

app.listen(3000);

This sample assumes application/json. If you use form encoding, parse the payload field from the raw body after verification.

Python

import hashlib
import hmac
import os

from flask import Flask, abort, request

app = Flask(__name__)
SECRET = os.environ["GITHUB_WEBHOOK_SECRET"].encode()


@app.post("/webhook")
def webhook():
    header = request.headers.get("X-Hub-Signature-256", "")
    expected = "sha256=" + hmac.new(SECRET, request.get_data(), hashlib.sha256).hexdigest()
    if not hmac.compare_digest(expected, header):
        abort(401)
    print(request.headers.get("X-GitHub-Event"), request.headers.get("X-GitHub-Delivery"))
    return "", 202


if __name__ == "__main__":
    app.run(port=3000)

Step 5: Forward deliveries to localhost

In the inspector's Forwarding panel, set the target to http://localhost:3000. The captured sub-path is appended, so .../abcd23efgh/github/webhook goes to http://localhost:3000/webhook. Use the browser relay (your server must allow CORS) or the CLI agent, which needs no browser and catches up on missed requests:

WI_TOKEN='<token>' inspector listen https://webhook.voronyuk.com/abcd23efgh/github --to localhost:3000

Copy the exact command with your token from the panel. Details in how to test webhooks locally.

Timeouts, failures and redelivery

  • Respond within 10 seconds. GitHub expects a 2xx within 10 seconds. If your server takes longer, GitHub ends the connection and marks the delivery as failed. Acknowledge first and process asynchronously.
  • No automatic retries. GitHub does not automatically redeliver failed deliveries. If your endpoint was down, you have to redeliver manually or through the REST API.
  • Recent Deliveries. In Settings → Webhooks, open the webhook and switch to Recent Deliveries. Each entry shows the request headers and payload plus the response your server returned. Click Redeliver to send it again.

Because failed deliveries aren't retried, keeping your own copy helps. Every delivery that hits the capture URL is stored, and you can replay it from the GitHub webhook tester to your local server as often as you need. To test how your setup reacts to failures, set a custom response on the URL, for example status 500, and check what GitHub logs under Recent Deliveries.

Related: Stripe webhooks and Slack Events API.

FAQ

How do I test a GitHub webhook?

Add a webhook in Settings → Webhooks with a public capture URL as the Payload URL. GitHub sends a ping right away. Push a commit or open a pull request to get real events, then inspect or redeliver them.

Why is my GitHub webhook signature invalid?

You're probably hashing a parsed or re-serialized body. Compute the HMAC over the raw bytes, include the sha256= prefix when comparing, and make sure the secret matches exactly, including no trailing whitespace.

Does GitHub retry failed webhook deliveries?

No. GitHub does not redeliver failed deliveries automatically. Use Redeliver in Recent Deliveries or the REST API.

What is the GitHub webhook timeout?

10 seconds. A delivery that takes longer to get a 2xx response is recorded as failed.

Should I use application/json or form-encoded?

application/json is easier: the body is the payload. With form encoding, the JSON arrives in a payload field and must be decoded first.

Try it now

Get a free webhook URL and watch the requests arrive live — no signup.

Get a webhook URL