← All guides
GitLabJuly 12, 20266 min read

Test GitLab Webhooks on Localhost with Relayers

Receive, route, and transform a gitlab webhook localhost in minutes. Forward push and merge request events to your dev machine with signatures intact.

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

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).

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:

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

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.