# 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.
- **`ok` envelope**: Successful JSON is `{ "ok": true, … }`; errors are
  `{ "ok": false, "error": "<code>" }`.
- **Opaque cursors**: `cursor` / `nextCursor` are server-issued tokens. Do not parse or
  construct them (v0 often used timestamps).
- **API versioning**: v1 uses dated [`Roam-Version`](/docs/guides/api-versioning) pins.
- **Chat membership**: Organization apps hear chats they are members of. See
  [Chat](/docs/guides/chat).

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`. JSON cues use `speakerId` (not display `speaker`); `meetingLinkId` / `invitees` move off this resource — see [Transcript cues](#transcript-cues) and [Meeting-link and invitee fields](#meeting-link-and-invitee-fields) |
| `/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

### `ok` envelope

v0 mixed success and error shapes: some endpoints returned `{ "status": "ok" }`,
[`groups.list`](/docs/api/groups-list) is a raw array, and errors put a
human-readable sentence in `error` with the catalog code in `code` (see
[Error Codes](/docs/guides/error-codes#response-shape-v0--alpha-api)).

v1 JSON is uniform. Branch on `ok`, then on the catalog `error` string:

```json
{ "ok": true, "chatId": "757dfe66-37b4-4772-baa5-8c86ec68c176" }
{ "ok": false, "error": "invalid_token" }
```

`204` responses and non-JSON bodies (for example
[`meeting.transcript`](/docs/api/meeting-transcript) as WebVTT) are not wrapped.
Full rules, `missing_scope` `needed` / `provided`, and auth codes:
[Responses and Errors](/docs/guides/responses-and-errors).

### Pagination cursors

v0 list endpoints often used timestamps (or other guessable values) as
`cursor`. v1 `cursor` / `nextCursor` values are **opaque** (they currently look
like `YzE6…` — do not depend on that encoding). Echo `nextCursor` back as
`cursor` unchanged. Do not construct cursors, parse them, or reuse them across
endpoints or API versions.

An invalid or expired cursor returns `400` with `error: "invalid_cursor"` —
restart pagination with no cursor. Details:
[Responses and Errors](/docs/guides/responses-and-errors#pagination-cursors).

### 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": { ... }
}
```

### Transcript cues

v0 [`transcript.info`](/docs/chat-api/get-transcript) JSON cues included a
display name:

```json
{ "speaker": "Alex Chen", "text": "Let's start with the sprint review.", "startOffset": 0, "endOffset": 3200 }
```

v1 [`meeting.transcript`](/docs/api/meeting-transcript) JSON cues identify the
speaker by ID only (`speakerId`). Hydrate names through
[`user.info`](/docs/api/user-info) / [`user.list?ids=`](/docs/api/user-list).
Timing fields are `start` / `end` in milliseconds (not `startOffset` /
`endOffset`).

Display names in the transcript itself appear in **WebVTT** voice tags when you
request `Accept: text/vtt`:

```
00:00:00.000 --> 00:00:03.200
<v Alex Chen>Let's start with the sprint review.
```

### Meeting-link and invitee fields

v0 `transcript.info` also published `meetingLinkId` and `invitees` (calendar
invitee emails) on the transcript object. v1
[`meeting.info`](/docs/api/meeting-info) / [`meeting.list`](/docs/api/meeting-list) /
[`meeting.transcript`](/docs/api/meeting-transcript) omit those fields.

| You need… | Use (v1) |
|-----------|----------|
| Meeting-link IDs for a conversation | [`conversation.list`](/docs/api/conversation-list) → `meetingLinkIds` |
| Meeting-link ID on a calendar event | [`calendar.list`](/docs/api/calendar-list) → `meetingLinkId` (Personal access) |
| Invitee name / email / RSVP | [`calendar.list`](/docs/api/calendar-list) → `invites` |

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

Pass the v1 filter as the `filter` field of
[`webhook.subscribe`](/docs/webhooks/webhook-subscribe):

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

Other `filter` values: `{"chatType": "group"}`, `{"mention": true}`,
`{"mention": true, "chatType": "dm"}`, `{"mention": true, "chatType": "group"}`.

### Static Webhook Configuration

In addition to the dynamic [`/webhook.subscribe`](/docs/webhooks/webhook-subscribe) endpoint, you
can now configure webhooks on your app's **Webhooks** tab in **Roam Administration > Developer**
(click **Edit** next to the app — see [Static configuration](/docs/webhooks/webhooks#static-configuration)):

- 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` |

## API versioning

v1 uses **dated version pins** (`Roam-Version`) so response and webhook shapes
can evolve without breaking existing clients. How pins work, the current
default, and the version history:
[API Versioning](/docs/guides/api-versioning).

## Chat membership

Organization apps receive [`chat.message`](/docs/webhooks/chat-message) and most
chat REST only for chats they are **members** of. @-mention does not join a
group — call [`group.join`](/docs/api/group-join) (or
[`group.add`](/docs/api/group-add)). Organization
[`chat.post`](/docs/api/chat-post) and
[`chat.startStream`](/docs/api/chat-start-stream) to a public group without
joining remain allowed.

Personal tokens hear chats the **owner** is in. `"filter": { "mention": true }`
on `chat.message` is @-only.

Full org vs personal rules (REST, webhooks, DMs, reactions, Slack bot-token vs
user-token mapping): [Chat](/docs/guides/chat).

## 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. **Branch on the [`ok` envelope](#ok-envelope)** — `ok: true` / `{ok:false, error:"<code>"}`.
   Do not read v0's prose `error` + `code` on `/v1`.
6. **Echo opaque `nextCursor` values** as `cursor`. Do not invent timestamps or
   other cursor strings ([pagination cursors](#pagination-cursors)).
7. **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}`
8. **Update OAuth scopes** if re-authorizing:
   - `groups:read` → `group:read`
   - v1 `meeting.*` needs `meetings:read` (and `admin:meetings:read` for roam-wide org access)
9. **Update group.members handling** to expect `{ userId, role }` objects
10. **Update actor handling** to read `userType` / `messageAuthorType` and hydrate IDs through
    [`user.info`](/docs/api/user-info) or [`user.list`](/docs/api/user-list)
11. **Org bots** must be **members** of groups they need to hear — [`group.join`](/docs/api/group-join)
    ([chat membership](#chat-membership), [Chat](/docs/guides/chat)).
12. **Pin shapes** with [`Roam-Version`](/docs/guides/api-versioning).
13. **Transcript JSON**: read `speakerId` (not `speaker`); names live in WebVTT `<v>` tags
    ([transcript cues](#transcript-cues)).
14. **`meetingLinkId` / `invitees`**: use [`conversation.list`](/docs/api/conversation-list) /
    [`calendar.list`](/docs/api/calendar-list), not `meeting.info`
    ([meeting-link and invitee fields](#meeting-link-and-invitee-fields)).

## Questions?

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