A GitLab webhook sends an HTTP POST to your URL when an event happens in a project or group, such as a push, a merge request or a new comment. Add one under Settings, Webhooks. Protect it with a signing token, and answer quickly with 200, because GitLab disables hooks that keep failing.
| Item | What GitLab documents |
|---|---|
| Who can add a project hook | Maintainer or Owner role |
| Group webhooks | Premium and Ultimate tiers |
| Temporary disable | After 4 consecutive failures, starting at 1 minute, growing up to 24 hours |
| Permanent disable | After 40 consecutive failures |
| GitLab.com timeout | 10 seconds |
| GitLab.com hooks per project / group | 100 / 50 |
| Recent events kept | Requests from the last two days |
Updated
Webhooks are how GitLab tells your site, deploy script or team chat that something changed, without your code asking over and over. Setup takes a minute; the parts that cause trouble later are proving a request came from GitLab and keeping the hook from being switched off. The checker below builds a signing-token signature in your browser so you can test your receiver.
Enter a signing token, the webhook-id, the webhook-timestamp and the raw body to get the webhook-signature value GitLab would send. Everything runs in your browser.
The prefilled token is a made-up example, not a real key. With these values the result is: v1,3C1kB/w3aOegehfSwaRWhDLWlaxg12CAgTAGOwWAhQw=
Consecutive failed deliveries: 0
Active: deliveries continue.
This receiver checks the signing token signature, rejects stale timestamps, and falls back to the old secret token while you migrate.
Tokens come from environment variables. The five-minute freshness window is our own choice; pick what suits you.
import crypto from "node:crypto";
import express from "express";
const app = express();
const SIGNING = process.env.GITLAB_SIGNING_TOKEN; // whsec_...
const SECRET = process.env.GITLAB_SECRET_TOKEN; // optional, old style
function safeEqual(a, b) {
const x = Buffer.from(a), y = Buffer.from(b);
return x.length === y.length && crypto.timingSafeEqual(x, y);
}
app.post("/webhooks/gitlab", express.raw({ type: "application/json" }), (req, res) => {
const sigHeader = req.get("webhook-signature");
if (sigHeader && SIGNING) {
const id = req.get("webhook-id");
const ts = req.get("webhook-timestamp");
if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return res.status(400).send("Stale");
const key = Buffer.from(SIGNING.replace(/^whsec_/, ""), "base64");
const mac = crypto.createHmac("sha256", key).update(`${id}.${ts}.`).update(req.body).digest("base64");
const ok = sigHeader.split(" ").some((s) => safeEqual(s, `v1,${mac}`));
if (!ok) return res.status(401).send("Bad signature");
} else if (!SECRET || !safeEqual(req.get("X-Gitlab-Token") || "", SECRET)) {
return res.status(401).send("Bad token");
}
res.status(200).send("ok");
console.log("Event:", req.get("X-Gitlab-Event"));
});
app.listen(3000);| Event | X-Gitlab-Event header |
|---|---|
| Push | Push Hook |
| Tag push | Tag Push Hook |
| Issue or work item | Issue Hook |
| Comment | Note Hook |
| Merge request | Merge Request Hook |
| Pipeline | Pipeline Hook |
| Job | Job Hook |
| Deployment | Deployment Hook |
| Release | Release Hook |
| Wiki page | Wiki Page Hook |
A signing token starts with whsec_. Drop that prefix and base64-decode the rest to get the key. GitLab signs the string made of the webhook-id, a dot, the webhook-timestamp, a dot and the raw body, using HMAC-SHA256.
The result is base64-encoded and sent in the webhook-signature header as v1, followed by the signature. GitLab follows the Standard Webhooks specification, so libraries written for that format can verify it too.
A hook can carry both tokens at once. Set up the signing token, change your receiver to check the signature when it is present and fall back to X-Gitlab-Token when it is not, then remove the old secret.
It is the older secret token. When you set one, GitLab sends it as plain text in the X-Gitlab-Token header and your server compares it. GitLab's docs now recommend a signing token for new webhooks instead.
GitLab disables a hook after four consecutive failures, meaning 4xx or 5xx responses, connection timeouts or other HTTP errors. It is first off for one minute, longer with each further failure up to 24 hours, and permanently after 40 in a row.
Read the X-Gitlab-Event header. Its value is the event type followed by Hook, for example Push Hook, Merge Request Hook, Note Hook for comments or Pipeline Hook.
Yes, on the Premium and Ultimate tiers. A group webhook fires for events in every project of the group, and can also receive group member, project and subgroup events.
GitLab's docs say it arrived in version 19.0 behind a feature flag that was on by default and became generally available in 19.1. Older self-managed versions only have the secret token.
Related: GitHub webhooks, Webhook vs API, Stripe webhooks, Discord webhooks.
Start for free or see all free tools. We.Inc is free to start, with no card needed. AI building is on paid plans from $20 a month.
We.Inc is an AI-powered website builder you can resell under your own brand. Launch a branded client dashboard, bill on Stripe Connect, and deliver AI-generated websites in minutes. White-label plans start at $99 a month for 25 client sites, with a 7-day free trial and no per-site fees.