Test CircleCI webhooks locally
Receive CircleCI outbound webhook events on localhost with a Horizon tunnel, and verify the circleci-signature header.
Receive CircleCI events on your laptop while you build, with a URL CircleCI 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 CircleCI project where you can open Project Settings
- A Next.js app that uses the App Router and runs on port 3000
Start your app
When you set a secret token on a webhook, CircleCI adds a circleci-signature header to each request. The header holds a comma-separated list of versioned signatures, like v1=<hex>,v2=.... Today v1 is the only version. CircleCI says to check only the latest version, to prevent downgrade attacks.
The v1 value is the HMAC-SHA256 digest of the request body, hex encoded, with your secret token as the key. Read the raw body with request.text().
import { createHmac, timingSafeEqual } from "node:crypto";
function readSignature(header: string, version: string) {
const entries = header.split(",").map((entry) => entry.split("=", 2));
return entries.find(([key]) => key.trim() === version)?.[1]?.trim() ?? "";
}
export async function POST(request: Request) {
const secret = process.env.CIRCLECI_WEBHOOK_SECRET;
if (!secret) {
return new Response("Missing CIRCLECI_WEBHOOK_SECRET", { status: 500 });
}
const body = await request.text();
const received = readSignature(request.headers.get("circleci-signature") ?? "", "v1");
const expected = 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("circleci-event-type");
console.log(`Received CircleCI event: ${event}`);
return new Response("ok", { status: 200 });
}Pick a secret and store it in an environment variable. Use a random, high-entropy string.
CIRCLECI_WEBHOOK_SECRET=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 CircleCI 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-circleci-appHORIZON: Tunnel connected
URL https://my-circleci-app.hrzn.run (reserved)
Forwarding http://localhost:3000
Request log https://hrzn.run/dashboard/tunnels/my-circleci-appYour public URL is https://my-circleci-app.hrzn.run. Keep this terminal open.
Add the webhook in CircleCI
- In the CircleCI web app, select your organization.
- Select Projects in the sidebar.
- Find your project, select the ellipsis, then select Project Settings.
- In the sidebar, select Webhooks.
- Select Add Webhook.
- Enter a Webhook name.
- Set URL to
https://my-circleci-app.hrzn.run/api/webhooks/circleci. - Leave Certificate Validation on. Horizon tunnels use HTTPS with a valid certificate.
- Set Secret token to the same value as
CIRCLECI_WEBHOOK_SECRET. - Under Select an event, pick at least one event. CircleCI offers
workflow-completedandjob-completed.
The Secret token field is optional in CircleCI. Without it, CircleCI sends no circleci-signature header and the handler above rejects every request.
Send a test event
In the webhook form, select Test Ping Event. CircleCI sends a test event with an abbreviated payload to your URL. You can send it before you save the webhook.
To get a real event, run a workflow in the project. CircleCI sends workflow-completed when the workflow reaches a terminal state, and job-completed for each job.
Check it works
After the test ping, your Horizon terminal prints one line:
POST 200 /api/webhooks/circleciYour app terminal prints the value of the circleci-event-type header. If the line shows [401], see Troubleshooting.
Troubleshooting
The signature doesn't match
The Horizon line shows [401]. Check these in order:
CIRCLECI_WEBHOOK_SECRETis identical to the Secret token in CircleCI, with no extra spaces or newline.- The handler computes the HMAC over the raw body. Don't run
JSON.parseandJSON.stringifyfirst, because that can change the bytes. - The handler reads the
v1entry from the header. The header holds a comma-separated list, so comparing the whole header value fails. - You restarted
npm run devafter you edited.env.local.
CircleCI sends a request but your app returns 404
The Horizon line shows [404]. The path in the webhook URL doesn't match your route. The file app/api/webhooks/circleci/route.ts serves /api/webhooks/circleci, and CircleCI sends a POST.
The URL changed after a restart
You started the tunnel without -s, so Horizon gave you a new random subdomain. Restart with -s my-circleci-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 URL in CircleCI ends with
/api/webhooks/circleci. - Check that the event you trigger is one you selected under Select an event.
Next steps
- Read CircleCI's outbound webhooks guide.