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 instance
  • X-Gitlab-Webhook-UUID: a unique ID per delivery
  • X-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

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_events is the webhook ID; it becomes the URL path (/webhook/gitlab_events).
  • module: log prints each authenticated payload to the container log. Zero dependencies for the first run; we swap in RabbitMQ at the end.
  • header_auth compares the X-Gitlab-Token header against your token with a constant-time comparison. case_sensitive: true makes 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 and GITLAB_WEBHOOK_TOKEN differ. 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.

Subscribe to Free Webhook Tool

Sign up now to get access to the library of members-only issues.
Jamie Larson
Subscribe