# Chat message received

**Event:** `chat.message`

## Description

A chat message addressed to the app — fired when a message is **created,
edited, or deleted**.

**Event name:** `chat.message`

**Required scope:** `chat:history`

Delivered inside the standard [event envelope](/docs/webhooks/webhooks#event-envelope);
the schema below shows the full delivery body with this event's payload
under `data`.

Delivery follows **membership**. Full matrix (REST and webhooks, org vs
personal, Slack comparison): [Chat](/docs/guides/chat).

- **Organization:** every message in chats the app is a member of (groups
  and DMs). @-mentioning the app does not add it to a group.
- **Personal:** every message in chats the **owner** is a member of
  (groups and DMs with other people), plus the `{bot, owner}` self-DM.
  Personal bots cannot be added as group members.

## Filters

Pass `filter` on [`webhook.subscribe`](/docs/webhooks/webhook-subscribe)
to limit which messages are delivered. Filters only restrict the
membership-gated stream above — they do not grant extra chats. Omit
`filter` to receive every message you are eligible for. An empty object
(`{}`) is rejected.

| Filter | Values | Delivers |
|--------|--------|----------|
| `chatType` | `"dm"` | Direct messages (1:1 and multi-person DMs) |
| `chatType` | `"group"` | Group messages (including meeting channels) |
| `mention` | `true` | Messages that @-mention your app |

Combine keys with AND. `chatType` uses the same vocabulary as
`data.chatType` on the delivered payload.

**DMs only** (v0's `chat:message:dm`):

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

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

## Message lifecycle: create, edit, delete

The same `chat.message` event fires for all three operations — the
`Roam-Event-Type` header is always `chat.message`. Dispatch on the event,
then read the `data.version` field (the message's edit revision) to tell
them apart:

| Operation | How to recognize it | Payload |
|-----------|---------------------|---------|
| New message | `data.version` is `1` | Full content |
| Edit | `data.version` > `1`, content present | The **new** content |
| Delete | `data.contentType` is `"deleted"` (version bumped again) | Identity only, no content |

A message keeps a stable identity of **`(chatId, timestamp)`** across its
lifecycle, and `version` increases on every change (so it also orders
events). To mirror chat, upsert/remove by that key: a later event for the
same key with a **higher `version`** supersedes your local copy, and a
`"deleted"` event removes it. The prior content of an edited or deleted
message is not re-sent — you already received it on the original event.

Edits and deletes are **v1-only**: the legacy v0 `chat:message:*` events
remain create-only. A delete carries no mentions, so a `{ "mention": true }`
filter never matches a delete (key on the original message you received).

**Adding an organization app to a group:**
1. Open the group in Roam
2. Click **Add Members**
3. Search for your app by name
4. Add the app to the group

Or call [`group.join`](/docs/api/group-join) (self-join a public group) or
[`group.add`](/docs/api/group-add). @-mention does not join. In a public
group the Roam client may prompt **Invite {app} to this group?** when
someone @-mentions a non-member org app; dismissing still sends the
message and the app is not added.

Once added, the app receives every message in that group (unless you
filter). Thread replies use the same membership rule as top-level
messages.

`data.userType` is the principal type of `data.userId` (`user` or `bot`).
It always matches [`user.info`](/docs/api/user-info) under the same app
credentials, so check it to prevent bot-to-bot loops.

Messages posted with a per-message sender display override carry it in the
optional `data.sender` object (`name` / `imageUrl`); `data.userId` remains
the authoring identity. See the
[Sender Profiles guide](/docs/guides/sender-profiles).

Poll messages arrive with `contentType: "poll"` and a `poll` object
(`question`, `options`, `allowMultipleAnswers`, `closesAt`) — the same
shape [`/chat.history`](/docs/api/chat-history) returns. Vote tallies are
not on this event; call [`/reaction.list`](/docs/api/reaction-list)
(`pollVotes`).

The field is additive on both supported webhook versions. Baseline
(`2026-06-01`) delivers it in the bare payload; Latest nests the same fields
under `data`:

```json
{ "userId": "b7c34e90-...", "userType": "bot", "contentType": "text" }
{ "type": "chat.message", "data": { "userId": "b7c34e90-...", "userType": "bot", "contentType": "text" } }
```

A malformed event whose author cannot be resolved as a visible principal is
not delivered with a missing type field.

## Webhook Payload


---

*Machine-readable API documentation.*
*Full documentation: https://developer.ro.am/docs/webhooks/chat-message*
