You wire up a GitLab webhook to react to merge requests or pushes, and then hit the wall every backend developer knows: GitLab needs a public URL, and your handler runs on localhost:3000. So you deploy half-finished code just to see a payload, or you copy-paste JSON from the webhook logs into curl and hope it matches reality. Neither is a good loop.
Relayers gives you a stable public ingest URL, tunnels the raw event to your machine, and lets you filter and reshape the payload before it ever hits your code — all with the original body and headers preserved so GitLab's X-Gitlab-Token still validates locally.
What you'll build
GitLab talks to one durable URL. Relayers decides what matches, optionally transforms it, and forwards it down a WebSocket tunnel to whatever port your app listens on.
Prerequisites
- A GitLab project (or group) where you have Maintainer/Owner access to configure webhooks.
- A local service running on a known port, e.g.
localhost:3000. - A free Relayers account — sign up at relayers.app and download the desktop app.
- The
relayersCLI on your PATH (bundled with the app).
1Create an endpoint in Relayers
Open the desktop app and go to Endpoints → Create. Give it a name like gitlab-dev. Relayers assigns a public ingest URL of the form:
https://api.relayers.app/v1/webhooks/wep_<id>
Click Copy URL — you'll paste it into GitLab next. While you're here, hit Send test to confirm the endpoint is live and shows up under Events.
2Add the webhook in GitLab
In GitLab, go to your project's Settings → Webhooks → Add new webhook (for org-wide events, use Group → Settings → Webhooks instead).
- URL: paste your Relayers ingest URL.
- Secret token: enter any string, e.g.
dev-secret-123. GitLab sends this back verbatim in theX-Gitlab-Tokenheader on every request. Note that this is a plain shared secret compared by equality — not an HMAC signature like some other providers use. - Trigger: check the events you care about — Push events, Merge request events, Pipeline events, Issues events.
Save the webhook. GitLab includes the event type in the X-Gitlab-Event header, e.g. Push Hook, Merge Request Hook, or Pipeline Hook. You'll use both headers shortly.
3Forward events to localhost
Log in and start the tunnel from your terminal:
relayers login
relayers listen --forward localhost:3000
Now trigger something real — push a commit, or open a merge request. GitLab delivers to Relayers, and Relayers streams the request down the tunnel to localhost:3000. Open the Events view and the payload inspector to see the full body and headers exactly as GitLab sent them.
4Route and filter with JQ
Firing your handler on every push and every MR update gets noisy fast. Each endpoint has a Rules list: each rule has a JQ-style filter and a destination, and the first matching rule wins. You can reorder rules by dragging them.
To only forward merge requests that were just opened (skipping updates, merges, and closes), add a rule with this filter:
{ "object_attributes": { "action": "open" } }
A rule matches when the incoming payload contains those key/value pairs. MRs where object_attributes.action is "update" or "merge" simply won't match, so your handler stays quiet.
You can also fan out by event type. Say you want push events handled by one service and merge requests by another:
- Rule 1 — filter on the merge request shape (
{ "object_attributes": { "action": "open" } }), destination tunnel →localhost:3000. - Rule 2 — filter on the push shape (
{ "object_kind": "push" }), destination tunnel →localhost:4000.
Because first match wins, order matters — put the most specific rules on top. Destinations can be a tunnel to a local port or a public URL, so you can mix a local dev target with a staging endpoint in the same rule set.
5Transform the payload
GitLab payloads are large. If your handler only needs a few fields, use a Transformation to slim it down before delivery. Relayers supports jq, jsonata, javascript, and go_template, and ships a test playground so you can paste a sample event and see the output before saving.
A jq transform that reduces a merge request event to the essentials:
{
project: .project.name,
mr: .object_attributes.iid,
title: .object_attributes.title,
author: .user.username
}
Your local handler then receives a compact object instead of the full GitLab envelope:
{
"project": "billing-service",
"mr": 142,
"title": "Add retry logic to dunning worker",
"author": "klederson"
}
Signatures and the token header
Relayers forwards the original body and headers untouched to your localhost target (transformations, when enabled, apply to the body you route). That means the X-Gitlab-Token header arrives exactly as GitLab set it, so your existing verification code keeps working locally:
def verify(request):
return request.headers.get("X-Gitlab-Token") == "dev-secret-123"
No special-casing for local vs. production — the same equality check runs in both. Just remember GitLab's token is a plain shared secret, not an HMAC over the payload.
Going to production
When you ship, point your real service at the endpoint by adding a rule whose destination is your public URL instead of a tunnel — the same filters and transformations apply. Keep the local tunnel rule around for debugging, ordered below the production rule, or split dev and prod into separate endpoints. Use Export to save your endpoint, rules, and transformations as a JSON bundle, and Import to replicate the setup for a teammate or a new environment.
Troubleshooting
- No events arriving? In GitLab's webhook settings, scroll to the saved webhook and click Test to resend a sample event (Push, Merge request, etc.). It should appear instantly under Events in Relayers.
- Handler rejects the request? A
401/token mismatch means the string in GitLab's Secret token field doesn't equal what your code compares against. It's an exact-equality check — watch for trailing spaces. - Wrong handler firing? Branch on the
X-Gitlab-Eventheader (Push Hookvs.Merge Request Hook) or tighten your rule filters. Check the payload inspector to confirm the event type and shape you actually received. - Nothing on localhost? Make sure
relayers listen --forward localhost:PORTis still running and the port matches your dev server. Use retry on a past event to replay it without touching GitLab.
Wrap-up
With one durable ingest URL, a tunnel, and a couple of JQ rules, you can develop GitLab integrations against real push and merge request events without deploying, and without breaking token verification. Filter out the noise, reshape payloads to fit your handler, and replay events as many times as you need.
Start free at relayers.app and download the app to point your first GitLab webhook at localhost.