Skip to main content
All posts
Developers7 min read

How to Send a Grafana Alert to a Shared Phone Room

Verify one Grafana webhook at a small relay, then forward fixed firing and resolved signals to a private PingRoom Room.

Two PingRoom avatars verify one sealed signal at a miniature monitoring bench while Pingo watches beside a brass gate.

To send one selected Grafana alert to a shared phone Room, create a Grafana webhook contact point, sign its requests with an HMAC secret and timestamp, verify both at a small relay, then forward fixed firing or resolved copy to a PingRoom incoming webhook.

This is a webhook recipe written by PingRoom, not a native Grafana integration. Grafana remains the source of truth for alert evaluation, history, silences, and investigation. PingRoom carries one sparse shared signal to the people in the Room.

Choose the notification route first

Grafana supports contact points for Pushover, email, Slack, Microsoft Teams, PagerDuty, Grafana IRM, webhooks, and other destinations. Use the route that owns the human response.

NeedBetter routeWhy
One selected state change for a small shared groupGrafana webhook → verifying relay → PingRoomThe Room gets one bounded signal without copying alert detail to lock screens
A personal phone destinationGrafana's Pushover contact pointGrafana already supports the direct individual route
Discussion with alert contextSlack or Microsoft TeamsThe collaboration tool owns threads and richer context
On-call schedules, acknowledgement, escalation, or pagingGrafana IRM, PagerDuty, or your established pagerPingRoom is not an incident-response or escalation system

Grafana's contact-point reference lists the current destinations. This guide covers the first case only.

For an illustrative setup, imagine a small team that wants a shared signal for one low-volume rule named CheckoutSignal in the Shared signals folder. Pingo, PingRoom's fictional CMO, appears at the miniature verification gate in the artwork. He cannot access Grafana, enter the private Room, choose the rule, or send a Ping. The human team configures and rehearses the route.

Create the private PingRoom destination

Open your PingRoom Rooms, choose a private Room, then open Settings → Connections → From another app.

  1. Select Add Webhook.
  2. Name it Grafana alert.
  3. Keep Quick Ping on Custom message.
  4. Use Test once, then copy the webhook URL.

Incoming webhooks require PingRoom Pro. The URL contains the secret, so store the complete value only in the relay's protected server-side configuration. Keep it out of Grafana payloads, source control, client-side code, screenshots, tickets, and logs. Regenerate it if it leaks.

The incoming-webhook reference documents the current request fields, cooldown, rate limit, idempotency behavior, and error responses. A successful request means PingRoom accepted the Ping; it does not guarantee what every phone displays under every network, Focus, mute, or notification setting.

Put a verifying relay between Grafana and PingRoom

Grafana can sign the untouched webhook body with HMAC-SHA256. When you configure a timestamp header, its documented input is:

HMAC-SHA256(timestamp + ":" + raw request body)

The relay below is a dependency-free Cloudflare Worker. It accepts only:

  • a POST signed with the configured Grafana secret;
  • a timestamp no more than five minutes from the relay clock;
  • the named receiver, folder, and alert rule;
  • exactly one alert instance in either firing or resolved state;
  • a non-empty Grafana fingerprint and transition time.

It never forwards labels, annotations, values, instance names, dashboard links, panel links, or Grafana URLs. Store these five Worker secrets and variables:

GRAFANA_HMAC_SECRET=<a separate random secret>
GRAFANA_RECEIVER=PingRoom shared signal
GRAFANA_FOLDER=Shared signals
GRAFANA_ALERT_NAME=CheckoutSignal
PINGROOM_WEBHOOK_URL=<the complete secret PingRoom URL>

Use this Worker module:

const encoder = new TextEncoder();
const MAX_SKEW_SECONDS = 300;

function reply(status, error) {
  return Response.json({ error }, { status });
}

function configured(env) {
  return [
    "GRAFANA_HMAC_SECRET",
    "GRAFANA_RECEIVER",
    "GRAFANA_FOLDER",
    "GRAFANA_ALERT_NAME",
    "PINGROOM_WEBHOOK_URL",
  ].every((name) => typeof env[name] === "string" && env[name].length > 0);
}

async function hexDigest(algorithm, keyText, value) {
  if (algorithm === "HMAC") {
    const key = await crypto.subtle.importKey(
      "raw",
      encoder.encode(keyText),
      { name: "HMAC", hash: "SHA-256" },
      false,
      ["sign"],
    );
    const bytes = await crypto.subtle.sign("HMAC", key, encoder.encode(value));
    return [...new Uint8Array(bytes)]
      .map((byte) => byte.toString(16).padStart(2, "0"))
      .join("");
  }

  const bytes = await crypto.subtle.digest("SHA-256", encoder.encode(value));
  return [...new Uint8Array(bytes)]
    .map((byte) => byte.toString(16).padStart(2, "0"))
    .join("");
}

function sameHex(left, right) {
  if (!/^[a-f0-9]{64}$/i.test(left) || !/^[a-f0-9]{64}$/i.test(right)) {
    return false;
  }
  let different = 0;
  for (let index = 0; index < 64; index += 1) {
    different |= left.charCodeAt(index) ^ right.charCodeAt(index);
  }
  return different === 0;
}

export default {
  async fetch(request, env) {
    if (request.method !== "POST") return reply(405, "method_not_allowed");
    if (!configured(env)) return reply(500, "relay_not_configured");

    const timestamp = request.headers.get("X-Grafana-Alerting-Timestamp") ?? "";
    const signature = request.headers.get("X-Grafana-Alerting-Signature") ?? "";
    if (!/^\d{10}$/.test(timestamp)) return reply(401, "invalid_signature");
    if (Math.abs(Date.now() / 1000 - Number(timestamp)) > MAX_SKEW_SECONDS) {
      return reply(401, "stale_request");
    }

    const rawBody = await request.text();
    const expected = await hexDigest(
      "HMAC",
      env.GRAFANA_HMAC_SECRET,
      `${timestamp}:${rawBody}`,
    );
    if (!sameHex(signature, expected)) return reply(401, "invalid_signature");

    let payload;
    try {
      payload = JSON.parse(rawBody);
    } catch {
      return reply(400, "invalid_json");
    }

    if (payload.receiver !== env.GRAFANA_RECEIVER) {
      return reply(403, "unexpected_receiver");
    }
    if (payload.truncatedAlerts > 0) {
      return reply(422, "truncated_alert_group");
    }
    if (!Array.isArray(payload.alerts) || payload.alerts.length !== 1) {
      return reply(422, "expected_one_alert");
    }

    const alert = payload.alerts[0];
    const status = alert?.status;
    if (!["firing", "resolved"].includes(status) || payload.status !== status) {
      return reply(422, "unexpected_status");
    }
    if (alert?.labels?.grafana_folder !== env.GRAFANA_FOLDER) {
      return reply(403, "unexpected_folder");
    }
    if (alert?.labels?.alertname !== env.GRAFANA_ALERT_NAME) {
      return reply(403, "unexpected_alert");
    }
    if (typeof alert.fingerprint !== "string" || !alert.fingerprint) {
      return reply(422, "missing_fingerprint");
    }

    const transitionTime = status === "firing" ? alert.startsAt : alert.endsAt;
    if (typeof transitionTime !== "string" || !transitionTime) {
      return reply(422, "missing_transition_time");
    }

    const idempotencyKey = await hexDigest(
      "SHA-256",
      null,
      `grafana:${alert.fingerprint}:${status}:${transitionTime}`,
    );
    const body =
      status === "firing"
        ? {
            title: "Grafana alert fired",
            message: "Selected alert needs attention.",
          }
        : {
            title: "Grafana alert resolved",
            message: "Selected alert returned to normal.",
          };

    const response = await fetch(env.PINGROOM_WEBHOOK_URL, {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
        "Idempotency-Key": idempotencyKey,
      },
      body: JSON.stringify(body),
    });

    return response.ok
      ? new Response(null, { status: 204 })
      : reply(502, "pingroom_delivery_failed");
  },
};

The code and local verifier are preserved in this article's production receipt.

Configure the Grafana webhook contact point

Grafana's current webhook notifier guide documents these settings. Names and availability can vary by Grafana edition and version, so confirm the HMAC and timestamp fields exist in the Grafana instance you operate.

  1. Open Alerts & IRM → Alerting → Contact points and add a contact point named PingRoom shared signal.
  2. Choose Webhook and set the URL to the public HTTPS endpoint for your relay—not the PingRoom URL.
  3. Keep the method on POST and Max Alerts on 0, so the relay can reject a grouped notification instead of accepting a silently truncated one.
  4. In HMAC Signature, enter the same secret stored as GRAFANA_HMAC_SECRET.
  5. Keep the signature header as X-Grafana-Alerting-Signature.
  6. Set the timestamp header to X-Grafana-Alerting-Timestamp.
  7. Save the contact point, then assign it directly to the selected low-volume rule or through a notification policy that matches only that rule.

This recipe reads Grafana's default webhook JSON. Do not add a custom payload unless you also update the relay and its tests. Grafana can group several alert instances into one notification; this relay rejects that shape because one shared phone signal should not pretend to represent an unseen group.

Rehearse both transitions

Use a non-critical test rule that produces one alert instance. Check the rule and contact point in the same Alertmanager selected for the notification route.

  1. Trigger the rule with a safe test condition.
  2. Confirm Grafana sends one signed payload and the Room receives Grafana alert fired — Selected alert needs attention.
  3. Restore the test condition.
  4. Confirm the Room receives Grafana alert resolved — Selected alert returned to normal.
  5. Replay the exact firing request and confirm the relay reuses the same PingRoom idempotency key.
  6. Send a stale timestamp, bad signature, wrong receiver, wrong folder, wrong alert name, and two-alert payload. Each must send nothing to PingRoom.
  7. Lock a test phone and confirm the fixed copy reveals no monitored service, instance, metric, customer, threshold, or investigation link.

The local fixture for this article passed firing, resolved, exact-retry, stale-timestamp, bad-signature, wrong-receiver, wrong-folder, wrong-alert, grouped-payload, and malformed-JSON cases with a mocked PingRoom endpoint. No live Grafana organization, PingRoom Room, or physical phone was used.

Two PingRoom avatars rehearse one raised and one settled status token at a miniature relay bench while Pingo watches the handoff.

At the miniature relay bench, Pingo watches one terracotta marker rise and one ivory marker settle. The humans still control the rule, signing secret, relay, and Room. His fictional Chief Marketing Officer title grants no system access or authority.

Read the signal conservatively

A firing Ping means Grafana sent a fresh, correctly signed payload matching the configured receiver, folder, and alert name, and PingRoom accepted the fixed signal. It does not prove that a particular phone displayed it, the alert is severe, the service is down, or anyone acknowledged the event.

A resolved Ping means Grafana reported the selected alert instance as resolved. It does not prove every related system is healthy. Keep Grafana's alert history, dashboard, and investigation links in Grafana. Keep paging, escalation, acknowledgement, and incident records in the response system your team trusts.

PingRoom retains incoming-webhook idempotency receipts for at least one hour. That protects immediate exact retries; it is not a permanent alert ledger. If every transition must survive an outage, put a durable queue between the verified receiver and PingRoom, monitor relay failures independently, and keep the native pager route in place.

For broader ownership and severity design, read how to route monitoring alerts. For the PingRoom request and response contract, use the webhook-to-push guide.

If the shared-signal boundary fits, open PingRoom, create the private Room and webhook, deploy the relay, then run the seven-step rehearsal before attaching it to a production rule.

The two editorial illustrations in this article were created with AI using approved PingRoom character references and reviewed by the PingRoom team.

PingRoom

Short-form communication.

View all posts