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:
- Join the
webhook-idvalue, thewebhook-timestampvalue and the raw body with dots:{id}.{timestamp}.{body}. - Take the signing token, remove the
whsec_prefix and base64-decode the rest. The result is the HMAC key. - Compute an HMAC-SHA256 digest of the joined string with that key. Encode it as base64 and prefix it with
v1,. - Compare the result with each space-separated entry in
webhook-signature.
Read the raw body with request.text() before you parse it.
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.
GITLAB_SIGNING_TOKEN=whsec_...Start 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 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-appHORIZON: Tunnel connected
URL https://my-gitlab-app.hrzn.run (reserved)
Forwarding http://localhost:3000
Request log https://hrzn.run/dashboard/tunnels/my-gitlab-appYour public URL is https://my-gitlab-app.hrzn.run. Keep this terminal open.
Add the webhook in GitLab
- Open your project in GitLab.
- In the left sidebar, select Settings > Webhooks.
- Select Add new webhook.
- In URL, enter
https://my-gitlab-app.hrzn.run/api/webhooks/gitlab. - Optional: enter a Name and a Description.
- Select Generate signing token. GitLab shows the token once, so copy it now. Paste it into
.env.localasGITLAB_SIGNING_TOKEN, then restartnpm run dev. - In the Trigger section, select the events you need.
- Keep Enable SSL verification checked. Horizon tunnels use HTTPS.
- Select Add webhook.
GitLab marks the Secret token field as not recommended. It sends your token as plain text in the X-Gitlab-Token header. It proves nothing about the body. Use the signing token for new webhooks.
Send a test event
- In Settings > Webhooks, find your webhook in the list.
- Open the Test dropdown list.
- 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:
POST 200 /api/webhooks/gitlabYour app terminal prints the value of the X-Gitlab-Event header, for example:
Received GitLab event: Push HookIf the line shows [401], see Troubleshooting.
Troubleshooting
The signature doesn't match
The Horizon line shows [401]. Check these in order:
GITLAB_SIGNING_TOKENis the token GitLab showed you, including thewhsec_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.parseandJSON.stringifyfirst, because that can change the bytes. - You restarted
npm run devafter 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.
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 forReconnected. - 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
- Read GitLab's webhook documentation.