Skip to main content

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, enabling, editing, rotating, and testing incoming webhooks requires Pro. An owner whose Pro access lapses can still disable or delete an existing private link immediately.

Fire endpoint (public, no auth header):

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. Only POST sends a Ping. A GET is read-only and returns setup information, which prevents browser previews and link scanners from firing the room accidentally.

Request body

Every field is optional. An empty POST still fires the room’s default action.

FieldTypeMeaning
titlestring ≤ 40Notification title. Overrides the webhook's saved title for this call.
messagestring ≤ 120 private / 160 publicNotification body. Overrides the webhook's saved message for this call; the room determines the limit.
actionint 1 to 4Which quick action to fire. Defaults to the webhook's configured action number.
emojistring ≤ 16Per-Ping display emoji. Overrides the webhook or action icon for this call.
iconroom-icon idPer-Ping v3 icon-catalog id. Must be a current PingRoom room icon.
color6-digit hexPer-Ping accent, with or without a leading # (for example #e33122).
soundcanonical sound idPer-Ping sound override from PingRoom's sound catalog.
dataobject ≤ 25 keys / 8KBFree-form object carried alongside the Ping. Lists are rejected; data.live_status is reserved and stripped.
correlation_idstring ≤ 255Your own id, echoed back unchanged on every read surface. Optional.
reply_tostring ≤ 255Id of the Ping this one answers. Optional.
is_urgentbooleanDeliver time-sensitive so the Ping breaks through Focus / Do Not Disturb. Delivery priority only, and independent of requires_ack — it asks nothing of the recipient.
requires_ackbooleanOverride whether this Ping needs acknowledgement; otherwise the quick action's setting applies. Shows a lock-screen card with an Acknowledge button, but does not raise the interruption level on its own. Feature gates may reject authoring.
ack_timeout_secondsint 1 to 86400Optional acknowledgement deadline, used when requires_ack resolves true.

A top-level live_status payload enters PingRoom’s separate streamed-status protocol and has its own state/template validation. It is not an ordinary custom-data field; nested data.live_status is reserved and removed.

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-Key header and a retried delivery replays the original response instead of firing a second Ping. Receipts are stored durably for at least 1 hour. Reusing a key with a different body returns 409 idempotency_conflict.
  • Cooldown. Each webhook has a per-webhook cooldown (default 5s, configurable up to 60s). Firing inside the window returns 429 cooldown_active with a retry_after in seconds. A completed same-key replay is returned before this check because it creates no new Ping or cooldown slot; a new key remains subject to the cooldown.
  • Rate limit. Each room+secret is capped at 60 calls 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.

HTTPerrorMeaning
404room_not_foundNo active room matches the invite code in the URL.
403invalid_secretThe secret in the URL does not match any webhook on the room.
403webhook_disabledThe webhook exists but is currently switched off.
403owner_not_proThe room owner's Pro lapsed. The webhook is paused, not deleted; it resumes once Pro is renewed.
410room_abandonedThe room owner's account no longer exists. The webhook was permanently disabled; stop retrying (later calls return 403 webhook_disabled).
409idempotency_conflictThe Idempotency-Key was already used with a different request body. Use a new key for a new Ping.
429cooldown_activeFired 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, message over the room limit, 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. Disabling or deleting an existing credential remains available after Pro lapses.

MethodPathDoes
GET/api/rooms/{inviteCode}/webhooksList a room's webhooks (each with its full webhook_url).
POST/api/rooms/{inviteCode}/webhooksCreate 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}/testSend 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. The receiver must accept PingRoom's signed JSON schema; use an adapter or automation step before destinations such as Slack incoming webhooks. 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. Creating, enabling, editing, testing, or rotating one on a free account returns 402 pro_required. A lapsed owner can still disable an existing connection and optionally erase its saved destination and event filter.

Delivery runs through a Cloudflare Worker that runs at the edge. Each handoff gets up to 3 target attempts, with a 10s timeout per attempt and short 300ms / 600ms waits before retries. A handoff that still fails stays retryable in the notification engine and can ultimately enter its dead-letter queue. After 15 counted delivery failures the webhook auto-disables so a dead endpoint stops burning invocations.

The event you receive

The JSON body contains the fields below. Message text, custom data, room identifiers, and Question response details are forwarded as supplied, so treat this as data leaving PingRoom and send it only to an endpoint you trust.

FieldTypeMeaning
eventstringUsually "ping". A resolved Question instead sends "question.answered", "question.expired", or "question.cancelled" (see below).
notification_iduuidStable id of the Ping; matches what the agent endpoints return.
roomobject{ name, code }: the room the Ping fired in.
titlestringThe Ping title (custom title if one was set, else the room name).
bodystringSender name + message, e.g. "Mia: Dinner's ready".
senderstringDisplay name of whoever (or whatever) fired the Ping.
action_numberint | nullWhich quick action fired, if any.
trigger_sourcestringHow the Ping was raised: "manual", "webhook", "location", "time", "telegram", "agent", "agent_question", "agent_approval", "system", "contact_share", "contact_request", "user_live", or "agent_live". A test delivery sends "test".
dataobject | nullThe structured-Ping payload, forwarded verbatim from the originating Ping.
correlation_idstring | nullEchoed from the originating Ping.
reply_tostring | nullId of the Ping the originating Ping answered.
is_urgentbooleanWhether this Ping was delivered time-sensitive. Independent of requires_ack.
requires_ackbooleanWhether recipients must acknowledge this Ping.
ack_expires_atISO-8601 | nullThe acknowledgement deadline, when one exists.
action_stateobjectCurrent acknowledgement/lifecycle state at the moment this event was emitted.
timestampISO-8601When the Ping fired.

What lands at your URL:

POST <your URL>
X-PingRoom-Signature: 3a7f… (legacy v1 HMAC-SHA256)
X-PingRoom-Signature-V2: 91c2… (delivery-bound v2 HMAC-SHA256)
X-PingRoom-Timestamp: 1780000000
X-PingRoom-Delivery: 019e79be-523d-7e34-a667-1d71a8be0ed1
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"
}

A non-empty management events filter accepts exactly these values. null or [] forwards all:

[
  "manual",
  "webhook",
  "time",
  "location",
  "telegram",
  "agent",
  "agent_question",
  "agent_approval",
  "system",
  "contact_share",
  "contact_request",
  "user_live",
  "agent_live",
  "notification.acked",
  "notification.expired",
  "question.answered",
  "question.expired",
  "question.cancelled"
]

Question events

When a Question your agent asks is created, its initial Ping uses the selectable agent_question source. When it 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). Every new delivery carries both signatures. Existing v1 receivers keep working unchanged, while new receivers can authenticate the delivery ID with v2.

  • V2 signed string is v2\n{timestamp}\n{deliveryId}\n{rawBody}. Use the exact X-PingRoom-Timestamp, X-PingRoom-Delivery, and raw request body bytes. Its lower-case hex HMAC is in X-PingRoom-Signature-V2.
  • The backward-compatible v1 signed string remains {timestamp}.{rawBody}. Its lower-case hex HMAC remains in X-PingRoom-Signature; the delivery ID is intentionally not part of v1.
  • Both use HMAC-SHA256(signing_secret, signedString). If a v2 header is present, require v2 to pass instead of falling back to a valid v1 header.
  • Reject any request whose X-PingRoom-Timestamp is more than 300 seconds in the past or future. This limits the replay window; it does not eliminate replay inside that window.
  • Compute the HMAC over the captured raw body bytes, never parsed or re-serialized JSON, and compare in constant time.
  • After successful v2 verification, process a repeated X-PingRoom-Delivery idempotently. V2 authenticates this ID; v1 does not, so v1-only receivers need application-level idempotency.
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 sigV1 = req.header("X-PingRoom-Signature");
  const sigV2 = req.header("X-PingRoom-Signature-V2");
  const ts = req.header("X-PingRoom-Timestamp");
  const deliveryId = req.header("X-PingRoom-Delivery");
  const hasV2 = sigV2 != null; // If v2 is present, never downgrade to v1.
  const suppliedHex = hasV2 ? sigV2 : sigV1;
  if (!suppliedHex || !ts) return false;

  // Freshness bound: reject malformed timestamps or anything outside ±300s.
  if (!/^[0-9]{1,12}$/.test(ts)) return false;
  const timestamp = Number(ts);
  if (Math.abs(Date.now() / 1000 - timestamp) > 300) return false;
  if (!/^[0-9a-f]{64}$/i.test(suppliedHex)) return false;
  if (hasV2 && (!deliveryId || !/^[!-~]{1,255}$/.test(deliveryId))) {
    return false;
  }

  const prefix = hasV2
    ? `v2\n${ts}\n${deliveryId}\n`
    : `${ts}.`;
  const signed = Buffer.concat([
    Buffer.from(prefix, "utf8"),
    req.rawBody, // the exact Buffer read from the request
  ]);
  const expected = crypto
    .createHmac("sha256", signingSecret)
    .update(signed)
    .digest();
  const supplied = Buffer.from(suppliedHex, "hex");

  return supplied.length === expected.length
    && crypto.timingSafeEqual(supplied, expected);
}

Target URL rules (SSRF safety)

  • HTTPS only, port 443 only, max 2048 chars. 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).
  • PingRoom re-resolves and reapplies the public-address rules immediately before the Go or synchronous Laravel handoff. Unsafe saved targets are stopped. The Worker also rejects obvious private literals/internal names and refuses redirects. This narrows DNS-rebinding exposure, but the Worker fetch cannot be cryptographically pinned to the earlier DNS answer; use a dedicated receiver hostname.

Managing the outgoing webhook

Owner-only, authenticated with the account JWT. Pro owners receive the signing secret; it is withheld from free owners. Every config response is marked no-store. A lapsed owner retains the disable/clear privacy off-ramp described above.

MethodPathDoes
GET/api/rooms/{inviteCode}/outgoing-webhookRead the room's outgoing webhook config. Pro owners receive signing_secret; it is withheld from free owners.
PUT/api/rooms/{inviteCode}/outgoing-webhookSet url / enabled / events (null or [] forwards all), or rotate the secret. A lapsed owner may only disable an existing config and optionally clear url/events.
POST/api/rooms/{inviteCode}/outgoing-webhook/testSend 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.