Test GitHub webhooks locally
Receive GitHub webhook events on localhost with a Horizon tunnel, and verify the X-Hub-Signature-256 signature.
Receive GitHub events on your laptop while you build, with a URL GitHub can reach.
Before you begin
- Node.js 18 or later
- A Horizon account and the CLI (see Getting started)
- A GitHub repository where you have admin access
Start your app
Create a route handler that checks the X-Hub-Signature-256 header. GitHub computes an HMAC (a keyed hash) of the raw request body with your webhook secret and sends it as sha256=<hex digest>. Read the raw body with request.text() before you parse it, and compare with crypto.timingSafeEqual, as GitHub recommends.
import { createHmac, timingSafeEqual } from "node:crypto";
export async function POST(request: Request) {
const secret = process.env.GITHUB_WEBHOOK_SECRET;
if (!secret) {
return new Response("Missing GITHUB_WEBHOOK_SECRET", { status: 500 });
}
const body = await request.text();
const received = request.headers.get("x-hub-signature-256") ?? "";
const expected = `sha256=${createHmac("sha256", secret).update(body).digest("hex")}`;
const receivedBuffer = Buffer.from(received);
const expectedBuffer = Buffer.from(expected);
const isValid =
receivedBuffer.length === expectedBuffer.length &&
timingSafeEqual(receivedBuffer, expectedBuffer);
if (!isValid) {
return new Response("Invalid signature", { status: 401 });
}
const event = request.headers.get("x-github-event");
console.log(`Received GitHub event: ${event}`);
return new Response("ok", { status: 200 });
}Pick a secret and store it in an environment variable. Use a random, high-entropy string.
GITHUB_WEBHOOK_SECRET=replace-with-a-long-random-stringStart the app on port 3000.
npm run devStart a tunnel
Use -s to pick a subdomain. Without it, the subdomain is random and changes every run, so your GitHub webhook would point at a dead URL after a restart.
hrzn tunnel http://localhost:3000 -s my-appHORIZON: Tunnel connectedYour public URL is https://my-app.hrzn.run. Keep this terminal open.
Add the webhook in GitHub
- Open your repository on GitHub and select Settings. If you don't see it, open the More dropdown first.
- In the left sidebar, select Webhooks, then Add webhook.
- Set Payload URL to
https://my-app.hrzn.run/api/webhooks/github. - Set Content type to
application/json. - Set Secret to the same value as
GITHUB_WEBHOOK_SECRET. - Under Which events would you like to trigger this webhook?, select Let me select individual events and pick only the events you need.
- Make sure Active is checked, then select Add webhook.
The Secret field is optional in GitHub. Without it, GitHub sends no X-Hub-Signature-256 header and the handler above rejects every request.
Redeliver an event
GitHub keeps recent deliveries, so you can resend one without triggering a new event. Redelivery works for deliveries from the past 3 days, and only for people with admin access to the repository.
- Open your webhook from Settings, Webhooks.
- Select the Recent deliveries tab.
- Select the delivery GUID you want to resend.
- Select Redeliver.
GitHub doesn't redeliver failed deliveries on its own. Redeliver them yourself.
Check it works
When you select Add webhook, GitHub sends a ping event to confirm the setup. It carries a zen string, the hook_id, and the hook object.
Your Horizon terminal prints one line for it:
HORIZON: Tunnel connected
POST | [200] | /api/webhooks/githubYour app terminal prints:
Received GitHub event: pingIf the line shows [401], see Troubleshooting. To test again, redeliver the ping from Recent deliveries.
Troubleshooting
The signature doesn't match
- Check that
GITHUB_WEBHOOK_SECRETis identical to the Secret in GitHub, with no extra spaces or newline. - Compute the HMAC over the raw body. Don't run
JSON.parseandJSON.stringifyfirst, because that can change the bytes. - Restart
npm run devafter you edit.env.local. - GitHub says to treat the payload as UTF-8, because payloads can contain unicode characters.
request.text()does this.
The content type isn't application/json
GitHub offers two content types. application/x-www-form-urlencoded sends the JSON as a form parameter named payload, so the body isn't plain JSON. Edit the webhook and set Content type to application/json.
The URL changed after a restart
You started the tunnel without -s, so Horizon gave you a new random subdomain. Restart with -s my-app and the URL stays the same. Reserving a subdomain keeps it yours across restarts. It's a paid feature, see Pricing.
Nothing reaches your app
- Check that the Horizon terminal still shows
HORIZON: Tunnel connected. - Check that the Payload URL ends with
/api/webhooks/github.
Next steps
- Read GitHub's guide to validating webhook deliveries.
Test Stripe webhooks locally
Receive Stripe webhook events on localhost with a Horizon tunnel, and verify their signatures in a Next.js route handler.
Test Shopify webhooks locally
Receive Shopify webhook events on localhost with a Horizon tunnel, and verify the X-Shopify-Hmac-Sha256 signature in Next.js.