Migration Guide: v0 to v1
This guide covers the changes between the Roam API v0 (Alpha) and v1, and how to update your integration.
Overview
v1 consolidates conventions across endpoints and simplifies several aspects in response to developer feedback:
- Simplified IDs: v0 used tagged IDs (e.g.
U-…,G-…) so the prefix encoded the resource type. v1 uses plain UUIDs and separate properties instead — e.g.userIdsvsgroupIdonchat.post, or an explicittypeon list items — so you never parse a tag off the ID. - Conversations vs meetings: v0's general meeting listing
(
meeting.list) becomesconversation.list— every conversation that occurred in the Roam. Separately, v0's transcript and recording surfaces (transcript.*,recording.list) consolidate under v1meeting.*: a v1 "meeting" is a conversation that has Magic Minutes and/or a video recording. Usemeeting.list/meeting.infofor that subset (summary, action items, chapters,hasVideo/videoStatus), andmeeting.transcriptfor transcript content. - Webhooks (see Webhook Changes below):
- Common event envelope — every delivery has top-level
type,eventId,timestamp,apiVersion, anddata, so one HTTPS URL can accept many event types and route ontype - Dot event names —
chat.messageinstead of colon forms likechat:message:dm - Message event consolidation — v0's
chat:message:dm/channel/mentionare onechat.messageevent with filters
- Common event envelope — every delivery has top-level
- Convention consistency: All endpoints follow singular
noun.verbnaming. Updates use POST with JSON body, reads use GET with query params.
v0 endpoints and webhooks will continue to be supported indefinitely, but new functionality will only be added to v1.
ID Format Changes
v0 used tagged UUIDs with prefixes like U-, G-, D-. The tag both namespaced the ID and
encoded the resource kind. v1 uses plain UUIDs and named fields (or explicit type enums) so
callers do not branch on a prefix:
v0: U-709b8a57-70bc-427a-b6f0-b16ba5297f8c
v1: 709b8a57-70bc-427a-b6f0-b16ba5297f8c
Migration: Strip the prefix and hyphen from stored IDs:
const v1Id = v0Id.replace(/^[UGDCB]-/, "");
Where v0 packed type into the ID, v1 uses separate properties, for example:
| v0 (tag or mixed field) | v1 |
|---|---|
Destination inferred from U-… vs G-… / tagged chat id | userIds or groupId or chatId on chat.post |
| Chat kind encoded in a tagged id | type: "dm" | "group" on chat.list items (same vocabulary as the chat.message chatType filter) |
Nested user: { id: "U-…" } | userId + userType (user | bot) on messages and webhooks |
Mention tokens <@U-…> / <@B-…> / <@C-…> / <@S-…> / <@all> in message text | Slack-syntax tokens: <@id> for principals (users and bots), <!subteam^id> for groups and channels, <!channel> for the broadcast (legacy <@all> accepted on write) |
Field Naming Convention
v1 follows a consistent convention:
idfor the primary identifier of the resource being returnedthingId(e.g.,userId,chatId,groupId) for references in filters, params, and nested objects
| v0 Field | v1 Field | Context |
|---|---|---|
chat | chatId | Request params and response fields |
group | id | In group responses; groupId in filters |
addressId | id | In list responses |
user (object) | userId | Webhook payloads (plain UUID instead of embedded object) |
| — | userType | Principal type beside message/webhook userId (user or bot) |
Endpoint Changes
| v0 Endpoint | v1 Endpoint | Notes |
|---|---|---|
/app.uninstall | /token.revoke | Renamed. Still only revokes the presented grant (access + refresh); does not uninstall the app. Fires token.revoked, not app.uninstalled. |
/groups.list (Legacy) | /group.list | Renamed |
/addr.info | /user.info or /group.info | Split by type: user.info for acting principals (users and bots), group.info for groups |
/user.lookup | /user.info with email param | Consolidated |
v0 /meeting.list | /conversation.list | Renamed. Lists conversations that occurred (not limited to Magic Minutes / recordings). |
/transcript.list | /meeting.list | Meetings with Magic Minutes and/or video (see also /meeting.info, /meeting.transcript). Field shape differs. |
/transcript.info | /meeting.transcript | Transcript content; summary / action items / chapters on /meeting.info |
/transcript.search | /meeting.search | Search over Magic Minutes meetings |
/transcript.prompt | /meeting.prompt | Prompt over a meeting transcript |
/recording.list | Prefer /meeting.list / /meeting.info; legacy /recording.list still registered | Use hasVideo / videoStatus + meeting.ended. Raw videoUrl remains on the legacy org-mode recording list only. |
/meetinglink.create | /meeting.link.create | Renamed |
/meetinglink.info | /meeting.link.info | Renamed |
/meetinglink.update | /meeting.link.update | Renamed |
/lobbyBooking.list | /lobby.booking.list | Renamed |
/chat.post (v0) | /chat.post | Same name; v1 uses UUID chatId / userId / groupId (no tagged prefixes), ok-envelope, and richer body (Block Kit, polls, schedule, streams) |
/chat.sendMessage (Legacy) | /chat.post | Legacy. chat.sendMessage remains registered for existing callers; new integrations must use chat.post |
| — | /reaction.remove | New |
| — | /reaction.list | New |
| — | /token.revoked / /app.uninstalled | New lifecycle webhook events (subscribe with webhook:write) |
Response Format Changes
group.members
{
"members": ["U-709b8a57-...", "U-af6663d5-..."]
}
{
"members": [
{ "userId": "709b8a57-...", "role": "member" },
{ "userId": "af6663d5-...", "role": "admin" }
]
}
token.info
{
"addr": "B-b893c426-6d54-4d9a-8e71-6bd53b26124e",
"scopes": ["chat:read", "chat:send_message"],
"roam": { ... }
}
{
"user": {
"id": "b893c426-6d54-4d9a-8e71-6bd53b26124e",
"name": "My Bot",
"imageUrl": "https://..."
},
"scopes": ["chat:read", "chat:send_message"],
"roam": { ... }
}
Webhook Changes
Payload Envelope (important)
Every v1 webhook body is a common envelope. The event-specific payload — what v0 delivered as
the whole body — is nested under data. The envelope's type is the full event name
(e.g. chat.message), so one HTTPS endpoint can accept many event types and route without guessing
from payload shape:
// One URL for chat.message, meeting.ended, lobby.booked, …
app.post("/webhooks/roam", (req, res) => {
const { type, eventId, data } = req.body;
// de-dupe on eventId, then dispatch:
switch (type) {
case "chat.message":
return handleChatMessage(data);
case "meeting.ended":
return handleMeetingEnded(data);
default:
// ignore or log unknown types — additive events should not break you
return;
}
});
// v0: the event object IS the body (no shared envelope)
{
"type": "message",
"chat": "D-3f9a1b2c-...",
"text": "Deploying v2.3.0 now"
}
// v1: self-describing envelope; business fields live under data
{
"type": "chat.message",
"eventId": "0197f9a1-7d2e-7cc3-9f6a-8b1c2d3e4f5a",
"timestamp": "2026-07-07T18:23:45.123456Z",
"apiVersion": "2026-07-07",
"data": {
"version": 1,
"contentType": "text",
"chatId": "3f9a1b2c-...",
"userId": "b7c34e90-...",
"text": "Deploying v2.3.0 now"
}
}
| Field | Role |
|---|---|
type | Full event name (chat.message), same as the subscription and the Roam-Event-Type header. v0's per-event inner discriminators ("message", "reaction") are gone. |
eventId | Stable id for this occurrence — identical across retries (and equals webhook-id). Use it to de-duplicate. |
timestamp | When the event occurred (RFC3339 UTC). |
apiVersion | Date version that shaped this body. Envelope exists from 2026-07-07 onward. |
data | Event-specific payload (what used to be the whole v0 body). |
You can also point several webhook.subscribe registrations at the same URL — one for
chat.message, one for meeting.ended, and so on — and still share a single handler that switches
on type.
See the event envelope section of the Webhooks overview for the full field reference.
A single event can currently produce two POSTs to the same URL with the same
webhook-id: one legacy tagged-id body (type: "message", sender: "B-…",
chat: "D-…") and one v1 body (untagged userId / chatId, or the envelope
above when the subscription is pinned to 2026-07-07 or later). De-duplicate on
webhook-id and prefer the v1 body. See
Dual bodies for one event.
Event Names
v0 uses colons, v1 uses dots to match API naming. /v1/webhook.subscribe
rejects colon names — event: "chat:message:dm" (or lobby:booked, etc.)
returns 400 / Unrecognized event. Use the v1 name in the table below.
| v0 Event | v1 Event |
|---|---|
chat:message:dm | chat.message |
chat:message:channel | chat.message |
chat:message:mention | chat.message |
chat:message:reaction | chat.reaction |
transcript:started | meeting.started |
transcript:saved | meeting.ended |
user:status:update | user.status.update |
lobby:booked | lobby.booked |
recording:saved | No v1 successor — keep a v0 recording:saved subscription if you still need it (v0 webhooks remain supported). For most apps, meeting.ended + the meeting APIs are enough — but note hasVideo only means the meeting was recorded, so poll /meeting.info until videoStatus is available to match recording:saved's "ready" signal. |
| — | token.revoked / app.uninstalled |
| — | chat.link.shared |
Event Consolidation
v0's three chat message events are consolidated into one
chat.message event with filter support:
| v0 Event | v1 Filter |
|---|---|
chat:message:dm | {"chatType": "dm"} |
chat:message:channel | {"chatType": "group"} |
chat:message:mention | {"mention": true} |
This consolidation allows adding new filters in the future without creating new event types.
Filter examples:
// DM messages only
{"chatType": "dm"}
// Group messages only
{"chatType": "group"}
// @mentions only
{"mention": true}
// DM mentions (combined)
{"mention": true, "chatType": "dm"}
// Group mentions (combined)
{"mention": true, "chatType": "group"}
Static Webhook Configuration
In addition to the dynamic /webhook.subscribe endpoint, you
can now configure webhooks directly in Roam Administration > Developer > API Client:
- Configure multiple webhook URLs per app (previously limited to one)
- Set up event filters without writing code
- Test webhook delivery with sample payloads
Payload Changes
All v1 payloads below live under the envelope's data key (see
Payload Envelope above).
chat.message
| v0 | v1 (inside data) |
|---|---|
"type": "message" | — (the envelope's type is chat.message) |
"chat": "D-abc123" | "chatId": "abc123" |
"user": {"id": "U-xyz", "name": "..."} | "userId": "xyz" |
| — | "userType": "user" or "bot"; equals user.info.type |
| — | "contentType": "text" (new field) |
| — | "version": 1 (new field; edits and deletes also fire — see chat.message) |
chat.reaction
v1 delivers one event per reaction change (Slack reaction_added / reaction_removed parity):
action is "added" or "removed", name is the single reaction that changed, and userId is
the user who changed it. v0 instead delivers the message's full reaction snapshot, coalesced and
debounced (~10s) per message — which cannot say who changed what, and can skip changes entirely when
an add and a remove land in the same window. See chat.reaction.
| v0 | v1 (inside data) |
|---|---|
Full "reactions": […] snapshot, debounced per message | One event per change: "action", "name", "emojiText" |
Reactors as tagged IDs in "reactions[].reactors" | "userId" = the user whose reaction changed |
| — | "userType" = actor principal type |
"messageSender" (tagged ID) | "messageAuthorId" = author of the reacted message |
| — | "messageAuthorType" = author principal type |
"chat": "D-abc123" (tagged ID) | "chatId": "abc123", plus "messageId" and "messageTimestamp" |
| — | the envelope's eventId deduplicates at-least-once deliveries; its timestamp is when the change happened |
Filter key "codes" | Filter key "names", matched against the changed reaction only |
To track a message's current reaction set, apply the deltas to state fetched from
/reaction.list — or simply re-fetch on each event.
New Headers
v1 adds delivery headers for event identification and versioning:
| Header | Description |
|---|---|
Roam-Event-Type | Event name (e.g., chat.message); same value as the body's type |
Roam-Version | The API version the body is rendered as; same value as the body's apiVersion |
webhook-id | The event's eventId; identical across retries — de-duplicate on it |
Subscribe and unsubscribe request shapes
v0 (/v0/webhook.*) | v1 (/v1/webhook.*) | |
|---|---|---|
| Event name | Colon (chat:message:dm) | Dotted (chat.message) |
| Subscribe body | JSON {url, event, filter?} | JSON {url, event, filter?, apiVersion?} |
| Unsubscribe body | application/x-www-form-urlencoded id=<uuid> | JSON {"id": "<uuid>"} |
Sending JSON {id} to /v0/webhook.unsubscribe returns id parameter required.
Sending a colon event name to /v1/webhook.subscribe returns Unrecognized event.
OAuth Scope Changes
Renamed Scopes
| v0 Scope | v1 Scope |
|---|---|
groups:read | group:read |
meeting:read | meetings:read |
Old scope names continue to work for existing integrations.
Scope re-consent for meetings
v1 meeting endpoints require meetings:read. Tokens that only hold legacy
transcript:read / recordings:read must re-authorize (or expand PAT groups)
before calling /meeting.*. See the Scopes catalog.
meetings:read is not roam-wide. Org-wide meetings, recordings, transcripts,
and those webhooks require admin:meetings:read (API keys auto-check it;
OAuth defaults it off). Personal tokens never hold it. See
Meeting width.
Timezone Handling
v1 endpoints respect the authenticated user's timezone for date-only inputs and human-readable response timestamps:
- Date-only query params (
after,before,startDate,endDate) are parsed at local midnight in the user's timezone, not UTC. For a user inAmerica/Los_Angeles,after=2026-04-15means2026-04-15T00:00:00-07:00. - Response timestamps carrying calendar/event data (e.g.
created,lastMessageTime,startTime,endTime,dateCreated) are emitted as RFC3339 with the user's offset. - Cursors and webhook payloads stay in UTC. Treat them as opaque server-to-server values.
This prevents off-by-a-day results for users west of UTC. v0 endpoints continue to use UTC.
Base URL
| Version | Base URL |
|---|---|
| v0 | https://api.ro.am/v0 |
| v1 | https://api.ro.am/v1 |
Migration Checklist
- Update base URL from
/v0to/v1 - Strip ID prefixes from all stored IDs
- Update field names in request/response handling:
chat→chatIdgroup→id(in responses)
- Update endpoint paths for renamed endpoints
- Update webhook handlers:
- Update event names (colons → dots). v1 subscribe rejects colon names.
- Parse the payload envelope: route on
type, read the event payload fromdata - De-duplicate deliveries on
eventId/webhook-id(a single event can currently arrive twice, once in each body shape) - Handle consolidated
chat.messageevent - Update field names in payloads
- Switch unsubscribe from form-urlencoded to JSON
{id}
- Update OAuth scopes if re-authorizing:
groups:read→group:read
- Update group.members handling to expect
{ userId, role }objects - Update actor handling to read
userType/messageAuthorTypeand hydrate IDs throughuser.infooruser.list?ids
Questions?
Contact us via Roam Support Chat or email developer@ro.am.