Horizon
GuidesWebhooks

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.

app/api/webhooks/github/route.ts
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.

.env.local
GITHUB_WEBHOOK_SECRET=replace-with-a-long-random-string

Start the app on port 3000.

npm run dev

Start 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-app
HORIZON: Tunnel connected

Your public URL is https://my-app.hrzn.run. Keep this terminal open.

Add the webhook in GitHub

  1. Open your repository on GitHub and select Settings. If you don't see it, open the More dropdown first.
  2. In the left sidebar, select Webhooks, then Add webhook.
  3. Set Payload URL to https://my-app.hrzn.run/api/webhooks/github.
  4. Set Content type to application/json.
  5. Set Secret to the same value as GITHUB_WEBHOOK_SECRET.
  6. Under Which events would you like to trigger this webhook?, select Let me select individual events and pick only the events you need.
  7. Make sure Active is checked, then select Add webhook.

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.

  1. Open your webhook from Settings, Webhooks.
  2. Select the Recent deliveries tab.
  3. Select the delivery GUID you want to resend.
  4. 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/github

Your app terminal prints:

Received GitHub event: ping

If 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_SECRET is identical to the Secret in GitHub, with no extra spaces or newline.
  • Compute the HMAC over the raw body. Don't run JSON.parse and JSON.stringify first, because that can change the bytes.
  • Restart npm run dev after 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

On this page