Test GitLab Webhooks Locally
GitLab will not even save the endpoint. The form comes back with Url is blocked: Requests to localhost are not allowed, and on a self-managed instance the same address needs an admin to unblock the local network first. Get a public URL GitLab accepts, read the exact webhook-signature, webhook-id and webhook-timestamp it sent, and replay the delivery until your HMAC matches.
30-second setup
From a stuck handler to a live GitLab delivery you can read and replay.
- 1Start LocalCan and point it at your local server:
localcan http 3000. It opens the tunnel and prints your.localdomains plus a public HTTPS URL. - 2Grab your persistent URL, something like
your-app-12.localcan.dev. It is saved to your project file and comes back identical after every restart, so you wire it up once. - 3In your project go to Settings → Webhooks (
/<group>/<project>/-/hooks) and select Add new webhook. Put your LocalCan URL with your route in URL, e.g.https://your-app-12.localcan.dev/webhook. For authentication pick Signing token → Generate signing token, which hands you awhsec_…value and is the recommended option. Tick what you need in the Trigger section and select Add webhook. Group hooks use the same form at/groups/<group>/-/hooks. - 4Open the Test dropdown on the hook and pick an event type. It posts a real payload on demand. Two conditions catch people out: you need the Maintainer or Owner role (Owner for a group hook), and a Push events test needs at least one commit in the project. For something already sent, Recent events → Resend Request repeats it with the same
Idempotency-Key. - 5Open Inspect Traffic in LocalCan. The delivery lands with full headers and body. Expand it, fix your handler, and hit Replay to send the exact same request again. No new test event needed.
Why localhost can't receive GitLab webhooks
GitLab dispatches webhooks from its own servers to a public HTTPS URL, so localhost:3000 is unreachable by definition. What makes GitLab different is when you find out: it refuses the address at the form, before any request is attempted.
Two walls, two fixes. GitLab.com rejects the URL string outright, with no setting to change it. A self-managed or Dedicated instance blocks the whole private range until an admin ticks Allow requests to the local network from webhooks and integrations under Admin → Settings → Network → Outbound requests (/admin/application_settings/network), instance-wide. A public HTTPS URL forwarding to your machine sidesteps both, and on GitLab.com it is the only way to save the endpoint at all.
Register the endpoint (and pass GitLab’s URL validation)
GitLab saysUrl is blocked: Requests to localhost are not allowed
GitLab validates the URL string when you submit the form. Nothing is sent to your endpoint at that moment (no challenge to echo, no ping event, no signed handshake), so this is a field error, not a failed delivery you can open in a log.
Paste a public …localcan.dev address and the form saves. On a self-managed instance a private-range address keeps failing until an admin enables local network requests (/admin/application_settings/network).
What makes the GitLab loop fast
One address, so the failure counter never starts
GitLab does not merely drop events when your endpoint is unreachable. It switches the hook off. Four consecutive failures disable it temporarily, from one minute up to 24 hours. Forty disable it permanently, and an address that stopped resolving overnight burns through four on the first busy morning.
A …localcan.dev URL is written into your project file and comes back byte-identical after a reboot, so the hook you registered stays the hook that works. Changing the address costs you a new hook, a freshly generated signing token in your environment, and the Trigger checkboxes ticked again.
Read the four inputs your HMAC depends on
GitLab signs {webhook-id}.{webhook-timestamp}.{raw body} and sends v1, plus a base64 digest. When it does not match, the symptom is identical whether you re-serialised the JSON or HMAC-ed the literal whsec_ string instead of its decoded bytes.
The inspector lays out webhook-signature, webhook-id, webhook-timestamp and the untouched body as they reached your machine. Recent events shows the request GitLab built. It cannot show what your middleware did to those bytes on the way in.
Re-fire a Push Hook without pushing anything
Replay sends a captured delivery again, byte for byte, so you iterate on a merge_request or pipeline handler without opening an MR or running a pipeline for every attempt.
It also outlives GitLab’s evidence: Recent events keeps two days, no automatic retry is documented to recover what your laptop missed while asleep, and Resend Request is capped at five a minute per project.
Inspect & replay GitLab events
Common events
pushX-Gitlab-Event: Push Hooktag_pushTag Push Hook: tags created or deletedmerge_requestcreated, updated, merged or closedissueIssue Hook (work items send object_kind work_item)notecomments on commits, MRs, issues and snippetspipelinePipeline Hook: status changes
Those are the object_kind values in the body. The header carries the human form (X-Gitlab-Event: Push Hook, Merge Request Hook), so branch on the header and confirm with object_kind. GitLab also sends X-Gitlab-Event-UUID, X-Gitlab-Instance and an Idempotency-Key that stays stable across redeliveries, which is the right key to deduplicate on.
Fire one from the Test dropdown, watch it land in Inspect Traffic with every header and the JSON body, then fix your handler and hit Replay to send the identical request again. One thing to watch while you do: a timeout counts as a failure toward the disable counter, so a handler paused on a breakpoint is spending your four attempts. Acknowledge first, work after.
- Timeout
- 10 seconds on GitLab.com. Self-managed instances set their own with
gitlab_rails['webhook_timeout'], and GitLab does not publish that default. - Retries
- No automatic retry schedule is documented. The only redelivery in the docs is manual: Resend Request, capped at five a minute per project.
- Auto-disable
- Temporarily disabled after 4 consecutive failures (one minute at first, extending up to 24 hours), and permanently disabled after 40.
Verify the GitLab request
- Header
- webhook-signature
- Algorithm
- HMAC-SHA256, base64, formatted
v1,<sig>(space-separated list) - What's signed
- GitLab signs the string
{webhook-id}.{webhook-timestamp}.{raw body}: thewebhook-idheader, a literal dot, thewebhook-timestampheader (Unix seconds), a literal dot, then the exact bytes of the body. The digest is base64-encoded and sent asv1,plus that value. The step almost everyone gets wrong is the key: it is not the token string. Strip thewhsec_prefix and base64-decode the remainder. The header can carry several space-separated signatures for key rotation, so split on spaces and accept a match against any entry. Hash a parsed and re-serialised body and the comparison can never succeed. - Secret
- The signing token from the hook form (Settings → Webhooks → Generate signing token,
/<group>/<project>/-/hooks), issued with awhsec_prefix. The older Secret token is a different mechanism: GitLab echoes it verbatim inX-Gitlab-Tokenwith no signing at all, so checking it is a string compare that proves nothing about the body.
import express from 'express'
import crypto from 'node:crypto'
// GitLab publishes Ruby and Python samples only, so this is the Node equivalent.
const SIGNING_TOKEN = process.env.GITLAB_WEBHOOK_SIGNING_TOKEN // whsec_...
const TOLERANCE_SECONDS = 300 // your choice: GitLab says "recent", not how recent
const app = express()
// The digest covers the exact bytes: mount express.raw BEFORE any JSON parser.
app.post('/webhook', express.raw({ type: '*/*' }), (req, res) => {
const id = req.headers['webhook-id']
const timestamp = req.headers['webhook-timestamp']
const signature = req.headers['webhook-signature'] // 'v1,<base64> v1,<base64> ...'
if (!id || !timestamp || !signature) {
// Signing token not configured on the hook, or a proxy stripped the headers.
return res.status(401).send('missing webhook-id / -timestamp / -signature')
}
// GitLab asks you to reject stale timestamps yourself to stop replay attacks.
const skew = Math.abs(Math.floor(Date.now() / 1000) - Number(timestamp))
if (!Number.isFinite(skew) || skew > TOLERANCE_SECONDS) {
return res.status(401).send('webhook-timestamp outside tolerance')
}
// The key is NOT the token: drop whsec_, base64-decode what is left.
const key = Buffer.from(SIGNING_TOKEN.replace(/^whsec_/, ''), 'base64')
// Sign "{id}.{timestamp}.{raw body}" over the original bytes, never a re-stringified object.
const signed = Buffer.concat([Buffer.from(id + '.' + timestamp + '.'), req.body])
const expected = crypto.createHmac('sha256', key).update(signed).digest('base64')
// Space-separated list for key rotation: strip the 'v1,' prefix, match any entry.
const ok = String(signature)
.split(' ')
.some((entry) => {
const sig = entry.split(',')[1]
if (!sig || sig.length !== expected.length) return false
return crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected))
})
if (!ok) return res.status(401).send('signature mismatch')
const event = req.headers['x-gitlab-event'] // 'Push Hook', 'Merge Request Hook', ...
const payload = JSON.parse(req.body.toString('utf8')) // payload.object_kind === 'push'
// Answer inside the timeout: a slow handler counts as a failed delivery.
res.status(200).send('ok')
queueWork(req.headers['idempotency-key'], event, payload)
})LocalCan does not verify the signature for you (your own constant-time compare does), but it puts every input to that comparison in front of you instead of leaving a silent 401. The inspector shows the webhook-signature, webhook-id and webhook-timestamp GitLab sent next to the body as your machine received it, which separates a body middleware rebuilt from a key used as a literal string in seconds.
Then Replay the same request to confirm the fix, instead of pushing another commit and reading Recent events to find out how it went.
The digest itself keeps matching forever. GitLab mixes webhook-timestamp into the signed string, but the timestamp travels in the header beside it, so a captured delivery stays internally consistent and the HMAC still comes out the same next week. What rejects a replay is the freshness check GitLab tells you to write yourself: it publishes no tolerance window, so whatever you chose (300 seconds is the usual pick) is what expires, and nothing on GitLab’s side is enforcing it. Replay promptly, or widen the tolerance in development.
LocalCan vs the GitLab webhook UI
There is no forwarding tool to compare against. glab is actively developed (v1.117.0 shipped in September 2026), but it has no webhook command of any kind, so GitLab has no equivalent of stripe listen.
The real choice is between capturing deliveries locally and working from GitLab’s own affordances: the Test dropdown, Recent events, and Resend Request. They are genuinely useful, and they all stop where your network begins.
| LocalCan | the GitLab webhook UI | |
|---|---|---|
| A URL GitLab will save | Persistent …localcan.dev, passes URL validation | Nothing to expose localhost with |
| Fire an event on demand | Push a commit, or use the Test dropdown | Test dropdown, every supported event type |
| Deliveries while your machine is asleep | Nothing arrives, nothing captured | Logged server-side with the failure reason |
| How long the record lasts | Captures stay until you clear them | Recent events: last two days (API: seven) |
| The body your handler received | Raw bytes as they reached your machine | The request GitLab sent, not what arrived |
| Send the same request again | One-click Replay, offline, unlimited | Resend Request, 5/min per project |
| Self-managed instance | Public HTTPS URL, no admin change needed | Admin must allow local network requests |
| Works across providers | One URL for GitLab, Stripe, Shopify… | GitLab hooks only |
GitLab webhook FAQ
Test webhooks for other providers
- Test Stripe Webhooks Locally →
- Test GitHub Webhooks Locally →
- Test Shopify Webhooks Locally →
- Test Twilio Webhooks Locally →
- Test Slack Webhooks Locally →
- Test Clerk Webhooks Locally →
- Test Supabase Webhooks Locally →
- Test Square Webhooks Locally →
- Test OpenAI Webhooks Locally →
- Test Discord Webhooks Locally →
- Test Resend Webhooks Locally →