GitLab webhooks fail in a handful of very specific ways: the URL gets rejected at save time with "Url is blocked: Requests to the local network are not allowed", the webhook quietly flips to Temporarily disabled after 4 consecutive failures (or Disabled for good after 40), or deliveries arrive but your handler rejects them. Here's how to diagnose each, fastest check first.
On self-managed GitLab, webhook and integration requests to local or private
addresses are refused by default as an SSRF protection. Blocked destinations include
the GitLab instance's own address and private ranges — 127.0.0.1,
::1, 0.0.0.0, 10.0.0.0/8,
172.16.0.0/12, 192.168.0.0/16, and IPv6 site-local
(ffc0::/10).
Two ways out:
curl https://hookden.pages.dev/new) and
use it as the webhook URL — it's a public HTTPS endpoint, so the local-network check
never triggers, and you see every delivery with full headers and body.GitLab automatically disables webhooks that fail 4 consecutive
times — a failure is any 4xx/5xx response, a
connection timeout, or another HTTP error. In Settings → Webhooks the
webhook shows a badge:
(On GitLab Self-Managed this behavior is feature-flag controlled —
auto_disabling_web_hooks — so your instance may differ. Badges were named
"Failing to connect" / "Fails to connect" before 17.11.)
To break the failure loop while you debug, point the webhook at a bin: bins answer
200 immediately, so the counter resets and GitLab keeps sending — and you
get every payload to inspect instead of a disabled webhook and silence.
Settings → Webhooks → Edit → Recent events lists every request from
the last two days: color-coded status code (green 2xx, red otherwise,
internal error for failed deliveries), the triggering event, and elapsed
time. You need Maintainer or Owner on the project to inspect request/response details.
From here you can also resend a delivery after fixing your endpoint.
Watch the elapsed-time column: on GitLab.com the delivery timeout is 10 seconds. If your handler does slow work inline, deliveries time out, count as failures, and feed the auto-disable counter. Respond 200 first, process the payload from a queue afterwards — and expect duplicates when a timeout made GitLab's delivery ambiguous.
GitLab has two token mechanisms, and they are often confused:
X-Gitlab-Token header. It is not a signature — no HMAC, nothing
computed over the body. Your handler must string-compare the header against the token
you configured. If verification fails, check for whitespace and check you're reading
X-Gitlab-Token, not X-Gitlab-Event (that one carries the
event name, like Push Hook).webhook-id and
webhook-timestamp, and with a signing token configured also
webhook-signature in the form v1,<base64> (potentially
several, space-separated). The HMAC-SHA256 is computed over
{webhook-id}.{webhook-timestamp}.{raw body} — the raw JSON bytes,
not a re-serialized object. If your framework parses and re-encodes the body before you
sign, verification will fail exactly the way it does for Stripe and WooCommerce.Capture one real delivery in a bin and you can see precisely which headers your GitLab version sends and what the raw body looks like, copy the request as curl, and replay it against your real handler until verification passes.
For gitlab.com projects, http://localhost:4000/hook isn't reachable —
and for self-managed, step 1's local-network block usually refuses it anyway. Instead
of running a tunnel, use a capture URL as the webhook target and relay deliveries to
your machine:
$ curl -s https://hookden.pages.dev/cli -o hookden && chmod +x hookden $ curl https://hookden.pages.dev/new $ ./hookden relay YOUR_BIN http://localhost:4000
Bodies are re-delivered byte-identical, so webhook-signature HMACs
still verify against the raw bytes GitLab signed. Details:
webhooks to localhost without a tunnel.
Same problem on a different host? See GitHub webhook not working and Bitbucket webhook not working.
No signup needed. Or from your terminal: curl https://hookden.pages.dev/new