Skip to main content
All posts
Developers7 min read

Send BugSnag Error Alerts to Your Team's Phones

Route one selected BugSnag new-error event through a narrow relay, then send fixed privacy-minimal copy to a shared PingRoom Room.

Two PingRoom avatars select one terracotta error token at a brass gate while Pingo watches beside a closed key box.

To send one selected BugSnag error alert to your team's phones, point BugSnag's webhook at a narrow server-side relay, accept only a new unhandled production error for one project, and forward fixed copy to a PingRoom incoming webhook. Keep the exception, user, device, stack trace, metadata, and investigation link in BugSnag.

This is a webhook recipe written by PingRoom, not a native BugSnag integration. The destination Room's owner needs PingRoom Pro. BugSnag remains the source of truth, and PingRoom should not replace an established pager, escalation policy, or incident record for critical systems.

Choose the route that owns the response

BugSnag can send a detailed payload for several error, project, release, and collaborator events. A phone should not receive all of them.

The team needsBetter routeWhy
One short shared signal for a selected new production errorBugSnag webhook → relay → PingRoom RoomThe stable group sees one bounded event without copying diagnostic details onto lock screens.
Full error context and investigationBugSnagBugSnag owns the event, grouping, release stage, stack trace, metadata, and state.
Incident acknowledgement, schedules, escalation, and pagingThe team's established incident platformPingRoom is not a guaranteed pager or incident-management system.
A richer incident-routing workflowA current BugSnag incident integrationUse the system that owns response policy instead of recreating it in a small relay.

This guide uses the first route. It deliberately accepts only BugSnag's firstException trigger for one production project.

For an illustrative setup, imagine a small product team that wants one private Room to know when BugSnag sees a new unhandled production error. Pingo the Pigeon, PingRoom's fictional CMO, appears in the artwork beside the gate. He cannot access BugSnag, enter the private Room, choose alert rules, or send Pings. The human team owns every configuration and claim.

Create the PingRoom connection

Create or choose a private Room for the people who should receive this signal. Open Settings → Connections → From another app, add an incoming webhook, and leave Quick Ping on Custom message. Use Test once, then copy the complete webhook URL into protected server-side configuration.

The incoming-webhook walkthrough covers the Room setup. The webhook reference lists the current request fields, idempotency behavior, cooldown, rate limits, and error responses.

Treat the complete PingRoom URL as a credential. Store it as PINGROOM_WEBHOOK_URL. Do not put it in source control, client-side code, screenshots, or logs.

This recipe sends only fixed copy:

{
  "action": null,
  "title": "BugSnag new error",
  "message": "Open BugSnag to review the selected production error"
}

No exception class, message, route, user, customer, device, stack trace, metadata, breadcrumb, or dashboard link reaches the phone.

Configure one BugSnag webhook

In BugSnag, open the selected project's settings, then choose Integrations and data forwarding → Webhook. Set its URL to your relay's public HTTPS endpoint. The last path segment is a randomly generated secret:

https://relay.example.com/bugsnag/<high-entropy-secret>

Enable A new error occurs (firstException) and apply BugSnag's available filters so the notification is limited to the intended production errors. Save the integration, but keep the production rule quiet until the relay's rejection cases have passed.

BugSnag's current webhook reference documents the setup, trigger names, payload, Test action, and source IPs for firewalled receivers.

The sender-authentication limit

BugSnag's documented webhook contract does not include a request signature or custom authentication header. The secret path is therefore a revocable bearer credential, not cryptographic proof that BugSnag sent the request.

Generate at least 32 URL-safe random characters. Store the same value as BUGSNAG_WEBHOOK_SECRET, keep the complete relay URL out of logs, and rotate it if the URL leaks. BugSnag also publishes source IPs for receivers behind a firewall. Recheck that official list before using it, enforce it at a trusted network edge, and do not treat an arbitrary forwarded-IP header as authentication.

If your threat model requires signed sender proof, use a destination that BugSnag authenticates through a stronger documented mechanism, or place an approved gateway in front of the relay. Do not rename the secret path a signature.

Filter before forwarding

This dependency-free Node handler checks the secret path, request size, one BugSnag project, firstException, production release stage, error severity, unhandled state, and open status. It never maps the diagnostic payload into the Ping.

import { timingSafeEqual } from "node:crypto";

const MAX_BODY_BYTES = 64 * 1024;
const FIXED_PING = Object.freeze({
  action: null,
  title: "BugSnag new error",
  message: "Open BugSnag to review the selected production error",
});

function required(name, env) {
  const value = env[name];
  if (typeof value !== "string" || value.length === 0) {
    throw new Error(`Missing ${name}`);
  }
  return value;
}

function validSecret(supplied, expected) {
  if (!/^[A-Za-z0-9_-]{32,128}$/.test(expected)) {
    throw new Error(
      "BUGSNAG_WEBHOOK_SECRET must be 32–128 URL-safe characters",
    );
  }
  const received = Buffer.from(supplied ?? "", "utf8");
  const wanted = Buffer.from(expected, "utf8");
  return received.length === wanted.length && timingSafeEqual(received, wanted);
}

function pathSecret(request) {
  const segments = new URL(request.url).pathname.split("/").filter(Boolean);
  return segments.at(-1);
}

export async function handleBugSnagRequest(request, env = process.env) {
  if (request.method !== "POST") {
    return new Response("Method Not Allowed", {
      status: 405,
      headers: { Allow: "POST" },
    });
  }

  if (
    !validSecret(pathSecret(request), required("BUGSNAG_WEBHOOK_SECRET", env))
  ) {
    return new Response("Not Found", { status: 404 });
  }

  const declaredLength = Number(request.headers.get("content-length"));
  if (Number.isFinite(declaredLength) && declaredLength > MAX_BODY_BYTES) {
    return new Response("Payload Too Large", { status: 413 });
  }

  const rawBody = Buffer.from(await request.arrayBuffer());
  if (rawBody.length > MAX_BODY_BYTES) {
    return new Response("Payload Too Large", { status: 413 });
  }

  let payload;
  try {
    payload = JSON.parse(rawBody.toString("utf8"));
  } catch {
    return new Response("Bad Request", { status: 400 });
  }

  const expectedProject = required("EXPECTED_BUGSNAG_PROJECT_ID", env);
  const accepted =
    payload?.project?.id === expectedProject &&
    payload?.trigger?.type === "firstException" &&
    payload?.error?.app?.releaseStage === "production" &&
    payload?.error?.severity === "error" &&
    payload?.error?.unhandled === true &&
    payload?.error?.status === "open";
  if (!accepted) {
    return new Response(null, { status: 204 });
  }

  const eventId = payload?.error?.id;
  if (typeof eventId !== "string" || !/^[A-Za-z0-9_-]{8,128}$/.test(eventId)) {
    return new Response("Missing event identity", { status: 422 });
  }

  const webhookUrl = new URL(required("PINGROOM_WEBHOOK_URL", env));
  if (webhookUrl.protocol !== "https:") {
    throw new Error("PINGROOM_WEBHOOK_URL must use HTTPS");
  }

  const eventKey = `bugsnag:${expectedProject}:firstException:${eventId}`;
  const response = await fetch(webhookUrl, {
    method: "POST",
    redirect: "error",
    headers: {
      "Content-Type": "application/json",
      "Idempotency-Key": eventKey,
    },
    body: JSON.stringify({ ...FIXED_PING, correlation_id: eventKey }),
  });
  const result = await response.json().catch(() => null);
  if (!response.ok || result?.success !== true) {
    throw new Error(`PingRoom delivery failed: HTTP ${response.status}`);
  }
  return Response.json({ delivered: true }, { status: 202 });
}

Wire the handler to the relay route shown above. Set EXPECTED_BUGSNAG_PROJECT_ID to the selected project's stable ID, use a runtime that provides the standard Request, Response, and fetch APIs, reject requests before reading an oversized body at the edge where possible, and keep runtime logs free of URLs and raw payloads.

Two PingRoom avatars rehearse accepted, duplicate, and rejected error tokens at a miniature workbench while Pingo watches beside a blank checklist card.

Why fixed copy is safer

BugSnag's webhook payload can include an exception message, request URL, affected user, device identifiers, metadata, breadcrumbs, code context, stack frames, linked issues, and release data. Those fields help inside BugSnag. They are a poor default for a lock screen.

The relay reads only the fields needed to decide whether the event belongs to this narrow route. It forwards no source text or URL. If the team later needs different fixed copy for a second trigger, add a separate tested allowlist entry. Do not fall back to trigger.message, error.message, or another arbitrary payload field.

The event's BugSnag ID becomes the PingRoom Idempotency-Key together with the project and trigger. An exact retry therefore reuses one key and identical body. PingRoom's idempotency receipt protects the normal immediate retry window; it is not a permanent incident ledger. Keep BugSnag and the incident system authoritative.

Rehearse the boundaries

Use BugSnag's Test action or a non-sensitive test project. Check all of these cases before enabling the production notification:

  1. One matching firstException event produces the fixed Room Ping.
  2. Replaying the same event reuses the same idempotency key and body.
  3. A missing or incorrect path secret returns 404 and sends nothing.
  4. A non-POST, oversized body, or malformed JSON is rejected.
  5. A different project, trigger, stage, severity, handled state, or error status sends nothing.
  6. A missing event ID fails instead of inventing retry identity.
  7. A PingRoom error fails visibly, and the relay refuses redirects.
  8. A locked test phone shows only the approved fixed copy.

The local fixture for this article passed the first seven deterministic checks against a mocked PingRoom endpoint and confirmed that representative exception, user, route, device, metadata, breadcrumb, and stack fields never entered the outbound body. No live BugSnag organization, PingRoom Room, or physical phone was used.

Read the result conservatively

The useful claim is: a request with the correct secret path matched the selected BugSnag project and event fields, and PingRoom accepted the bounded shared signal. Because this webhook is unsigned, that is not cryptographic proof of BugSnag origin. It also does not mean a particular phone displayed the Ping, the error still exists, the issue is severe, anyone acknowledged it, or an incident was resolved.

For service ownership and severity routing, use the broader monitoring-alert guide. For a source with a signed custom-integration path, compare the separate Sentry alert recipe rather than copying its signature assumptions into BugSnag.

When the small shared-signal case fits, open PingRoom Rooms, create the private destination Room, deploy the relay, and pass the boundary checks before attaching it to a production notification.

PingRoom

Short-form communication.

View all posts