# Buttons on a Ping

**API status: available · iPhone and Android apps: updated versions only**

A Ping sent by an agent or an incoming webhook can carry up to **three
buttons**. A button either opens a web page, or tells **your server** that the
person tapped it. The buttons show on the notification's long-press actions and
under the Ping's card in the app.

> **In one sentence.** Put a `data.actions` list on a Ping; a tap on a
> `webhook` button is delivered by PingRoom's servers to your URL as a signed
> `notification.action` event, once per person, for 24 hours.

A button is not a Question: nothing waits for it, nobody's answer is recorded
as the Ping's result, and the Ping itself does not change when a button is
tapped. For a decision with one attributable answer, ask a
[Question](https://pingroom.io/questionprotocol.md). For a changing state on the
Lock Screen, use [Live Activities](https://pingroom.io/liveactivities.md).

Buttons are behind PingRoom's operator switch `sending.ping_buttons`. While it
is off, a send that carries `data.actions` is `403 feature_disabled`, taps are
refused the same way, and the apps hide the button row.

---

## 1. The two kinds of button

| `type` | A tap does |
|---|---|
| `open_url` | Opens the web page in the app's in-app browser, which shows the address. Free: nothing leaves PingRoom. |
| `webhook` | The phone calls **PingRoom**, never your server. PingRoom delivers the tap from its own servers to the button's URL, signed with the Ping's own secret. The room owner must be on Pro. |

The URL of a `webhook` button and its `body` are never shown to anyone: not in
the push, not in `data`, not on any read, and not in the call log of an
incoming webhook. The push carries only `{id, label, type, style}` for each
button: the app loads the destination from the API when the button is used.

---

## 2. Send one

Base URL `https://api.pingroom.io`. An agent credential needs the scope
`pingroom:broadcast:send` and a grant on the room.

### 2.1 From an agent credential

```bash
curl -X POST https://api.pingroom.io/api/agent/rooms/ab12cd/notifications \
  -H "Authorization: Bearer $PINGROOM_TOKEN" -H "Content-Type: application/json" \
  -d '{
    "title": "Disk alert",
    "message": "Disk 91% on db-2",
    "correlation_id": "disk-db-2",
    "data": {
      "actions": [
        {"id": "dash", "label": "Open dashboard", "type": "open_url",
         "url": "https://grafana.example.com/d/disk"},
        {"id": "clean", "label": "Clean up now", "type": "webhook",
         "url": "https://ops.example.com/cleanup", "style": "danger",
         "body": {"host": "db-2"}}
      ]
    }
  }'
```

The `201` answer is the Ping, with the buttons as every recipient sees them and,
because one of them is a `webhook` button, the secret its deliveries are signed
with:

```json
{
  "id": "9b2f5c1e-…",
  "message": "Disk 91% on db-2",
  "actions": [
    {"id": "dash", "label": "Open dashboard", "type": "open_url", "style": "default",
     "url": "https://grafana.example.com/d/disk", "host": "grafana.example.com"},
    {"id": "clean", "label": "Clean up now", "type": "webhook", "style": "danger"}
  ],
  "action_signing_secret": "<64 hex characters>"
}
```

**Store `action_signing_secret` now, keyed by the Ping's id.** It is returned
only in the answer to the send (a keyed retry of the same send, the MCP
`broadcast` result and the incoming-webhook answer included). No read returns
it, and it cannot be fetched later.

### 2.2 From an incoming webhook

The same `data.actions` goes in the body of a room's incoming webhook. The URL
names the room and carries the secret, so there is no auth header.

```bash
curl -sX POST https://api.pingroom.io/api/webhooks/ABC123/whk_9f3c…e1 \
  -H "Content-Type: application/json" \
  -d '{
    "title": "Disk alert",
    "message": "Disk 91% on db-2",
    "data": {
      "actions": [
        {"id": "clean", "label": "Clean up now", "type": "webhook",
         "url": "https://ops.example.com/cleanup", "body": {"host": "db-2"}}
      ]
    }
  }'
```

The `200` answer is the webhook's usual body (`success`, `notification_id`,
`room`, …) plus `actions` (the public shape above) and, when a button is a
`webhook` button, `action_signing_secret`. The webhook's own rules (secret,
cooldown, owner's plan) apply first; see
[Webhook documentation](https://pingroom.io/documents/webhooks).

### 2.3 Over MCP, the SDK and the CLI

- **MCP:** `broadcast` takes `data.actions`, and its tool result carries
  `action_signing_secret`. See [Connect over MCP](https://pingroom.io/connect-mcp.md).
- **SDK:** `pingAction.openUrl(label, url, options?)` and
  `pingAction.webhook(label, url, options?)` build and check each button before
  any request, for `broadcast()` and `sendIncomingWebhook()`. The result carries
  `action_signing_secret`.
- **CLI:** `pingroom ping --action-url "Label=https://…"` and
  `--action-webhook "Label=https://…"` (each repeatable), with
  `--action-body '<json>'` for a webhook button's `body`. The output prints
  `Button signing secret: <hex>` (with `--json`, `action_signing_secret` in the
  raw response).

**The SDK builders and the CLI flags are in the source but not in the npm
releases published when this was written (CLI 0.16.0, SDK 0.9.0).** Until the
next releases, send `data.actions` over REST or MCP as above.
`verifyWebhookSignature` (§5) is in the published SDK.

---

## 3. Rules

### 3.1 Who may attach buttons, and where

| Rule | Refusal |
|---|---|
| Only an agent credential (REST `POST /api/agent/rooms/{inviteCode}/notifications`, MCP `broadcast`) or an incoming webhook may attach buttons. A person's own send from the app, the web app or a user token may not | `422 ping_buttons_agents_only` |
| Not in a public room, and not in **any** personal room, the sender's own included | `422 ping_buttons_unsupported_room` |
| Not together with a confirmation request (`requires_ack`, or a quick action that requires one) | `422 ping_buttons_with_ack` |
| A `webhook` button needs the **room owner** on PingRoom Pro, checked at send, again at tap and again at delivery. `open_url` buttons are free | `403 ping_buttons_require_pro` |
| An `open_url` button is a link, so it also needs the operator switch `sending.links`; every button needs `sending.ping_buttons` | `403 feature_disabled` |
| Never on a Question, a Handoff, an agent approval, a live-status stream (including an incoming webhook's `live_status` body), a quick-action press, or a replacement of a sent Ping | `422` on `data.actions` |

The refusals with a code come back as `{"message": …, "code": …}`; on an
incoming webhook the body is `{"success": false, "error": …, "code": …,
"message": …}`.

### 3.2 The buttons

`data.actions` is a JSON list of 1 to 3 objects. It counts as one of `data`'s 25
keys and inside its 8 KiB. An object takes no key other than these:

| Field | Rule |
|---|---|
| `id` | Optional. `^[a-z0-9_-]{1,24}$`, unique on the Ping. Defaults to `a1`, `a2`, `a3` by position |
| `label` | Required. 1 to 26 characters after cleaning (§3.4) |
| `type` | Required: `open_url` or `webhook` |
| `url` | Required. At most 2048 characters (§3.3) |
| `style` | Optional: `default` (the default), `primary` or `danger`. `danger` is drawn destructive |
| `body` | `webhook` only (`422` on an `open_url` button). A JSON object of at most 10 top-level keys and 1024 bytes of PHP's escaped `json_encode` (the measure `data`'s 8 KiB uses). Echoed to your URL on every tap; never shown to anyone else |

A button the rules refuse is a `422` whose `errors` name the field, such as
`data.actions.1.label`.

### 3.3 URLs

The same rules apply to both types, so the host a person sees is the host PingRoom
read.

- `https://` only, in lowercase. No `http`, no other scheme.
- At most 2048 characters, every one printable ASCII, with no backslash.
  Percent-encode anything else.
- The part before the first `/`, `?` or `#` is a bare hostname and an optional
  port: no user name or password (`user:pass@`), no IP address in brackets, no
  percent-encoded or Unicode host.
- A public hostname: at least two labels of `a-z`, `0-9` and `-` (not starting
  or ending with `-`), an alphabetic (or `xn--`) last label, no trailing dot.
  That refuses every IPv4 spelling, and these last labels: `localhost`,
  `localdomain`, `local`, `internal`, `lan`, `intranet`, `corp`, `home`, `arpa`,
  `onion`, `test`, `invalid`, `example`.
- **Internationalized domains must be given as punycode** (`xn--…`) and are shown
  that way. A non-ASCII host is refused, not converted, so a Cyrillic "а" in
  "pаypal.com" shows as `xn--pypal-4ve.com` instead of passing for the real
  domain.
- An `open_url` button may name a port (1 to 65535). A `webhook` URL **cannot**:
  deliveries go to the standard HTTPS port.
- A `webhook` URL also has to pass the delivery check, so the send refuses what
  it would: a punycode last label (`.xn--p1ai`; a punycode name below it is
  fine), a `#` straight after the hostname (put the `/` first), and a `%` in
  the path that does not start an escape such as `%20` (write `%25`).

The check at send time is about the text of the URL. A name that resolves to a
public address now can resolve to an internal one later, so a `webhook` URL is
resolved again and refused if it points at a private address when the tap is
delivered (the delivery is then `failed` and not retried).

### 3.4 Labels

A label is Unicode-normalized (NFC), runs of spaces are collapsed and the ends
trimmed. Then it is refused when it:

- is empty, or is longer than 26 characters;
- contains a line break, a control or private-use character, or an invisible
  formatting character: bidirectional overrides and isolates, left-to-right and
  right-to-left marks, zero-width space, word joiner, byte-order mark. The
  zero-width joiners that emoji and Persian need and the emoji tag characters
  are allowed. The label is refused, not silently cleaned;
- **contains anything shaped like a web address**: `://`, `www.`, a dotted IPv4
  address, or `name.tld` with an alphabetic or punycode last label, after
  folding full-width letters and the ideographic full stops browsers read as
  dots. `paypal.com`, `Log in at login.bank.co`, `www.example`, `https://x`
  and `Open 10.0.0.5` are refused. A label may never claim a destination: the
  host line the app draws under the button is the only place that says where it
  goes. Prose with a dot is refused too ("Node.js docs");
- **equals one of PingRoom's own notification buttons** in any language the app
  ships (Pong back, Mute room, Confirm, "I'll take it", Done, Release, Cancel
  and their translations, plus `pong`, `mute`, `unmute`, `confirm`,
  `acknowledge`, `ack`, `pingroom`). The comparison keeps only letters, digits
  and marks after compatibility folding and lowercasing, so "PONG back",
  "✓ Confirm" and "Done" are refused. It is an exact match, not a substring:
  "Mark done" is fine.

---

## 4. What happens on a tap

1. A recipient taps the button in the notification or on the card.
2. `open_url`: the app loads the Ping from the API (the push never carries a
   URL) and opens the link in the in-app browser. Nothing is sent to you, and
   no run is recorded.
3. `webhook`: the app calls PingRoom, which records one run for that person and
   button and queues a delivery. PingRoom's notifier hands it to PingRoom's
   outgoing-webhook worker, which checks the destination again, signs the
   request and POSTs it to your URL without following redirects.
4. Your server answers with a `2xx`.

**One run per person per button.** A second tap by the same person on the same
button delivers nothing and answers with the first run.

**Buttons expire 24 hours after the Ping was sent.** A tap after that is
refused.

**A Ping with buttons cannot be replaced** (§7): the text around a button is
part of what a person agreed to when they tap, so it is frozen. It can still be
**taken back** (`POST /api/agent/rooms/{inviteCode}/notifications/{notification}/withdraw`,
MCP `withdraw_notification`): that disables its buttons, and a tap on a
withdrawn Ping is refused.

Only a signed-in person's app can run a button, through
`POST /api/notifications/{notification}/actions/{actionId}/run`. An agent
credential cannot call it (`401`), and it is not part of the agent API. The
caller must be a recipient: the Ping was delivered to them and they are still in
the room. The sender's own person is not a recipient unless the Ping was
addressed back to them (a solo room).

A tap is refused with one of these codes (shown to the person in the app):

| HTTP | `code` | When |
|---|---|---|
| 404 | `notification_not_found` | The caller cannot see the Ping |
| 404 | `ping_action_not_found` | No such button on the Ping |
| 409 | `ping_withdrawn` | The sender took the Ping back |
| 409 | `ping_action_expired` | More than 24 hours since the Ping |
| 403 | `not_a_recipient` | The Ping was not delivered to the caller, or they left the room |
| 422 | `ping_action_opens_link` | An `open_url` button runs nothing server-side |
| 403 | `ping_buttons_require_pro` | The room owner is no longer on Pro |
| 429 | `ping_action_rate_limited` | A limit in §6; honor `Retry-After` |
| 403 | `feature_disabled` | PingRoom has switched buttons off |
| 503 | `serialization_conflict` | Rare: a concurrent write kept losing after retries; the app tries again |

A delivery that was queued when buttons were switched off, or after the room
owner stopped being Pro, or after the Ping was taken back, is skipped and not
sent. A tap that was already handed to the delivery worker before the switch
went off is delivered.

---

## 5. The delivery your server receives

A `POST` to the button's URL, from PingRoom's servers:

```
POST /cleanup HTTP/1.1
Host: ops.example.com
Content-Type: application/json
User-Agent: PingRoom-Webhook/1.0
X-PingRoom-Event: notification.action
X-PingRoom-Timestamp: 1762855261
X-PingRoom-Delivery: 5b1d…-…
X-PingRoom-Signature: <hex HMAC-SHA256(secret, "{timestamp}.{raw body}")>
X-PingRoom-Signature-V2: <hex HMAC-SHA256(secret, "v2\n{timestamp}\n{delivery id}\n{raw body}")>

{"event":"notification.action","notification_id":"9b2f5c1e-…","action_id":"clean",
 "room":{"code":"ABC123","name":"Ops"},
 "actor":{"id":"3f2a…(32 hex)","display_name":"Sam"},
 "correlation_id":"disk-db-2","body":{"host":"db-2"},
 "timestamp":"2026-11-11T10:01:00+00:00"}
```

| Field | Meaning |
|---|---|
| `event` | Always `notification.action` (also in `X-PingRoom-Event`) |
| `notification_id` | The Ping's id. Use it only to look up the secret you stored (below) |
| `action_id` | The button's `id` |
| `room` | `{code, name}` of the room |
| `actor` | Who tapped: `id`, a 32-hex pseudonym, and `display_name` |
| `correlation_id` | The Ping's `correlation_id`, or `null` |
| `body` | The button's `body`, or `null` when it had none |
| `timestamp` | ISO 8601, when the tap was recorded |

**What you learn about the person.** `actor.id` is a pseudonym that is stable
for that person **in that room** and different in every other room, so servers
cannot join what they learn across rooms. It is not their PingRoom id.
`actor.display_name` is the name the room already shows for them. You never
receive their PingRoom id, email, handle, phone number or device, and nothing
else about them.

### Verify before you trust anything

`notification.action` is signed with the Ping's own `action_signing_secret`, not
with the room's outgoing-webhook secret, and the scheme is the one documented
for [outgoing webhooks](https://pingroom.io/documents/webhooks):
V2 signs `v2\n{timestamp}\n{delivery id}\n{raw body}`, V1 signs
`{timestamp}.{raw body}`, both HMAC-SHA256 as lowercase hex, and a timestamp
more than 300 seconds off is refused.

Each Ping has its own secret, so read `notification_id` from the raw body only
to look the secret up. It is a lookup key and nothing more until the signature
verifies.

```js
import express from 'express';
import {
  verifyWebhookSignature,
  WEBHOOK_SIGNATURE_HEADER,
  WEBHOOK_SIGNATURE_V2_HEADER,
  WEBHOOK_TIMESTAMP_HEADER,
  WEBHOOK_DELIVERY_HEADER,
} from '@pingroom/sdk';

const app = express();

// `secrets` is your store of action_signing_secret by Ping id, filled when you sent the Ping.
// `seen(id)` returns true when this delivery id was already processed, and records it.
app.post('/cleanup', express.raw({ type: 'application/json' }), async (req, res) => {
  let notificationId;
  try {
    notificationId = JSON.parse(req.body.toString('utf8')).notification_id;
  } catch {
    return res.status(400).end();
  }
  const secret = typeof notificationId === 'string' ? await secrets.get(notificationId) : undefined;
  if (!secret) return res.status(401).end();

  const ok = await verifyWebhookSignature({
    payload: req.body,                                   // the RAW bytes (a Buffer)
    signature: req.get(WEBHOOK_SIGNATURE_HEADER),
    signatureV2: req.get(WEBHOOK_SIGNATURE_V2_HEADER),
    timestamp: req.get(WEBHOOK_TIMESTAMP_HEADER),
    deliveryId: req.get(WEBHOOK_DELIVERY_HEADER),
    secret,
    requireV2: true,                                     // refuse the legacy v1-only form
  });
  if (!ok) return res.status(401).end();

  // Delivered at least once: run each X-PingRoom-Delivery only once.
  if (await seen(req.get(WEBHOOK_DELIVERY_HEADER))) return res.status(204).end();

  const event = JSON.parse(req.body.toString('utf8'));
  // event.action_id === 'clean', event.body.host === 'db-2', event.actor.display_name === 'Sam'
  res.status(204).end();
});
```

- Verify over the **exact raw bytes**. `express.raw()` ignores
  `application/json` unless you name the type, and an `express.json()` ahead of
  it hands you a parsed object, which makes `verifyWebhookSignature` throw a
  `TypeError`. That is a bug in the receiver, not a forged request.
- `requireV2: true` also refuses a stale timestamp (more than 5 minutes off) and
  a request with only the legacy signature. Dedupe on `X-PingRoom-Delivery` only
  in that mode.
- A delivery is at least once. It is retried a few times on a `429`, a `5xx` or
  no answer, and a `4xx` is final. Answer `2xx` once you have it, and do slow
  work after answering. A redirect is not followed, and counts as a failure.
- A delivery that still fails goes to PingRoom's outgoing-webhook dead-letter
  queue, from which an operator replay may deliver it again. Treat repeats of
  the same `X-PingRoom-Delivery` as the same delivery.
- The secret is derived from a server key, not stored. If PingRoom rotates that
  key, a tap delivered after the rotation does not verify with the secret you
  stored. Buttons expire after 24 hours, so the window is a day.

---

## 6. Limits

| Limit | Value |
|---|---|
| Buttons per Ping | 3 |
| Label | 26 characters |
| URL | 2048 characters |
| `webhook` `body` | 10 top-level keys, 1024 bytes of escaped JSON |
| Button id | `[a-z0-9_-]{1,24}` |
| Runs | 1 per person per button |
| Button lifetime | 24 hours from the Ping |
| A person's taps | 30 per minute |
| Taps on one Ping | 120 per minute |
| Deliveries per sending account and destination host | 60 per minute, across all its Pings (`429 ping_action_rate_limited`, with `Retry-After`) |
| `data` | 25 keys and 8 KiB in all, `data.actions` included |

A tap does not spend a free account's daily agent allowance; the send that
carried the buttons did.

---

## 7. Errors on a send

| HTTP | `code` | Meaning |
|---|---|---|
| 422 | `ping_buttons_agents_only` | A person's own send carried `data.actions` |
| 422 | `ping_buttons_unsupported_room` | A public room or a personal room |
| 422 | `ping_buttons_with_ack` | The Ping also asks for a confirmation |
| 403 | `ping_buttons_require_pro` | A `webhook` button and the room owner is not on Pro |
| 403 | `feature_disabled` | PingRoom has switched buttons (`sending.ping_buttons`) or links (`sending.links`, for an `open_url` button) off. Don't retry |
| 422 | (validation) | A button breaks §3.2 to §3.4. `errors` name the field, such as `data.actions.0.url` |
| 422 | (validation) | `data.actions` on a route that never carries buttons: Questions, Handoffs, agent approvals, live status, quick-action presses and replacements |
| 422 | `unsupported_actions` | `PUT /api/agent/rooms/{inviteCode}/notifications/{notification}` on a Ping that has buttons. A replacement that itself carries `data.actions` is a `422` as well. A keyed send (`replace_key`) with buttons always creates a new Ping, and never replaces one that has buttons |

---

## 8. Reading a Ping with buttons

Every Ping read (the app's Ping reads, the room list and room reads, the
agent's list, listen and single-Ping reads, and the MCP `list_notifications`,
`get_notification` and `broadcast` results) carries, **only on a Ping that has
buttons**:

```json
"actions": [
  {"id": "dash", "label": "Open dashboard", "type": "open_url", "style": "default",
   "url": "https://grafana.example.com/d/disk", "host": "grafana.example.com"},
  {"id": "clean", "label": "Clean up now", "type": "webhook", "style": "danger"}
],
"actions_expire_at": "2026-11-12T10:00:00+00:00",
"action_runs": [{"action_id": "clean", "status": "queued", "ran_at": "2026-11-11T10:01:00+00:00"}]
```

- A `webhook` button never shows its URL, `body` or host. An `open_url` button
  shows its `url` and the server-derived `host`.
- `action_runs` is the **reading person's own** taps (`[]` when none), never
  anyone else's. A run's `status` is `queued`, `delivered`, `failed` or
  `skipped`. A sender's read does not include run counts or who tapped: the
  delivery already told your server.
- The stored `data` never holds `actions`.
- A Ping without buttons gains none of these keys.

---

## 9. Security model

What protects the people who receive a Ping with buttons, and what does not.

**What protects recipients**

- **A tap never runs anything on the recipient's phone.** A `webhook` button
  calls PingRoom, and PingRoom calls the URL. An `open_url` button opens a page
  in the in-app browser, which shows the address.
- **The host is derived by PingRoom and shown beside the button.** The app
  shows the host under an `open_url` button and draws the button only when the
  link's hostname equals that host. A homoglyph domain is refused as a
  non-ASCII host, and its punycode form is shown as punycode.
- **A label cannot claim a destination or pass for PingRoom.** Web addresses,
  domains and IP addresses are refused in labels, and so are invisible
  characters and every label that copies PingRoom's own notification buttons.
- **Only developers' sends carry buttons**, and not in public or personal
  rooms, where the audience may be strangers.
- **A webhook button's URL and body are not readable by anyone** after the send:
  they are stored encrypted, left out of every read, the push, the stored
  `data`, the room owner's outgoing-webhook event and the incoming webhook's call
  log.
- **Your server learns only a per-room pseudonym and a display name** (§5).
- **PingRoom is not a request cannon.** One run per person per button, the rate
  limits in §6, and a Pro room owner for webhook buttons.
- **Deliveries are signed per Ping**, with a secret that is not the room's
  outgoing-webhook secret. A sender is often not the room's owner, and signing
  a sender-chosen `body` under the owner's key would hand that sender
  signatures to replay at the owner's own endpoint.
- **The destination is checked again at delivery**: the notifier resolves a
  `webhook` URL and refuses a private address, and the worker applies its own
  checks and never follows a redirect.
- **A stop switch exists.** PingRoom can turn buttons off without an app
  release (`403 feature_disabled`; queued taps are suppressed).

**What does not protect them**

- **A lookalike domain you own passes.** `paypal-support.com` is a real, public
  hostname and is shown as such. The host line beside the button is the
  recipient's defence, and the label rule only stops the label contradicting it.
- **A label can still lie about what your server does.** "Restart" on a button
  whose server deletes something is possible: PingRoom cannot know what a server
  does with a tap. Name what the button does.
- **A room member's own connected agent can show buttons to the other
  members.** An agent acts as its person, who may send in that room. The buttons
  say what they do and where they go, and the person can be removed from the
  room or the sender blocked.
- **Prose with a dot is refused in labels** ("Node.js docs"). That is the price
  of the rule.
- **The URL of a tap in flight is visible to PingRoom's delivery path.** It
  travels in the delivery job as every outgoing-webhook event's content does,
  and, if delivery fails, in the dead-letter queue. It is redacted from log
  previews.
- **Anyone holding PingRoom's server signing key can derive every Ping's
  secret**, as they can already issue any user's token.
- **Treat the body of a delivery as untrusted until the signature verifies**,
  and treat `notification_id` as a lookup key only.

---

## 10. Not supported yet

- Buttons on Live Activities and Android live updates.
- Buttons on the web app's Ping card (it does not show or run them).
- Buttons on the Apple Watch and Vision Pro.
- Buttons in the chat mirrors (Telegram, Slack, Discord, Teams).
- Web Push notifications never carry buttons.
- A composer for buttons in the apps: only agents and incoming webhooks attach
  them.
- Run counts or who tapped on the sender's read, and per-button analytics.
- Buttons on a Question, a confirmation request, a Handoff or a replacement.

Buttons appear only in updated iPhone and Android apps. An older app, or a
device that has not told PingRoom it supports buttons, gets the ordinary
notification, never a withheld one. If the push is too large, the buttons are
dropped before the Ping's `data`, so a Ping never loses its text to keep them.

---

See also: [Agent guide](https://pingroom.io/agent.md) ·
[Webhook documentation](https://pingroom.io/documents/webhooks) ·
[Connect over MCP](https://pingroom.io/connect-mcp.md) ·
[Question Protocol](https://pingroom.io/questionprotocol.md)
