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.
TRIGGER
cron · CI · agent
EVENT
one POST
correlation_id deploy-8f21
PINGROOM
room + state
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.
- 01
Fire from CI, cron, a monitor, or a form
Incoming webhookSecret URL - 02
Keep live progress on the Lock Screen
Webhook + live_statusSecret URL - 03
Receive every eligible room event
Outgoing webhookHMAC - 04
Build a room-based application
Human REST APIBearer JWT - 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"
import { PingRoom } from "@pingroom/sdk";
const pr = new PingRoom({ token: process.env.PINGROOM_TOKEN });
await pr.broadcast("ab12cd", {
message: "Deploy shipped",
requires_ack: true,
});
codex mcp add pingroom --url \
https://api.pingroom.io/api/agent/mcp
codex mcp login pingroom
https://api.pingroom.io/apihttps://api.pingroom.io/api/agent/mcpOne URL. One POST. No package required.
POST https://api.pingroom.io/api/webhooks/
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.
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.
Live in 41s
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 }
}
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.
title40charactersmessage120 / 160characters · private / public roomdata25keys · 8 KiBcorrelation_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.
| HTTP | Cause | Recovery |
|---|---|---|
| 403 | invalid_secret / disabled / owner_not_pro | Re-copy, re-enable, or restore the room owner's plan. |
| 410 | room_abandoned | Stop retrying; the owner account is gone. |
| 429 | cooldown / rate limit | Honor retry_after and retry once. |
| 422 | invalid payload | Check 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.