Skip to content

Receive GitLab webhooks reliably

This guide puts Railhook between GitLab and your application. GitLab sends its webhooks to a Railhook source, which checks the secret token, stores each webhook as it arrived and forwards it to your service with retries. GitLab only ever talks to Railhook, which answers straight away, so your service being slow or down no longer counts against the webhook.

GitLab disables a webhook that keeps failing. After four consecutive failures it is disabled temporarily, for one minute at first and for up to 24 hours as failures continue; after 40 consecutive failures it is disabled permanently and is not re-enabled on its own. GitLab also asks you to respond before its timeout and to be ready for duplicate events when a webhook times out. (GitLab: Webhooks)

A few bad deploys can therefore switch your integration off without anyone noticing. With Railhook in front:

  • GitLab’s requests are answered as soon as they are stored, so they do not time out or fail because of your service.
  • Your service gets up to 5 attempts per forward, with waits from 1 minute to 1 hour, and what still fails lands in Failed Forwards.
  • A duplicate carrying the same webhook-id or Idempotency-Key header is answered with the stored copy and not forwarded again.

You need a Railhook project and an API key. Everything below can also be done under Incoming in the dashboard.

Terminal window
export RAILHOOK_URL=https://railhook.io # or your own instance
export RAILHOOK_API_KEY=...
export PROJECT_ID=...
  1. Choose a secret token. With GitLab you pick it yourself:

    Terminal window
    export GITLAB_WEBHOOK_TOKEN=$(openssl rand -hex 32)
  2. Create the source with that token as the secret:

    Terminal window
    curl -X POST "$RAILHOOK_URL/api/v1/projects/$PROJECT_ID/incoming-sources" \
    -H "X-API-Key: $RAILHOOK_API_KEY" \
    -H "Content-Type: application/json" \
    -d '{"name":"GitLab","providerType":"GITLAB","verificationMode":"PROVIDER","hmacSecret":"'"$GITLAB_WEBHOOK_TOKEN"'"}'

    Keep the id and the ingressUrl from the response.

  3. Add a destination, the URL of your own service:

    Terminal window
    curl -X POST "$RAILHOOK_URL/api/v1/projects/$PROJECT_ID/incoming-sources/$SOURCE_ID/destinations" \
    -H "X-API-Key: $RAILHOOK_API_KEY" \
    -H "Content-Type: application/json" \
    -d '{"url":"https://api.example.com/webhooks/gitlab","authType":"BEARER","authConfig":"{\"token\":\"...\"}","enabled":true}'
  4. Register the webhook in GitLab. In the project or group, open Settings → Webhooks and add a new webhook. Paste the ingressUrl as the URL, the same token as the Secret token, and select the triggers you want.

  5. Send a test with the webhook’s Test button. It appears under Incoming → Received, with one forward per destination.

GitLab does not sign the body; it sends the secret token in the X-Gitlab-Token header. Railhook checks that the header equals the source’s secret and answers 401, storing nothing, when it does not. Because a token is not a signature, keep the ingress URL private and serve it over HTTPS.

The body exactly as GitLab sent it, byte for byte. Railhook passes GitLab’s event headers through, X-Gitlab-Event, X-Gitlab-Event-UUID and X-Gitlab-Instance, so your service can tell a push from a merge request. X-Gitlab-Token is never forwarded: your service authenticates Railhook through the destination’s credentials instead. See Destinations.

  1. Install the CLI and log in, as in CLI, then open a tunnel to your app’s port:

    Terminal window
    railhook listen 3000
  2. Add a second destination to the source with the printed public URL and your route appended, such as https://<host>/tunnel/<slug>/webhooks/gitlab. It reaches http://localhost:3000/webhooks/gitlab.

  3. Click Test on the GitLab webhook, and the webhook arrives in your local app with its X-Gitlab-Event header. Disable that destination when you stop the tunnel.

Replay what arrived while your service was down. Each replayed webhook goes to every destination of the source as a new forward:

Terminal window
curl -X POST "$RAILHOOK_URL/api/v1/projects/$PROJECT_ID/incoming-events/bulk-replay" \
-H "X-API-Key: $RAILHOOK_API_KEY" \
-H "Content-Type: application/json" \
-d '{"sourceId":"'"$SOURCE_ID"'","from":"2026-09-18T10:00:00Z","to":"2026-09-18T14:00:00Z","verified":true}'

Replay reaches back as far as events are kept: 7 days on Railhook Cloud, and whatever you configure when self-hosting. Make your handler idempotent on what the webhook is about, such as the commit or merge request in the body, since a replay can send webhooks your service already processed. See Replay.