Skip to main content

Send the signal.Keep the state.

Most systems can deliver a message. PingRoom holds the event open until a person acknowledges it, answers it, or lets it expire — then returns that result to your endpoint, signed.

  1. TRIGGER

    cron · CI · agent

  2. EVENT

    one POST

    correlation_id deploy-8f21

  3. PINGROOM

    room + state

  4. STATE

    held open

    AWAITING · ack · answer

Pick the lane that matches the job.

An incoming webhook is the shortest route to a person. The authenticated lanes use the same room and event model when you need durable state or a signed return path.

  1. 01

    Fire from CI, cron, a monitor, or a form

    Incoming webhookSecret URL
  2. 02

    Keep live progress on the Lock Screen

    Webhook + live_statusSecret URL
  3. 03

    Receive every eligible room event

    Outgoing webhookHMAC
  4. 04

    Build a room-based application

    Human REST APIBearer JWT
  5. 05

    Connect an autonomous agent

    Hosted MCP / Agent APIOAuth 2.1

Same signal. Different tools.

Whatever you build with, the interaction keeps the same shape: name a room, send an event, wait for what comes back.

npm install --global @pingroom/cli
pingroom
pingroom ping -m "Deploy succeeded"
Base URLshttps://api.pingroom.io/apihttps://api.pingroom.io/api/agent/mcp

One URL. One POST. No package required.

POST https://api.pingroom.io/api/webhooks/{ROOM_CODE}/{SECRET}

The URL is the credential. Keep it in a secret store, never in source control or logs.

GET is deliberately read-only, so link scanners and browser prefetch can never fire the room. Only POST sends a Ping.

The event loop stays connected.

Delivery is only the first half.

Producer

CI · cron · agent

Ingress

one authenticated POST

Room

event + durable state

Person

push · feed · live card

Signed return

The acknowledgement, answer, or expiry arrives back at your HTTPS endpoint, carrying the same correlation_id you sent.

notification_id
PingRoom's durable join key for feed and lifecycle events.
correlation_id
Your request ID across the Ping, stream, ack, and hook.
data
Machine-readable context that survives every read surface.
requires_ack
Turns delivery into acknowledged or expired resolution.
is_urgent
Requests time-sensitive delivery, which may be allowed through Focus according to the recipient's device settings. It is independent of requires_ack; urgency does not request a confirmation.

One card, updated in place.

Send live_status with a stable correlation_id. The first leg alerts, the middle legs stay silent, the last one closes the card.

Deploysdone

Live in 41s

alerts once, then closes100%

One card advances 10% → 60% → 100% in place; only the terminal leg alerts again.

{
  "correlation_id": "deploy-8f21",
  "live_status": { "state": "running", "progress": 0.6 }
}
Read the full live-status contract

The result comes back signed.

An outgoing webhook sends each eligible event to your HTTPS endpoint. Three guarantees hold every time.

Signature
Verify V2 whenever it is present. Never fall back to V1.
Replay window
Five minutes around the signed Unix timestamp.
Delivery ID
Stable across retries. Store it before you process.
Show the verification example
import crypto from "node:crypto";
import express from "express";

const app = express();
const secret = process.env.PINGROOM_SIGNING_SECRET;

app.post(
  "/hooks/pingroom",
  express.raw({ type: "application/json" }),
  (req, res) => {
    const rawBody = req.body;
    const timestamp = req.get("X-PingRoom-Timestamp") ?? "";
    const deliveryId = req.get("X-PingRoom-Delivery") ?? "";
    const signatureV2 = req.get("X-PingRoom-Signature-V2");
    const signatureV1 = req.get("X-PingRoom-Signature") ?? "";
    const hasV2 = signatureV2 !== undefined;
    const age = Math.abs(Date.now() / 1000 - Number(timestamp));

    if (!secret || !Number.isFinite(age) || age > 300) {
      return res.sendStatus(401);
    }

    // V1 is considered only when the V2 header is absent.
    const prefix = hasV2
      ? `v2\n${timestamp}\n${deliveryId}\n`
      : `${timestamp}.`;
    const expected = crypto
      .createHmac("sha256", secret)
      .update(prefix)
      .update(rawBody)
      .digest("hex");
    const received = hasV2 ? signatureV2 : signatureV1;
    const a = Buffer.from(expected, "hex");
    const b = Buffer.from(received, "hex");

    if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
      return res.sendStatus(401);
    }

    const event = JSON.parse(rawBody.toString("utf8"));
    // Enqueue event and dedupe by deliveryId, then ACK fast.
    return res.sendStatus(200);
  },
);

Parse JSON only after verification. Re-serializing the object changes the signed bytes.

Small contract. Sharp guardrails.

Four limits worth putting into tests.

  • title40characters
  • message120 / 160characters · private / public room
  • data25keys · 8 KiB
  • correlation_id255characters

Every 429 carries retry_after; idempotent retries replay the stored response instead of sending twice. Never log an incoming webhook URL — its secret lives in the path.

HTTPCauseRecovery
403invalid_secret / disabled / owner_not_proRe-copy, re-enable, or restore the room owner's plan.
410room_abandonedStop retrying; the owner account is gone.
429cooldown / rate limitHonor retry_after and retry once.
422invalid payloadCheck title length, object shape, and byte limits.

The same event model goes further.

Stop checking. Start knowing.

Create a room, copy its webhook URL, and turn the next system event into a Ping that lands.