Skip to main content
Version: 1.0

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

EndpointMethodDescription
/webhook.listGETList webhook subscriptions
/webhook.subscribePOSTCreate or update a webhook subscription
/webhook.unsubscribePOSTRemove a webhook subscription
/webhook.deliveriesGETList failed deliveries for debugging and replay

Available Events

EventDescriptionFilter
chat.messageChat message created, edited, or deletedchatType, mention
chat.reactionA reaction was added to or removed from a messagenames
chat.link.sharedA new message contains a link matching the app's registered domains
lobby.bookedA lobby meeting was booked
magicast.createdA magicast has been created
meeting.startedA meeting has started
meeting.endedA meeting has ended; transcript and summary are readyhasVideo
user.status.updateA user checked in or out
onair.event.createdAn On-Air event was createdeventId
onair.event.updatedAn On-Air event was updatedeventId
onair.event.canceledAn On-Air event was canceledeventId
onair.guest.rsvpA guest's RSVP status changedeventId, status
onair.guest.addedOne or more guests were added to an eventeventId
token.revokedAn OAuth token for your app was revoked
app.uninstalledYour 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" }
}
FieldDescription
typeThe full event name (matches the subscription's event and the Roam-Event-Type header)
eventIdUnique ID of the event occurrence — identical across retries, so de-duplicate on it
timestampWhen the event occurred, RFC3339 UTC with microsecond precision
apiVersionThe API version this body is rendered as; equals the Roam-Version header
dataThe 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 bodytype 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 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.

EventFilterExample filter value
chat.messagechatType: "dm" | "group"; mention: true{"chatType": "dm"}
chat.reactionnames: string[]{"names": ["white_check_mark"]}
meeting.endedhasVideo: true only{"hasVideo": true}
onair.event.created, updated, canceled, onair.guest.addedeventId{"eventId": "…"}
onair.guest.rsvpeventId; 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:

HeaderDescription
Content-Typeapplication/json
Roam-Event-TypeEvent name (e.g., chat.message); also in the body as type
Roam-VersionThe API version the body is rendered as; also in the body as apiVersion
webhook-idThe event's eventId. Identical on every attempt of the same event — this is your de-duplication key
webhook-timestampUnix timestamp when sent (only on signed deliveries)
webhook-signatureStandard Webhooks signature (only when a signing secret is configured)
Roam-Retry-Num1, 2, or 3 — which retry this is. Absent on the first attempt, so its presence alone means "redelivery"
Roam-Retry-ReasonWhy 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:

  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. 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. 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 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).

AfterWhat happens
24 hours of consecutive terminal failuresSubscription 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 streakDynamic 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 GoneSubscription 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):

FieldPresent when
lastSuccessAtThe destination has succeeded at least once (kept across a pause/resume)
failStreakStartedAtThe current consecutive-failure span is open
disabledAtThe 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

Pass your API Key or OAuth access token as a Bearer token. Example: Authorization: Bearer <token>

Security Scheme Type:

http

HTTP Authorization Scheme:

bearer

Contact

Team Roam Support Chat:

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

Terms of Service

https://ro.am/terms