NewLocalCan 3.0 with CLI, Multi-region and more ⟶

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.

Start Free Trial →DownloadFree trial. No credit card required.

30-second setup

From a stuck handler to a live GitLab delivery you can read and replay.

  1. 1Start LocalCan and point it at your local server: localcan http 3000. It opens the tunnel and prints your .local domains plus a public HTTPS URL.
  2. 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.
  3. 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 a whsec_… 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.
  4. 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.
  5. 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 Hook
  • tag_pushTag Push Hook: tags created or deleted
  • merge_requestcreated, updated, merged or closed
  • issueIssue Hook (work items send object_kind work_item)
  • notecomments on commits, MRs, issues and snippets
  • pipelinePipeline 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}: the webhook-id header, a literal dot, the webhook-timestamp header (Unix seconds), a literal dot, then the exact bytes of the body. The digest is base64-encoded and sent as v1, plus that value. The step almost everyone gets wrong is the key: it is not the token string. Strip the whsec_ 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 a whsec_ prefix. The older Secret token is a different mechanism: GitLab echoes it verbatim in X-Gitlab-Token with no signing at all, so checking it is a string compare that proves nothing about the body.
Node.js
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.

LocalCanthe GitLab webhook UI
A URL GitLab will savePersistent …localcan.dev, passes URL validationNothing to expose localhost with
Fire an event on demandPush a commit, or use the Test dropdownTest dropdown, every supported event type
Deliveries while your machine is asleepNothing arrives, nothing capturedLogged server-side with the failure reason
How long the record lastsCaptures stay until you clear themRecent events: last two days (API: seven)
The body your handler receivedRaw bytes as they reached your machineThe request GitLab sent, not what arrived
Send the same request againOne-click Replay, offline, unlimitedResend Request, 5/min per project
Self-managed instancePublic HTTPS URL, no admin change neededAdmin must allow local network requests
Works across providersOne URL for GitLab, Stripe, Shopify…GitLab hooks only

GitLab webhook FAQ

Stop guessing why the webhook 401'd. Get a stable GitLab endpoint, read every delivery, and replay it until your handler is right.

Free trial. No credit card required.