Skip to content

Ask the docs

Answers come only from these docs and are written by an AI model (Gemini), so check the linked pages. Your question is not kept. Do not include personal details or keys.

Send text, media and buttons

Message kinds · send WhatsApp buttons API · iMessage tapback API · Telegram inline keyboard AI agent · WhatsApp voice note API · send image WhatsApp API · messaging message kinds

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.

TypeScript
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
curl
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"}]}'

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

KindEndpointTelegramWhatsAppiMessageSMS (US, pilot)
basic/basicyesyesyesyes; media only from numbers with MMS, no documents
voice/voiceyesyesyesno
buttons/buttons1 to 10, reply and URL mixed1 to 3 reply buttons, or one URL buttonnono
list/listnoup to 10 rowsnono
carousel/carouselno2 to 10 cardsnono
template/templatenoyes; the only kind outside the 24-hour windownono
contact_card/contact-cardyes, with a phone numberyesnono
location/locationyes; name and address togetheryesnono
effect/effectnonoyesno
reaction/reactionTelegram’s reaction emoji onlyany one emojiany one emoji; not in groupsno
typing/typingyeswithin 24 hours of the contact’s last messagewithin 5 minutes of it; not in groupsno
read/readnoyesthe whole conversation; not in groupsno
editPATCH /v0/messages/{id}yes, no time limitnowithin 15 minutes; not the first messageno
unsendDELETE /v0/messages/{id}within 48 hoursnowithin 2 minutesno

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.

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, and nothing is sent:

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

RefusedSend instead
carousel on Telegrama basic message with the text, then one buttons message with an image header per card
carousel on iMessage or SMSa basic message, then one basic message per card with its image as media
buttons or list on iMessage or SMSa basic message whose text lists the choices; the answer comes back in words
more than 3 buttons on WhatsAppa list (up to 10 rows)
location or contact_card on iMessage or SMSa 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). 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).

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.
{ "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).

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.

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

curl
curl https://api.flow.engineer/v0/files \
-H "Authorization: Bearer $FLOW_MESSAGING_KEY" \
-F file=@receipt.png -F channel=telegram
{ "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 and is not stored, and if the scan cannot run just then the upload answers a retryable 500 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

Section titled “Typing, read receipts, edits and reactions”
ActionEndpointTypeScript
Show typingPOST /v0/conversations/{id}/typingconversation.typing()
Mark as readPOST /v0/conversations/{id}/readconversation.read()
ReactPOST /v0/conversations/{id}/reactionconversation.react(messageId, "👍")
Edit textPATCH /v0/messages/{id}flow.messages.edit(id, "New text")
UnsendDELETE /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.

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 and a channel_code:

channel_codeWhen
edit_window_closedAn iMessage edit more than 15 minutes after sending.
unsend_window_closedAn iMessage unsend more than 2 minutes after sending, or a Telegram unsend more than 48 hours after.
first_message_not_editableAn 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.