# Webhooks

The Roam Webhooks API delivers real-time notifications to your application via webhooks. Subscribe to events and receive HTTP callbacks when things happen in your Roam workspace.

**OpenAPI Spec:** [webhooks-v1.json](https://developer.ro.am/webhooks-v1.json)

See the [Migration Guide](/docs/guides/migration-v0-to-v1) for details on upgrading from v0.

Chat actor IDs use the shared [Identity & Principals](/docs/guides/identity-and-principals)
contract. Message and reaction events carry principal types inline so receivers
can prevent bot loops without another lookup.

## Configuring Webhooks

You can configure webhooks in two ways:
- **Static:** In **Roam Administration > Developer > API Client**, add webhook URLs directly to your app configuration
- **Dynamic:** Use the subscription endpoints below to manage webhooks programmatically

## Subscription Endpoints

| Endpoint | Method | Description |
|----------|--------|-------------|
| [`/webhook.list`](/docs/webhooks/webhook-list) | GET | List webhook subscriptions |
| [`/webhook.subscribe`](/docs/webhooks/webhook-subscribe) | POST | Create or update a webhook subscription |
| [`/webhook.unsubscribe`](/docs/webhooks/webhook-unsubscribe) | POST | Remove a webhook subscription |
| [`/webhook.deliveries`](/docs/webhooks/webhook-deliveries) | GET | List failed deliveries for debugging and replay |

## Available Events

| Event | Description | Filter |
|-------|-------------|--------|
| [`chat.message`](/docs/webhooks/chat-message) | Chat message created, edited, or deleted | `chatType`, `mention` |
| [`chat.reaction`](/docs/webhooks/chat-reaction) | A reaction was added to or removed from a message | `names` |
| [`chat.link.shared`](/docs/webhooks/chat-link-shared) | A new message contains a link matching the app's registered domains | — |
| [`lobby.booked`](/docs/webhooks/lobby-booked) | A lobby meeting was booked | — |
| [`magicast.created`](/docs/webhooks/magicast-created) | A magicast has been created | — |
| [`meeting.started`](/docs/webhooks/meeting-started) | A meeting has started | — |
| [`meeting.ended`](/docs/webhooks/meeting-ended) | A meeting has ended; transcript and summary are ready | `hasVideo` |
| [`user.status.update`](/docs/webhooks/user-status-update) | A user checked in or out, or Will Return / Out of Roam changed | — |
| [`onair.event.created`](/docs/webhooks/onair-event-created) | An On-Air event was created | `eventId` |
| [`onair.event.updated`](/docs/webhooks/onair-event-updated) | An On-Air event was updated | `eventId` |
| [`onair.event.canceled`](/docs/webhooks/onair-event-canceled) | An On-Air event was canceled | `eventId` |
| [`onair.guest.rsvp`](/docs/webhooks/onair-guest-rsvp) | A guest's RSVP status changed | `eventId`, `status` |
| [`onair.guest.added`](/docs/webhooks/onair-guest-added) | One or more guests were added to an event | `eventId` |
| [`token.revoked`](/docs/webhooks/token-revoked) | An OAuth token for your app was revoked | — |
| [`app.uninstalled`](/docs/webhooks/app-uninstalled) | Your app was uninstalled from a Roam | — |

**v1 event names are dotted** (`chat.message`, `lobby.booked`). Colon names
(`chat:message:dm`, `lobby:booked`) are the v0 catalog — sending them to
`/v1/webhook.subscribe` returns `400` / `Unrecognized event`. The mapping
is in the [Migration Guide](/docs/guides/migration-v0-to-v1#event-names).

## Event Envelope

Every delivery body is a common envelope; the event-specific payload is
nested under `data`:

```json
{
  "type": "chat.message",
  "eventId": "0197f9a1-7d2e-7cc3-9f6a-8b1c2d3e4f5a",
  "timestamp": "2026-07-07T18:23:45.123456Z",
  "apiVersion": "2026-07-07",
  "data": { "...": "the event payload" }
}
```

| Field | Description |
|-------|-------------|
| `type` | The full event name (matches the subscription's `event` and the `Roam-Event-Type` header) |
| `eventId` | Unique ID of the event occurrence — identical across retries, so de-duplicate on it |
| `timestamp` | When the event occurred, RFC3339 UTC with microsecond precision |
| `apiVersion` | The [API version](/docs/guides/api-versioning) this body is rendered as; equals the `Roam-Version` header |
| `data` | The event payload — see each event's page for its shape |

One handler can therefore verify the signature, de-dupe on `eventId`,
and route on `type` before any event-specific parsing.

Pin `apiVersion` on subscribe (e.g. `"2026-08-25"`) if you want the
envelope regardless of the credential's default. Subscriptions on
`2026-06-01` still receive the **bare** payload (`type` is a short
discriminator such as `"message"`, fields at the top level).

### Dual bodies for one event

A single event can currently produce **two POSTs** to the same URL with
the same `webhook-id` and different JSON bodies:

1. **Legacy tagged-id body** — `type` is a short discriminator
   (`"message"`), IDs carry prefixes (`sender: "B-…"`, `chat: "D-…"`).
   This is the v0 shape.
2. **v1 body** — untagged UUIDs (`userId`, `chatId`, `userType`). On
   pins `2026-07-07` and later this is the envelope above. On the
   `2026-06-01` baseline it is bare (`type: "message"`, `version`,
   fields at the top level).

Treat them as one event: de-duplicate on `webhook-id`. Prefer the v1
body (`userId` / `chatId` present, or envelope `type` equal to the
subscribed event name such as `chat.message`). Do not process both.

## Event Filters

Pass an optional `filter` object on
[`webhook.subscribe`](/docs/webhooks/webhook-subscribe) to limit which
occurrences are delivered. Omit `filter` to receive every event you are
eligible for. An empty object (`{}`) is rejected. A filter that does not
apply to the event, or uses an invalid value, returns `400`. Keys combine
as AND when an event accepts more than one.

| Event | Filter | Example `filter` value |
|-------|--------|------------------------|
| [`chat.message`](/docs/webhooks/chat-message) | `chatType`: `"dm"` \| `"group"`; `mention`: `true`; `self`: `true` | `{"self": true}` |
| [`chat.reaction`](/docs/webhooks/chat-reaction) | `names`: string[] | `{"names": ["white_check_mark"]}` |
| [`meeting.ended`](/docs/webhooks/meeting-ended) | `hasVideo`: `true` only | `{"hasVideo": true}` |
| [`onair.event.created`](/docs/webhooks/onair-event-created), [updated](/docs/webhooks/onair-event-updated), [canceled](/docs/webhooks/onair-event-canceled), [`onair.guest.added`](/docs/webhooks/onair-guest-added) | `eventId` | `{"eventId": "…"}` |
| [`onair.guest.rsvp`](/docs/webhooks/onair-guest-rsvp) | `eventId`; `status`: `invited` \| `going` \| `maybe` \| `notGoing` | `{"status": "going"}` |

Other events do not accept a filter (`lobby.booked` dropped v0's
`lobbyId` filter).

**DMs only** — a complete subscribe body:

```json
{
  "url": "https://example.com/hooks/messages",
  "event": "chat.message",
  "filter": { "chatType": "dm" }
}
```

Other `chat.message` `filter` values: `{"chatType": "group"}`,
`{"mention": true}`, `{"mention": true, "chatType": "dm"}`,
`{"self": true}` (personal-token 1:1 with the bot only).
`meeting.ended`'s `hasVideo` filter keys on "was recorded", not "the
recording is ready to fetch" — `false` is rejected; omit the filter to
receive every `meeting.ended` event.

## Webhook Delivery Headers

Each webhook delivery includes these headers:

| Header | Description |
|--------|-------------|
| `Content-Type` | `application/json` |
| `Roam-Event-Type` | Event name (e.g., `chat.message`); also in the body as `type` |
| `Roam-Version` | The API version the body is rendered as; also in the body as `apiVersion` |
| `webhook-id` | The event's `eventId`. **Identical on every attempt of the same event** — this is your de-duplication key |
| `webhook-timestamp` | Unix timestamp when sent (only on signed deliveries) |
| `webhook-signature` | Standard Webhooks signature (only when a signing secret is configured) |
| `Roam-Retry-Num` | `1`, `2`, or `3` — which retry this is. Absent on the first attempt, so its presence alone means "redelivery" |
| `Roam-Retry-Reason` | Why the *previous* attempt failed: `transport` (connection error or timeout), `http_5xx`, or `http_429`. Sent alongside `Roam-Retry-Num` |

## Signature Verification

Webhooks are signed using the [Standard Webhooks](https://github.com/standard-webhooks/standard-webhooks) specification.

Your **Webhook Signing Secret** is available in **Roam Administration > Developer > API Client**.

To verify a webhook:
1. Concatenate: `{webhook-id}.{webhook-timestamp}.{payload}`
2. Compute HMAC-SHA256 using your signing secret (base64-decoded)
3. Compare with the signature in `webhook-signature` header

We recommend using the [standard-webhooks client libraries](https://github.com/standard-webhooks/standard-webhooks#libraries).
Verify over the **raw** request body — parsing and re-serializing the JSON
first will change the bytes and break the signature.

### Node.js / Express

```javascript
import express from "express";
import { Webhook } from "standardwebhooks";

const app = express();
const webhook = new Webhook(process.env.ROAM_WEBHOOK_SIGNING_SECRET);

app.post("/webhooks/roam", express.raw({ type: "application/json" }), (req, res) => {
  let payload;
  try {
    payload = webhook.verify(req.body.toString("utf8"), {
      "webhook-id": req.get("webhook-id"),
      "webhook-timestamp": req.get("webhook-timestamp"),
      "webhook-signature": req.get("webhook-signature"),
    });
  } catch {
    return res.sendStatus(401);
  }

  // Acknowledge quickly; enqueue payloads for background processing.
  res.sendStatus(200);
});
```

### Python / Flask

```python
import os
from flask import Flask, abort, request
from standardwebhooks.webhooks import Webhook

app = Flask(__name__)
webhook = Webhook(os.environ["ROAM_WEBHOOK_SIGNING_SECRET"])

@app.post("/webhooks/roam")
def receive_roam_webhook():
    try:
        payload = webhook.verify(request.get_data(), dict(request.headers))
    except Exception:
        abort(401)

    # Acknowledge quickly; enqueue payloads for background processing.
    return "", 200
```

## Delivery Behavior

- **Timeout**: Each attempt must return a 2xx within **3 seconds**. A slower
  response counts as a failed attempt even if your handler eventually
  succeeds, so acknowledge first and do the work in the background.
- **Retries**: Transient failures (connection errors, timeouts, `429`, `5xx`)
  get **three retries after the initial attempt**: near-immediate (about a
  second), then about **+1 minute**, then about **+5 minutes** — roughly a
  six-minute window end to end. This deliberately mirrors Slack's Events API
  ladder, so a handler ported from Slack needs no new retry assumptions.
  Other non-2xx responses are not retried. A `410 Gone` response deletes the
  subscription.
- **`Retry-After`**: On a `429` or `503`, a `Retry-After` header is honored in
  place of the ladder's next wait, capped at 5 minutes.
- **De-duplication**: `webhook-id` (header) is **identical on every attempt of
  the same event** and equals the body's `eventId` — it is the de-duplication
  key. Retries additionally carry `Roam-Retry-Num` and `Roam-Retry-Reason`;
  the first attempt carries neither. Treat deliveries as at-least-once and
  key your idempotency on `webhook-id`.
- **Retries are not durable**: pending retries are held in memory only. A Roam
  process restart drops them, and a bounded pending-retry set sheds ladders
  under extreme load. If your endpoint is unreachable for longer than the
  ~6-minute window, those events are lost — this is best-effort at-least-once
  delivery, not a durable outbox.
- **Order**: Webhooks are delivered asynchronously and may arrive out of order.
  Retries widen that window: a retried event can land minutes after events that
  occurred later.

For reliable processing, we recommend:
- Acknowledge webhooks immediately with a 200 response, within 3 seconds
- Process webhook data asynchronously in a background job
- Use `webhook-id` / `eventId` for idempotency
- Reconcile with [`/webhook.deliveries`](/docs/webhooks/webhook-deliveries) if
  you were down longer than the retry window

### Subscription health

Repeated **terminal** failures pause a destination rather than retrying
every later event forever. Terminal means the retry ladder finished (or
the response was not retryable — `4xx` other than `429`).

| After | What happens |
|-------|--------------|
| 24 hours of consecutive terminal failures | Subscription is **paused** (`disabledAt` is set). While paused, Roam sends **one real event per day**. A 2xx, or [`/webhook.subscribe`](/docs/webhooks/webhook-subscribe) to the same event+URL, turns it back on. |
| 14 days of the same fail streak | **Dynamic** subscriptions (`dynamic: true`, created via subscribe) are **deleted**. Static Developer Settings URLs stay paused until they succeed or you save the config. |
| Destination returns `410 Gone` | Subscription is **deleted** immediately (static or dynamic). |

A gap longer than 24 hours since the last failure starts a new streak —
a sparse destination (for example a weekly `meeting.ended`) is not paused
after two far-apart failures. There is no minimum failure count.

[`/webhook.list`](/docs/webhooks/webhook-list) and
[`/webhook.subscribe`](/docs/webhooks/webhook-subscribe) return these
fields on the webhook object (each omitted when null):

| Field | Present when |
|-------|--------------|
| `lastSuccessAt` | The destination has succeeded at least once (kept across a pause/resume) |
| `failStreakStartedAt` | The current consecutive-failure span is open |
| `disabledAt` | The subscription is paused |

### Debugging failed deliveries

Every failed delivery (timeouts included) is recorded and queryable for
~30 days via [`/webhook.deliveries`](/docs/webhooks/webhook-deliveries) —
the outcome, status code, and (for HTTP errors) a truncated copy of your
server's response body. If your endpoint was down, use it to find what you
missed and re-fetch the affected resources to replay them.

## Authentication

```
Authorization: Bearer YOUR_TOKEN
```

## Errors

The subscription endpoints return a JSON body with an `error` message.
`401` means missing or invalid credentials; `403` means the token is valid
but lacks the required scope (e.g. `webhook:write`).

## Base URL

```
https://api.ro.am/v1
```

---
Have questions? Contact us via [Team Roam Support Chat](https://ro.am/support/contact-us).


## Base URL

```
https://api.ro.am/v1
```

## Authentication

```
Authorization: Bearer YOUR_API_KEY
```

## All Endpoints

| Method | Endpoint | Description |
|--------|----------|-------------|
| GET | [`/webhook.list`](/docs/webhooks/webhook-list) | List webhook subscriptions |
| POST | [`/webhook.subscribe`](/docs/webhooks/webhook-subscribe) | Subscribe to an event webhook |
| POST | [`/webhook.unsubscribe`](/docs/webhooks/webhook-unsubscribe) | Unsubscribe from an event webhook |
| GET | [`/webhook.deliveries`](/docs/webhooks/webhook-deliveries) | List failed webhook deliveries |

## Contact

- Website: https://ro.am/support/contact-us

---

*Machine-readable API documentation.*
*Full documentation: https://developer.ro.am/docs/webhooks*
