Webhooks
PingRoom rooms have two webhook connectors. Incoming webhooks let any system fire a ping into a room. Outgoing webhooks forward every ping back out to a URL you control — signed, retried, and delivered at the edge. Signals in, actions out.
Input · Free
Incoming webhooks
An incoming webhook is a single URL that fires a room when anything POSTs to it. A room can carry up to four, one per quick action — CI, a cron job, a form backend, an IoT button, whatever can make an HTTP request. Incoming webhooks are free on every account.
Fire endpoint (public — no auth header):
GET | POST /api/webhooks/{inviteCode}/{secret}The {secret} in the path is the credential — anything that captures the URL can fire the webhook, so treat it like a password. PingRoom returns webhook URLs only to the room owner and marks every such response no-store. A GET works for systems that can only open a link; a POST with a JSON body unlocks the fields below.
Request body
Every field is optional — an empty POST still fires the room’s default action.
| Field | Type | Meaning |
|---|---|---|
title | string ≤ 40 | Notification title. Overrides the webhook's saved title for this call. |
message | string ≤ 500 | Notification body. Overrides the webhook's saved message for this call. |
action | int 1–4 | Which quick action to fire. Defaults to the webhook's configured action number. |
data | object | Free-form key/value bag carried alongside the ping. Optional. |
Example:
# Fire a room. The secret in the URL is the credential — no auth header.
curl -sX POST https://api.pingroom.io/api/webhooks/ABC123/whk_9f3c…e1 \
-H 'Content-Type: application/json' \
-H 'Idempotency-Key: build-4821' \
-d '{
"title": "Deploy finished",
"message": "Production is live ✅",
"action": 1,
"data": { "commit": "a1b2c3d", "env": "prod" }
}'A successful fire returns 200:
{
"success": true,
"message": "Webhook triggered successfully",
"notification_id": "019e79be-3acd-73b6-b440-8ab0a7bffed8",
"room": { "id": "…", "name": "Build Alerts" },
"webhook": { "id": "…", "name": "CI", "emoji": "🚀" },
"action": { "number": 1, "label": "Deploy done", "icon": "🚀" },
"notification_title": "Deploy finished",
"notification_message": "Production is live ✅",
"triggered_at": "2026-06-02T18:24:05+00:00"
}Idempotency & cooldown
- Idempotency. Pass an
Idempotency-Keyheader and a retried delivery replays the original response instead of firing a second ping. Keys are remembered for1 hour. - Cooldown. Each webhook has a per-webhook cooldown (default and max
5s). Firing inside the window returns429 cooldown_activewith aretry_afterin seconds. Cooldown is checked before idempotency, so a reused key cannot bypass it. - Rate limit. Each room+secret is capped at
60calls per minute on top of the cooldown.
Incoming errors
Failures carry a stable error field — branch on the HTTP status and error, not the human message.
| HTTP | error | Meaning |
|---|---|---|
| 404 | room_not_found | No active room matches the invite code in the URL. |
| 403 | invalid_secret | The secret in the URL does not match any webhook on the room. |
| 403 | webhook_disabled | The webhook exists but is currently switched off. |
| 429 | cooldown_active | Fired again inside the cooldown window — honor retry_after (seconds). |
| 429 | (rate limit) | More than 60 calls/min for this room+secret. Standard Retry-After header. |
| 422 | (validation) | A field failed validation — e.g. title over 40 chars or action outside 1–4. |
Managing incoming webhooks
Owner-only, authenticated with the account JWT (Authorization: Bearer …). The fire endpoint above needs no auth; these management endpoints do.
| Method | Path | Does |
|---|---|---|
| GET | /api/rooms/{inviteCode}/webhooks | List a room's webhooks (each with its full webhook_url). |
| POST | /api/rooms/{inviteCode}/webhooks | Create a webhook. Auto-assigns the next free action number (1–4). |
| GET | /api/rooms/{inviteCode}/webhooks/{id} | Read one webhook. |
| PUT | /api/rooms/{inviteCode}/webhooks/{id} | Update label, title, message, emoji, sound, cooldown, enabled. |
| DELETE | /api/rooms/{inviteCode}/webhooks/{id} | Delete a webhook. |
| POST | /api/rooms/{inviteCode}/webhooks/{id}/test | Send a test fire to verify wiring. |
Output · Pro
Outgoing webhooks
Point a room at a URL and PingRoom calls it every time the room pings — Slack, a dashboard, a logging pipeline, anything that receives a POST. One outgoing webhook per room. It fires independently of push recipients, so it still delivers even when no one is around to be pinged. Outgoing webhooks are a Pro feature; configuring one on a free account returns 402 pro_required.
Delivery runs through a Cloudflare Worker — globally distributed and low-latency. Deliveries are queued and retried up to 3 times with 10s / 30s / 60s backoff on transient failures. After 15 consecutive failures the webhook auto-disables so a dead endpoint stops burning invocations.
The event you receive
A compact, signal-only JSON body — no tokens, no PII beyond the sender’s display name.
| Field | Type | Meaning |
|---|---|---|
event | string | Always "ping" today. |
room | object | { name, code } — the room the ping fired in. |
title | string | The ping title (custom title if one was set, else the room name). |
body | string | Sender name + message, e.g. "Mia: Dinner's ready". |
sender | string | Display name of whoever (or whatever) fired the ping. |
action_number | int | null | Which quick action fired, if any. |
trigger_source | string | How the ping was raised: "manual", "webhook", "location", "time", "agent", "test". |
timestamp | ISO-8601 | When the ping fired. |
What lands at your URL:
POST <your URL>
X-PingRoom-Signature: 3a7f… (lower-case hex HMAC-SHA256)
X-PingRoom-Timestamp: 1780000000
Content-Type: application/json
{
"event": "ping",
"room": { "name": "Build Alerts", "code": "ABC123" },
"title": "Deploy finished",
"body": "CI: Production is live ✅",
"sender": "CI",
"action_number": 1,
"trigger_source": "webhook",
"timestamp": "2026-06-02T18:24:05+00:00"
}Verifying the signature
Every delivery is signed with the room’s signing_secret (read it from the config endpoint; rotate it with regenerate_secret). Recompute the HMAC and reject anything that doesn’t match — or whose timestamp is stale.
- Signed string is
{timestamp}.{rawBody}— theX-PingRoom-Timestampvalue, a literal dot, then the exact request body bytes. - Signature is
HMAC-SHA256(signing_secret, signedString), lower-case hex, sent inX-PingRoom-Signature. - Reject any request whose
X-PingRoom-Timestampis more than±300sfrom now — that window is what makes a captured request un-replayable. - Compare in constant time, and verify the raw bytes before any JSON re-parse.
import crypto from "node:crypto";
// Express-style receiver. You MUST read the raw body bytes — verify the exact
// bytes that were signed, before any JSON re-serialization.
function verify(req, signingSecret) {
const sig = req.header("X-PingRoom-Signature");
const ts = req.header("X-PingRoom-Timestamp");
if (!sig || !ts) return false;
// Replay protection: reject anything older than ±300s.
if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false;
const signed = `${ts}.${req.rawBody}`; // "{timestamp}.{rawBody}"
const expected = crypto
.createHmac("sha256", signingSecret)
.update(signed)
.digest("hex");
// Constant-time compare.
return crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected));
}Target URL rules (SSRF safety)
- HTTPS only, port
443only, max2048chars. No embedded credentials (https://user:pass@host). - The host must resolve to a public IP. Private, loopback, link-local, and reserved ranges are rejected — including obfuscated literals (decimal, octal, hex, IPv4-mapped IPv6).
- The Worker re-resolves and egress-filters at delivery time too, so a host that flips to an internal address after registration still can’t be reached.
Managing the outgoing webhook
Owner-only, authenticated with the account JWT. The config response includes the signing secret and is marked no-store.
| Method | Path | Does |
|---|---|---|
| GET | /api/rooms/{inviteCode}/outgoing-webhook | Read the room's outgoing webhook config + signing_secret. |
| PUT | /api/rooms/{inviteCode}/outgoing-webhook | Set url / enabled; pass regenerate_secret to rotate the signing secret. |
| POST | /api/rooms/{inviteCode}/outgoing-webhook/test | Send a synchronous test delivery to the configured URL. |
See also
For the bigger picture of how rooms wire to the outside world, see Connections. To let an AI agent fire rooms on your behalf, see Agent Access.