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

- Published: 2026-10-28
- Author: PingRoom
- Category: Developers
- Topics: GitLab merge request notification, GitLab webhook phone alert, GitLab merge notification
- Canonical: https://pingroom.io/blog/gitlab-merge-request-merged-push-notifications
- Cover image: https://pingroom.io/blog/gitlab-merge-request-merged-push-notifications-cover.webp
- Cover description: Two PingRoom avatars fit wooden rails into one joined block beneath a miniature press while Pingo watches beside a closed folio.

**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](https://docs.gitlab.com/user/profile/notifications/#notifications-for-issues-merge-requests-and-epics). 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](/blog/webhook-to-push-notification) covers setup, and the [webhook reference](/documents/webhooks) carries the current field limits, replay behavior, cooldowns, and response contract.

Store these values in the relay:

```text
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.

```js
// 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.

![One clay message rests in an accepted tray while a duplicate stays behind a divider and Pingo watches beside a face-down phone.](/blog/gitlab-merge-request-merged-push-notifications-1.webp)

[Pingo the Pigeon](/Pingo), 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.

1. Open or update a merge request. The relay should return `204` and send no Ping.
2. Merge a test merge request into a different branch. The relay should return `204` and send no Ping.
3. Merge into the selected branch. One **GitLab merge completed** Ping should appear with only the two reviewed labels.
4. Resend the same GitLab delivery from **Recent events**. It should not create a second Ping.
5. Send a payload with an invalid signature or stale timestamp. The relay should return `401`.
6. 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](/app/rooms), deploy the relay, and test with non-sensitive copy. For pipeline results instead, use the separate [GitLab CI pipeline notification guide](/blog/gitlab-ci-pipeline-push-notifications).

_GitLab is a trademark of GitLab B.V. PingRoom is not affiliated with or endorsed by GitLab._
