Horizon

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().

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

.env.local
CIRCLECI_WEBHOOK_SECRET=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 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-app
Output
HORIZON: Tunnel connected
  URL          https://my-circleci-app.hrzn.run (reserved)
  Forwarding   http://localhost:3000
  Request log  https://hrzn.run/dashboard/tunnels/my-circleci-app

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

Add the webhook in CircleCI

  1. In the CircleCI web app, select your organization.
  2. Select Projects in the sidebar.
  3. Find your project, select the ellipsis, then select Project Settings.
  4. In the sidebar, select Webhooks.
  5. Select Add Webhook.
  6. Enter a Webhook name.
  7. Set URL to https://my-circleci-app.hrzn.run/api/webhooks/circleci.
  8. Leave Certificate Validation on. Horizon tunnels use HTTPS with a valid certificate.
  9. Set Secret token to the same value as CIRCLECI_WEBHOOK_SECRET.
  10. Under Select an event, pick at least one event. CircleCI offers workflow-completed and job-completed.

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:

Output
  POST    200  /api/webhooks/circleci

Your 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_SECRET is 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.parse and JSON.stringify first, because that can change the bytes.
  • The handler reads the v1 entry from the header. The header holds a comma-separated list, so comparing the whole header value fails.
  • You restarted npm run dev after 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 for Reconnected.
  • 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

On this page