# Room Metrics

**API status: available · phone widget: not released yet**

A **metric** is a named value that lives in a PingRoom room: a script sets it,
and a widget on a phone will show it later. Key `mrr`, label "MRR", format
`currency`, unit `USD`, value `12400`.

> **In one sentence.** Set a number (or a short text) in a room from a script,
> an agent, or a room's incoming webhook; **setting it sends no Ping and no
> push**, and a repeated value changes nothing.

The widget that displays metrics on a phone is **not released yet**. This
document covers the API that stores them, which is available now. It does not
describe how any widget behaves.

A metric is not a Ping and not a trigger: nothing is queued for delivery,
no trigger log is written, and the room's outgoing webhook is not called. For
a changing state that should reach a person's Lock Screen, use
[Live Activities](https://pingroom.io/liveactivities.md); for a message, send a
Ping.

---

## 1. Fields

| Field | Rule |
|---|---|
| `key` | 1 to 40 characters: letters, digits, `-` and `_`, starting with a letter or digit. Trimmed and lowercased (`MRR` is `mrr`). It is the metric's address in a URL path |
| `label` | 1 to 24 characters, no control characters. On a new metric it defaults to the key |
| `format` | `number`, `currency`, `percent`, `unit` or `text`. On a new metric it defaults to `number` for a number and `text` for a string |
| `unit` | `currency`: an ISO 4217 code such as `USD` (uppercased for you), required. `unit`: free text of 1 to 8 characters such as `ms`, required. Any other format: leave it out |
| `value` | Send it **raw**, never `"$12,400"`: clients format it for the reader. A finite number with an absolute value of at most 10^15 (a JSON number, or a plain decimal string such as `"12.5"`; no `NaN`, `Infinity`, exponent or hex), rounded to four decimal places. `text` takes a string of at most 24 characters with no control characters. `percent` takes the percentage itself (`12.5` for 12.5%) |

A room holds at most **8** metrics. A ninth is `422 metrics_limit_reached`;
updating an existing key is never refused for it.

Every route that returns a metric returns this object:

```json
{
  "key": "mrr",
  "label": "MRR",
  "format": "currency",
  "unit": "USD",
  "value": 12400,
  "value_updated_at": "2026-11-11T08:12:30.000000Z"
}
```

`value` is a number for the numeric formats (`12400`, not `12400.0`), a string
for `text`, and `null` until one is set. `value_updated_at` is `null` until a
value is set.

### An unchanged value changes nothing

A write that equals the stored metric (the same value, and the same label,
format and unit when they are sent) stores nothing: `value_updated_at` stays
at the last time the number actually changed, and the answer is the stored
metric with `200`. A script that repeats itself every minute therefore leaves
a truthful "updated" time. Numbers are compared after rounding to four places.
Changing only the label, format or unit leaves `value_updated_at` alone.

### Changing the format

A write with a new `format` and no `value` keeps a value that still fits
(`number` to `percent`) and clears one that does not (a number cannot be text,
and the reverse): `value` and `value_updated_at` become `null`. A format that
needs a unit (`currency`, `unit`) needs one in the same request unless the
stored unit already fits it.

---

## 2. Routes

Three ways in: an agent credential (this section), a room's incoming webhook
(§3), and the signed-in app. Every one is behind PingRoom's operator switch for
room metrics: when it is off the call answers `403 feature_disabled` and a
stored metric is kept.

### 2.1 An agent credential

| Action | Endpoint | Scope |
|---|---|---|
| List | `GET /api/agent/rooms/{inviteCode}/metrics` | `pingroom:metrics:read` |
| Set (create or update) | `PUT /api/agent/rooms/{inviteCode}/metrics/{key}` | `pingroom:metrics:write` |
| Delete | `DELETE /api/agent/rooms/{inviteCode}/metrics/{key}` | `pingroom:metrics:write` |

Base URL `https://api.pingroom.io`, header `Authorization: Bearer <agent-token>`.
Reads are limited to 60 per minute, writes and deletes to 120. Metric routes
spend none of the free daily Ping allowance.

An agent acts as the human it is connected to. **Reading** needs the scope, the
room grant and that the human is a member of the room. **Writing** also needs
that human to be the room's owner or an admin (admins are not honoured in
personal rooms); a member's agent can read but not write.

`PUT` is an upsert and `value` is required. It answers `201` when it created
the metric and `200` otherwise.

```bash
curl -X PUT https://api.pingroom.io/api/agent/rooms/ab12cd/metrics/mrr \
  -H "Authorization: Bearer $PINGROOM_TOKEN" -H "Content-Type: application/json" \
  -d '{"value": 12400, "label": "MRR", "format": "currency", "unit": "USD"}'

# later: only the value; label, format and unit are kept
curl -X PUT https://api.pingroom.io/api/agent/rooms/ab12cd/metrics/mrr \
  -H "Authorization: Bearer $PINGROOM_TOKEN" -H "Content-Type: application/json" \
  -d '{"value": 12650}'

curl https://api.pingroom.io/api/agent/rooms/ab12cd/metrics \
  -H "Authorization: Bearer $PINGROOM_TOKEN"
# → {"metrics":[{"key":"mrr","label":"MRR","format":"currency","unit":"USD","value":12650,"value_updated_at":"…"}],"limit":8}

curl -X DELETE https://api.pingroom.io/api/agent/rooms/ab12cd/metrics/mrr \
  -H "Authorization: Bearer $PINGROOM_TOKEN"
# → {"message":"Metric deleted successfully"}
```

The list is oldest first. Over MCP the routes are the tools `list_metrics`,
`set_metric` and `delete_metric` with the same scopes. `pingroom:full` covers
both scopes.

### 2.2 The app

The PingRoom app and web app use the signed-in person's own routes,
`GET` and `POST /api/rooms/{inviteCode}/metrics` and `PUT` and `DELETE
/api/rooms/{inviteCode}/metrics/{key}`. Any member who can view the room may
list; the owner or an admin may write. `POST` creates (`409 metric_exists` for
a key already there) and `PUT` changes any of `label`, `format`, `unit` and
`value`. They are for the app; automation should use §2.1 or §3.

---

## 3. A room's incoming webhook

Put a top-level `metric` object in the body of a room's incoming webhook. No
token and no room argument: the URL names the room and carries the secret.

```bash
curl -sX POST "$PINGROOM_WEBHOOK_URL" -H "Content-Type: application/json" \
  -d '{"metric": {"key": "mrr", "value": 12400, "label": "MRR", "format": "currency", "unit": "USD"}}'
# → 201 {"success":true,"metric":{"key":"mrr","label":"MRR","format":"currency","unit":"USD","value":12400,"value_updated_at":"…"}}
```

- `key` and `value` are required; `label`, `format` and `unit` are optional and
  follow §1.
- It sends no Ping, and the other Ping fields in the body are ignored.
- **It is not subject to the webhook's cooldown** and does not start one. The
  route's `60` calls per minute limit still applies.
- **It cannot be combined with `live_status`**: a body with both is a `422`
  naming `metric`.
- The answer is `201` when the call created the metric and `200` otherwise. A
  call that repeats the stored value answers `200` with `"unchanged": true`.
- The room's webhook rules still apply first: unknown room, wrong secret, a
  disabled webhook, a deleted owner and the owner's plan answer as they do for
  an ordinary fire (see https://pingroom.io/documents/webhooks).

---

## 4. CLI and SDK

The CLI and the SDK have metric support in their source. **It is not in the npm
releases published when this was written (CLI 0.16.0, SDK 0.9.0)**; until the
next releases, use the REST routes above. Once released:

```bash
# CLI: needs a token that owns or administers the room
pingroom metric set ab12cd mrr 12400 --label MRR --format currency --unit USD
pingroom metric set ab12cd mrr 12650        # keeps the label, format and unit
pingroom metric set ab12cd latency 240 --format unit --unit ms
pingroom metric list ab12cd
pingroom metric delete ab12cd mrr

# through a room's incoming webhook
pingroom metric set --webhook-url "$PINGROOM_WEBHOOK_URL" mrr 12400 --label MRR --format currency --unit USD
```

```ts
// SDK
await pr.metrics.set('ab12cd', 'mrr', { value: 12400, label: 'MRR', format: 'currency', unit: 'USD' });
const { metrics, limit } = await pr.metrics.list('ab12cd');
await pr.metrics.delete('ab12cd', 'mrr');

await sendIncomingWebhook(process.env.PINGROOM_WEBHOOK_URL!, {
  metric: { key: 'mrr', value: 12400 },
});
```

---

## 5. Errors

| HTTP | `code` / `error` | Meaning |
|---|---|---|
| 403 | `feature_disabled` | PingRoom has switched room metrics off (`feature: "rooms.metrics"`). Don't retry; stored metrics are kept |
| 403 | `insufficient_scope` | Agent route: the credential lacks `pingroom:metrics:read` or `pingroom:metrics:write` |
| 403 | `room_not_granted` | Agent route: the room is outside the credential's room grant |
| 403 | (message) | A write by someone who is not the room's owner or an admin, or a read by a non-member |
| 403 | `invalid_secret` / `webhook_disabled` / `owner_not_pro` | Webhook route: the usual webhook refusals |
| 404 | `metric_not_found` | No metric with that key in the room (`PUT` of the app route, `DELETE`). Also returned for an unknown room |
| 409 | `metric_exists` | The app's `POST` for a key already in the room. The agent `PUT` and the webhook upsert instead |
| 422 | `metrics_limit_reached` | The room already holds 8 metrics and the key is new |
| 422 | (validation) | `{message, errors}` naming `key`, `label`, `format`, `unit` or `value`; on a webhook body, `metric.*` |
| 429 | `rate_limited` | A route limit (§2.1, §3); honor `Retry-After` |
| 503 | `serialization_conflict` | Rare: a concurrent write kept losing after retries. Send the call again |

On the webhook route the machine-readable value is `error`, on the agent and
app routes `code`; the strings are the same.

---

See also: [Agent guide](https://pingroom.io/agent.md) ·
[Webhook documentation](https://pingroom.io/documents/webhooks) ·
[Live Activities](https://pingroom.io/liveactivities.md)
