# 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. `userIds` vs `groupId` on
  [`chat.post`](/docs/api/chat-post), or an explicit `type` on list items — so you never parse a
  tag off the ID.
- **Conversations vs meetings**: v0's general meeting listing
  ([`meeting.list`](/docs/chat-api/meeting-list)) becomes
  [`conversation.list`](/docs/api/conversation-list) — every conversation that occurred in the
  Roam. Separately, v0's **transcript** and **recording** surfaces (`transcript.*`,
  `recording.list`) consolidate under v1 **`meeting.*`**: a v1 "meeting" is a conversation that
  has **Magic Minutes and/or a video recording**. Use
  [`meeting.list`](/docs/api/meeting-list) / [`meeting.info`](/docs/api/meeting-info) for that
  subset (summary, action items, chapters, `hasVideo` / `videoStatus`), and
  [`meeting.transcript`](/docs/api/meeting-transcript) for transcript content.
- **Webhooks** (see [Webhook Changes](#webhook-changes) below):
  - **Common event envelope** — every delivery has top-level `type`, `eventId`, `timestamp`,
    `apiVersion`, and `data`, so one HTTPS URL can accept many event types and route on `type`
  - **Dot event names** — `chat.message` instead of colon forms like `chat:message:dm`
  - **Message event consolidation** — v0's `chat:message:dm` / `channel` / `mention` are one
    `chat.message` event with filters
- **Convention consistency**: All endpoints follow singular `noun.verb` naming. 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:

```javascript
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`](/docs/api/chat-post) |
| Chat kind encoded in a tagged id | `type: "dm" \| "group"` on [`chat.list`](/docs/api/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:

- `id` for the primary identifier of the resource being returned
- `thingId` (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`](/docs/chat-api/uninstall-app) | [`/token.revoke`](/docs/api/token-revoke) | Renamed. Still only revokes the presented grant (access + refresh); does not uninstall the app. Fires `token.revoked`, not `app.uninstalled`. |
| [`/groups.list`](/docs/api/groups-list) (Legacy) | [`/group.list`](/docs/api/group-list) | Renamed |
| [`/addr.info`](/docs/chat-api/addr-info) | [`/user.info`](/docs/api/user-info) or [`/group.info`](/docs/api/group-info) | Split by type: `user.info` for acting principals (users and bots), `group.info` for groups |
| [`/user.lookup`](/docs/chat-api/user-lookup) | [`/user.info`](/docs/api/user-info) with `email` param | Consolidated |
| v0 [`/meeting.list`](/docs/chat-api/meeting-list) | [`/conversation.list`](/docs/api/conversation-list) | Renamed. Lists conversations that occurred (not limited to Magic Minutes / recordings). |
| `/transcript.list` | [`/meeting.list`](/docs/api/meeting-list) | Meetings with Magic Minutes and/or video (see also `/meeting.info`, `/meeting.transcript`). Field shape differs. |
| `/transcript.info` | [`/meeting.transcript`](/docs/api/meeting-transcript) | Transcript content; summary / action items / chapters on `/meeting.info` |
| `/transcript.search` | [`/meeting.search`](/docs/api/meeting-search) | Search over Magic Minutes meetings |
| `/transcript.prompt` | [`/meeting.prompt`](/docs/api/meeting-prompt) | Prompt over a meeting transcript |
| `/recording.list` | Prefer [`/meeting.list`](/docs/api/meeting-list) / [`/meeting.info`](/docs/api/meeting-info); legacy [`/recording.list`](/docs/api/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`](/docs/api/meeting-link-create) | Renamed |
| `/meetinglink.info` | [`/meeting.link.info`](/docs/api/meeting-link-info) | Renamed |
| `/meetinglink.update` | [`/meeting.link.update`](/docs/api/meeting-link-update) | Renamed |
| `/lobbyBooking.list` | [`/lobby.booking.list`](/docs/api/lobby-booking-list) | Renamed |
| [`/chat.post`](/docs/chat-api/chat-post) (v0) | [`/chat.post`](/docs/api/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`](/docs/api/chat-send-message) (Legacy) | [`/chat.post`](/docs/api/chat-post) | **Legacy.** `chat.sendMessage` remains registered for existing callers; new integrations must use `chat.post` |
| — | [`/reaction.remove`](/docs/api/reaction-remove) | New |
| — | [`/reaction.list`](/docs/api/reaction-list) | New |
| — | [`/token.revoked`](/docs/webhooks/token-revoked) / [`/app.uninstalled`](/docs/webhooks/app-uninstalled) | New lifecycle webhook events (subscribe with `webhook:write`) |

## Response Format Changes

### group.members

[v0 response](/docs/chat-api/group-members):

```json
{
  "members": ["U-709b8a57-...", "U-af6663d5-..."]
}
```

[v1 response](/docs/api/group-members):

```json
{
  "members": [
    { "userId": "709b8a57-...", "role": "member" },
    { "userId": "af6663d5-...", "role": "admin" }
  ]
}
```

### token.info

[v0 response](/docs/chat-api/token-info):

```json
{
  "addr": "B-b893c426-6d54-4d9a-8e71-6bd53b26124e",
  "scopes": ["chat:read", "chat:send_message"],
  "roam": { ... }
}
```

[v1 response](/docs/api/token-info):

```json
{
  "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:

```js
// 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;
  }
});
```

```json
// 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](/docs/guides/api-versioning) 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](/docs/webhooks/webhooks#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](/docs/webhooks/webhooks#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`](/docs/webhooks/chat-message)       |
| `chat:message:channel`  | [`chat.message`](/docs/webhooks/chat-message)       |
| `chat:message:mention`  | [`chat.message`](/docs/webhooks/chat-message)       |
| `chat:message:reaction` | [`chat.reaction`](/docs/webhooks/chat-reaction)     |
| `transcript:started`    | [`meeting.started`](/docs/webhooks/meeting-started) |
| `transcript:saved`      | [`meeting.ended`](/docs/webhooks/meeting-ended)     |
| `user:status:update`    | [`user.status.update`](/docs/webhooks/user-status-update) |
| `lobby:booked`          | [`lobby.booked`](/docs/webhooks/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`](/docs/api/meeting-info) until `videoStatus` is `available` to match `recording:saved`'s "ready" signal. |
| —                       | [`token.revoked`](/docs/webhooks/token-revoked) / [`app.uninstalled`](/docs/webhooks/app-uninstalled) | New lifecycle events |
| —                       | [`chat.link.shared`](/docs/webhooks/chat-link-shared) | New (link unfurling) |

### Event Consolidation

v0's three chat message events are consolidated into one
[`chat.message`](/docs/webhooks/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:**

```json
// 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`](/docs/webhooks/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](#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](/docs/webhooks/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](/docs/webhooks/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`](/docs/api/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](/docs/guides/api-versioning) 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](/docs/guides/scopes).

`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](/docs/guides/scopes#meeting-width-adminmeetingsread).


## 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 in `America/Los_Angeles`, `after=2026-04-15`
  means `2026-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

1. **Update base URL** from `/v0` to `/v1`
2. **Strip ID prefixes** from all stored IDs
3. **Update field names** in request/response handling:
   - `chat` → `chatId`
   - `group` → `id` (in responses)
4. **Update endpoint paths** for renamed endpoints
5. **Update webhook handlers**:
   - Update event names (colons → dots). v1 subscribe rejects colon names.
   - Parse the [payload envelope](#payload-envelope): route on `type`, read the event payload from
     `data`
   - De-duplicate deliveries on `eventId` / `webhook-id` (a single event can currently
     arrive twice, once in each body shape)
   - Handle consolidated `chat.message` event
   - Update field names in payloads
   - Switch unsubscribe from form-urlencoded to JSON `{id}`
6. **Update OAuth scopes** if re-authorizing:
   - `groups:read` → `group:read`
7. **Update group.members handling** to expect `{ userId, role }` objects
8. **Update actor handling** to read `userType` / `messageAuthorType` and hydrate IDs through
   [`user.info`](/docs/api/user-info) or `user.list?ids`

## Questions?

Contact us via [Roam Support Chat](https://ro.am/support/contact-us) or email
[developer@ro.am](mailto:developer@ro.am).