> ## Documentation Index
> Fetch the complete documentation index at: https://docs.flow.engineer/llms.txt
> Use this file to discover all available pages before exploring further.

# Send text, media and buttons

> One endpoint per message kind: what each channel takes, and what to send where it does not.

Every message is one **kind**: `basic` (text, or media with a caption), `voice`, `buttons`, `list`, `carousel`, `template`, `contact_card`, `location`, `effect` or `reaction`. Each kind you send has its own endpoint, named after it, whose body is the kind object itself. The same kind objects, with `kind`, describe what you receive (`Message.content`), webhook replies and stream frames.

```ts TypeScript theme={null}
import { buttons, image } from "@flow-engineer/messaging";

await flow.messages.send(conv, "Order 1042 is ready for pickup.");                      // POST .../basic
await flow.messages.send(conv, image("https://example.com/receipt.png", "Your receipt")); // POST .../basic with media
await flow.messages.send(conv, buttons("When should we deliver?", [
  { id: "today", label: "Today" },
  { id: "tomorrow", label: "Tomorrow" },
]));                                                                                       // POST .../buttons
```

```bash curl theme={null}
curl https://api.flow.engineer/v0/conversations/$CONV/buttons \
  -H "Authorization: Bearer $FLOW_MESSAGING_KEY" -H "Content-Type: application/json" \
  -H "Idempotency-Key: evt_123:when" \
  -d '{"text": "When should we deliver?", "buttons": [{"id": "today", "label": "Today"}, {"id": "tomorrow", "label": "Tomorrow"}]}'
```

## What each channel takes

Each endpoint's page opens with its channels and limits; this is the summary. The WhatsApp column applies on WhatsApp numbers connected to your app (your own, or one the Flow team connects).

| Kind | Endpoint | Telegram | WhatsApp | iMessage | SMS (US, pilot) |
| - | - | - | - | - | - |
| `basic` | [`/basic`](https://docs.flow.engineer/api-reference/messages/send-a-basic-message-text-or-media-with-a-caption) | yes | yes | yes | yes; `media` only from numbers with MMS, no documents |
| `voice` | [`/voice`](https://docs.flow.engineer/api-reference/messages/send-a-voice-note) | yes | yes | yes | no |
| `buttons` | [`/buttons`](https://docs.flow.engineer/api-reference/messages/send-text-with-buttons) | 1 to 10, reply and URL mixed | 1 to 3 reply buttons, or one URL button | no | no |
| `list` | [`/list`](https://docs.flow.engineer/api-reference/messages/send-a-list-to-pick-one-option-from) | no | up to 10 rows | no | no |
| `carousel` | [`/carousel`](https://docs.flow.engineer/api-reference/messages/send-a-carousel-of-cards) | no | 2 to 10 cards | no | no |
| `template` | [`/template`](https://docs.flow.engineer/api-reference/messages/send-a-whatsapp-template) | no | yes; the only kind outside the 24-hour window | no | no |
| `contact_card` | [`/contact-card`](https://docs.flow.engineer/api-reference/messages/send-a-contact-card) | yes, with a phone number | yes | no | no |
| `location` | [`/location`](https://docs.flow.engineer/api-reference/messages/send-a-place) | yes; `name` and `address` together | yes | no | no |
| `effect` | [`/effect`](https://docs.flow.engineer/api-reference/messages/send-text-with-an-imessage-effect) | no | no | yes | no |
| `reaction` | [`/reaction`](https://docs.flow.engineer/api-reference/messages/react-to-a-message) | Telegram's reaction emoji only | any one emoji | any one emoji; not in groups | no |
| typing | [`/typing`](https://docs.flow.engineer/api-reference/conversations/show-the-typing-indicator) | yes | within 24 hours of the contact's last message | within 5 minutes of it; not in groups | no |
| read | [`/read`](https://docs.flow.engineer/api-reference/conversations/mark-messages-as-read) | no | yes | the whole conversation; not in groups | no |
| edit | [`PATCH /v0/messages/{id}`](https://docs.flow.engineer/api-reference/messages/edit-a-sent-message) | yes, no time limit | no | within 15 minutes; not the first message | no |
| unsend | [`DELETE /v0/messages/{id}`](https://docs.flow.engineer/api-reference/messages/unsend-a-message) | within 48 hours | no | within 2 minutes | no |

`text` is at most 4096 UTF-16 code units on Telegram (an emoji such as 😀 counts as 2), 4096 characters on WhatsApp, 9999 on iMessage and 10 segments on SMS; a caption at most 1024. Longer text is refused with `400 invalid_request`. Files: Telegram 50 MB, WhatsApp and iMessage 100 MB, MMS 5 MB.

## Exact or 422

What you send is what the contact gets. Flow never converts a message into another kind, never drops a field and never answers `2xx` for something it did not send. A kind, field, channel block or value the conversation's channel does not take fails with `422` [`unsupported_content`](https://docs.flow.engineer/unified-messaging-api/errors/unsupported_content), and nothing is sent:

```json theme={null}
{"error": {"type": "unsupported_content", "channel": "telegram", "param": "kind",
  "message": "Carousels work on WhatsApp only.",
  "hint": "On Telegram, send a basic message, then one buttons message with an image header per card."}}
```

`param` names what was refused (`kind`, a field such as `reply_to` or `telegram.is_big`, or a value's field such as `emoji` or `buttons`), `channel` the conversation's channel, and `channel_code` a few cases that depend on the conversation (`group_unsupported`, `not_on_sms`, `no_mms`, the edit and unsend windows). `hint` says what to send instead. Choosing the substitute is your code's job, per channel:

| Refused | Send instead |
| - | - |
| `carousel` on Telegram | a basic message with the text, then one `buttons` message with an image `header` per card |
| `carousel` on iMessage or SMS | a basic message, then one basic message per card with its image as `media` |
| `buttons` or `list` on iMessage or SMS | a basic message whose text lists the choices; the answer comes back in words |
| more than 3 `buttons` on WhatsApp | a `list` (up to 10 rows) |
| `location` or `contact_card` on iMessage or SMS | a basic message with a maps link, or the name and numbers |

A 422 is kept under its `Idempotency-Key`, so send the substitute with a new key: the refused one plus what it is, such as `evt_123:card:2:basic` ([Idempotency](https://docs.flow.engineer/unified-messaging-api/concepts/idempotency.md)). A field this endpoint does not know at all is `400 invalid_request` naming it.

Moving code from `/v1` (`content`, `fallback`, `delivered_as`, `GET /v1/capabilities`)? The `flow-messaging-migrate` skill does it with your coding agent: `claude plugin marketplace add flow-engineer/sdk`, then `claude plugin install flow-messaging@flow-engineer` ([source](https://github.com/flow-engineer/sdk/tree/main/plugin/skills/flow-messaging-migrate)).

## Styles, subjects and link previews

`text` is sent exactly as written on every channel: Flow reads no markdown, strips no marks and gives no warning. Style it in the channel's own terms, in a block named after the channel:

* **Telegram:** `telegram.parse_mode` `HTML` or `MarkdownV2`, Telegram's own formatting. Its length limits count the text after parsing. The block also takes `disable_notification`, `protect_content`, and on media `show_caption_above_media` and `has_spoiler`.
* **iMessage:** `imessage.styles`, ranges of the text (`start` and `length` in UTF-16 code units) with `bold`, `italic`, `underline`, `strikethrough`, `big`, `small` or the animations `shake`, `nod`, `explode`, `ripple`, `bloom` and `jitter`. `imessage.subject` (up to 200 characters) is the bold subject line. Text that looks like markup shows as written.
* **WhatsApp:** the app reads its own `*bold*` and `_italic_` by itself. No block.
* **SMS:** plain text. No block.

```json theme={null}
{ "text": "Order 1042 is on its way.", "imessage": { "styles": [{ "start": 0, "length": 10, "style": "bold" }], "subject": "Order 1042" } }
```

A block for another channel answers `422` with `param` the block's name (`telegram`); an unknown key in a block, `400 invalid_request`.

**Link previews.** `link_preview` on a basic message: left out, the channel's default (Telegram, WhatsApp and iMessage show previews); `false` sends links as plain text. `true` on SMS, or with `media`, answers `422` with `param` `link_preview`.

**iMessage lines with the SMS/RCS add-on.** A message to a contact who cannot get iMessage goes by SMS or RCS; the message's `network` says which. It shows no styles, subject or effect, and cannot be edited or unsent (`422`, `channel_code` `not_on_sms`).

## What you receive

Inbound messages are `basic` (text and/or one file), `voice`, `button_reply`, `reaction`, `location`, `contact_card` and `file_blocked`, read by `content.kind`. Each carries `from`, the contact who wrote it (in an iMessage group chat, which participant), and on iMessage `network` (`imessage`, `sms` or `rcs`).

* **Media and voice notes** always come with the file: `media.url` (a voice note's `url`; a Flow file URL that redirects to short-lived signed bytes) and `file_id`, plus `mime_type` and `size_bytes`. Fetch it with your API key, or with `flow.files.download(url)`; send it again by its `file_id`.
* **Voice note transcripts**: Flow makes no transcripts of its own on any channel, and the app setting `transcription` is not used yet. On iMessage a received voice note carries a `transcript` when iMessage made one; no other channel has one. The audio is always included.
* **Taps** on a reply button, or on a list row, arrive as `button_reply` with `button_id` (the button's or row's `id`), `label` (its label or the row's title) and `message_id`.
* **`file_blocked`** means the person sent a file Flow did not keep: its type is not passed on (programs, web pages), it was too large, it failed the malware scan, or Flow could not scan it (`scan_failed`: ask them to send it again). `reason` says which, `media_type` what the channel said it was, and there is no `url`.
* **Documents are malware-scanned.** Documents and archives people send you (PDF, Office files, zip and the like) are scanned before Flow keeps them; images, audio and video are not.

In TypeScript, `contentText(content)` returns the readable text of any message: the text, a caption, a transcript (when there is one) or a tapped button's label.

## Sending files

Give `media` an HTTPS `url` Flow can fetch, or upload the file first and send its `file_id`:

```bash curl theme={null}
curl https://api.flow.engineer/v0/files \
  -H "Authorization: Bearer $FLOW_MESSAGING_KEY" \
  -F file=@receipt.png -F channel=telegram
```

```json theme={null}
{ "text": "Your receipt", "media": { "type": "image", "file_id": "file_..." } }
```

Files up to 100 MB are taken; with `channel` set, the file is checked against that channel's limit at once. Documents and archives are malware-scanned before Flow stores them: one that fails answers `422` [`file_blocked`](https://docs.flow.engineer/unified-messaging-api/errors/file_blocked) and is not stored, and if the scan cannot run just then the upload answers a retryable `500` [`api_error`](https://docs.flow.engineer/unified-messaging-api/errors/api_error) with `Retry-After` (retry with the same `Idempotency-Key`; nothing was stored). `media.type` is `image`, `video`, `document` or `audio`; `media.filename` names a document (on Telegram only with `file_id`). Files are kept for your plan's retention period (30 days by default).

## Typing, read receipts, edits and reactions

| Action | Endpoint | TypeScript |
| - | - | - |
| Show typing | `POST /v0/conversations/{id}/typing` | `conversation.typing()` |
| Mark as read | `POST /v0/conversations/{id}/read` | `conversation.read()` |
| React | `POST /v0/conversations/{id}/reaction` | `conversation.react(messageId, "👍")` |
| Edit text | `PATCH /v0/messages/{id}` | `flow.messages.edit(id, "New text")` |
| Unsend | `DELETE /v0/messages/{id}` | `flow.messages.unsend(id)` |

In a webhook reply or a stream frame, these are kinds too: `typing`, `read`, `reaction`, `edit` and `unsend` (the last two with the target in `message_id`).

**Typing** has no "off": the body is empty (`{}`; on iMessage `imessage.duration_seconds`, 1 to 60), and no channel clears the indicator on request. It clears itself when your next message goes or after a few seconds, so call again while your agent works, or use `conversation.responding(fn)`. Typing and read receipts go to the channel at once, not behind queued messages, so they can fail with `409 outside_window` (for example iMessage typing more than 5 minutes after the contact's last message) or `502 channel_error`. Both are safe to ignore: never hold back a reply because of them. On iMessage, marking read marks the whole conversation, so `up_to` there answers `422` with `param` `up_to`. In an iMessage group, reactions, typing and read receipts answer `422` with `channel_code` `group_unsupported`.

### Edit and unsend limits

Flow checks each edit and unsend when you call it, against its own record of when the message was sent (with a few seconds' allowance at the limit). Past the channel's limit the call is refused with `422` [`unsupported_content`](https://docs.flow.engineer/unified-messaging-api/errors/unsupported_content) and a `channel_code`:

| `channel_code` | When |
| - | - |
| `edit_window_closed` | An iMessage edit more than 15 minutes after sending. |
| `unsend_window_closed` | An iMessage unsend more than 2 minutes after sending, or a Telegram unsend more than 48 hours after. |
| `first_message_not_editable` | An iMessage edit of the message that started the conversation. |

The error's `message` states the limit and the message's age, for example "iMessage allows unsends within 2 minutes of sending; this message was sent 6 minutes ago." WhatsApp and SMS refuse every edit and unsend (`param` `kind`). Flow never sends a substitute: to correct a message you can no longer change, send a new one. An edit's text is sent as written, styled with `telegram.parse_mode` or `imessage.styles`.

A `200` means Flow accepted the change, not that it is done. The change goes out in order, behind the conversation's earlier messages:

* **Done:** `message.sent` arrives for the `edit` or `unsend` message; its `content.message_id` names the original. After an unsend, the original's `status` is `unsent`.
* **Refused by the channel:** if the channel still refuses after Flow accepted the change (for example it waited in the queue until the limit passed), `message.failed` arrives for the `edit` or `unsend` message with the channel's reason in `error`. The original is unchanged.

## Related

* [Streaming replies](https://docs.flow.engineer/unified-messaging-api/concepts/streaming-replies.md): turning model output into chat bubbles.
* API: [Send a basic message](https://docs.flow.engineer/api-reference/messages/send-a-basic-message-text-or-media-with-a-caption.md), [Send text with buttons](https://docs.flow.engineer/api-reference/messages/send-text-with-buttons.md), [Upload a file](https://docs.flow.engineer/api-reference/files/upload-a-file.md).


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.