Skip to main content
All posts
Developers7 min read

Send AWS CloudWatch Alarm Alerts to Your Team's Phones

Route one selected CloudWatch alarm state change through EventBridge and a narrow Lambda, then send fixed shared copy to a private PingRoom Room.

Two PingRoom avatars select one terracotta alarm token at a miniature monitoring instrument while Pingo waits beside a closed credential capsule.

To send one selected AWS CloudWatch alarm state change to your team's phones, match the exact alarm in EventBridge, invoke a narrow Lambda, and forward fixed copy to a private PingRoom Room. Keep the alarm reason, metric, dimensions, tags, account, Region, and investigation in AWS.

First check whether AWS User Notifications already solves the job. It can deliver CloudWatch notifications through supported channels, including push through the AWS Console Mobile Application. Use the PingRoom route when a small stable group needs one shared signal in an existing Room.

This is a webhook recipe written by PingRoom, not a native AWS integration. The destination Room's owner needs PingRoom Pro. CloudWatch remains the source of truth. Keep your established pager, acknowledgement flow, escalation policy, and incident record for critical systems.

Choose the route that owns the response

The team needsBetter routeWhy
Personal AWS-native mobile pushAWS User NotificationsIt stays inside AWS and already supports the AWS Console Mobile Application.
One short shared signal for one selected alarmEventBridge → Lambda → PingRoom RoomA stable group sees bounded copy without alarm details reaching lock screens.
Alarm state, reason, metric, history, and investigationCloudWatchCloudWatch owns the evaluation and evidence.
Schedules, acknowledgement, escalation, and urgent pagingYour established incident platformPingRoom is not a guaranteed pager or incident-management system.

This guide uses the second route. It accepts only one AWS account, one Region, one alarm ARN, and the ALARM or OK state. An OK state means CloudWatch evaluated the alarm as OK; it is not proof that the service recovered or that a person checked it.

For an illustrative setup, imagine a small service team that wants its private operations Room to know when one customer-facing latency alarm changes state. Pingo the Pigeon, PingRoom's fictional CMO, appears in the artwork beside the boundary. He cannot open AWS, read the Room, choose the alarm, or send a Ping. The human team owns the configuration and every decision.

Create the private Room connection

Create or choose a private Room for the people who should receive the 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 Lambda 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 code, screenshots, EventBridge event patterns, or logs.

This recipe sends one of two fixed bodies:

{
  "action": null,
  "title": "AWS alarm active",
  "message": "Open CloudWatch to review the selected alarm"
}
{
  "action": null,
  "title": "AWS alarm state changed",
  "message": "CloudWatch returned to OK. Verify the service in AWS"
}

No alarm name, reason, metric, dimension, tag, account ID, Region, ARN, dashboard, or AWS link reaches the phone.

Match one alarm in EventBridge

CloudWatch sends alarm state-change events to EventBridge. Create a rule in the same Region as the alarm and use an event pattern like this, replacing all example values:

{
  "source": ["aws.cloudwatch"],
  "detail-type": ["CloudWatch Alarm State Change"],
  "account": ["123456789012"],
  "region": ["us-east-1"],
  "resources": [
    "arn:aws:cloudwatch:us-east-1:123456789012:alarm:CustomerFacingLatency"
  ],
  "detail": {
    "state": {
      "value": ["ALARM", "OK"]
    }
  }
}

Set the Lambda as the rule target. The Lambda repeats every important gate because the event bus rule is routing configuration, not the only trust boundary.

AWS documents the top-level EventBridge fields and the CloudWatch alarm state-change detail in its CloudWatch and EventBridge reference. Use a real event captured from a non-sensitive test alarm to confirm the contract in your account and Region.

Filter again before forwarding

This dependency-free Node.js Lambda handler checks the event source and type, configured AWS account, Region, exact alarm ARN, accepted state, and EventBridge UUID. It maps none of the CloudWatch detail into the Ping.

<!-- recipe-source: cloudwatch-to-pingroom.mjs -->
const FIXED_PINGS = Object.freeze({
  ALARM: Object.freeze({
    action: null,
    title: "AWS alarm active",
    message: "Open CloudWatch to review the selected alarm",
  }),
  OK: Object.freeze({
    action: null,
    title: "AWS alarm state changed",
    message: "CloudWatch returned to OK. Verify the service in AWS",
  }),
});

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

function validUuid(value) {
  return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(
    value ?? "",
  );
}

export async function handleCloudWatchEvent(
  event,
  env = process.env,
  fetchImpl = globalThis.fetch,
) {
  const expectedAccount = required("EXPECTED_AWS_ACCOUNT_ID", env);
  const expectedRegion = required("EXPECTED_AWS_REGION", env);
  const expectedAlarmArn = required("EXPECTED_ALARM_ARN", env);

  if (!/^\d{12}$/.test(expectedAccount)) {
    throw new Error("EXPECTED_AWS_ACCOUNT_ID must be 12 digits");
  }
  if (!/^[a-z0-9-]+$/.test(expectedRegion)) {
    throw new Error("EXPECTED_AWS_REGION is invalid");
  }
  if (
    !expectedAlarmArn.startsWith("arn:") ||
    !expectedAlarmArn.includes(":cloudwatch:")
  ) {
    throw new Error("EXPECTED_ALARM_ARN must be a CloudWatch ARN");
  }

  const accepted =
    event?.source === "aws.cloudwatch" &&
    event?.["detail-type"] === "CloudWatch Alarm State Change" &&
    event?.account === expectedAccount &&
    event?.region === expectedRegion &&
    Array.isArray(event?.resources) &&
    event.resources.length === 1 &&
    event.resources[0] === expectedAlarmArn;
  if (!accepted) {
    return { ignored: true };
  }

  const state = event?.detail?.state?.value;
  if (state !== "ALARM" && state !== "OK") {
    return { ignored: true };
  }
  if (!validUuid(event?.id)) {
    throw new Error("Missing valid EventBridge event id");
  }

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

  const eventKey = `cloudwatch:${event.id}:${state}`;
  const response = await fetchImpl(webhookUrl, {
    method: "POST",
    redirect: "error",
    headers: {
      "Content-Type": "application/json",
      "Idempotency-Key": eventKey,
    },
    body: JSON.stringify({
      ...FIXED_PINGS[state],
      correlation_id: eventKey,
    }),
    signal: AbortSignal.timeout(8_000),
  });
  const result = await response.json().catch(() => null);
  if (!response.ok || result?.success !== true) {
    throw new Error(`PingRoom delivery failed: HTTP ${response.status}`);
  }

  return { delivered: true, state };
}

export const handler = handleCloudWatchEvent;

Package the file as an ES module in a currently supported AWS Lambda Node.js runtime. Configure these server-side environment variables:

  • EXPECTED_AWS_ACCOUNT_ID
  • EXPECTED_AWS_REGION
  • EXPECTED_ALARM_ARN
  • PINGROOM_WEBHOOK_URL

Restrict who can read or change the Lambda configuration. Give EventBridge permission to invoke only this function. The handler uses an eight-second outbound timeout and refuses redirects, insecure webhook URLs, missing identity, unexpected resources, and unsupported states.

Two PingRoom avatars rehearse active, OK, and duplicate alarm tokens at a miniature workbench while Pingo watches beside a blank timing dial.

Reuse the EventBridge event ID

Every EventBridge event includes an id. The handler combines that UUID with the state to make the PingRoom Idempotency-Key and correlation_id:

cloudwatch:6a7e8feb-b491-4cf7-a9f1-bf3703467718:ALARM

An exact retry of the same event keeps the same key and body. A later state-change event gets a different EventBridge ID, even if the alarm returns to the same state.

PingRoom's idempotency receipt protects the normal immediate retry window; it is not a permanent event ledger. Keep CloudWatch alarm history, EventBridge metrics, Lambda logs, and any dead-letter queue authoritative.

Keep the phone copy deliberately small

The CloudWatch event can include the alarm name, reason, timestamp, metric configuration, dimensions, tags, account, Region, and ARN. Those fields belong in AWS. They can reveal infrastructure names or customer context and are a poor default for a lock screen.

The Lambda reads only the fields needed to decide whether the event belongs to this route. Its two outbound bodies are fixed in code. If another alarm needs different copy or a different audience, create a separate reviewed allowlist entry or function instead of forwarding arbitrary event text.

This is the same principle behind routing monitoring alerts to the right people: preserve the system that evaluates the condition, then send the smallest useful shared signal.

Rehearse the boundaries

Use a non-sensitive test alarm and a test Room before enabling the production rule:

  1. A matching ALARM event produces only the fixed active copy.
  2. A matching OK event produces only the fixed state-change copy.
  3. Replaying the exact event reuses the same idempotency key and body.
  4. A different source, detail type, account, Region, alarm ARN, or state sends nothing.
  5. A missing or malformed EventBridge event ID fails instead of inventing retry identity.
  6. A PingRoom error fails the Lambda invocation, and redirects are refused.
  7. A locked test phone shows only the approved fixed copy.

The local fixture for this article passed the first six deterministic checks against a mocked PingRoom endpoint. It also confirmed that representative alarm reasons, metrics, dimensions, tags, account IDs, Regions, ARNs, and dashboard links never entered the outbound body. No live AWS account, EventBridge rule, Lambda, PingRoom Room, or physical phone was used.

Configure bounded retries and a dead-letter queue

EventBridge retries failed target delivery with exponential backoff. Its current retry-policy documentation says the default is up to 24 hours and 185 attempts. Set a deliberate maximum event age and retry count for this signal, and attach an Amazon SQS dead-letter queue so exhausted deliveries remain visible to the team that owns the rule.

AWS also notes that a target can occasionally be invoked more than once. The stable idempotency key makes an exact replay safe for PingRoom's documented receipt window. It does not turn this direct path into exactly-once delivery or durable incident processing.

Read the result conservatively: the selected CloudWatch state-change event passed the configured gates, and PingRoom accepted the bounded shared signal. That does not mean a phone displayed it, someone acknowledged it, the condition still exists, or the service recovered.

When the shared-signal case fits, open PingRoom Rooms, create the private destination Room, deploy the Lambda, and pass the boundary checks before enabling the production EventBridge rule.

PingRoom

Short-form communication.

View all posts