Skip to main content
All posts
Developers7 min read

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.

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. A participant can also customize one pull-request thread 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.

NeedBetter starting point
Authors, reviewers, or watchers need normal repository updatesGitHub notifications
One selected merge should reach a small shared phone groupSigned GitHub webhook → relay → PingRoom
Someone needs the diff, reviews, checks, or merge controlsOpen the pull request in GitHub
A merge must deploy, approve, or change another systemKeep 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 covers setup; the webhook reference carries the current field limits, cooldowns, idempotency behavior, and response contract.

Store these values in the relay:

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.

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

Pingo the Pigeon, 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, deploy the relay, and test with non-sensitive copy. For the earlier handoff into review, use the separate ready-for-review guide.

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

PingRoom

Short-form communication.

View all posts