How to Receive Webhooks from GitLab
Self-host a GitLab webhook receiver with secret token authentication in 10 minutes. Every command tested against the real Docker image.
By the end of this guide you will have a self-hosted endpoint that receives GitLab webhooks, authenticates every delivery with a secret token, and routes the payloads to a log, RabbitMQ, or any other destination. Every command below was run and verified against the real Core Webhook Module Docker image before publishing; you can copy-paste them in order. It works the same for GitLab.com and self-managed GitLab.
How GitLab webhooks work
When an event happens in a project (a push, a merge request, a pipeline run), GitLab sends an HTTP POST to your webhook URL with these headers:
X-Gitlab-Event: the event name (Push Hook,Merge Request Hook,Pipeline Hook, ...)X-Gitlab-Instance: the hostname of the sending GitLab instanceX-Gitlab-Webhook-UUID: a unique ID per deliveryX-Gitlab-Token: the secret token you configured, sent as a plain header value
Authentication: GitLab's classic mechanism is the secret token. You set a random value in the webhook form, GitLab sends it back in the X-Gitlab-Token header on every delivery, and your receiver rejects requests where it does not match, using a constant-time comparison. Because the token travels in a header, HTTPS is mandatory; the token is only as secret as the transport. GitLab has also introduced a newer signing-token scheme (an HMAC in the webhook-signature header computed over {message_id}.{timestamp}.{body}); Core Webhook Module's HMAC validator signs the raw body only, so this guide uses the secret token method, which GitLab fully supports and most existing webhooks use.
Delivery behavior worth knowing: on GitLab.com, a webhook that fails four times in a row is temporarily disabled, starting at one minute and backing off up to 24 hours. A receiver that responds quickly and reliably keeps your webhook healthy.
Prerequisites
- Docker on any machine (your laptop is fine for the test run)
- A public HTTPS URL for production. Two ways to get one:
- HTTPS for webhooks with nginx and Let's Encrypt if you have a public server
- Receiving webhooks through Cloudflare Tunnel if you do not want to open inbound ports
Step 1: Configure the receiver
Create a working directory with two files.
webhooks.json:
{
"gitlab_events": {
"data_type": "json",
"module": "log",
"header_auth": {
"header_name": "X-Gitlab-Token",
"api_key": "{$GITLAB_WEBHOOK_TOKEN}",
"case_sensitive": true
}
}
}
connections.json:
{}
What each parameter does:
gitlab_eventsis the webhook ID; it becomes the URL path (/webhook/gitlab_events).module: logprints each authenticated payload to the container log. Zero dependencies for the first run; we swap in RabbitMQ at the end.header_authcompares theX-Gitlab-Tokenheader against your token with a constant-time comparison.case_sensitive: truemakes the token value match exactly.{$GITLAB_WEBHOOK_TOKEN}pulls the token from an environment variable, so it never lives in a config file.
Generate a strong token (or use our webhook secret generator):
openssl rand -hex 32
Step 2: Run it
docker run -d --name webhook-gateway -p 8000:8000 \
-v "$PWD/webhooks.json:/app/webhooks.json:ro" \
-v "$PWD/connections.json:/app/connections.json:ro" \
-e GITLAB_WEBHOOK_TOKEN="your-generated-token" \
spiderhash/webhook:latest
Confirm it is up:
docker logs webhook-gateway
# ... INFO: Application startup complete.
The receiver now answers on http://localhost:8000/webhook/gitlab_events, with auto-generated API docs at http://localhost:8000/docs.
Step 3: Test it locally
You do not need GitLab to test; you need a request shaped like GitLab's. Save a realistic push event payload:
cat > push.json <<'EOF'
{"object_kind":"push","event_name":"push","before":"95790bf891e76fee5e1747ab589903a6a1f80f22","after":"da1560886d4f094c3e6c9ef40349f7d38b5d27d7","ref":"refs/heads/main","user_name":"John Smith","project":{"path_with_namespace":"acme/api"},"commits":[{"id":"b6568db1bc1dcd7f8b4d5a946b0b91f9dacd7327","message":"Update Catalan translation to e38cb41."}],"total_commits_count":1}
EOF
Send it with the token:
curl -s -w "\nHTTP %{http_code}\n" -X POST http://localhost:8000/webhook/gitlab_events \
-H "Content-Type: application/json" \
-H "X-Gitlab-Event: Push Hook" \
-H "X-Gitlab-Token: your-generated-token" \
--data-binary @push.json
Expected result:
{"message":"200 OK"}
HTTP 200
Now prove the authentication works. A wrong token:
{"detail":"Invalid API key in header: X-Gitlab-Token"}
HTTP 401
And a request with no token header at all:
{"detail":"Missing required header: X-Gitlab-Token"}
HTTP 401
Those are the exact responses the gateway returns. docker logs webhook-gateway shows the accepted payload.
Step 4: Create the webhook in GitLab
In your project: Settings > Webhooks > Add new webhook, then:
- URL:
https://your-domain/webhook/gitlab_events - Secret token: the same value you passed as
GITLAB_WEBHOOK_TOKEN - Trigger: pick the events you want; push events is a good start
- SSL verification: leave enabled
Save, then use the Test dropdown next to the webhook and pick an event type (for example "Push events"). GitLab sends a real delivery immediately; you should see it in docker logs webhook-gateway, and the webhook's edit page in GitLab keeps a log of recent deliveries with request and response details for debugging.
Troubleshooting
401 {"detail":"Invalid API key in header: X-Gitlab-Token"}: the token in GitLab's Secret token field andGITLAB_WEBHOOK_TOKENdiffer. They must be byte-identical; watch for trailing whitespace and shell quoting.401 {"detail":"Missing required header: X-Gitlab-Token"}: the Secret token field in GitLab is empty. GitLab only sends the header when a token is configured.- Self-managed GitLab refuses to call your receiver on a private address: by default, self-managed GitLab blocks webhook requests to local network addresses. An administrator can change this under Admin > Settings > Network > Outbound requests ("Allow requests to the local network from webhooks and integrations"), or you give the receiver a public HTTPS URL instead.
- Webhook shows as disabled in GitLab.com: four consecutive failed deliveries disable it temporarily with growing backoff (one minute up to 24 hours). Fix the receiver, then re-test from the webhook page.
Going to production
Printing to logs is not a pipeline. Swap the module for a real destination; here is the RabbitMQ variant, verified end to end (the test delivery above landed as a message in the gitlab_events queue):
webhooks.json:
{
"gitlab_events": {
"data_type": "json",
"module": "rabbitmq",
"connection": "rabbitmq_local",
"module-config": {
"queue_name": "gitlab_events"
},
"header_auth": {
"header_name": "X-Gitlab-Token",
"api_key": "{$GITLAB_WEBHOOK_TOKEN}",
"case_sensitive": true
}
}
}
connections.json:
{
"rabbitmq_local": {
"type": "rabbitmq",
"host": "{$RABBITMQ_HOST:localhost}",
"port": "{$RABBITMQ_PORT:5672}",
"user": "{$RABBITMQ_USER:guest}",
"pass": "{$RABBITMQ_PASS:guest}"
}
}
The same pattern works for 17 other destinations: archive deliveries to S3, publish to Kafka, or fan out to several at once with webhook chaining. Built-in rate limiting and retry handling cover the operational edges, and because GitLab disables webhooks that keep failing, having the gateway acknowledge fast and hand off to a durable queue is exactly what keeps your webhook healthy. The full authentication reference, including header auth and 11 other methods, is in the docs.