How to Send a GitLab Merge-Request Merged Alert to Your Team's Phones
Verify one GitLab merge-request webhook, keep code-review details in GitLab, and send fixed replay-safe copy to one shared PingRoom Room.

Use GitLab's own merge-request notifications when each person already follows the work there. When one selected project's completed merge should reach a separate shared group, verify GitLab's signed Merge Request Hook in a small relay and send fixed copy to a PingRoom Room.
The relay below accepts only action: "merge" and state: "merged" for one project and target branch. GitLab keeps the title, description, author, reviewers, labels, branches, commit details, and discussion. PingRoom carries one short signal: GitLab merge completed — Selected project · Main branch.
This is a webhook recipe, not a native GitLab integration. A merged merge request does not prove that a deployment happened, that production is healthy, or that a release was approved.
Start with GitLab's notifications
GitLab can notify people when a merge request is merged. Use that path when the recipients already work in GitLab and need its full context.
Add a shared PingRoom alert only when the merge has a separate attention job.
| Need | Better starting point |
|---|---|
| Authors, reviewers, or subscribers need normal MR updates | GitLab notification settings |
| One selected merge should reach a small shared phone group | Signed GitLab webhook → relay → PingRoom |
| Someone needs code, comments, approvals, or pipeline details | Open the merge request in GitLab |
| A merge must deploy or approve another system | Keep that system's workflow authoritative |
The last row is the important boundary. This alert reports a state GitLab already recorded. It must not perform the merge, deploy code, or grant approval.
Create a private Room and incoming webhook
Create or choose a private Room for the people who need this update. The Room owner needs PingRoom Pro for incoming webhooks.
Open Settings → Connections → From another app, add a webhook, and leave Quick Ping on Custom message. Copy its full URL into protected server configuration. The incoming-webhook walkthrough covers setup, and the webhook reference carries the current field limits, replay behavior, cooldowns, and response contract.
Store these values in the relay:
GITLAB_SIGNING_TOKEN=whsec_<the token GitLab showed once> GITLAB_PROJECT_ID=<numeric project ID> GITLAB_TARGET_BRANCH=main PINGROOM_WEBHOOK_URL=https://api.pingroom.io/... PINGROOM_PROJECT_LABEL=Selected project PINGROOM_TARGET_LABEL=Main branch
The signing token and PingRoom URL are credentials. Do not place them in repository files, merge-request text, client-side code, screenshots, or logs.
Add the GitLab project webhook
GitLab's current webhook setup requires a Maintainer or Owner role for a project webhook. Open Settings → Webhooks, add your relay's public HTTPS URL, choose Generate signing token, save the token once, and select Merge request events.
This guide uses GitLab's current signing-token flow, which became generally available in GitLab 19.1. It verifies the HMAC-SHA256 signature over webhook-id, webhook-timestamp, and the raw request body. An older self-managed GitLab installation that only provides the legacy X-Gitlab-Token header needs a deliberately different receiver; do not remove signature verification from this one.
Verify and filter the merge event
Run this core handler in a trusted Node 20+ server or function and adapt its Request/Response wrapper to your host. It reads no merge-request prose and forwards no field from GitLab's payload.
// gitlab-merge-handler-start import { createHmac, timingSafeEqual } from "node:crypto"; const MAX_BODY_BYTES = 64 * 1024; const MAX_CLOCK_SKEW_SECONDS = 5 * 60; function reply(status, body = "") { return new Response(status === 204 ? null : body, { status, headers: body ? { "Content-Type": "text/plain; charset=utf-8" } : {}, }); } function singleLine(value, max) { return ( typeof value === "string" && value.length > 0 && value.length <= max && !/[\r\n]/.test(value) ); } function sameText(left, right) { const a = Buffer.from(left); const b = Buffer.from(right); return a.length === b.length && timingSafeEqual(a, b); } function validSignature(token, messageId, timestamp, rawBody, header) { if ( !token.startsWith("whsec_") || !singleLine(messageId, 80) || !singleLine(header, 400) ) return false; const key = Buffer.from(token.slice(6), "base64"); if (key.length < 16) return false; const signed = `${messageId}.${timestamp}.${rawBody}`; const expected = `v1,${createHmac("sha256", key).update(signed).digest("base64")}`; return header.split(" ").some((candidate) => sameText(expected, candidate)); } export async function handleGitlabMerge( request, env = process.env, nowMs = Date.now(), ) { if (request.method !== "POST") return reply(405, "Method not allowed"); const { GITLAB_SIGNING_TOKEN, GITLAB_PROJECT_ID, GITLAB_TARGET_BRANCH, PINGROOM_WEBHOOK_URL, PINGROOM_PROJECT_LABEL, PINGROOM_TARGET_LABEL, } = env; let destination; try { destination = new URL(PINGROOM_WEBHOOK_URL); } catch { return reply(500, "Server configuration error"); } if ( destination.protocol !== "https:" || !/^\d+$/.test(GITLAB_PROJECT_ID ?? "") || !singleLine(GITLAB_TARGET_BRANCH, 200) || !singleLine(PINGROOM_PROJECT_LABEL, 80) || !singleLine(PINGROOM_TARGET_LABEL, 80) ) { return reply(500, "Server configuration error"); } const declaredLength = Number(request.headers.get("content-length") ?? "0"); if (Number.isFinite(declaredLength) && declaredLength > MAX_BODY_BYTES) return reply(413, "Payload too large"); const rawBody = await request.text(); if (Buffer.byteLength(rawBody, "utf8") > MAX_BODY_BYTES) return reply(413, "Payload too large"); const messageId = request.headers.get("webhook-id") ?? ""; const timestamp = request.headers.get("webhook-timestamp") ?? ""; const signature = request.headers.get("webhook-signature") ?? ""; const timestampNumber = Number(timestamp); const nowSeconds = Math.floor(nowMs / 1000); if ( !/^\d+$/.test(timestamp) || Math.abs(nowSeconds - timestampNumber) > MAX_CLOCK_SKEW_SECONDS || !validSignature( GITLAB_SIGNING_TOKEN ?? "", messageId, timestamp, rawBody, signature, ) ) { return reply(401, "Invalid webhook signature"); } let event; try { event = JSON.parse(rawBody); } catch { return reply(400, "Invalid JSON"); } const attributes = event?.object_attributes; const selected = request.headers.get("x-gitlab-event") === "Merge Request Hook" && event?.object_kind === "merge_request" && event?.event_type === "merge_request" && String(event?.project?.id ?? "") === GITLAB_PROJECT_ID && attributes?.action === "merge" && attributes?.state === "merged" && attributes?.target_branch === GITLAB_TARGET_BRANCH; if (!selected) return reply(204); const eventKey = `gitlab-mr:${messageId}`; const body = JSON.stringify({ action: null, title: "GitLab merge completed", message: `${PINGROOM_PROJECT_LABEL} · ${PINGROOM_TARGET_LABEL}`, correlation_id: eventKey, }); let response; try { response = await fetch(destination, { method: "POST", headers: { Accept: "application/json", "Content-Type": "application/json", "Idempotency-Key": eventKey, }, body, redirect: "error", signal: AbortSignal.timeout(8000), }); } catch { return reply(502, "PingRoom request failed"); } if (!response.ok) return reply(502, "PingRoom rejected the alert"); try { const accepted = await response.json(); if (accepted.success !== true) return reply(502, "PingRoom rejected the alert"); } catch { return reply(502, "PingRoom returned an invalid response"); } return reply(200, "Accepted"); } // gitlab-merge-handler-end
GitLab documents object_attributes.action: "merge" as the merged transition. The additional state: "merged", project ID, target branch, event header, and object-kind checks narrow the receiver to one state you chose.
The relay verifies the signature before parsing or filtering the event. It also refuses timestamps more than five minutes from the server clock. Keep the host clock synchronized; a stale or future delivery should not pass merely because its signature is valid.
Keep code-review details in GitLab
The Ping contains only two reviewed configuration labels. It does not forward the merge-request title, description, source branch, author, merger, reviewers, labels, commit SHA, pipeline state, project URL, or discussion.
If even the project label is sensitive, use neutral copy such as Selected project. Room members can open GitLab through their normal authorized route when they need the record. Do not copy private code-review context onto a lock screen.

Pingo the Pigeon, PingRoom's fictional Chief Marketing Officer, is watching the duplicate remain on the other side of the gate. He cannot read GitLab, enter a private Room, verify code, merge a branch, or send the Ping. The human team chooses the project, branch, recipients, copy, and failure policy.
These AI-generated editorial illustrations feature approved PingRoom avatars and Pingo. They are not GitLab or PingRoom screenshots and do not depict customers.
Give one GitLab delivery one replay identity
GitLab's webhook-id stays consistent across retries. The relay uses it for PingRoom's Idempotency-Key and correlation_id, so an exact GitLab retry can return the original accepted result instead of creating another Ping.
If you change the two reviewed labels between retries, the body changes under the same key and PingRoom can reject the conflict. Treat copy changes as configuration changes, not as a reason to rewrite an event already in flight.
The relay returns a non-success response when PingRoom rejects or cannot accept the alert. GitLab can record that delivery in Recent events, where a Maintainer or Owner can inspect the request and response and resend the same request. GitLab remains the durable source for the merge itself.
Test six boundaries
Use a harmless test project, a temporary target branch, and a small test Room.
- Open or update a merge request. The relay should return
204and send no Ping. - Merge a test merge request into a different branch. The relay should return
204and send no Ping. - Merge into the selected branch. One GitLab merge completed Ping should appear with only the two reviewed labels.
- Resend the same GitLab delivery from Recent events. It should not create a second Ping.
- Send a payload with an invalid signature or stale timestamp. The relay should return
401. - Temporarily use a test PingRoom URL that rejects the request. The relay should return
502, while the merge remains recorded in GitLab.
For this article, we ran the exact embedded handler against signed GitLab-style fixtures and a stateful local PingRoom mock. The checks covered acceptance, exact replay, event and branch filtering, project filtering, bad and stale signatures, malformed and oversized bodies, downstream rejection, required headers, fixed copy, and privacy exclusions. They did not use a live GitLab project, PingRoom Room, phone, or notification receipt.
An HTTP 200 with Accepted means the relay received a PingRoom success response. It does not prove every phone displayed the Ping. Connectivity, notification permissions, Focus settings, Room mutes, and operating-system policy still affect presentation.
If a selected merge deserves a separate shared signal, create the destination Room and webhook, deploy the relay, and test with non-sensitive copy. For pipeline results instead, use the separate GitLab CI pipeline notification guide.
GitLab is a trademark of GitLab B.V. PingRoom is not affiliated with or endorsed by GitLab.
PingRoom
Short-form communication.


