Horizon

Test Buildkite webhooks locally

Receive Buildkite pipeline webhook events on localhost with a Horizon tunnel, and verify the X-Buildkite-Signature header.

Receive Buildkite build and job events on your laptop while you build, with a URL Buildkite can reach.

Before you begin

  • Node.js 18 or later
  • A Horizon account and the CLI (see Getting started)
  • A reserved subdomain for -s. Reserve one on the Subdomains page.
  • A Buildkite organization where you can open Settings and a pipeline you can build
  • A Next.js app that uses the App Router and runs on port 3000

Start your app

Buildkite can authenticate a webhook in two ways. It either sends your token as plain text in X-Buildkite-Token, or it sends a signature in X-Buildkite-Signature. Buildkite calls the signature the more secure option, so this guide uses it.

The X-Buildkite-Signature header looks like timestamp=1619071700,signature=<hex>. The signature is an HMAC-SHA256 digest, hex encoded. The key is your webhook token. The signed message is the timestamp, a dot, and the raw request body. The timestamp is the time Buildkite sent the request, not the time of the event.

Read the raw body with request.text().

app/api/webhooks/buildkite/route.ts
import { createHmac, timingSafeEqual } from "node:crypto";

function readParts(header: string) {
  const parts = Object.fromEntries(
    header.split(",").map((entry) => entry.split("=", 2).map((value) => value.trim())),
  );
  return { timestamp: parts.timestamp ?? "", signature: parts.signature ?? "" };
}

export async function POST(request: Request) {
  const token = process.env.BUILDKITE_WEBHOOK_TOKEN;
  if (!token) {
    return new Response("Missing BUILDKITE_WEBHOOK_TOKEN", { status: 500 });
  }

  const body = await request.text();
  const { timestamp, signature } = readParts(request.headers.get("x-buildkite-signature") ?? "");
  const expected = createHmac("sha256", token).update(`${timestamp}.${body}`).digest("hex");

  const receivedBuffer = Buffer.from(signature);
  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-buildkite-event");
  console.log(`Received Buildkite event: ${event}`);

  return new Response("ok", { status: 200 });
}

Buildkite also suggests you reject requests whose timestamp is outside a short window, for example 5 minutes. The timestamp is part of the signed message, so an attacker can't change it. Add that check before you go to production.

Pick a token and store it in an environment variable. Use a random, high-entropy string.

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

Start the app on port 3000.

npm run dev

Start a tunnel

Use -s with a subdomain you reserved. Without it, the subdomain is random and changes every run, so your Buildkite webhook would point at a dead URL after a restart. Reserve it first on the Subdomains page. Reserved subdomains are a paid feature, see Pricing.

hrzn tunnel http://localhost:3000 -s my-buildkite-app
Output
HORIZON: Tunnel connected
  URL          https://my-buildkite-app.hrzn.run (reserved)
  Forwarding   http://localhost:3000
  Request log  https://hrzn.run/dashboard/tunnels/my-buildkite-app

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

Add the webhook in Buildkite

  1. In Buildkite, select Settings in the global navigation, then Notification Services.
  2. Select Add on Webhook.
  3. Enter a Description.
  4. Set Webhook URL to https://my-buildkite-app.hrzn.run/api/webhooks/buildkite.
  5. Leave Verify TLS Certificates checked. Horizon tunnels use HTTPS.
  6. Under Token, enter the same value as BUILDKITE_WEBHOOK_TOKEN. Choose to send it as a signature in X-Buildkite-Signature, not as a plain text X-Buildkite-Token.
  7. Under Events, select the events you need. The groups are build, job, agent, ping and agent token, and third-party integration events.
  8. Under Pipelines, choose which pipelines trigger the webhook.
  9. Select Add Webhook Notification.

Trigger an event

Buildkite sends a ping event when the webhook's notification settings change. Select the ping event in step 7 and save, or edit and save the webhook again, to send one.

For a build event, create a build on a pipeline the webhook covers. Buildkite sends events such as build.scheduled, build.running and build.finished.

To see what Buildkite sent, open the webhook's settings page. At the bottom, select Load recent requests. Buildkite keeps the last 20 requests and responses.

Check it works

After the ping or a build, your Horizon terminal prints one line per event:

Output
  POST    200  /api/webhooks/buildkite

Your app terminal prints the value of the X-Buildkite-Event header, for example:

Output
Received Buildkite event: build.scheduled

If the line shows [401], see Troubleshooting.

Troubleshooting

The signature doesn't match

The Horizon line shows [401]. Check these in order:

  • BUILDKITE_WEBHOOK_TOKEN is identical to the Token in Buildkite, with no extra spaces or newline.
  • The webhook sends the token as a signature. In plain text mode Buildkite sends X-Buildkite-Token and no X-Buildkite-Signature.
  • The handler signs timestamp.body, with a dot between them, and hashes the raw body. Don't run JSON.parse and JSON.stringify first, because that can change the bytes.
  • You restarted npm run dev after you edited .env.local.

The URL changed after a restart

You started the tunnel without -s, so Horizon gave you a new random subdomain. Restart with -s my-buildkite-app and the URL stays the same. -s needs a subdomain you reserved, see Pricing.

Nothing reaches your app

  • Check that the Horizon terminal is still running. If its last line is Connection lost. Reconnecting…, wait for Reconnected.
  • Check that the Webhook URL ends with /api/webhooks/buildkite.
  • Check that the event you trigger is selected under Events and that the pipeline is selected under Pipelines.
  • Open Load recent requests on the webhook page. A row there with an error response tells you whether Buildkite reached the tunnel.

Next steps

On this page