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 · Pro
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. Creating and managing incoming webhooks requires Pro.
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 to 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. |
correlation_id | string ≤ 255 | Your own id, echoed back unchanged on every read surface. Optional. |
reply_to | string ≤ 255 | Id of the Ping this one answers. Optional. |
Example:
# Fire a room. The secret in the URL is the credential, so 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
5s, configurable up to60s). 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. |
| 403 | owner_not_pro | The room owner's Pro lapsed. The webhook is paused, not deleted; it resumes once Pro is renewed. |
| 410 | room_abandoned | The room owner's account no longer exists. The webhook was permanently disabled; stop retrying (later calls return 403 webhook_disabled). |
| 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 to 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 to 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 that runs at the edge. 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 | Usually "ping". A resolved Question instead sends "question.answered", "question.expired", or "question.cancelled" (see below). |
notification_id | uuid | Stable id of the Ping; matches what the agent endpoints return. |
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". |
data | object | null | The structured-Ping payload, forwarded verbatim from the originating Ping. |
correlation_id | string | null | Echoed from the originating Ping. |
reply_to | string | null | Id of the Ping the originating Ping answered. |
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",
"notification_id": "019e79be-3acd-73b6-b440-8ab0a7bffed8",
"room": { "name": "Build Alerts", "code": "ABC123" },
"title": "Deploy finished",
"body": "CI: Production is live ✅",
"sender": "CI",
"action_number": 1,
"trigger_source": "webhook",
"data": { "commit": "a1b2c3d", "env": "prod" },
"correlation_id": "deploy-4821",
"reply_to": null,
"timestamp": "2026-06-02T18:24:05+00:00"
}
Question events
When a Questionyour agent asked resolves, the room’s outgoing webhook also fires a lifecycle event: question.answered, question.expired, or question.cancelled. These are matched by event name against the same events filter, so while null/[] forwards all, a non-empty filter must list the question.* names explicitly to keep them (atrigger_source-only filter like ["agent"] will drop them). The body carries the chosen answer (value and label for a tapped option, or text for a typed reply, plus the human who answered and when), so an event-driven consumer can act without long-polling. answer is null for expired (no answer) and cancelled (withdrawn), keeping the three outcomes distinct. Same HMAC signing as every other delivery.
{
"event": "question.answered",
"question_id": "019e7a02-1c4d-7e10-9b21-2f3a4b5c6d7e",
"room": { "name": "Build Alerts", "code": "ABC123" },
"state": "answered",
"answer": {
"value": "approve",
"label": "Approve",
"text": null,
"responder": { "id": "…", "display_name": "Federica" },
"answered_at": "2026-06-02T18:25:10+00:00"
},
"correlation_id": "deploy-4821",
"reply_to": null,
"data": { "commit": "a1b2c3d" },
"timestamp": "2026-06-02T18:25:10+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 and 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 / events (a filter of trigger_source values; null or [] forwards all); 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.