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().
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.
BUILDKITE_WEBHOOK_TOKEN=replace-with-a-long-random-stringStart the app on port 3000.
npm run devStart 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-appHORIZON: Tunnel connected
URL https://my-buildkite-app.hrzn.run (reserved)
Forwarding http://localhost:3000
Request log https://hrzn.run/dashboard/tunnels/my-buildkite-appYour public URL is https://my-buildkite-app.hrzn.run. Keep this terminal open.
Add the webhook in Buildkite
- In Buildkite, select Settings in the global navigation, then Notification Services.
- Select Add on Webhook.
- Enter a Description.
- Set Webhook URL to
https://my-buildkite-app.hrzn.run/api/webhooks/buildkite. - Leave Verify TLS Certificates checked. Horizon tunnels use HTTPS.
- Under Token, enter the same value as
BUILDKITE_WEBHOOK_TOKEN. Choose to send it as a signature inX-Buildkite-Signature, not as a plain textX-Buildkite-Token. - Under Events, select the events you need. The groups are build, job, agent, ping and agent token, and third-party integration events.
- Under Pipelines, choose which pipelines trigger the webhook.
- 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:
POST 200 /api/webhooks/buildkiteYour app terminal prints the value of the X-Buildkite-Event header, for example:
Received Buildkite event: build.scheduledIf the line shows [401], see Troubleshooting.
Troubleshooting
The signature doesn't match
The Horizon line shows [401]. Check these in order:
BUILDKITE_WEBHOOK_TOKENis 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-Tokenand noX-Buildkite-Signature. - The handler signs
timestamp.body, with a dot between them, and hashes the raw body. Don't runJSON.parseandJSON.stringifyfirst, because that can change the bytes. - You restarted
npm run devafter 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 forReconnected. - 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
- Read Buildkite's pipeline webhooks reference for every event and payload.