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
See the Migration Guide for details on upgrading from v0.
Chat actor IDs use the shared Identity & 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 | GET | List webhook subscriptions |
/webhook.subscribe | POST | Create or update a webhook subscription |
/webhook.unsubscribe | POST | Remove a webhook subscription |
/webhook.deliveries | GET | List failed deliveries for debugging and replay |
Available Events
| Event | Description | Filter |
|---|---|---|
chat.message | Chat message created, edited, or deleted | chatType, mention |
chat.reaction | A reaction was added to or removed from a message | names |
chat.link.shared | A new message contains a link matching the app's registered domains | — |
lobby.booked | A lobby meeting was booked | — |
magicast.created | A magicast has been created | — |
meeting.started | A meeting has started | — |
meeting.ended | A meeting has ended; transcript and summary are ready | hasVideo |
user.status.update | A user checked in or out | — |
onair.event.created | An On-Air event was created | eventId |
onair.event.updated | An On-Air event was updated | eventId |
onair.event.canceled | An On-Air event was canceled | eventId |
onair.guest.rsvp | A guest's RSVP status changed | eventId, status |
onair.guest.added | One or more guests were added to an event | eventId |
token.revoked | An OAuth token for your app was revoked | — |
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.
Event Envelope
Every delivery body is a common envelope; the event-specific payload is
nested under data:
{
"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 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:
- Legacy tagged-id body —
typeis a short discriminator ("message"), IDs carry prefixes (sender: "B-…",chat: "D-…"). This is the v0 shape. - v1 body — untagged UUIDs (
userId,chatId,userType). On pins2026-07-07and later this is the envelope above. On the2026-06-01baseline 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 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 | chatType: "dm" | "group"; mention: true | {"chatType": "dm"} |
chat.reaction | names: string[] | {"names": ["white_check_mark"]} |
meeting.ended | hasVideo: true only | {"hasVideo": true} |
onair.event.created, updated, canceled, onair.guest.added | eventId | {"eventId": "…"} |
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:
{
"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"}.
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 specification.
Your Webhook Signing Secret is available in Roam Administration > Developer > API Client.
To verify a webhook:
- Concatenate:
{webhook-id}.{webhook-timestamp}.{payload} - Compute HMAC-SHA256 using your signing secret (base64-decoded)
- Compare with the signature in
webhook-signatureheader
We recommend using the standard-webhooks client 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
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
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. A410 Goneresponse deletes the subscription. Retry-After: On a429or503, aRetry-Afterheader 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'seventId— it is the de-duplication key. Retries additionally carryRoam-Retry-NumandRoam-Retry-Reason; the first attempt carries neither. Treat deliveries as at-least-once and key your idempotency onwebhook-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/eventIdfor idempotency - Reconcile with
/webhook.deliveriesif 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 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 and
/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 —
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.
Authentication
- HTTP: Bearer Auth
Pass your API Key or OAuth access token as a Bearer token.
Example: Authorization: Bearer <token>
Security Scheme Type: | http |
|---|---|
HTTP Authorization Scheme: | bearer |
Terms of Service
https://ro.am/terms🗃️ Subscription
4 items
🗃️ Events
15 items