# How to Send a GitHub Pull-Request-Merged Alert to a Shared Room

> Verify one GitHub pull-request webhook, keep review details in GitHub, and send fixed replay-safe copy to a shared PingRoom Room.

- Published: 2026-10-30
- Author: PingRoom
- Category: Developers
- Topics: GitHub pull request merged notification, GitHub webhook merge alert, GitHub PR phone notification
- Canonical: https://pingroom.io/blog/github-pull-request-merged-push-notifications
- Cover image: https://pingroom.io/blog/github-pull-request-merged-push-notifications-cover.webp
- Cover description: Two PingRoom avatars guide one clay merge token through a small wooden branch gate while Pingo watches beside a closed laptop.

**Use GitHub's own notifications when repository participants need the normal merge update. When one merge into a selected base branch should reach a separate shared group, verify GitHub's signed pull-request webhook in a small relay and send one fixed, retry-safe Ping to a PingRoom Room.**

The relay below accepts only a `pull_request` delivery with `action: "closed"`, `merged: true`, one repository, and one base branch. GitHub keeps the title, description, author, branches, commits, review history, and discussion. PingRoom carries only a short signal: **Pull request merged — Mobile project · PR #418.**

This is a webhook recipe, not a native GitHub integration. A merge does not prove that code was deployed, production is healthy, or a release was approved.

## Start with GitHub's notification controls

GitHub already lets people [watch repositories and choose notification channels](https://docs.github.com/en/subscriptions-and-notifications/get-started/configuring-notifications). A participant can also [customize one pull-request thread](https://docs.github.com/en/subscriptions-and-notifications/how-tos/viewing-and-triaging-notifications/triaging-a-single-notification) to receive merged, closed, or reopened updates.

Use those controls when the intended recipient works in GitHub and wants its full context. Add a shared PingRoom path only when the merge has a separate attention job.

| Need                                                           | Better starting point                         |
| -------------------------------------------------------------- | --------------------------------------------- |
| Authors, reviewers, or watchers need normal repository updates | GitHub notifications                          |
| One selected merge should reach a small shared phone group     | Signed GitHub webhook → relay → PingRoom      |
| Someone needs the diff, reviews, checks, or merge controls     | Open the pull request in GitHub               |
| A merge must deploy, approve, or change another system         | Keep that system's own workflow authoritative |

The last row is the boundary: this alert reports a state GitHub already recorded. It must not perform the merge or become a release gate.

## Create the Room and protect both secrets

Create or choose a private Room for the people who need the update. The Room owner needs **PingRoom Pro** for incoming webhooks.

Open **Settings → Connections → From another app**, add an incoming webhook, and leave **Quick Ping** on **Custom message**. Copy the full URL into protected server configuration. The [incoming-webhook walkthrough](/blog/webhook-to-push-notification) covers setup; the [webhook reference](/documents/webhooks) carries the current field limits, cooldowns, idempotency behavior, and response contract.

Store these values in the relay:

```text
GITHUB_WEBHOOK_SECRET=<a high-entropy random secret>
GITHUB_REPOSITORY=example/mobile
GITHUB_TARGET_BRANCH=main
PINGROOM_WEBHOOK_URL=https://api.pingroom.io/...
PINGROOM_REPOSITORY_LABEL=Mobile project
```

The GitHub secret and PingRoom URL are credentials. Do not place them in repository files, pull-request text, browser code, screenshots, or logs.

## Add a narrowly subscribed repository webhook

In GitHub, open the repository's **Settings → Webhooks → Add webhook**. Use the relay's public HTTPS address as the payload URL, choose `application/json`, enter the same webhook secret, select **Let me select individual events**, and enable only **Pull requests**.

GitHub sends the event name in `X-GitHub-Event`, a globally unique delivery GUID in `X-GitHub-Delivery`, and an HMAC-SHA256 signature in `X-Hub-Signature-256`. The relay must verify that signature over the unmodified request bytes before it parses or filters the JSON.

## Verify, filter, and forward the merge

Run this core handler in a trusted Node 20+ server or function and adapt its `Request`/`Response` wrapper to your host. It never checks out or runs pull-request code and never forwards pull-request prose.

```js
// github-pr-merge-handler-start
import { createHmac, timingSafeEqual } from "node:crypto";

const MAX_BODY_BYTES = 1024 * 1024;

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 validSignature(secret, rawBody, signature) {
  if (!singleLine(secret, 200) || !/^sha256=[a-f0-9]{64}$/.test(signature))
    return false;
  const expected = `sha256=${createHmac("sha256", secret).update(rawBody).digest("hex")}`;
  const a = Buffer.from(expected);
  const b = Buffer.from(signature);
  return a.length === b.length && timingSafeEqual(a, b);
}

export async function handleGithubPullRequest(request, env = process.env) {
  if (request.method !== "POST") return reply(405, "Method not allowed");

  const {
    GITHUB_WEBHOOK_SECRET,
    GITHUB_REPOSITORY,
    GITHUB_TARGET_BRANCH,
    PINGROOM_WEBHOOK_URL,
    PINGROOM_REPOSITORY_LABEL,
  } = env;

  let destination;
  try {
    destination = new URL(PINGROOM_WEBHOOK_URL);
  } catch {
    return reply(500, "Server configuration error");
  }

  if (
    destination.protocol !== "https:" ||
    !/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(GITHUB_REPOSITORY ?? "") ||
    !singleLine(GITHUB_TARGET_BRANCH, 200) ||
    !singleLine(PINGROOM_REPOSITORY_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 = Buffer.from(await request.arrayBuffer());
  if (rawBody.length > MAX_BODY_BYTES) return reply(413, "Payload too large");

  const signature = request.headers.get("x-hub-signature-256") ?? "";
  if (!validSignature(GITHUB_WEBHOOK_SECRET ?? "", rawBody, signature))
    return reply(401, "Invalid webhook signature");

  let event;
  try {
    event = JSON.parse(rawBody.toString("utf8"));
  } catch {
    return reply(400, "Invalid JSON");
  }

  const selected =
    request.headers.get("x-github-event") === "pull_request" &&
    event?.action === "closed" &&
    event?.pull_request?.merged === true &&
    event?.repository?.full_name === GITHUB_REPOSITORY &&
    event?.pull_request?.base?.ref === GITHUB_TARGET_BRANCH;

  if (!selected) return reply(204);

  const deliveryId = request.headers.get("x-github-delivery") ?? "";
  const pullRequestNumber = event?.pull_request?.number;
  if (
    !/^[0-9a-f-]{16,80}$/i.test(deliveryId) ||
    !Number.isSafeInteger(pullRequestNumber) ||
    pullRequestNumber < 1
  ) {
    return reply(400, "Invalid event identity");
  }

  const pullRequestUrl = `https://github.com/${GITHUB_REPOSITORY}/pull/${pullRequestNumber}`;
  const eventKey = `github-pr-merged:${deliveryId}`;
  const body = JSON.stringify({
    action: null,
    title: "Pull request merged",
    message: `${PINGROOM_REPOSITORY_LABEL} · PR #${pullRequestNumber}`,
    data: {
      url: pullRequestUrl,
      button_label: "Open pull request",
      pull_request_number: pullRequestNumber,
    },
    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");
}
// github-pr-merge-handler-end
```

GitHub documents `closed` as a pull-request webhook action, with `pull_request.merged` distinguishing a merge from an unmerged closure. The repository and base-branch checks narrow the receiver to the route you chose. A closed-but-unmerged pull request, an update, or a merge into another branch returns `204` and sends no Ping.

The handler verifies GitHub's signature first. It also requires the event header and delivery GUID. GitHub recommends using that GUID to reject or deduplicate replays; here it becomes PingRoom's idempotency identity.

## Keep review details in GitHub

The Ping contains one reviewed configuration label, the numeric pull-request number, and a URL constructed from the configured repository. It does **not** forward the pull-request title, body, author, merger, source branch, commit SHA, review text, labels, or check results.

The **Open pull request** button returns an authorized Room member to GitHub for the full record. If the repository name is sensitive, use a neutral label such as **Mobile project**. The URL still contains the repository path and is visible to Room members, so choose the Room accordingly.

![Two PingRoom avatars inspect one accepted clay merge token beside an empty duplicate tray while Pingo watches with a deadpan expression.](/blog/github-pull-request-merged-push-notifications-1.webp)

[Pingo the Pigeon](/Pingo), PingRoom's fictional Chief Marketing Officer, is watching the duplicate tray stay empty. He cannot read GitHub, enter a private Room, merge code, verify a signature, or send the Ping. The human team chooses the repository, branch, recipients, copy, and failure policy.

_These AI-generated editorial illustrations feature approved PingRoom avatars and Pingo. They are not GitHub or PingRoom screenshots and do not depict customers._

## Give one GitHub delivery one replay identity

GitHub's `X-GitHub-Delivery` GUID identifies the delivery. The relay maps it to both PingRoom's `Idempotency-Key` and `correlation_id`, so an exact GitHub redelivery can return the original accepted result instead of creating another Ping.

Keep the request body identical when redelivering the same event. Changing the configured label changes the body under the same key, and PingRoom can reject that conflict. Treat copy changes as configuration changes, not as a way to rewrite an event already in flight.

The relay returns a non-success response if PingRoom rejects or cannot accept the alert. GitHub keeps recent webhook deliveries so a repository administrator can inspect and redeliver the same signed request. GitHub remains the durable record of the merge.

## Test seven boundaries

Use a harmless test repository or branch and a small test Room.

1. Open or update a pull request. The relay should return `204` and send no Ping.
2. Close a pull request without merging. It should return `204`.
3. Merge into a different base branch. It should return `204`.
4. Merge into the selected branch. One **Pull request merged** Ping should appear with only the reviewed label and PR number.
5. Redeliver the same event from GitHub's recent deliveries. It should not create a second Ping.
6. Send a modified body or invalid signature. The relay should return `401`.
7. Temporarily use a test PingRoom URL that rejects the request. The relay should return `502`, while GitHub still records the merge.

For this article, we ran the exact embedded handler against signed GitHub-shaped fixtures and a stateful local PingRoom mock. The checks covered acceptance, exact replay, event, repository, branch, and merge filtering; invalid signatures; malformed and oversized bodies; missing delivery identity; downstream rejection; fixed copy; and privacy exclusions. They did not use a live GitHub repository, 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 one 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 the earlier handoff into review, use the separate [ready-for-review guide](/blog/github-pull-request-ready-push-notifications).

_GitHub is a trademark of GitHub, Inc. PingRoom is not affiliated with or endorsed by GitHub._
