# Send a message

`POST /chat.post`

## Description

Send a message to a chat. Messages can be plain markdown text, rich [Block Kit](/docs/guides/block-kit) layouts, or polls.

**Destination (ONE of the following is required):**
- `chatId` - Post to an existing chat by its ID
- `groupId` - Post to a group chat
- `userIds` - Post to a DM or Multi-DM with the specified users

You must specify exactly one destination. Specifying multiple destinations (e.g., both `chatId` and `groupId`) will return a 400 error.

Mentions use Slack's token syntax with Slack's semantics: `<@ID>` mentions a principal (a user or bot, e.g. `<@7861a4c6-765a-495d-898d-fae3d8fbba2d>` — resolvable via [`user.info`](/docs/api/user-info)), `<!subteam^ID>` mentions a group or channel, notifying its members (resolvable via [`group.info`](/docs/api/group-info)), and `<!channel>` notifies everyone in the chat.
When rendered in the client, the tag will automatically be replaced with the human-readable display name (or "everyone" for `<!channel>`).
On write, either token form is accepted for any mentionable ID; the legacy `<@all>` broadcast alias is accepted; and a Slack-style `|label` suffix (e.g. `<@7861a4c6-…|Rob>`, `<!subteam^59c1a4d2-…|@eng>`) is accepted and ignored — the mentioned entity's live display name is always used. Write-side acceptance is identical on every [API version](/docs/guides/api-versioning). Messages read back always carry bare canonical tokens, and `<!channel>` for the broadcast — on API versions from `2026-08-07`; clients pinned to older versions read the older grammar (`<@ID>` for every mention, `<@all>`). Slack forms Roam does not implement are reserved and stay literal text: `<#ID>` channel links, `<!here>`, and `<!everyone>`.

**Custom sender (optional):** see the [Sender Profiles guide](/docs/guides/sender-profiles).
- `sender.name` / `sender.imageUrl` are per-message display overrides, stored on the message itself.
- `sender.id` authors the message as a configured bot persona (Roam Administration > Developer > edit your app > Add Bot Persona). Ids that don't match a configured persona are accepted and ignored — the message is authored by the app's root identity. Sending never creates or renames personas.
- **Personal access tokens**: Reject the `sender` field with 400. PATs always post as their personal bot.

**Access:** Organization tokens can post to chats the bot is a member of,
and to **public groups** in the workspace without joining. Personal tokens
can post only where the owner is a member (`403` `not_in_chat` for an
unjoined public group).

**Required scope:** `chat:send_message` or `chat:write`

---

**OpenAPI Spec:** [chat-v1.json](https://developer.ro.am/chat-v1.json)

## Authentication

```
Authorization: Bearer YOUR_API_KEY
```

## Request Body

| Property | Type | Required | Description |
|----------|------|----------|-------------|
| `chatId` | string | No | Post to an existing chat by ID (mutually exclusive with groupId/userIds) |
| `groupId` | string | No | Post to a group channel (mutually exclusive with chatId/userIds) |
| `userIds` | string[] | No | Post to a DM or Multi-DM with these users (mutually exclusive with chatId/groupId) |
| `threadTimestamp` | integer | No | Reply to a specific thread by providing the thread's timestamp. If the timestamp doesn't correspond to an existing message, a 400 error is returned. Mutually exclusive with `threadKey`.  |
| `threadKey` | string | No | A stable external identifier used to group related messages into a thread. On the first use of a given `threadKey`, a new message is posted and the resulting thread timestamp is stored. Subsequent mes |
| `replyTimestamp` | integer | No | Reply directly to a specific message by its timestamp. Unlike `threadTimestamp` (which threads a reply under a parent message in a group), `replyTimestamp` is a direct reply used in DMs — which have n |
| `text` | string | No | Message text in GitHub-flavored markdown |
| `markdown` | boolean | No | Text is markdown by default. If set to false, markdown interpretation will be disabled. |
| `items` | string[] | No | Array of Item IDs to attach to this message. |
| `assetIds` | string[] | No | Array of asset IDs from [`/asset.create`](/docs/api/asset-create) to attach to this message. Each asset must be owned by your app and fully uploaded (processed and ready). Combines with `text`/`items` |
| `blocks` | object[] | No | Array of [Block Kit](/docs/guides/block-kit) block objects for rich message formatting. Cannot be combined with `text` or `items`. Maximum 10 blocks, 8,000 bytes total payload.  |
| `color` | string | No | Colored vertical strip on the side of the message. Only used with `blocks`. Named values: `good` (green), `warning` (yellow), `danger` (red), or a hex color like `#5B3FD9`.  |
| `poll` | object | No | Create a poll message. Mutually exclusive with `text`, `items`, and `blocks`.  |
| `sender` | Sender | No |  |
| `sync` | boolean | No | If set, the post will be performed synchronously and its timestamp returned. Incompatible with `sendAt`. |
| `sendAt` | string | No | Schedule the message for later delivery (RFC 3339). Requirements: - Must be in the **future** and within **30 days** - Must fall on a **15-minute UTC boundary** (`:00`, `:15`, `:30`, or `:45`; seconds |

**blocks** (array item):

| Property | Type | Required | Description |
|----------|------|----------|-------------|
| `type` | "header" | "section" | "context" | "divider" | "actions" | No | The block type. |

**poll**:

| Property | Type | Required | Description |
|----------|------|----------|-------------|
| `question` | string | Yes | The poll question (1–256 characters). |
| `options` | string[] | Yes | Poll answer options (at least 2, each 1–128 characters). |
| `allowMultipleAnswers` | boolean | No | Whether voters can select multiple options. Defaults to false. |
| `closesAt` | string | No | Optional RFC-3339 datetime when the poll automatically closes. |

**Sender**:

| Property | Type | Required | Description |
|----------|------|----------|-------------|
| `id` | string | No | Code of a configured bot persona to author the message as (trimmed, case-insensitive). Omitted, empty, or `_` posts as the app's root identity. Unconfigured ids are accepted and ignored — supplying an |
| `name` | string | No | Display name override for this message only (max 128 characters). Does not rename the app or persona.  |
| `imageUrl` | string | No | Avatar URL override for this message only. Must be an absolute HTTP(S) URL.  |

## Responses

### 200 - Message posted or scheduled successfully. Immediate posts return
`chatId` (and `timestamp` when `sync` is set). Scheduled posts
(`sendAt`) return `chatId`, `scheduledMessageId`, and `sendAt`.
All success bodies include `"ok": true` — see
[Responses and Errors](/docs/guides/responses-and-errors).


| Property | Type | Required | Description |
|----------|------|----------|-------------|
| `ok` | "true" | No |  |
| `chatId` | string | No | ID of the chat where the message was (or will be) posted |
| `threadTimestamp` | integer | No | Thread timestamp if replying to a thread |
| `timestamp` | integer | No | Message timestamp (present if sync is set; omitted for scheduled posts) |
| `scheduledMessageId` | string | No | ID of the scheduled message (only when `sendAt` was provided). Pass to `/chat.scheduled.cancel` to cancel, or find it later via `/chat.scheduled.list`. |
| `sendAt` | string | No | Scheduled send time echoed from the request (only when `sendAt` was provided) |

### 400 - Bad request. Common causes:
- No destination specified (chatId, groupId, or userIds required)
- Multiple destinations specified (only one allowed)
- `threadTimestamp` does not correspond to an existing message
- Both `threadKey` and `threadTimestamp` were provided (they are mutually exclusive)
- `threadKey` exceeds 64 characters
- `replyTimestamp` provided on a non-text message (`blocks` or `poll`)
- `replyTimestamp` used outside a DM or a channel thread
- `sender.name` exceeds 128 characters
- `sender.imageUrl` is not an absolute HTTP(S) URL
- Personal access token provided a `sender` field (`access_mode_not_supported`)
- Multiple content types provided (only one of `text`/`items`, `blocks`, or `poll` allowed)
- An `assetId` was not found, not owned by your app, or not a file asset
- An `assetId` is still processing — retry once its upload completes
- `blocks` array exceeds 10 blocks or 8,000 bytes
- Invalid block structure (see [Block Kit guide](/docs/guides/block-kit))
- Interactive buttons sent without an Interactivity URL configured
- Invalid `color` value
- Poll question empty or exceeds 256 characters
- Poll has fewer than 2 options, or an option exceeds 128 characters
- `sendAt` not on a 15-minute UTC boundary, not in the future, more than 30 days ahead, or combined with `sync` / `poll` / `threadKey` / `replyTimestamp`


### 401 - Presented invalid authentication credentials.

### 403 - Forbidden (`not_in_chat` / `not_in_group`). Organization tokens may
still post to a public group in the workspace without joining. Personal
tokens require the owner to be a member.


### 405 - An unsupported method was requested.

### 413 - Message content exceeds the maximum allowed size.

### 500 - An internal error occurred.

---

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