Skip to main content
All posts
Developers8 min read

Send Honeycomb Trigger Alerts to Your Team's Phones

Authenticate one Honeycomb Trigger webhook, accept only firing or OK transitions, and send fixed privacy-minimal copy to a shared PingRoom Room.

Two PingRoom avatars inspect one terracotta signal at a brass gate while Pingo watches beside an amber light.

To send one Honeycomb Trigger transition to your team's phones, create a custom Honeycomb webhook with a shared secret, accept only one Trigger and environment at a narrow relay, and forward fixed TRIGGERED or OK copy to a PingRoom incoming webhook. Keep the query, threshold, groups, descriptions, datasets, and investigation links in Honeycomb.

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

Choose what owns the response

Honeycomb can expose rich Trigger context to a webhook. A phone does not need all of it.

The team needsBetter routeWhy
One short shared signal for one production TriggerHoneycomb webhook → relay → PingRoom RoomA stable group sees a bounded state change without copying observability details to lock screens.
Query results, groups, threshold, and investigationHoneycombHoneycomb owns the Trigger evaluation and diagnostic context.
Acknowledgement, schedules, escalation, and pagingThe team's incident platformPingRoom is not a guaranteed pager or incident-management system.
Several Triggers with different owners or response rulesA maintained routing serviceOne small relay should not become an unreviewed alert policy engine.

This guide uses the first route. It accepts one Trigger ID, one environment, and only the TRIGGERED and OK states.

For an illustrative setup, imagine a small team that wants one private Room to know when a production latency Trigger changes state. Pingo the Pigeon, PingRoom's fictional CMO, appears in the artwork beside the gate. He cannot access Honeycomb, enter the private Room, change the Trigger, or send Pings. The human team owns every setting 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 errors.

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.

The relay sends only one of these fixed messages:

{
  "action": null,
  "title": "Honeycomb trigger firing",
  "message": "Open Honeycomb to investigate the selected production trigger"
}
{
  "action": null,
  "title": "Honeycomb trigger returned to OK",
  "message": "Open Honeycomb to verify the selected trigger and service health"
}

OK is Honeycomb's Trigger state. It does not prove the service recovered, that every group is healthy, or that a person acknowledged the alert.

Create one Honeycomb webhook recipient

In Honeycomb, open Team Settings → Integrations. Under Trigger, SLOs, and Anomaly Detection Recipients, choose Add Integration, select Webhook, and enter:

  • a recognizable name;
  • the relay's public HTTPS URL; and
  • a new high-entropy shared secret.

Honeycomb sends that secret in X-Honeycomb-Webhook-Token. Store the same value as HONEYCOMB_WEBHOOK_TOKEN at the relay. Generate at least 32 printable characters, keep it out of logs, and rotate it if exposed.

In the recipient's Payload tab, enable the Trigger payload and use this narrow template:

{
  "trigger_id": "{{ .ID }}",
  "environment": "{{ .Environment }}",
  "instance_id": "{{ .Alert.InstanceID }}",
  "status": "{{ .Alert.Status }}",
  "is_test": {{ .Alert.IsTest }},
  "timestamp": "{{ .Alert.Timestamp }}"
}

This excludes the Trigger name, description, datasets, groups, result values, summary, and URLs. Honeycomb's webhook documentation explains recipient creation, custom payloads, headers, delivery timing, and retries. Its variable reference defines the fields used here.

Add the recipient to one existing Trigger: open Triggers, choose the Trigger, add the webhook in Recipients, then save. Record the Trigger's stable ID and exact environment name as EXPECTED_HONEYCOMB_TRIGGER_ID and EXPECTED_HONEYCOMB_ENVIRONMENT at the relay.

Do not attach the recipient to several Triggers just because the template works. Review one route, owner, threshold, and response first. Honeycomb's Trigger setup guide remains the authority for query and threshold design.

Authenticate, filter, and deduplicate at the relay

Honeycomb gives each delivery an X-Honeycomb-Webhook-Delivery-ID. The value stays the same across retries of that delivery. The relay hashes it with the expected Trigger ID to create a bounded PingRoom Idempotency-Key, so an exact Honeycomb retry reuses one delivery identity without exposing the source ID.

This dependency-free Node handler also compares the shared secret in constant time, rejects Honeycomb test notifications, refuses redirects, and hashes the alert instance into a correlation ID. It never maps arbitrary source text into a Ping.

import { createHash, timingSafeEqual } from "node:crypto";

const MAX_BODY_BYTES = 16 * 1024;
const ALLOWED_STATUSES = new Set(["TRIGGERED", "OK"]);

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

function validSharedSecret(supplied, expected) {
  if (!/^[\x21-\x7E]{32,128}$/.test(expected)) {
    throw new Error(
      "HONEYCOMB_WEBHOOK_TOKEN must contain 32–128 printable ASCII characters",
    );
  }
  const received = Buffer.from(supplied ?? "", "utf8");
  const wanted = Buffer.from(expected, "utf8");
  return received.length === wanted.length && timingSafeEqual(received, wanted);
}

function sha256(value) {
  return createHash("sha256").update(value, "utf8").digest("hex");
}

function fixedPing(status) {
  return status === "TRIGGERED"
    ? {
        action: null,
        title: "Honeycomb trigger firing",
        message:
          "Open Honeycomb to investigate the selected production trigger",
      }
    : {
        action: null,
        title: "Honeycomb trigger returned to OK",
        message:
          "Open Honeycomb to verify the selected trigger and service health",
      };
}

function ignored() {
  return new Response(null, { status: 204 });
}

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

  if (
    !validSharedSecret(
      request.headers.get("x-honeycomb-webhook-token"),
      required("HONEYCOMB_WEBHOOK_TOKEN", env),
    )
  ) {
    return new Response("Unauthorized", { status: 401 });
  }

  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 });
  }

  if (
    payload?.trigger_id !== required("EXPECTED_HONEYCOMB_TRIGGER_ID", env) ||
    payload?.environment !== required("EXPECTED_HONEYCOMB_ENVIRONMENT", env) ||
    payload?.is_test !== false
  ) {
    return ignored();
  }

  if (!ALLOWED_STATUSES.has(payload?.status)) {
    return ignored();
  }

  const deliveryId = request.headers.get("x-honeycomb-webhook-delivery-id");
  const instanceId = payload?.instance_id;
  if (
    typeof deliveryId !== "string" ||
    deliveryId.length < 1 ||
    deliveryId.length > 256 ||
    /[\u0000-\u001F\u007F]/.test(deliveryId) ||
    typeof instanceId !== "string" ||
    instanceId.length < 1 ||
    instanceId.length > 256
  ) {
    return new Response("Missing delivery 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 triggerId = payload.trigger_id;
  const idempotencyKey = `honeycomb:${sha256(`${triggerId}:${deliveryId}`)}`;
  const correlationId = `honeycomb:${sha256(`${triggerId}:${instanceId}`)}`;

  let response;
  try {
    response = await fetch(webhookUrl, {
      method: "POST",
      redirect: "error",
      headers: {
        "Content-Type": "application/json",
        "Idempotency-Key": idempotencyKey,
      },
      body: JSON.stringify({
        ...fixedPing(payload.status),
        correlation_id: correlationId,
      }),
    });
  } catch {
    return new Response("Bad Gateway", { status: 502 });
  }

  const result = await response.json().catch(() => null);
  if (response.ok && result?.success === true) {
    return Response.json({ delivered: true }, { status: 202 });
  }

  if ([429, 503].includes(response.status)) {
    const retryAfter = response.headers.get("retry-after");
    return new Response("Temporarily unavailable", {
      status: response.status,
      headers: retryAfter ? { "Retry-After": retryAfter } : {},
    });
  }

  if (response.status === 502) {
    return new Response("Bad Gateway", { status: 502 });
  }

  return new Response("PingRoom delivery failed", { status: 500 });
}

Wire the handler to the HTTPS URL entered in Honeycomb. Use a runtime with the standard Request, Response, and fetch APIs, reject oversized bodies at the edge where possible, and keep raw bodies and headers out of logs.

Two PingRoom avatars sort firing, OK, duplicate, and rejected signal tokens at a wooden bench while Pingo observes.

Match Honeycomb's retry contract

Honeycomb expects a response within 15 seconds. It does not retry a timeout because the receiver may already have processed the request. Keep this handler fast; move slow work to a durable queue if your deployment needs it.

Honeycomb documents up to three retries for connection failures, 408, 502, and a 429 or 503 that includes Retry-After. It does not retry every other 4xx or 5xx. The handler therefore:

  • returns 202 only after PingRoom reports success;
  • returns 204 for an authenticated event outside the narrow allowlist;
  • returns 502 for a network or gateway failure;
  • preserves eligible 429/503 retry permission and Retry-After; and
  • returns 500 for a non-retryable PingRoom rejection.

Honeycomb says it treats Retry-After as permission but does not wait for that duration. Do not use this path as an unlimited delivery queue. If a missed shared signal is unacceptable, keep the established incident route as the reliable response owner.

Rehearse the boundaries

Use Honeycomb's test controls and a non-sensitive test Trigger. Because the production relay deliberately rejects .Alert.IsTest, first confirm at the relay log or a temporary isolated test route that Honeycomb reached the endpoint and the shared secret matched. Then use a controlled real evaluation or a local fixture to check the full production path.

Before enabling the recipient, verify all of these cases:

  1. One matching TRIGGERED delivery produces the fixed firing Ping.
  2. One matching OK delivery produces the fixed OK Ping, without claiming recovery.
  3. Replaying one delivery reuses the same idempotency key and body.
  4. A wrong secret, method, oversized body, or malformed JSON is rejected.
  5. A different Trigger, environment, test flag, or status sends nothing.
  6. A missing delivery or alert-instance identity fails instead of inventing one.
  7. A PingRoom redirect is refused; eligible temporary failures preserve Honeycomb's retry signal.
  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. It also confirmed that representative Trigger descriptions, dataset names, Honeycomb URLs, and user text never entered the outbound body. No live Honeycomb organization, PingRoom Room, or physical phone was used.

Read the result conservatively

The useful claim is: Honeycomb sent an authenticated delivery for the selected Trigger, the relay matched its environment and state, and PingRoom accepted the bounded shared signal. That does not mean a particular phone displayed it, the service is unhealthy or recovered, someone acknowledged it, or an incident was resolved.

For ownership and severity design, use the broader monitoring-alert guide. Keep Trigger evaluation and investigation in Honeycomb, and keep critical paging in the system that owns escalation.

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 the production Trigger.

PingRoom

Short-form communication.

View all posts