Horizon

Test GitLab webhooks locally

Receive GitLab webhook events on localhost with a Horizon tunnel, and verify the webhook-signature header in a Next.js route handler.

Receive GitLab events on your laptop while you build, with a URL GitLab 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 GitLab project where you have the Maintainer or Owner role
  • A Next.js app that uses the App Router and runs on port 3000

Start your app

GitLab signs each delivery with a signing token, following the Standard Webhooks specification. Each request carries three headers: webhook-id, webhook-timestamp and webhook-signature.

The handler rebuilds the signature in four parts:

  1. Join the webhook-id value, the webhook-timestamp value and the raw body with dots: {id}.{timestamp}.{body}.
  2. Take the signing token, remove the whsec_ prefix and base64-decode the rest. The result is the HMAC key.
  3. Compute an HMAC-SHA256 digest of the joined string with that key. Encode it as base64 and prefix it with v1,.
  4. Compare the result with each space-separated entry in webhook-signature.

Read the raw body with request.text() before you parse it.

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

const SIGNING_TOKEN_PREFIX = "whsec_";

function isEqual(received: string, expected: string) {
  const receivedBuffer = Buffer.from(received);
  const expectedBuffer = Buffer.from(expected);
  return (
    receivedBuffer.length === expectedBuffer.length &&
    timingSafeEqual(receivedBuffer, expectedBuffer)
  );
}

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

  const body = await request.text();
  const messageId = request.headers.get("webhook-id") ?? "";
  const timestamp = request.headers.get("webhook-timestamp") ?? "";
  const receivedSignatures = (request.headers.get("webhook-signature") ?? "").split(" ");

  const key = Buffer.from(signingToken.replace(SIGNING_TOKEN_PREFIX, ""), "base64");
  const digest = createHmac("sha256", key)
    .update(`${messageId}.${timestamp}.${body}`)
    .digest("base64");
  const expected = `v1,${digest}`;

  const isValid = receivedSignatures.some((signature) => isEqual(signature, expected));
  if (!isValid) {
    return new Response("Invalid signature", { status: 401 });
  }

  const event = request.headers.get("x-gitlab-event");
  console.log(`Received GitLab event: ${event}`);

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

GitLab also tells you to check that webhook-timestamp is recent, to stop replay attacks. Add that check before you go to production.

You get the signing token in a later step. Add a placeholder to .env.local now.

.env.local
GITLAB_SIGNING_TOKEN=whsec_...

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

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

Add the webhook in GitLab

  1. Open your project in GitLab.
  2. In the left sidebar, select Settings > Webhooks.
  3. Select Add new webhook.
  4. In URL, enter https://my-gitlab-app.hrzn.run/api/webhooks/gitlab.
  5. Optional: enter a Name and a Description.
  6. Select Generate signing token. GitLab shows the token once, so copy it now. Paste it into .env.local as GITLAB_SIGNING_TOKEN, then restart npm run dev.
  7. In the Trigger section, select the events you need.
  8. Keep Enable SSL verification checked. Horizon tunnels use HTTPS.
  9. Select Add webhook.

Send a test event

  1. In Settings > Webhooks, find your webhook in the list.
  2. Open the Test dropdown list.
  3. Select the type of event to test.

GitLab doesn't support testing for every event type. GitLab tracks the gaps in issue 379201.

To send an earlier delivery again, select Edit on the webhook. In the Recent events section, select Resend Request on the event. GitLab keeps the last two days of events.

Check it works

After you select a test event, your Horizon terminal prints one line:

Output
  POST    200  /api/webhooks/gitlab

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

Output
Received GitLab event: Push Hook

If the line shows [401], see Troubleshooting.

Troubleshooting

The signature doesn't match

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

  • GITLAB_SIGNING_TOKEN is the token GitLab showed you, including the whsec_ prefix. GitLab shows it once. If you lost it, generate a new one on the webhook.
  • The handler decodes the token as base64 after it removes whsec_. Using the token text as the key fails.
  • The handler 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 webhook has no Signing token field

Older GitLab versions don't have it. The GitLab docs say the field was introduced in GitLab 19.0. In that case, use the Secret token field instead and compare the X-Gitlab-Token header with your token.

app/api/webhooks/gitlab/route.ts
const received = request.headers.get("x-gitlab-token") ?? "";
const isValid = isEqual(received, process.env.GITLAB_SECRET_TOKEN ?? "");

This check only proves the sender knows the token. It doesn't cover the body.

The URL changed after a restart

You started the tunnel without -s, so Horizon gave you a new random subdomain. Restart with -s my-gitlab-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 GitLab ends with /api/webhooks/gitlab.
  • Check that the webhook has the event you trigger selected in Trigger.

Next steps

On this page