GitLab Integration
Receive and route GitLab webhooks for pushes, merge requests, issues, notes and pipelines.
GitLab webhooks can authenticate in two ways, and Hookbase has a provider for each. Pick the one that matches how the GitLab webhook is configured:
| GitLab setting | Hookbase provider | Who creates the value | What arrives |
|---|---|---|---|
| Signing token (recommended) | gitlab-signing-token — "GitLab (Signing Token)" | GitLab generates it; you paste it into Hookbase | An HMAC-SHA256 signature in webhook-signature |
| Secret token (legacy, "not recommended" by GitLab) | gitlab — "GitLab (Secret Token)" | Hookbase generates it (or you choose one); you paste it into GitLab | The token itself, as plain text, in X-Gitlab-Token |
Signing tokens arrived in GitLab 19.0 behind a feature flag and are generally available from 19.1 on GitLab.com, Self-Managed and Dedicated. A self-managed instance older than that only offers the secret token.
Setup
1. Create a Source in Hookbase
Signing Token Mode (recommended)
GitLab generates the signing token, so create the GitLab webhook (step 2) first, then create the source with the token GitLab showed you:
curl -X POST https://api.hookbase.app/api/sources \
-H "Authorization: Bearer whr_your_api_key" \
-H "Content-Type: application/json" \
-d '{
"name": "GitLab Production",
"slug": "gitlab",
"provider": "gitlab-signing-token",
"signingSecret": "whsec_..."
}'The token is always whsec_ followed by 44 base64 characters (50 in all). Paste it exactly — a
leading space or a value Hookbase generated for you can never verify.
Secret Token Mode (legacy)
GitLab does not generate this value. Let Hookbase generate it (leave signingSecret out, or use the
dashboard's generated value), or choose a long random string yourself, then paste the same value into
GitLab's Secret token field.
curl -X POST https://api.hookbase.app/api/sources \
-H "Authorization: Bearer whr_your_api_key" \
-H "Content-Type: application/json" \
-d '{
"name": "GitLab Production",
"slug": "gitlab",
"provider": "gitlab",
"signingSecret": "a-long-random-secret-token"
}'Save your webhook URL:
https://api.hookbase.app/ingest/{orgSlug}/gitlab2. Create the GitLab Webhook
- Open the project's Settings → Webhooks (or the group's, for a group webhook) and click Add new webhook
- Set the URL to your Hookbase ingest URL
- Set up authentication:
- Signing token: click Generate signing token and copy the token right away — GitLab shows it only once. Paste it into the Hookbase source's Signing Token field. Clicking Regenerate token later makes a new value that the source will not match
- Secret token: paste the Hookbase-generated value into GitLab's Secret token field
- Select the trigger events you want delivered
- Click Add webhook (or Save changes when editing). A signing token only takes effect once the form is saved — a token you copied from an unsaved form is not the one GitLab signs with
- Use GitLab's Test delivery to confirm the event arrives verified before you rely on it
See GitLab's webhook documentation for the trigger list.
3. Create Destinations and Routes
# Create a destination
curl -X POST https://api.hookbase.app/api/destinations \
-H "Authorization: Bearer whr_your_api_key" \
-H "Content-Type: application/json" \
-d '{"name": "Pipeline Bridge", "slug": "gitlab-bridge", "url": "https://api.myapp.com/webhooks/gitlab"}'
# Create a route
curl -X POST https://api.hookbase.app/api/routes \
-H "Authorization: Bearer whr_your_api_key" \
-H "Content-Type: application/json" \
-d '{"name": "GitLab to Pipeline Bridge", "sourceId": "src_...", "destinationId": "dst_..."}'A route carries one destination. Fanning one source out to several endpoints is one route each.
Signature Verification
Signing Token Mode
GitLab's signing token mode follows the Standard Webhooks specification. Each request carries three headers:
webhook-id: <same value as Idempotency-Key>
webhook-timestamp: 1767225600
webhook-signature: v1,<base64 HMAC-SHA256>GitLab signs {webhook-id}.{webhook-timestamp}.{body} with HMAC-SHA256, keyed with the base64-decoded
part of the token after whsec_, and sends v1, plus the base64 digest. Hookbase checks it under the
gitlab-signing-token provider:
{
"provider": "gitlab-signing-token",
"signingSecret": "whsec_..."
}Requests whose webhook-timestamp is more than 5 minutes away from Hookbase's clock fail verification,
which is the replay protection this mode adds. The signature covers the body, so a tampered payload
does not verify.
Use gitlab-signing-token rather than the generic standard-webhooks provider: the signature check is
the same, but only the GitLab provider also reads X-Gitlab-Event for the event type.
Secret Token Mode
Warning
This mode is not a signature scheme. GitLab sends the token you configured back to you as plain
text in the X-Gitlab-Token header, and Hookbase compares that header value with the signingSecret
stored on the source. Nothing is hashed, and the token says nothing about the body it arrived with — it
authenticates the sender, not the payload.
Treat it as a bearer credential: anyone who learns the token can post any payload they like to your ingest URL and it will compare equal. Use a long random value, keep it out of logs, and rotate it on both sides at once — or move the webhook to a signing token.
GitLab echoes the secret token:
X-Gitlab-Token: a-long-random-secret-tokenHookbase compares that header value with the source's signingSecret in constant time — the token is
a credential, so the comparison is timing-safe even though no hash is involved — and records the result
as signature_valid exactly as it would for an HMAC provider. No algorithm, encoding, prefix or signing
string applies to this mode, because nothing is hashed:
{
"provider": "gitlab",
"signingSecret": "a-long-random-secret-token"
}Two consequences of there being no hash are worth planning for:
- The token does not cover the body. A valid token on a tampered payload still compares equal. Anything you would normally infer from a valid signature — that the bytes are unmodified — does not follow here.
- There is no timestamp, so no clock tolerance applies. The scheme offers no replay protection of its own. Hookbase's own deduplication is what collapses a repeated delivery; see below.
The comparison is against the whole header value, byte for byte. There is no prefix to strip and no trimming, so a trailing space or newline in either the GitLab field or the stored secret makes every delivery fail to verify.
Rejecting Unverified Requests
Once events are arriving with signature_valid: true, set rejectInvalidSignatures: true on the
source to have requests with a missing or wrong signature or token refused with 401 instead of
stored. It defaults to false, which is why a wrong secret shows up first as an unverified badge
rather than as lost traffic.
Switching Modes
GitLab lets one webhook carry both a secret token and a signing token while you migrate, but a Hookbase
source holds one secret and verifies one way. To move to signing tokens, generate the token in GitLab,
save the webhook, then change the source's provider to gitlab-signing-token and its signingSecret
to the whsec_ token in the same update.
Common Events
GitLab names the event in the X-Gitlab-Event header and repeats a machine-readable form in the body
as object_kind:
X-Gitlab-Event | object_kind | Description |
|---|---|---|
Push Hook | push | Commits were pushed to a branch |
Tag Push Hook | tag_push | A tag was pushed |
Issue Hook | issue | An issue was opened, updated or closed |
Merge Request Hook | merge_request | A merge request changed state |
Note Hook | note | A comment was added |
Pipeline Hook | pipeline | A pipeline changed state |
Job Hook | build | A job changed state |
Release Hook | release | A release was created or updated |
Hookbase reads the event type for a GitLab source out of the X-Gitlab-Event header, so events are
recorded under the names in the first column — Push Hook, not push. Filters, which read the
payload, see object_kind instead.
Transform Examples
A javascript transform receives the parsed payload and returns the body Hookbase delivers.
Push Summary
function transform(payload) {
const project = payload.project || {};
return {
kind: payload.object_kind || null,
project: project.path_with_namespace || null,
project_url: project.web_url || null,
ref: payload.ref || null,
checkout_sha: payload.checkout_sha || null,
pushed_by: payload.user_username || null,
commit_count: payload.total_commits_count != null
? payload.total_commits_count
: (payload.commits || []).length,
received_at: new Date().toISOString()
};
}Slack Alert for Merge Requests
function transform(payload) {
const mr = payload.object_attributes || {};
const project = payload.project || {};
return {
text: `Merge request ${mr.action || "updated"}: ${mr.title || mr.iid || "unknown"}`,
blocks: [
{
type: "section",
fields: [
{ type: "mrkdwn", text: `*Project:*\n${project.path_with_namespace || "unknown"}` },
{ type: "mrkdwn", text: `*Title:*\n${mr.title || "unknown"}` },
{ type: "mrkdwn", text: `*Branch:*\n${mr.source_branch || "?"} to ${mr.target_branch || "?"}` },
{ type: "mrkdwn", text: `*State:*\n${mr.state || "unknown"}` }
]
}
]
};
}Filter Examples
Filter conditions read dotted paths out of the payload. logic must be AND or OR.
Pushes Only
{
"name": "Pushes Only",
"logic": "AND",
"conditions": [
{
"field": "object_kind",
"operator": "equals",
"value": "push"
}
]
}Merge Requests That Opened or Merged
{
"name": "Merge Request Opened or Merged",
"logic": "OR",
"conditions": [
{
"field": "object_attributes.action",
"operator": "equals",
"value": "open"
},
{
"field": "object_attributes.action",
"operator": "equals",
"value": "merge"
}
]
}One Project
{
"name": "Web App Project",
"logic": "AND",
"conditions": [
{
"field": "project.path_with_namespace",
"operator": "equals",
"value": "acme/web-app"
}
]
}Headers
| Header | Mode | Description |
|---|---|---|
webhook-signature | Signing token | v1, plus the base64 HMAC-SHA256 signature. Sent only when a signing token is set |
webhook-id | Signing token | Message id, signed; the same value as Idempotency-Key |
webhook-timestamp | Signing token | Unix timestamp (seconds), signed |
X-Gitlab-Token | Secret token | The secret token, as plain text. Compared with signingSecret; not a signature |
GitLab also sends X-Gitlab-Event, X-Gitlab-Webhook-UUID and Idempotency-Key in both modes.
Hookbase reads X-Gitlab-Event as the event type and stores it with the event, and uses
Idempotency-Key for deduplication. None of these is replayed to your destination — a delivery to an
HTTP destination carries Hookbase's own X-Event-ID and X-Delivery-ID headers plus whatever the
destination is configured to add — so anything your handler needs should come out of the payload.
Troubleshooting
Signature Verification Failed (Signing Token)
- Confirm the source's
providerisgitlab-signing-token. Agitlabsource looks forX-Gitlab-Token, which GitLab does not send when only a signing token is set, and acustomsource reads header names GitLab never sends - Confirm
signingSecretis the exact 50-characterwhsec_...token GitLab showed you, with no leading space. A value Hookbase generated, or one set by Rotate Secret, can never match a token GitLab generated - Confirm the GitLab webhook was saved after the token was generated, and that nobody clicked Regenerate token since. Either way GitLab signs with a different token than the one you copied; generate a new one, save, and update the source
- Confirm the GitLab instance is 19.0 or later — older self-managed instances send no
webhook-signatureat all - Check the server clock if you run self-managed GitLab: a
webhook-timestampmore than 5 minutes off fails verification
Token Comparison Failed (Secret Token)
- Confirm the source's
providerisgitlab— a source left oncustomreadsX-Signature,X-Webhook-SignatureandX-Hub-Signature-256, none of which GitLab sends - Confirm
signingSecretand GitLab's Secret token field hold the same string, with no trailing whitespace on either side. The whole header value is compared, so a stray newline is a mismatch - Confirm the webhook is in secret token mode. If it was configured with a signing token instead,
there is no
X-Gitlab-Tokento compare and the source needs thegitlab-signing-tokenprovider - Check the project. A group webhook and a project webhook each have their own token
No Events Arriving
- Check the webhook URL matches your ingest URL exactly, org and source slugs included
- Check the trigger events selected on the webhook cover what you are doing — push events do not fire for a merge request, and vice versa
- Use GitLab's test delivery and read the response it shows you (Edit → Recent events shows
the request headers too). A
401there means the signature or token did not verify and the source hasrejectInvalidSignatureson - Check GitLab has not disabled the webhook after repeated failures
Duplicate Events
GitLab sends an Idempotency-Key header on every request, kept the same when it retries a delivery.
On a source with dedupEnabled: true, the default auto strategy uses it as the event id (on a
signing-token source it is also the signed webhook-id), and falls back to hashing the payload only
when it is absent. In secret token mode nothing covers that header, so it is a convenience against
GitLab retrying, not a defence against a replayer. Set dedupStrategy to payload_hash if you would
rather key on the body itself.
A source with a signing secret also gets a 24-hour payload-hash replay check on every request that
verified, whether or not dedupEnabled is on. In secret token mode that is the only replay protection
in play, since the scheme carries no timestamp; signing token mode adds the 5-minute timestamp check on
top.