> ## Agent Instructions > Flow Messaging is one HTTP API (https://api.flow.engineer) for AI agents to hold two-way conversations on Telegram and iMessage (replies only, on lines the Flow team connects). WhatsApp is coming and not available yet. > Build the app on the REST API. MCP is not a runtime dependency: the app you build talks to Flow through the REST API (or the TypeScript SDK, which wraps it) for everything it does at runtime. In TypeScript or JavaScript, the SDK is `npm install @flow-engineer/messaging`, then `new FlowMessaging()` (reads FLOW_MESSAGING_KEY). Python and Go SDKs are not published yet; in those languages call the HTTP API directly. > No key yet? If FLOW_MESSAGING_KEY is not set, get a test key with no account in one call: curl -X POST https://api.flow.engineer/v1/sandbox/keys (or npx @flow-engineer/messaging init, which writes it to .env). Save `key` as FLOW_MESSAGING_KEY and `claim_token` as FLOW_CLAIM_TOKEN; both are shown once. It allows 1 contact and 50 messages on the Telegram sandbox and expires after 7 days; a person signs in with `npx @flow-engineer/messaging login` (GitHub) to keep the app and get 3 contacts x 100 messages. Signed in, live keys (fk_live_...) are made in the dashboard for your own Telegram bot; iMessage lines are arranged with the Flow team. > The hosted MCP server (https://api.flow.engineer/mcp) is optional, for testing during development. Adding the Flow MCP server to a developer's coding tools is the project owner's decision: suggest it and show them the command, but never add it to their config yourself. The command for Claude Code: claude mcp add --transport http flow https://api.flow.engineer/mcp --header "Authorization: Bearer $FLOW_MESSAGING_KEY". If its tools are already available to you, use them for sandbox testing. > Authenticate with `Authorization: Bearer fk_test_...` (sandbox) or `fk_live_...` (real contacts). Never put a live key in client-side code. > Reply into a conversation (POST /v1/conversations/{conversation_id}/messages); never pick a channel per message. Switch on `error.type`, not the message text. > Full documentation for agents: https://docs.flow.engineer/llms-full.txt. OpenAPI spec: https://raw.githubusercontent.com/flow-engineer/sdk/main/openapi/openapi.yaml. # Get the calling app Source: https://docs.flow.engineer/api-reference/app/get-the-calling-app /openapi.yaml get /v1/app Returns the app, account and API key that made the request, and the mode it runs in. Useful to check a key and to read the app's pinned API version. # Get a conversation's capabilities Source: https://docs.flow.engineer/api-reference/capabilities/get-a-conversations-capabilities /openapi.yaml get /v1/capabilities Says what the conversation's channel can show right now, content type by content type (natively, through a fallback, or not at all), and whether the conversation's window is open. Use it to choose content before sending, instead of handling `unsupported_content` and `outside_window` afterwards. # Get a contact Source: https://docs.flow.engineer/api-reference/contacts/get-a-contact /openapi.yaml get /v1/contacts/{contact_id} Returns one contact and the address they are reached at on their channel. # List contacts Source: https://docs.flow.engineer/api-reference/contacts/list-contacts /openapi.yaml get /v1/contacts Lists the contacts your app has talked with in this mode, newest first. # Get a conversation Source: https://docs.flow.engineer/api-reference/conversations/get-a-conversation /openapi.yaml get /v1/conversations/{conversation_id} Returns one conversation with its window state. # List a conversation's messages Source: https://docs.flow.engineer/api-reference/conversations/list-a-conversations-messages /openapi.yaml get /v1/conversations/{conversation_id}/messages Lists the messages in a conversation, inbound and outbound, newest first. Message content is kept for your plan's retention period (30 days by default); older messages are not returned. # List conversations Source: https://docs.flow.engineer/api-reference/conversations/list-conversations /openapi.yaml get /v1/conversations Lists your app's conversations in this mode, newest first. # Mark messages as read Source: https://docs.flow.engineer/api-reference/conversations/mark-messages-as-read /openapi.yaml post /v1/conversations/{conversation_id}/read Shows the contact that their messages were read, up to and including `up_to` (default: the latest inbound message). Telegram bots cannot send read receipts, so there `delivered_as` says it was skipped. iMessage has no per-message read receipt: the whole conversation is marked read, and `up_to` is accepted but has no effect there. The call goes to the channel at once, so the channel's answer is the call's answer. It fails with `409 outside_window` when the channel's window for the conversation is closed, and with `502 channel_error` when the channel failed or timed out. Both are safe to ignore; never hold back a reply because of them. # Show or stop the typing indicator Source: https://docs.flow.engineer/api-reference/conversations/show-or-stop-the-typing-indicator /openapi.yaml post /v1/conversations/{conversation_id}/typing Turns the typing indicator on or off in a conversation. Channels clear it by themselves after a few seconds, so keep turning it on while your agent works. Where a channel has no typing indicator the call succeeds and `delivered_as` says it was skipped. The call goes to the channel at once, not behind the conversation's queued messages, so the channel's answer is the call's answer. It fails with `409 outside_window` when the channel allows typing only inside a window (iMessage: within 5 minutes of the contact's last message), and with `502 channel_error` when the channel failed or timed out. Both are safe to ignore: typing is a courtesy, so never hold back a reply because of it. # Get an event Source: https://docs.flow.engineer/api-reference/events/get-an-event /openapi.yaml get /v1/events/{event_id} Returns one event from the log. # List events Source: https://docs.flow.engineer/api-reference/events/list-events /openapi.yaml get /v1/events Reads your app's event log in this mode, **oldest first**. Pass the ID of the last event you processed as `after` to catch up after downtime, or to replay from any point. The log is the same one webhooks and the stream deliver from, so it is also how you recover events whose webhook deliveries failed. The log only returns events once every event before them is committed, so reading forward with `after` never skips an event. # Download a file Source: https://docs.flow.engineer/api-reference/files/download-a-file /openapi.yaml get /v1/files/{file_id} Redirects to a short-lived signed URL for the file's bytes, on Flow's separate file domain. Inbound media (photos, documents, voice notes) is fetched from the channel once, before its event is delivered, and served this way; event payloads link to this endpoint. # Upload a file Source: https://docs.flow.engineer/api-reference/files/upload-a-file /openapi.yaml post /v1/files Uploads media to send later as `media` or `voice` content (by `file_id`). The real type is checked from the file's first bytes: programs, web pages and unknown types are refused with `422 unsupported_content`. Documents (PDF, Office, archives) are scanned for malware, and a file that fails the scan is refused with `422 file_blocked` and not stored; images, audio and video are not scanned. Files up to 100 MB are taken; give `channel` to check the file against that channel's own size limit now (otherwise it is checked when you send it, with `422 unsupported_content`). Files are kept for your plan's retention period (30 days by default). # API reference: Flow Messaging HTTP API Source: https://docs.flow.engineer/api-reference/introduction Reference for the Flow Messaging HTTP API at https://api.flow.engineer: authentication with bearer API keys, request and error shapes, pagination, idempotency and versioning, generated from the OpenAPI 3.1 spec. This reference documents every endpoint of the Flow Messaging HTTP API, generated from its [OpenAPI 3.1 spec](https://github.com/flow-engineer/sdk/blob/main/openapi/openapi.yaml). ```bash theme={null} curl https://api.flow.engineer/v1/app \ -H "Authorization: Bearer fk_test_..." \ -H "Flow-Version: 2026-11-01" ``` ## Basics | | | | - | - | | Base URL | `https://api.flow.engineer` | | Authentication | `Authorization: Bearer fk_test_...` or `fk_live_...` ([test and live mode](/concepts/test-and-live-mode)); never in the query string. No key yet: `POST /v1/sandbox/keys` needs none and returns a test key ([keys and sign-in](/get-a-key)). In a browser, the live stream takes the key as a WebSocket subprotocol ([Events and webhooks](/concepts/events-and-webhooks#the-live-stream)) | | Version | `Flow-Version: 2026-11-01` ([versioning](/concepts/versioning)) | | Bodies | JSON (`Content-Type: application/json`), except `POST /v1/files` (multipart) | | Retries | `Idempotency-Key` on every `POST` ([idempotency](/concepts/idempotency)) | | Spec | [openapi.yaml](https://github.com/flow-engineer/sdk/blob/main/openapi/openapi.yaml) | ## Errors Errors share one shape. Switch on `error.type`, a closed list; never parse `message`. ```json theme={null} { "error": { "type": "outside_window", "message": "Last message from the contact was 31h ago; WhatsApp allows only templates now.", "hint": "Send a template instead: POST /v1/messages with content.type=template.", "doc_url": "https://api.flow.engineer/docs/errors/outside_window", "conversation": "conv_01JB8ZC3K5M7P9R1T3V5X7Z9B1" } } ``` | Status | `type` | | - | - | | 400 | [`invalid_request`](/errors/invalid_request) | | 401 | [`authentication`](/errors/authentication) | | 403 | [`permission`](/errors/permission) | | 404 | [`not_found`](/errors/not_found) | | 409 | [`idempotency_conflict`](/errors/idempotency_conflict), [`outside_window`](/errors/outside_window) | | 422 | [`unsupported_content`](/errors/unsupported_content), [`file_blocked`](/errors/file_blocked) | | 429 | [`new_contact_limit`](/errors/new_contact_limit), [`sender_throttled`](/errors/sender_throttled), [`rate_limited`](/errors/rate_limited) | | 501 | [`not_implemented`](/errors/not_implemented) | | 502 | [`channel_error`](/errors/channel_error) | | 500, 503 | [`api_error`](/errors/api_error) | Errors that clear by themselves carry `retry_after` in seconds; every error carries `request_id`; quote it when asking for help. ## Pagination Lists take `limit` (1 to 100, default 20) and `after` or `before`, set to the ID of an item you already have, and answer `{ "data": [...], "has_more": true }`. The event log (`GET /v1/events`) is oldest first, so `after` moves forward in time; every other list is newest first. ## Events The reference lists `GET /v1/events`, the live stream (`GET /v1/stream`, a WebSocket) and the [webhook delivery](/api-reference/webhook-endpoints/an-event-delivered-to-your-endpoint) Flow sends to your endpoint. See [Events and webhooks](/concepts/events-and-webhooks). # Edit a sent message Source: https://docs.flow.engineer/api-reference/messages/edit-a-sent-message /openapi.yaml patch /v1/messages/{message_id} Replaces the text of one of your outbound messages (on Telegram, also the caption of a media message, which takes at most 1024 characters where a text message takes 4096). Telegram and iMessage support edits (iMessage: within 15 minutes of sending, and not the message that started the conversation); WhatsApp does not (`422 unsupported_content`). The edit passes the send gate and goes out in order with the conversation's other messages. The answer shows the message with its new content; the stored message takes it once the channel accepted the edit. If the channel refuses it, you receive `message.failed` for an `edit` message naming this one. # Get a message Source: https://docs.flow.engineer/api-reference/messages/get-a-message /openapi.yaml get /v1/messages/{message_id} Returns one message, inbound or outbound, with its current status. # Send a message into a conversation Source: https://docs.flow.engineer/api-reference/messages/send-a-message-into-a-conversation /openapi.yaml post /v1/conversations/{conversation_id}/messages Sends one piece of content into an existing conversation. This is the normal way to reply to a contact. The send passes the send gate (window rules, pacing) and is queued; the answer is the queued message. Its progress arrives as `message.sent`, `message.delivered`, `message.read` or `message.failed` events. Messages in one conversation go out in the order they were accepted, one at a time. # Start a conversation Source: https://docs.flow.engineer/api-reference/messages/start-a-conversation /openapi.yaml post /v1/messages Sends the first message from one of your senders to a contact, creating the conversation if it does not exist. Starting a conversation spends the sender's new-contact budget, and on WhatsApp outside the 24-hour window only a `template` may be sent. If the contact already has an open conversation with this sender, the message goes into it and spends no budget. In test mode, `to` must be a contact who joined the sandbox through your app. # Unsend a message Source: https://docs.flow.engineer/api-reference/messages/unsend-a-message /openapi.yaml delete /v1/messages/{message_id} Removes one of your outbound messages from the contact's chat, where the channel allows it (Telegram: yes, within 48 hours; iMessage: yes, within 2 minutes of sending; WhatsApp: no, `422 unsupported_content`). The unsend passes the send gate and goes out in order with the conversation's other messages; the message stays in your history and takes status `unsent` once the channel removed it. Repeating the call is safe. # Get a test key without an account Source: https://docs.flow.engineer/api-reference/onboarding/get-a-test-key-without-an-account /openapi.yaml post /v1/sandbox/keys Creates a new app with a `fk_test_` key, without an account or sign-in. This is the first call for an AI coding agent that has no key: no API key is sent, and the answer holds everything needed to start (the key, the sandbox senders with their links and the app's join code). The key and the `claim_token` are shown **once**: save both. The app has a sandbox allowance of 1 contact and 50 messages sent in total on the Telegram sandbox, and WhatsApp's when it opens (inbound messages are free; iMessage is not included), and its keys **expire after 7 days**. After expiry the keys stop working and the contact is removed from the sandbox; a person can still claim the app. To keep the app, a person signs in through the device flow with the `claim_token` (`POST /v1/device/authorizations`), or opens `claim_url` in a browser. The app then shares the person's signed-in allowance (3 contacts and 100 messages each, one allowance per person over all their apps). A claim through `claim_url` revokes this key unless the person chooses to keep their agent's key working; a device sign-in replaces it with a new key. Calls are limited per client address, per network (/24, /64) and wider network (/16, /48), and service-wide per day; going over answers `429 rate_limited` with `retry_after`. Do not call this when you already have a key: check `FLOW_MESSAGING_KEY` first, and reuse the key you saved. # Poll a device sign-in for its key Source: https://docs.flow.engineer/api-reference/onboarding/poll-a-device-sign-in-for-its-key /openapi.yaml post /v1/device/token Answers the state of a sign-in started with `POST /v1/device/authorizations`. While the person has not finished, `status` is `pending`: wait `interval` seconds and poll again. Polling faster answers `429 rate_limited` with `retry_after`. Once they approve, `status` is `approved` and the answer holds a new `fk_test_` key, shown once; the device code is then used up, and later polls answer `expired`. If the sign-in claimed a sandbox app, that app's sandbox keys stop working when this key is handed out: replace `FLOW_MESSAGING_KEY` with it. (A claim through `claim_url` in a browser hands out no key, and revokes the sandbox key unless the person chooses to keep their agent's key working.) `denied` means the person refused, and `expired` that the code ran out (after `expires_in` seconds): start again. Polls are also limited per client network, unknown codes included. # Start a sign-in from an agent or CLI (device flow) Source: https://docs.flow.engineer/api-reference/onboarding/start-a-sign-in-from-an-agent-or-cli-device-flow /openapi.yaml post /v1/device/authorizations Starts a sign-in that a person finishes in a browser with GitHub (the OAuth 2.0 device authorization grant, RFC 8628, in Flow's JSON shape). Show the person `verification_uri` and `user_code`: they open the page, sign in and type the code you show them. Then poll `POST /v1/device/token` with `device_code` every `interval` seconds until it returns a key. To claim an app made with `POST /v1/sandbox/keys`, pass its `claim_token`, or send that app's test key as `Authorization: Bearer fk_test_...` (a key of an app that is already claimed is ignored). When the person approves, the app joins their account: its data and keys are kept, its keys no longer expire (a key that expired less than 30 days ago works again), and the app moves under the person's signed-in allowance: 3 contacts and 100 messages each, one allowance per person, shared by every app they own or claim. A person may claim up to 10 apps; past that the approval page refuses the claim. An expired key claims its app only for 30 days after its `expires_at`; after that, use the `claim_token`. Without either, approving gives a new test key for the person's own app (made at their first sign-in). The approval page asks the person to type `user_code` as the agent or CLI shows it, so a link alone cannot approve a sign-in someone else started. No API key is needed. Calls are limited per client address and network. # Get a sender Source: https://docs.flow.engineer/api-reference/senders/get-a-sender /openapi.yaml get /v1/senders/{sender_id} Returns one sender with its status, limits and, on WhatsApp, its quality rating. # List senders Source: https://docs.flow.engineer/api-reference/senders/list-senders /openapi.yaml get /v1/senders Lists the senders your app can use in this mode, newest first. Test keys see the shared sandbox senders; live keys see your dedicated senders. # Request a dedicated sender Source: https://docs.flow.engineer/api-reference/senders/request-a-dedicated-sender /openapi.yaml post /v1/senders Connects a dedicated sender to your app. Today this connects a Telegram bot (below). iMessage lines are connected by the Flow team, not by API: a request with `channel: "imessage"` answers `501 not_implemented`; ask the Flow team, and the line then appears in `GET /v1/senders`. WhatsApp is not available yet (it waits on Meta's approval), so `channel: "whatsapp"` answers `501 not_implemented`; once it ships, a WhatsApp number starts as `pending`, you receive `sender.status_changed` when it is ready, and your business is verified through Meta's Embedded Signup. Live keys only. To get a live key (`fk_live_...`), a person signs in to the dashboard at `https://api.flow.engineer/admin` (GitHub), switches to **Live**, and clicks **Create live key** on the Keys page (`https://api.flow.engineer/admin/keys?mode=live`). An app made without an account (`POST /v1/sandbox/keys`) is claimed first, by signing in through the device flow or its `claim_url`. A test key gets `403 permission` here, with a `hint` naming these steps. Telegram bots are self-serve; iMessage lines are arranged with the Flow team. A Telegram bot is connected at once: give the token BotFather issued as `telegram_bot_token`. Flow checks it, keeps it encrypted, points the bot's webhook at Flow, and answers `200` with the sender `active`. The token is never returned. One bot is one sender: - Connecting a bot that is already a sender of this app updates its token in place and answers with the same sender (use it after revoking a token in @BotFather; a sender `flagged` because Telegram rejected its old token becomes `active` again, or `throttled` while an abuse throttle still runs, and its queued messages go out). - Connecting a bot that is a sender of another app moves it here: holding the token proves control of the bot. The old sender is retired (`banned`) and its app receives `sender.status_changed`. - A bot that is one of Flow's sandbox senders is refused with `403 permission`. To disconnect a bot, call `DELETE /v1/senders/{sender_id}`. # Open the live event stream (WebSocket) Source: https://docs.flow.engineer/api-reference/stream/open-the-live-event-stream-websocket /openapi.yaml get /v1/stream Upgrades to a WebSocket that pushes your app's events as they happen, in log order. Pass `after` to resume from the last event you saw: the stream first replays everything after it, then goes live, so a reconnect never loses an event. Every WebSocket text frame is one JSON object (`StreamFrame`). The server sends `event` frames; the client may send `send` frames with the same bodies as the HTTP send endpoints, and gets one `ack` or `error` frame back for each, matched by `ref`. When the server is about to restart it sends a `reconnect` frame; reconnect with `after` set to the last event you received. Authenticate with the `Authorization` header where your WebSocket client can set headers. Where it cannot (a browser's `WebSocket`, and Node's global `WebSocket` on the server), offer the key as a WebSocket subprotocol instead: offer both `flow` and `flow.key.`, for example `new WebSocket("wss://api.flow.engineer/v1/stream", ["flow", "flow.key." + key])`. This works from server-side clients as well as browsers. The server selects `flow` and never echoes the key. Offering the key protocol without `flow` is refused with a plain `400 invalid_request` answer, without an upgrade. When an `Authorization` header is present it takes precedence. Keys are never accepted in the query string, since URLs end up in logs. A key used in a browser is visible to whoever uses that page: do this only for internal tools or with test keys (`fk_test_`). **Refusals arrive on the socket.** Many WebSocket clients (Node's and browsers' among them) cannot read the HTTP status of a refused upgrade, so a WebSocket request that Flow refuses (a missing, unknown, revoked or expired key, a bad parameter, too many streams for the key, a restart) is still upgraded: the server sends one `error` frame with the usual error body (`type`, `message`, `hint`, `retry_after`, `channel_code`, ...), then closes with an application close code of 4000 plus the HTTP status the error has elsewhere, and a short reason: - `4401`: `authentication`. The key is missing, malformed, unknown, revoked or expired (`channel_code` `sandbox_key_expired`). Stop reconnecting until you have a working key. - `4403`: `permission`. The key may not open this stream. Stop reconnecting. - `4400`: `invalid_request`, for example a bad `after` or `type`. Fix the request; reconnecting unchanged fails again. - `4429`: `rate_limited`, for example more open streams than the key may hold. Reconnect after `retry_after` seconds. - `4500`, `4503`: Flow could not open the stream just now, or is restarting. Reconnect after `retry_after` seconds with the same `after`. An open stream re-checks its key about once a minute: when the key is revoked, the stream sends an `authentication` error frame and closes with `4401` too. Other closes: `1012` after a `reconnect` frame (reconnect at once with `after`), `1008` after too many rate-limited `send` frames in a row, `1001` when pings go unanswered, `1011` when the event log is unavailable; reconnect with `after` after any of these. A request without a WebSocket upgrade gets the same errors as plain HTTP answers. # Create a template Source: https://docs.flow.engineer/api-reference/templates/create-a-template /openapi.yaml post /v1/templates Submits a WhatsApp message template for review for one of your dedicated WhatsApp senders. It starts as `pending`; you receive `template.status_changed` when WhatsApp approves, rejects or pauses it. Templates are WhatsApp only: a Telegram or iMessage sender gets `422 unsupported_content`, and a name and language the sender already has gets `400 invalid_request`. # Delete a template Source: https://docs.flow.engineer/api-reference/templates/delete-a-template /openapi.yaml delete /v1/templates/{template_id} Deletes the template at Meta and here. Messages already sent with it are unaffected. # Get a template Source: https://docs.flow.engineer/api-reference/templates/get-a-template /openapi.yaml get /v1/templates/{template_id} Returns one template with its approval status. # List templates Source: https://docs.flow.engineer/api-reference/templates/list-templates /openapi.yaml get /v1/templates Lists WhatsApp message templates for your senders, newest first. # An event delivered to your endpoint Source: https://docs.flow.engineer/api-reference/webhook-endpoints/an-event-delivered-to-your-endpoint /openapi.yaml webhook event Flow `POST`s each event your endpoint subscribes to, one event per request, in order per conversation. Verify `Flow-Signature` before trusting the body (see "Webhooks" in the introduction). Deliveries are at least once: deduplicate on the event's `id`. To reply at once to a `message.received` event, answer `200` with a `WebhookReply` body; the reply goes into the event's conversation through the send gate, as if you had called `POST /v1/conversations/{conversation_id}/messages` with the event's `id` as the idempotency key. `fallback` applies to every piece of the reply. For any other event, or to reply later, answer `200` with an empty body, `{}`, `{"reply": null}` or any body that is not a JSON object with `reply` (plain text such as `OK` included): nothing is sent and it is not an error. An answer to a `message.received` delivery that is a JSON object with a `reply` that is not valid (a `reply` that is not content or a list of content, an empty list, more than 10 pieces, an unknown `fallback`), or a body that starts with `{` but is not valid JSON, sends nothing at all, not even the valid pieces. It is recorded on the delivery as an `invalid_request` error with the reason, which the MCP tool `get_webhook_deliveries` shows; the delivery counts as delivered and is not retried. A piece that the send gate refuses is reported as a `message.failed` event. # Create a webhook endpoint Source: https://docs.flow.engineer/api-reference/webhook-endpoints/create-a-webhook-endpoint /openapi.yaml post /v1/webhook_endpoints Registers an HTTPS URL to receive your app's events of the listed types. The answer includes the endpoint's signing `secret`, shown only this once. Status events (`message.sent`, `message.delivered`, ...) are high volume; subscribe only to the types you use. A URL is registered once per app and mode: a URL another endpoint already has (scheme and host compared in any case) answers `400 invalid_request` with `param` `url`, naming that endpoint. Change its event types with `PATCH /v1/webhook_endpoints/{webhook_endpoint_id}` instead; one endpoint can receive every event type. # Delete a webhook endpoint Source: https://docs.flow.engineer/api-reference/webhook-endpoints/delete-a-webhook-endpoint /openapi.yaml delete /v1/webhook_endpoints/{webhook_endpoint_id} Stops deliveries to the endpoint and deletes it. Events stay in the log. # Get a webhook endpoint Source: https://docs.flow.engineer/api-reference/webhook-endpoints/get-a-webhook-endpoint /openapi.yaml get /v1/webhook_endpoints/{webhook_endpoint_id} Returns one webhook endpoint. The signing secret is not included. # List webhook endpoints Source: https://docs.flow.engineer/api-reference/webhook-endpoints/list-webhook-endpoints /openapi.yaml get /v1/webhook_endpoints Lists your app's webhook endpoints in this mode, newest first. Signing secrets are not included. # Update a webhook endpoint Source: https://docs.flow.engineer/api-reference/webhook-endpoints/update-a-webhook-endpoint /openapi.yaml patch /v1/webhook_endpoints/{webhook_endpoint_id} Changes an endpoint's URL, event types, description, or whether it is enabled. Fields you leave out are kept. A `url` another endpoint of the app already has in this mode answers `400 invalid_request` with `param` `url`. # For AI coding agents: integrate Flow Messaging with Claude Code, Codex or Cursor Source: https://docs.flow.engineer/coding-agents Instructions for coding agents (Claude Code, Codex, Cursor, ChatGPT) adding Telegram or iMessage to an AI agent with Flow Messaging: install, environment variables, the MCP server, the skill, and the rules to follow. This page is written for AI coding agents: follow it step by step to give the user's AI agent Telegram or iMessage with Flow Messaging. What exists today: Telegram is live (the shared sandbox bot with a test key, the user's own bot with a live key). iMessage is live for replies only, on lines the Flow team connects to the user's app; it is not in the sandbox. WhatsApp is not available yet (it waits on Meta's approval), and there is no SMS. If the user asks for WhatsApp, say so and build on Telegram: the code does not change per channel. Add two-way Telegram messaging (and iMessage replies, if the Flow team has connected a line) to my AI agent using Flow Messaging. Follow [https://docs.flow.engineer/coding-agents.md](https://docs.flow.engineer/coding-agents.md) exactly: use FLOW\_MESSAGING\_KEY if it is set, otherwise get a test key yourself with `npx @flow-engineer/messaging init` (or `curl -X POST https://api.flow.engineer/v1/sandbox/keys`), use the TypeScript SDK `@flow-engineer/messaging` (or the HTTP API at [https://api.flow.engineer](https://api.flow.engineer) in other languages), receive `message.received` events, reply with my agent's answer through `event.conversation.reply(...)`, and verify it end to end in the sandbox before you finish. Build on the REST API; the Flow MCP server is an optional development tool that I add myself if I want it. ## 1. Get a key and set up First check for a key: if `FLOW_MESSAGING_KEY` is set (in the environment or `.env`), use it. Otherwise get a test key yourself, with no account, in one call. Do not invent a key, and do not get a second one when you already have one. ```bash theme={null} npx @flow-engineer/messaging init ``` With no key in the environment or `.env`, `init` gets one (`POST /v1/sandbox/keys`) without asking, then: * writes `FLOW_MESSAGING_KEY` and `FLOW_CLAIM_TOKEN` to `.env` and adds `.env` to `.gitignore`; * prints the sandbox link and join code, the allowance and the expiry; * with the user's approval (they are the project owner, so leave the answer to them and never pass `--yes` yourself), installs the Claude Code skill in `.claude/skills/flow-messaging/`, adds a Flow Messaging section to `AGENTS.md` (read by Codex and other agents), and registers the MCP server `flow` for Claude Code (`.mcp.json`) and Codex (`codex mcp add`). Flags: `--yes` (no questions; the owner's choice, not an agent's), `--key fk_test_...` (use a key you already have), `--no-agent-files`, `--no-mcp`, `--no-codex`, `--dir `. Without the CLI, the same key by HTTP: ```bash theme={null} curl -X POST https://api.flow.engineer/v1/sandbox/keys ``` Save `key` as `FLOW_MESSAGING_KEY` and `claim_token` as `FLOW_CLAIM_TOKEN` in `.env` (which must be in `.gitignore`); both are shown once. The answer also holds the app's `sandbox_join_code` and the sandbox `senders` with their links; later, `GET /v1/app` and `GET /v1/senders` give them again. This key has a sandbox allowance: 1 contact and 50 messages in total on the Telegram sandbox, and it expires after 7 days. Tell the user, and offer to sign them in to keep the app (3 contacts x 100 messages, no expiry): run `npx @flow-engineer/messaging login --no-wait`, give them the link and code it prints, and once they say they approved, run `npx @flow-engineer/messaging login` to collect the new key. Full details, the curl version and the errors: [Keys and sign-in](/get-a-key). Then add the SDK to a TypeScript or JavaScript project: ```bash theme={null} npm install @flow-engineer/messaging ``` The Python and Go SDKs are not published yet: in those languages call the HTTP API directly, as each page's Python and Go examples show. ## 2. Environment variables | Variable | Required | What it is | | - | - | - | | `FLOW_MESSAGING_KEY` | yes | The API key: `fk_test_...` while building, `fk_live_...` in production. Server side only. | | `FLOW_CLAIM_TOKEN` | until signed in | The `claim_token` (`fct_...`) from `POST /v1/sandbox/keys`. `npx @flow-engineer/messaging login` uses it to claim the app, then removes it. | | `FLOW_MESSAGING_WEBHOOK_SECRET` | with webhooks | The endpoint's signing secret, `whsec_...`, returned once by `POST /v1/webhook_endpoints` (and by `POST /v1/webhook_endpoints/{id}/rotate_secret` when you rotate it). | | `FLOW_MESSAGING_BASE_URL` | no | Defaults to `https://api.flow.engineer`. | Never hard-code keys, never commit `.env`, and never put a key in browser or mobile code. ## 3. The MCP server (optional, for development) **Build on the REST API; the MCP server is for development.** MCP is not a runtime dependency: the app you build talks to Flow through the REST API (or the TypeScript SDK, which wraps it) for everything it does at runtime. Never make the app call the MCP server. The hosted MCP server at `https://api.flow.engineer/mcp` (Streamable HTTP, the same API key as a Bearer token) is an optional tool for testing and operating the integration while you build: with a test key it shows the sandbox join link, sends test messages, waits for events, reads webhook deliveries and replays events; with a live key it reads and answers conversations. Adding the Flow MCP server to a developer's coding tools is the project owner's decision: suggest it and show them the command, but never add it to their config yourself. If its tools are already available to you, use them for sandbox testing. To suggest it, show the project owner the line for their tool. The server is named `flow` and reads the key from `FLOW_MESSAGING_KEY` in their environment: Claude Code: ```bash theme={null} claude mcp add --transport http flow https://api.flow.engineer/mcp --header "Authorization: Bearer $FLOW_MESSAGING_KEY" ``` Codex (`~/.codex/config.toml`): ```toml theme={null} [mcp_servers.flow] url = "https://api.flow.engineer/mcp" bearer_token_env_var = "FLOW_MESSAGING_KEY" ``` Cursor (`~/.cursor/mcp.json`): ```json theme={null} {"mcpServers": {"flow": {"url": "https://api.flow.engineer/mcp", "headers": {"Authorization": "Bearer ${env:FLOW_MESSAGING_KEY}"}}}} ``` Test keys get `sandbox_join`, `send_test_message`, `wait_for_event`, `list_events`, `get_webhook_deliveries`, `replay_event`; live keys get `send_message`, `reply`, `react`, `typing`, `list_conversations`, `get_conversation_messages`; both get `whoami`, `capabilities`, `explain_error`. After a test send, wait for `message.sent` or `message.failed`, never `message.delivered` (Telegram never sends it). `npx @flow-engineer/messaging mcp` is a stdio bridge to `https://api.flow.engineer/mcp` that reads `FLOW_MESSAGING_KEY` from the environment or `.env`, for clients that run MCP servers as commands. Full tool list: [MCP server](/mcp). ## 4. The skill * **Claude Code:** `init` installs the skill in `.claude/skills/flow-messaging/`. It tells Claude how to integrate, which errors to handle and how to test. * **Any agent:** a skill file is published at [https://docs.flow.engineer/skill.md](https://docs.flow.engineer/skill.md). Install it with `npx skills add https://docs.flow.engineer`. ## 5. Integrate: what to write 1. **Receive** `message.received` events. In a web app, add a webhook route with `flow.webhooks.handler({ onEvent })` (or `constructEvent` on the raw body). In a worker or script, use `for await (const event of flow.events.stream({ types: ["message.received"] }))`. 2. **Read** what the person said with `contentText(event.data.message.content)`; it covers text, captions, voice note transcripts and button taps. 3. **Answer** with `event.conversation.reply(x)`, where `x` is a string or the user's LLM stream (OpenAI, Anthropic, Vercel AI SDK, OpenAI Agents SDK, Claude Agent SDK, LangChain, Mastra). Pass `{ idempotencyKey: event.id }`. 4. **Register** the webhook: `POST /v1/webhook_endpoints` with the public URL and `["message.received"]`; save the returned secret as `FLOW_MESSAGING_WEBHOOK_SECRET`. Locally, with no public URL, read events from the live stream instead: `GET /v1/stream` (WebSocket, resumable with `after`; `flow.events.stream(...)` in TypeScript). See [Local development](/guides/local-development). 5. **Handle errors** by `error.type` (TypeScript: `err instanceof OutsideWindowError` etc.). Each error carries a `hint` and a `doc_url` such as [https://api.flow.engineer/docs/errors/outside\_window](https://api.flow.engineer/docs/errors/outside_window). ```ts TypeScript (minimal complete integration) theme={null} import { FlowMessaging, contentText } from "@flow-engineer/messaging"; import { runAgent } from "./agent"; // the user's existing agent: returns a string or an LLM stream const flow = new FlowMessaging(); export const POST = flow.webhooks.handler({ onEvent: async (event) => { if (event.type !== "message.received") return; const input = contentText(event.data.message.content); await event.conversation.reply(await runAgent(input, { conversationId: event.conversation.id }), { idempotencyKey: event.id, }); }, }); ``` ## 6. Verify end to end Do not finish until this works: 1. Ask the user to join the sandbox from their phone, if they have not: give them the sandbox sender's link from `sandbox_join` (or `GET /v1/senders`). On Telegram, opening the link and tapping **Start** joins; otherwise they send `join `, for example `join wild-otter-04508705`. 2. Start the app (reading `GET /v1/stream` when developing locally without a public URL). 3. If the Flow MCP tools are available to you, call `send_test_message` or ask the user to send a message, then `wait_for_event` for `message.received`. Without them, ask the user to send a message and read it with `GET /v1/events?type=message.received`. After a send, wait for `message.sent` or `message.failed` (not `message.delivered`, which not every channel reports). Each `wait_for_event` call waits up to 50 seconds (a longer `timeout_seconds` is clamped to 50, with a note, not refused); if it times out, call it again with `after` set to the `next_after` it returned. 4. If a delivery failed, read it with `get_webhook_deliveries` (MCP), fix the handler, and `replay_event`; without the MCP tools, fix the handler and have the user write again. ## Rules * **Build on the REST API (or the TypeScript SDK);** the MCP server is never a runtime dependency. * **Use the SDK in TypeScript/JavaScript;** do not hand-roll HTTP calls there. * **Reply into the conversation** (`event.conversation`, or `POST /v1/conversations/{conversation_id}/messages`). Never choose a channel per message and never assume a contact has a phone number. * **Verify webhook signatures** on the raw body before trusting it; deduplicate on the event `id`. * **Answer webhooks within 10 seconds.** Run slow agents after the response (`after()`, `waitUntil()`, a queue) or use the event stream. * **Do not send unsupported content blindly.** Set `fallback: "auto"` or check `GET /v1/capabilities`. `reply()` already sets it. * **Never message first on iMessage:** the person writes first; on `outside_window`, wait for them to write, do not retry the same content. (WhatsApp, once available: send an approved `template` instead.) * **Test keys only while building.** Do not switch to `fk_live_` unless the user asks. * **Do not work around the sandbox allowance.** On `403 permission` with `channel_code` `sandbox_allowance_used` or `sandbox_contact_limit`, or `401 authentication` with `sandbox_key_expired`, do not retry and do not get more keys: ask the user to sign in (`npx @flow-engineer/messaging login --no-wait`, then `login` once they approve). ## Docs for agents | URL | What | | - | - | | [https://docs.flow.engineer/llms.txt](https://docs.flow.engineer/llms.txt) | Index of every page with a one-line description | | [https://docs.flow.engineer/llms-full.txt](https://docs.flow.engineer/llms-full.txt) | The whole documentation in one file | | Any page URL + `.md` | That page as Markdown (for example [https://docs.flow.engineer/quickstart.md](https://docs.flow.engineer/quickstart.md)) | | [https://raw.githubusercontent.com/flow-engineer/sdk/main/openapi/openapi.yaml](https://raw.githubusercontent.com/flow-engineer/sdk/main/openapi/openapi.yaml) | The OpenAPI 3.1 spec | | [https://github.com/flow-engineer/sdk](https://github.com/flow-engineer/sdk) | SDK source and examples | # Choosing a messaging API for your AI agent: Telegram, WhatsApp and iMessage Source: https://docs.flow.engineer/compare/choosing-a-messaging-api A neutral guide to putting an AI agent on Telegram, WhatsApp or iMessage: Flow Messaging, Photon, the Telegram Bot API, Twilio and other CPaaS, and iMessage APIs (Sendblue, Linq, Blooio) compared by channels, two-way support, MCP, pricing model, sandbox and self-hosting, with sources. Says when Flow is not the right choice. **Last verified: 10 Oct 2026.** Every claim about another product on this page links to that product's own docs, pricing page or repository, as read in October 2026. Prices and features change; check the linked source before relying on a detail, and [open an issue](https://github.com/flow-engineer/sdk/issues) if something here is out of date. This page is written by the Flow Messaging team. This guide helps you pick how to give an AI agent two-way conversations on Telegram, WhatsApp or iMessage, including when another product fits better than Flow. ## TL;DR: pick by need | If you need... | Consider | Why | | - | - | - | | One Telegram bot, and you are happy to run its server | The Telegram Bot API directly | Free ([Telegram](https://core.telegram.org/bots), Oct 2026), and nothing sits between you and Telegram | | WhatsApp in production today | Twilio, Photon (Business plan), Blooio, Infobip, Sinch, Vonage; Linq (beta) | Flow's WhatsApp is **coming**, pending Meta's approval | | SMS, MMS or voice, in many countries | Twilio, Sinch, Vonage, Infobip | SMS and voice are not available on Flow yet | | iMessage where your agent writes first, or group chats | Sendblue, Linq, Blooio, Photon; groups need a paid plan on Sendblue and Linq (table below) | Flow's iMessage line is replies only, one-to-one | | FaceTime calls from an API | Sendblue, on a purchased FaceTime line ([source](https://docs.sendblue.com/llms.txt)); Blooio, on dedicated plans by request ([source](https://blooio.com/pricing.md)) | Flow has no calling | | Apple's official business channel | Apple Messages for Business, through an Apple-approved provider | Apple requires bots to connect through one ([Apple](https://register.apple.com/resources/messages/messaging-documentation/faq), Oct 2026) | | Open source you can self-host | Photon's `spectrum-ts` | MIT, runs without Photon's cloud ([Photon](https://photon.codes/pricing), Oct 2026) | | Any language over plain HTTP, hosted senders, an ordered event log, idempotent sends and channel rules enforced for you, on Telegram and iMessage (replies) | Flow Messaging | One API across its channels, signed webhooks retried for 3 days, one send gate | | A coding agent that can get a key and test on its own, with no account | Flow Messaging | `POST /v1/sandbox/keys` returns a test key and a ready sandbox bot | ## Side by side | | Channels | Two-way | MCP server | Pricing model | Free tier or sandbox | Self-host | | - | - | - | - | - | - | - | | **Flow Messaging** | Telegram; iMessage (replies only, lines arranged with the Flow team); WhatsApp **coming** | Yes: webhooks, WebSocket stream, polling | Hosted (`api.flow.engineer/mcp`), optional | Plans; prices not published yet; for WhatsApp, Meta's fees passed through | Test key with no account: 1 contact, 50 messages, 7 days; signed in with GitHub: 3 contacts, 100 messages each | No | | **Photon** (`spectrum-ts`) | iMessage, WhatsApp Business, Telegram, terminal, SIP voice ([source](https://photon.codes/docs/spectrum-ts/introduction.md)); RCS and SMS fallback (pricing page); the Beta API adds SMS and email ([source](https://photon.codes/docs/beta/api-reference/messages/send-a-message)) | Yes | Docs search ([source](https://photon.codes/docs/mcp)); Spectrum skills for AI coding tools ([source](https://photon.codes/docs/spectrum-ts/introduction.md)) | Free; Pro \$25/mo; Business \$250/line/mo; Enterprise custom ([source](https://photon.codes/pricing)) | Free plan, up to 10 users | Yes (MIT) | | **Telegram Bot API** | Telegram | Yes: webhook or `getUpdates` ([source](https://core.telegram.org/bots/api#getting-updates)) | — | Free | Free | You run the bot; Telegram runs the API | | **Twilio** | SMS, MMS, RCS, WhatsApp; Messenger (public beta) ([source](https://www.twilio.com/docs/messaging)); Apple Messages for Business (private beta) ([source](https://www.twilio.com/en-us/messaging/channels/apple-messages-for-business)) | Yes: webhooks | Hosted docs search (Public Beta), does not call APIs ([source](https://www.twilio.com/docs/ai/mcp)); the open-source `@twilio-alpha/mcp` server calls the APIs ([source](https://github.com/twilio-labs/mcp)) | Per message: US SMS from \$0.0083/segment plus carrier fees; 10DLC registration extra ([source](https://www.twilio.com/en-us/sms/pricing/us)); WhatsApp \$0.005 plus Meta's fees ([source](https://www.twilio.com/en-us/whatsapp/pricing)) | Trial: free units, 30 days, verified numbers only, in your sign-up country ([source](https://www.twilio.com/docs/usage/tutorials/how-to-use-your-free-trial-account)) | — | | **Sendblue** | iMessage, SMS, MMS, RCS ([source](https://docs.sendblue.com/llms.txt)) | Yes | Local (`npx`) ([source](https://docs.sendblue.com/mcp)) | Per line: AI Agent \$100/month; no per-message fees ([source](https://www.sendblue.com/pricing)) | Sandbox: 10 verified contacts, they write first | — | | **Linq** | iMessage, RCS, SMS; voice (per its homepage) ([source](https://linqapp.com)); WhatsApp beta on request ([source](https://docs.linqapp.com/channel/whatsapp/getting-started/connect-whatsapp/)) | Yes | Local (`npx`) ([source](https://docs.linqapp.com/channel/imessage/guides/resources/faq/)) | Hobby \$0; Pro from \$260/mo; Enterprise custom ([source](https://linqapp.com/s/pricing)) | Hobby: 20 contacts; Pro: 7-day trial | — | | **Blooio** | iMessage, RCS; SMS through your own Twilio account ([source](https://blooio.com/guides/self-serve.md)); WhatsApp Business ([source](https://docs.blooio.com/guides/whatsapp-business)) | Yes | Hosted ([source](https://blooio.com/index.md)) | Starter \$39/mo; Commercial Shared \$89/mo; Commercial Dedicated \$289/mo per line; Inbound \$98/mo (reply-only); Enterprise Dedicated from \$195/mo per line at 6+ lines; no per-message fees ([source](https://blooio.com/pricing.md)) | Free trial: 20 messages | — | All sources as of Oct 2026. "—" means the vendor does not document it. ## When Flow is not the right choice Pick something else if any of these is true today: * **You need WhatsApp in production now.** Flow's WhatsApp is waiting for Meta's approval. * **You need SMS, RCS, voice, email, Slack, Discord or any channel beyond Telegram and iMessage.** None of these is available on Flow yet. Twilio and the other CPaaS providers cover SMS, RCS and voice, the iMessage APIs below fall back to SMS, and Photon lists more channels than Flow, with RCS and SMS fallback on its plans. * **Your agent must start iMessage conversations, or use groups or FaceTime calls.** Flow's iMessage line answers people who wrote first, one-to-one, and lines are arranged with the Flow team rather than self-serve. * **You must self-host, or keep message data on your own infrastructure.** Flow is a hosted service only; its spec and SDKs are open source, the service is not. * **You need published prices, a contractual SLA or a compliance certification today.** Flow is in beta and publishes none of these yet. * **You run one simple Telegram bot.** The Bot API is free, and a few dozen lines of code may be all you need (see below). * **You want a Python or Go SDK today.** Flow's TypeScript SDK is on npm (`@flow-engineer/messaging`); the Python and Go SDKs are coming, so until then other languages call the HTTP API. ## The Telegram Bot API directly **Raw is fine when** you run one bot, your server is up, and an occasional duplicate or out-of-order message would not matter. Telegram hosts the API for free; you write the bot. **What you take on:** * **Downtime.** Telegram retries a failed webhook and gives up "after a reasonable amount of attempts" ([Telegram](https://core.telegram.org/bots/api#setwebhook), Oct 2026), and keeps undelivered updates for at most 24 hours ([Telegram](https://core.telegram.org/bots/api#getting-updates), Oct 2026). * **Order and duplicates.** `update_id` increases sequentially so that you can drop repeats and restore the order yourself when updates arrive out of order ([Telegram](https://core.telegram.org/bots/api#update), Oct 2026). * **Rate limits.** About one message per second per chat, 20 per minute in a group and about 30 per second in bulk unless paid broadcasts are enabled, with `429` errors beyond that ([Telegram](https://core.telegram.org/bots/faq), Oct 2026); the error's `retry_after` gives the seconds to wait ([Telegram](https://core.telegram.org/bots/api#responseparameters), Oct 2026). * **Sending twice.** If a send times out, you cannot tell whether it went out, so you need your own guard against repeating it. * **Who can be messaged.** Bots cannot start conversations; the person messages the bot first ([Telegram](https://core.telegram.org/bots), Oct 2026). This rule applies through Flow too. Telegram also gives bots things Flow does not expose yet, such as streaming a draft message while it is generated (`sendMessageDraft`, private chats) ([Telegram](https://core.telegram.org/bots/api-changelog), Oct 2026). **What Flow adds on top of Telegram:** * **Ordering:** events reach you in order per conversation, one at a time. * **Retries without duplicates:** webhooks are retried for 3 days and every event stays in a log you can replay; every send takes an `Idempotency-Key`, so a retry never sends twice. * **The send gate:** pacing per sender, refusals with typed errors (`type`, `hint`, `doc_url`) instead of raw channel errors. * **Signed webhooks:** an HMAC-SHA256 `Flow-Signature` on every delivery, and the option to reply in the webhook answer. * **The sandbox:** a shared test bot and a test key with no account, so you can try it before you make a bot. * **One API across channels:** the same code answers on iMessage, and on WhatsApp when it opens. **Same task: receive a Telegram message and reply.** First with the Bot API directly, following Telegram's `setWebhook` and `sendMessage` reference ([Telegram](https://core.telegram.org/bots/api#sendmessage), Oct 2026): ```ts Telegram Bot API (direct) theme={null} // app/api/telegram/route.ts. Register it once: // curl "https://api.telegram.org/bot$TELEGRAM_BOT_TOKEN/setWebhook" \ // -d url=https://example.com/api/telegram -d secret_token=$TELEGRAM_WEBHOOK_SECRET export async function POST(req: Request) { if (req.headers.get("X-Telegram-Bot-Api-Secret-Token") !== process.env.TELEGRAM_WEBHOOK_SECRET) { return new Response(null, { status: 401 }); } const update = await req.json(); const msg = update.message; if (msg?.text) { await fetch(`https://api.telegram.org/bot${process.env.TELEGRAM_BOT_TOKEN}/sendMessage`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ chat_id: msg.chat.id, text: `Echo: ${msg.text}` }), }); } return new Response("ok"); } ``` Then with Flow, over plain HTTP (no SDK needed). Register the URL once with `POST /v1/webhook_endpoints` and keep the `whsec_...` secret it returns: ```ts Flow (HTTP, no SDK) theme={null} // app/api/flow/route.ts import { createHmac, timingSafeEqual } from "node:crypto"; // Flow-Signature: t=,v1=."> function verify(body: string, header: string, secret: string): boolean { const parts = header.split(",").map((p) => p.trim().split("=")); const t = parts.find(([k]) => k === "t")?.[1]; if (!t || Math.abs(Date.now() / 1000 - Number(t)) > 300) return false; const want = createHmac("sha256", secret).update(`t.${t}.${body}`).digest(); return parts.some(([k, v = ""]) => k === "v1" && /^[0-9a-f]{64}$/.test(v) && timingSafeEqual(want, Buffer.from(v, "hex"))); } export async function POST(req: Request) { const body = await req.text(); // the raw body: a re-serialized one will not verify if (!verify(body, req.headers.get("Flow-Signature") ?? "", process.env.FLOW_MESSAGING_WEBHOOK_SECRET!)) { return new Response(null, { status: 400 }); } const event = JSON.parse(body); if (event.type !== "message.received") return Response.json({}); // The reply goes through the send gate into the same conversation, keyed by the event's ID. return Response.json({ reply: `Echo: ${event.data.message.content.text ?? ""}` }); } ``` The Flow version is about the same length. The difference is what happens around it: with Flow, a redelivered event cannot make Flow send the webhook-answer reply twice, an outage of up to 3 days loses nothing, and the same handler works on Flow's iMessage line. ## Twilio and other CPaaS providers Twilio and the other CPaaS (communications platform) providers are broad, mature platforms built around phone numbers. **Where Twilio is stronger than Flow:** SMS that reaches 180+ countries ([Twilio](https://www.twilio.com/en-us/messaging/channels/sms), Oct 2026), voice, a 99.95% API availability SLA (99.99% with Enterprise Edition), with service credits ([Twilio](https://www.twilio.com/en-us/legal/service-level-agreement/twilio-apis), Oct 2026), server SDKs in seven languages ([Twilio](https://www.twilio.com/docs/libraries), Oct 2026), and Agent Connect, a generally available Python and TypeScript SDK that connects AI agents to Voice, SMS, chat, WhatsApp and RCS ([Twilio](https://www.twilio.com/en-us/changelog/twilio-agent-connect-is-now-generally-available), Oct 2026). **What to know for agent conversations on Twilio:** * **Telegram** is not among the channels on Twilio's Messaging docs, and Apple Messages for Business is a private beta (sources in the table above). * **Webhooks** are signed with HMAC-SHA1 in `X-Twilio-Signature` ([Twilio](https://www.twilio.com/docs/usage/security), Oct 2026). Retries are set per webhook URL with connection overrides, a fragment on the URL such as `#rc=3&rp=5xx,ct`: by default a webhook is retried once, on connection failures only, within 15 seconds in total, and you can set up to 5 retries and which failures count ([Twilio](https://www.twilio.com/docs/usage/webhooks/webhooks-connection-overrides), Oct 2026). Status callbacks are not guaranteed to arrive in order ([Twilio](https://www.twilio.com/docs/messaging/guides/track-outbound-message-status), Oct 2026). * **WhatsApp's 24-hour window** is yours to handle: outside it only approved templates go, and other sends fail with error 63016 ([Twilio](https://www.twilio.com/docs/whatsapp/key-concepts), Oct 2026). * **US SMS** from a 10DLC number needs A2P 10DLC registration, hobbyists included ([Twilio](https://www.twilio.com/docs/messaging/compliance/a2p-10dlc), Oct 2026). **Other CPaaS providers:** Sinch's Conversation API includes Telegram and WhatsApp among its channels ([Sinch](https://developers.sinch.com/docs/conversation/channel-support), Oct 2026). Infobip's Conversations API includes Telegram and Apple Messages for Business ([Infobip](https://www.infobip.com/docs/conversations-api), Oct 2026), and Infobip hosts an MCP server per channel ([Infobip](https://github.com/infobip/mcp), Oct 2026). The Vonage Messages API covers SMS, MMS, RCS, WhatsApp, Messenger, Viber and email ([Vonage](https://developer.vonage.com/en/messages/overview), Oct 2026). **Choose a CPaaS provider if** you need SMS or voice, many countries, an SLA, or channels Flow lacks. **Choose Flow if** your agent lives on Telegram or iMessage replies and you want ordered, replayable events, idempotent sends and channel rules enforced for you. ## iMessage APIs for agents Apple's official channel for businesses is Apple Messages for Business. Apple says automation and virtual agents must connect through an Apple-approved Messaging Service Provider, with a path to a live agent ([Apple](https://register.apple.com/resources/messages/messaging-documentation/faq), Oct 2026). Blooio says it is one of those providers ([Blooio](https://blooio.com/integrations/api.md), Oct 2026). Several APIs give an agent an iMessage phone line instead: | | Sendblue | Linq | Blooio | Flow Messaging | | - | - | - | - | - | | Plans | Sandbox free; AI Agent \$100/month per line; Enterprise custom ([source](https://www.sendblue.com/pricing)) | Hobby \$0; Pro from \$260/mo; Enterprise custom ([source](https://linqapp.com/s/pricing)) | Starter \$39/mo; Commercial Shared \$89/mo; Commercial Dedicated \$289/mo per line; Inbound \$98/mo; Enterprise Dedicated from \$195/mo per line at 6+ lines; \$75 per custom area code ([source](https://blooio.com/pricing.md)) | Prices not published yet | | Agent writes first | Enterprise ("Full outbound messaging"; pricing page) | Paid lines; on the free tier the contact writes first ([source](https://linqapp.com/cli)) | Starter 5 new contacts a day, Commercial Shared 15, dedicated plans unlimited; Inbound plan reply-only (pricing page) | No: replies only | | Group chats | On select (paid) plans ([source](https://docs.sendblue.com/llms.txt)) | Pro and above (pricing page) | All plans (pricing page) | No: one-to-one | | FaceTime | On a purchased FaceTime line (same source) | — | Dedicated plans, on request (pricing page) | No | | Webhooks | Retried up to 3 times on 5xx ([source](https://docs.sendblue.com/getting-started/webhooks)); shared-secret header (`sb-signing-secret`) ([source](https://docs.sendblue.com/security)) | Standard Webhooks signing; about 10 retries in 30 minutes ([source](https://docs.linqapp.com/channel/imessage/guides/webhooks/)) | HMAC-SHA256 ([source](https://docs.blooio.com/webhook-signatures)) | HMAC-SHA256; retried for 3 days; ordered per conversation | | SDKs | Node.js/TypeScript, Python; community Go, Rust, Ruby ([source](https://docs.sendblue.com/llms.txt)) | TypeScript/Node.js, Python, Go ([source](https://docs.linqapp.com/channel/imessage/guides/resources/faq/)) | Node.js/TypeScript, Python, Java, Go ([source](https://blooio.com/llms-full.txt)) | HTTP for any language; TypeScript SDK on npm (`@flow-engineer/messaging`); Python and Go coming | | MCP | Local (`npx`) | Local (`npx`) | Hosted | Hosted, optional | | Compliance statement | SOC 2 Type II, company-wide; HIPAA on a dedicated HIPAA instance (security page above) | SOC 2 Type I and Type II ([source](https://linqapp.com/s/security)) | SOC 2 Type 1 and Type 2 in progress; GDPR and CCPA/CPRA passing ([source](https://trust.blooio.com)); HIPAA-scoped deployments with a BAA on request ([source](https://blooio.com/llms-full.txt)) | None published | All sources as of Oct 2026. **Choose Sendblue, Linq or Blooio if** your agent must write first, you need groups, FaceTime calls (Sendblue, Blooio) or RCS and SMS fallback, you want to set up a dedicated line yourself, or you need a SOC 2 report today (Sendblue, Linq; Blooio's is in progress). **Choose Flow if** your agent answers people who write to it, and you want the same API, events and send gate as on Telegram. On Flow's iMessage line the person writes first (the line's opt-in link helps them do it), and lines are arranged with the Flow team; the iMessage sandbox is not part of the self-serve test key. ## About Flow, today * **Live:** Telegram (sandbox bot, or your own bot once you sign in with GitHub); the TypeScript SDK on npm (`@flow-engineer/messaging`); iMessage replies on lines arranged with the Flow team; signed, ordered webhooks and a WebSocket stream; the send gate; typed errors with hints; a hosted MCP server, which the project owner can choose to add to their coding tools; test keys with no account (`POST /v1/sandbox/keys`); GitHub sign-in and a dashboard. * **Not available yet:** SMS and voice. * **Coming:** WhatsApp (pending Meta's approval); Python and Go SDKs; docs.flow\.engineer (not deployed yet; meanwhile see [llms.txt](https://api.flow.engineer/llms.txt) and the [OpenAPI spec](https://api.flow.engineer/openapi.yaml)). ## Related * [Flow vs Photon (spectrum-ts)](/compare/flow-vs-photon) * [Quickstart](/quickstart), [Keys and sign-in](/get-a-key), [The send gate](/concepts/send-gate), [Events and webhooks](/concepts/events-and-webhooks) # Flow Messaging vs Photon (spectrum-ts) for AI agents Source: https://docs.flow.engineer/compare/flow-vs-photon Flow Messaging and Photon's Spectrum (spectrum-ts) compared for putting an AI agent on Telegram, iMessage and WhatsApp: channels, API shape, delivery guarantees, channel rules, MCP, sandbox, pricing and self-hosting. Every Photon claim links to Photon's own docs. **Last verified: 10 Oct 2026.** Every claim about Photon on this page links to Photon's own docs, pricing page or repository, as read in October 2026. Both products change quickly, so check the linked source before relying on a detail, and [open an issue](https://github.com/flow-engineer/sdk/issues) if something here is out of date. This page is written by the Flow Messaging team. This page compares Flow Messaging with Photon's Spectrum (`spectrum-ts`). Both give an AI agent two-way conversations on messaging apps. They are built differently: Spectrum is an open-source TypeScript SDK with an optional managed cloud, and Flow is a hosted HTTP API with SDKs on top. ## TL;DR | | Flow Messaging | Photon (Spectrum) | | - | - | - | | What it is | A hosted HTTP API (OpenAPI 3.1) that hosts the senders and keeps an event log | An open-source TypeScript SDK (`spectrum-ts`) plus the managed Spectrum Cloud ([source](https://photon.codes/docs/spectrum-ts/getting-started.md), Oct 2026) | | Channels today | Telegram (live); iMessage (live, replies only, lines arranged with the Flow team); WhatsApp **coming**, pending Meta's approval | iMessage, WhatsApp Business, Telegram, terminal, and SIP voice on iMessage lines ([source](https://photon.codes/docs/spectrum-ts/introduction.md), Oct 2026); RCS and SMS fallback on its Free, Pro and Business plans ([source](https://photon.codes/pricing), Oct 2026). The Beta API adds SMS and email ([source](https://photon.codes/docs/beta/api-reference/messages/send-a-message), Oct 2026) | | Languages | Any language over HTTP. TypeScript SDK on npm (`@flow-engineer/messaging`). Python and Go **coming** | `spectrum-ts` is TypeScript. Beta HTTP API clients for TypeScript, Python and Rust ([source](https://photon.codes/docs/beta/api-client/index.md), Oct 2026) | | HTTP send API | Yes, every operation | Stable docs: no, the SDK is the supported path ([source](https://photon.codes/docs/webhooks/quickstart.md), Oct 2026). Beta docs: yes ([source](https://photon.codes/docs/beta/api-reference/messages/send-a-message), Oct 2026) | | Receiving | Signed webhooks, a resumable WebSocket stream, or polling the event log | An async iterator in the SDK ([source](https://photon.codes/docs/spectrum-ts/getting-started.md), Oct 2026), or signed webhooks handled by `app.webhook()` on an HTTP route ([source](https://photon.codes/docs/spectrum-ts/webhooks.md), Oct 2026) | | Webhook retries and order | Retried with backoff for 3 days, then kept in the log; in order per conversation | Stable: up to 6 attempts within a default backoff budget of about 30 seconds (about 26 s on average, 39 s at most; up to about 3.5 minutes of wall clock if every attempt times out), tunable by Photon; no dead-letter queue and no per-space ordering guarantee, and Photon recommends reconciling through its API ([source](https://photon.codes/docs/webhooks/delivery.md), Oct 2026). Beta: webhook destinations with a retry budget, manual retry and dead-lettering ([source](https://api.photon.codes/openapi.json), Oct 2026) | | Status of your sends | `message.sent` and `message.failed` events, plus `delivered` and `read` where the channel reports them (Telegram reports neither) | Stable: outbound messages are not echoed as webhooks (same source). Beta: accepted, dispatched, delivered and failed events ([source](https://api.photon.codes/openapi.json), Oct 2026) | | Channel rules | One send gate for every send: new-contact budgets and pacing (and the WhatsApp window once WhatsApp opens); content a channel cannot show is refused or sent as the fallback you chose | Per feature: no-op, automatic degrade, or `UnsupportedError` ([source](https://photon.codes/docs/spectrum-ts/reactions-and-replies.md), Oct 2026) | | MCP | A hosted MCP server at `api.flow.engineer/mcp` that the project owner can choose to add | A docs-search MCP server ([source](https://photon.codes/docs/mcp), Oct 2026), and Spectrum skills that add Spectrum knowledge to AI coding tools ([source](https://photon.codes/docs/spectrum-ts/introduction.md), Oct 2026) | | Try it free | A test key with no account in one call: 1 contact, 50 messages, 7 days. Signed in with GitHub: 3 contacts, 100 messages each | Free plan: up to 10 users, unlimited daily messages with Auto Scale ([source](https://photon.codes/pricing), Oct 2026) | | Paid pricing | Not published yet; for WhatsApp, Meta's fees passed through | Pro \$25/mo; Business \$250/line/mo; Enterprise custom (same source) | | Self-hosting | No. The service is hosted only; the spec and SDKs are open source (Apache-2.0) | Yes: "fully open-sourced", self-hostable without Spectrum Cloud ([source](https://photon.codes/pricing), Oct 2026) | | Maturity | Beta, launched October 2026 | `spectrum-ts`: MIT, about 1,979 GitHub stars ([source](https://github.com/photon-hq/spectrum-ts), Oct 2026) | ## Choose Photon if * **You want to self-host, or run on your own Mac.** Spectrum is open source and runs without Photon's cloud. Its local iMessage mode reads the Messages database on your own Mac and needs no project credentials ([source](https://photon.codes/docs/spectrum-ts/providers/imessage/connection-and-routing.md), Oct 2026). * **You need WhatsApp, SMS, voice or Slack today.** Flow's WhatsApp is not live yet, and Flow has no SMS yet; Photon's plans include RCS and SMS fallback ([source](https://photon.codes/pricing), Oct 2026). Photon describes itself as an official Meta Business Technology Provider ([source](https://photon.codes/platform/whatsapp), Oct 2026), and the `spectrum-ts` README lists a Slack provider ([source](https://github.com/photon-hq/spectrum-ts), Oct 2026). * **You need iMessage groups, polls or cold outreach.** Photon says iMessage is where Spectrum is most mature, with groups, effects and per-line routing ([source](https://photon.codes/docs/spectrum-ts/introduction.md), Oct 2026). It can send polls ([source](https://photon.codes/docs/spectrum-ts/content/polls.md), Oct 2026), and its Business plan supports cold outreach to up to 50 new contacts a day per line ([source](https://photon.codes/pricing), Oct 2026). Flow's iMessage line is replies only, and Flow conversations are one-to-one. * **You write TypeScript and want streaming built into the SDK.** `text()` takes an OpenAI, Anthropic or AI SDK stream. On iMessage (remote mode) it edits the message in place; on Telegram private chats it shows a native draft preview ([source](https://photon.codes/docs/spectrum-ts/content/text.md), Oct 2026). * **You build on the Vercel Chat SDK.** Photon publishes an iMessage adapter for it ([source](https://photon.codes/docs/integrations/chat-sdk.md), Oct 2026). * **You need a compliance statement now.** Photon's homepage states it is SOC 2 Type II compliant ([source](https://photon.codes), Oct 2026). Flow does not publish a compliance certification. ## Choose Flow if * **Your agent is not written in TypeScript.** Every operation is plain HTTP and JSON with an [OpenAPI 3.1 spec](https://github.com/flow-engineer/sdk/blob/main/openapi/openapi.yaml), so Python, Go or any other language works today, without waiting for an SDK. * **You want a durable event log.** Webhooks are retried for 3 days and arrive in order per conversation; after that the events stay in the log, and `GET /v1/events?after=...` or the live stream replays them. See [Events and webhooks](/concepts/events-and-webhooks). * **Duplicate messages would hurt.** Every `POST` takes an `Idempotency-Key`, so a retried request never sends twice ([Idempotency](/concepts/idempotency)), and replies sent in a webhook answer use the event's ID as their key. * **You want channel rules enforced for you.** Every send passes one [send gate](/concepts/send-gate). Nothing is converted silently: content a channel cannot show fails with `422 unsupported_content`, or goes as the `fallback` you chose, and the message reports what was shown. * **You want errors an agent can act on.** Every error has a closed `type`, a `hint` for this case and a `doc_url` ([error types](/errors/invalid_request)). * **A coding agent should be able to start on its own.** `POST /v1/sandbox/keys` returns a test key with no account, and the shared Telegram sandbox bot is ready to use ([Keys and sign-in](/get-a-key)). The project owner can also add Flow's hosted [MCP server](/mcp) to their coding tools, so the agent can send a real test message and see what the webhook answered. * **You don't want to hold channel credentials.** Flow hosts the senders: the sandbox bot, or your own Telegram bot connected once with its token. ## Same task: receive a Telegram message and reply **Photon** (`spectrum-ts`), from Photon's Telegram setup page ([source](https://photon.codes/docs/spectrum-ts/providers/telegram/setup.md), Oct 2026). This example runs the `app.messages` loop in a long-lived process; with `projectId` and `projectSecret` (cloud mode) the provider registers the bot's webhook on startup. Spectrum can also receive over HTTP instead (see below). ```ts Photon (spectrum-ts) theme={null} import { Spectrum } from "spectrum-ts"; import { telegram } from "spectrum-ts/providers/telegram"; const app = await Spectrum({ projectId: process.env.PROJECT_ID!, projectSecret: process.env.PROJECT_SECRET!, providers: [ telegram.config({ botToken: process.env.TELEGRAM_BOT_TOKEN!, }), ], }); for await (const [space, message] of app.messages) { if (message.content.type === "text") { await space.send(`Echo: ${message.content.text}`); } } ``` **Flow**, as a webhook in any runtime with the Fetch API (Next.js, Hono, Workers, Bun). It uses plain HTTP and Node's `crypto`, so the same shape works in any language without an SDK. The bot is Flow's sandbox bot, or your own bot connected with `POST /v1/senders`; Flow holds the token and receives Telegram's webhook. ```ts Flow (HTTP, no SDK) theme={null} // app/api/flow/route.ts import { createHmac, timingSafeEqual } from "node:crypto"; // Flow-Signature: t=,v1=."> function verify(body: string, header: string, secret: string): boolean { const parts = header.split(",").map((p) => p.trim().split("=")); const t = parts.find(([k]) => k === "t")?.[1]; if (!t || Math.abs(Date.now() / 1000 - Number(t)) > 300) return false; const want = createHmac("sha256", secret).update(`t.${t}.${body}`).digest(); return parts.some(([k, v = ""]) => k === "v1" && /^[0-9a-f]{64}$/.test(v) && timingSafeEqual(want, Buffer.from(v, "hex"))); } export async function POST(req: Request) { const body = await req.text(); // the raw body: a re-serialized one will not verify if (!verify(body, req.headers.get("Flow-Signature") ?? "", process.env.FLOW_MESSAGING_WEBHOOK_SECRET!)) { return new Response(null, { status: 400 }); } const event = JSON.parse(body); if (event.type !== "message.received") return Response.json({}); // The reply goes through the send gate into the same conversation. return Response.json({ reply: `Echo: ${event.data.message.content.text ?? ""}` }); } ``` Register the URL once (the answer holds the `whsec_...` signing secret, shown once): ```bash theme={null} curl https://api.flow.engineer/v1/webhook_endpoints \ -H "Authorization: Bearer $FLOW_MESSAGING_KEY" \ -H "Content-Type: application/json" \ -d '{"url": "https://example.com/api/flow", "events": ["message.received"]}' ``` What differs: * **Where the process runs.** Both can run as a webhook. Spectrum has the `app.messages` loop for a long-lived process and a webhook mode, which Photon describes as "Receive messages via HTTP instead of a long-lived process": `app.webhook()` handles a `POST` route, with first-party adapters for Hono, Express and Elysia ([source](https://photon.codes/docs/spectrum-ts/webhooks.md), Oct 2026). Flow's handler is a stateless webhook in any language, and Flow also offers a stream (`GET /v1/stream`) for long-running workers and local development. * **What happens when your server is down.** Flow keeps retrying for 3 days and keeps every event in the log for replay. Photon's stable webhooks make up to 6 attempts within a default backoff budget of about 30 seconds, which the Photon team can tune; there is no dead-letter queue, and for zero loss Photon recommends reconciling against its API ([source](https://photon.codes/docs/webhooks/delivery.md), Oct 2026). Photon's Beta API contract adds webhook destinations with a retry budget, manual retry and dead-lettering ([source](https://api.photon.codes/openapi.json), Oct 2026). * **Who holds the bot token.** With Flow, you send the token once to `POST /v1/senders` and never handle it again. With Spectrum on Telegram, your process holds it. ## Details ### Channels * **Flow today:** Telegram is live, on the shared sandbox bot or your own bot (self-serve once you sign in). iMessage is live for replies: the person writes first, and lines are arranged with the Flow team. WhatsApp is **coming**: Flow is waiting for Meta's approval. See [Channel guides](/guides/telegram-agent). * **Photon today:** iMessage, WhatsApp Business, Telegram, terminal and SIP voice in its stable docs ([source](https://photon.codes/docs/spectrum-ts/introduction.md), Oct 2026), with RCS and SMS fallback on its Free, Pro and Business plans ([source](https://photon.codes/pricing), Oct 2026). Its Beta send endpoint adds SMS and email ([source](https://photon.codes/docs/beta/api-reference/messages/send-a-message), Oct 2026). Photon covers more channels than Flow does today. ### Channel rules * **WhatsApp window.** Flow's gate refuses free-form content more than 24 hours after the person's last message with `409 outside_window`, and the agent sends a template instead. Photon's low-level WhatsApp kit documents templates as the only way to send outside the window ([source](https://photon.codes/docs/advanced-kits/whatsapp/templates.md), Oct 2026). (Flow's WhatsApp is coming.) * **iMessage limits.** Photon documents 50 new conversations started per line per day ([source](https://photon.codes/docs/spectrum-ts/providers/imessage/connection-and-routing.md), Oct 2026). Flow's iMessage line is replies only, so the gate refuses a message to someone who has not written to the line. * **Unsupported content.** Spectrum no-ops some features silently (a reply on a platform without replies is not sent as a regular message) ([source](https://photon.codes/docs/spectrum-ts/reactions-and-replies.md), Oct 2026), degrades others (markdown reaches platforms without native formatting as readable plain text) ([source](https://photon.codes/docs/spectrum-ts/content/markdown.md), Oct 2026), and surfaces provider-specific constraints as an `UnsupportedError` ([source](https://photon.codes/docs/spectrum-ts/spaces-and-users.md), Oct 2026). Flow never converts silently: it refuses with a typed error, or sends your `fallback` and reports what was shown in `delivered_as` ([Content types](/concepts/content-types)). ### Status of Flow items marked coming * **WhatsApp:** waiting for Meta's approval. * **Python and Go SDKs:** not written yet; use the HTTP API. * **docs.flow\.engineer:** not deployed yet. Until then, the API serves [llms.txt](https://api.flow.engineer/llms.txt), the [agent quickstart](https://api.flow.engineer/docs/quickstart.md) and the [OpenAPI spec](https://api.flow.engineer/openapi.yaml). ## Related * [Choosing a messaging API for your AI agent](/compare/choosing-a-messaging-api): Flow, Photon, the Telegram Bot API, Twilio and iMessage APIs side by side. * [Quickstart](/quickstart) and [Keys and sign-in](/get-a-key). # Content types: text, media, voice notes, buttons and reactions per channel Source: https://docs.flow.engineer/concepts/content-types Every message carries one typed piece of content: text, media, voice, buttons, reactions, WhatsApp templates, locations, contact cards and iMessage effects. See what WhatsApp, Telegram and iMessage each show, and how fallbacks work. Every message carries exactly one piece of typed **content**, selected by `content.type`; the same union is used for what you receive and what you send. ```ts TypeScript theme={null} import { buttons, image, markdown } from "@flow-engineer/messaging"; await conversation.send(markdown("**Order 1042** is ready for pickup.")); await conversation.send({ content: image("https://example.com/receipt.png", { caption: "Your receipt" }) }); await conversation.send({ content: buttons("When should we deliver?", [ { id: "today", label: "Today" }, { id: "tomorrow", label: "Tomorrow" }, ]), fallback: "auto", // iMessage has no buttons: send numbered text instead }); ``` ```json HTTP body theme={null} { "content": { "type": "buttons", "text": "When should we deliver?", "buttons": [{ "id": "today", "label": "Today" }, { "id": "tomorrow", "label": "Tomorrow" }] }, "fallback": "auto" } ``` ## What each channel shows WhatsApp is not available yet; its column shows what it will support. | `type` | Telegram | WhatsApp (coming) | iMessage | `fallback: "auto"` sends | | - | - | - | - | - | | `text` | yes | yes | yes | markdown becomes plain text | | `media` | yes | yes | yes | none | | `voice` | yes | yes | yes | an audio file | | `buttons` | inline keyboard | up to 3 buttons, else a list | no | numbered text; replies matched back to button IDs | | `reaction` | yes | yes | tapbacks; other emoji as emoji reactions | the closest tapback, else skipped | | `template` | no | yes | no | none (WhatsApp only) | | `location` | yes | yes | no | a maps link as text | | `contact_card` | yes | yes | no | text | | `effect` | no | no | yes | plain text | | `typing` | yes | yes | yes, within 5 minutes of the contact's last message | skipped | | `read` | no (bots) | yes | yes, the whole conversation | skipped | | `edit` | yes | no | yes, within 15 minutes | none | | `unsend` | yes, within 48 hours | no | yes, within 2 minutes | none | Text is at most the channel's `max_text_length`: 4096 characters on Telegram and WhatsApp, 9999 on iMessage; longer text is refused with `invalid_request`. Telegram counts UTF-16 code units of the text as shown (after markdown), so an emoji such as 😀 counts as 2. A media `caption` takes at most 1024, also when an edit replaces it. Ask the API instead of this table: `GET /v1/capabilities?conversation=conv_...` returns, per content type, `native`, `fallback` (with what `auto` sends) or `unsupported`, plus the channel's size limits and whether the window is open. ## Nothing is converted silently When a channel cannot show some content: 1. **Without `fallback`**, the send fails with `422` [`unsupported_content`](/errors/unsupported_content). Nothing is sent. 2. **With `fallback: "auto"`**, Flow sends the documented default from the table above. 3. **With `fallback` set to a content object**, Flow sends exactly that instead. When what the person sees differs from what you sent, the message carries `delivered_as`: ```json theme={null} { "delivered_as": { "type": "text", "reason": "iMessage cannot show buttons; sent numbered text" } } ``` A reply to numbered-text buttons ("2") still arrives as a `button_reply` with the button's `id`, so your code handles taps the same way on every channel. ## Content you receive Inbound messages use `text`, `media`, `voice`, `button_reply`, `reaction`, `location`, `contact_card` and `file_blocked`. * **Media and voice notes** always come with the file: `url` (a Flow file URL that redirects to short-lived signed bytes) and `file_id`. Fetch it with your API key, or with `flow.files.download(url)`. * **Voice note transcripts**: Flow does not transcribe voice notes yet (the app setting `transcription` cannot be turned on). On iMessage, a voice note may carry a `transcript` when the channel provides one; on Telegram it never does. The audio is always included. * **Button taps** arrive as `button_reply` with `button_id` and `label`. * **`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, or (once malware scanning is on) it failed or could not get the scan. The `reason` says which, and there is no `url`. * **Documents are not malware-scanned yet.** Documents and archives people send you (PDF, Office files, zip and the like) are kept unscanned: treat them as untrusted. In TypeScript, `contentText(content)` returns the readable text of any content: the text, a caption, a transcript (when there is one) or a tapped button's label. ## Sending files Give media as an HTTPS `url` Flow can fetch, or upload it first and send its `file_id`: ```bash curl theme={null} curl https://api.flow.engineer/v1/files \ -H "Authorization: Bearer $FLOW_MESSAGING_KEY" \ -F file=@receipt.png -F channel=telegram ``` Files up to 100 MB are taken; with `channel` set, the file is checked against that channel's limit at once. Until malware scanning is on, uploads of documents and archives (PDF, Office files, zip and the like) are refused with `422` [`unsupported_content`](/errors/unsupported_content), and so are sends of documents people sent you; images, audio and video are not affected. Files are kept for your plan's retention period (30 days by default). ## Typing, read receipts, edits and reactions These are content too, so they work the same way over HTTP, the live stream and webhook replies. Over HTTP each also has its own endpoint: | Action | Endpoint | TypeScript | | - | - | - | | Typing on/off | `POST /v1/conversations/{id}/typing` | `conversation.typing("on")` | | Mark as read | `POST /v1/conversations/{id}/read` | `conversation.markRead()` | | React | send `reaction` content | `conversation.react(messageId, "👍")` | | Edit text | `PATCH /v1/messages/{id}` | `flow.messages.edit(id, "New text")` | | Unsend | `DELETE /v1/messages/{id}` | `flow.messages.unsend(id)` | Typing indicators clear themselves after a few seconds on every channel; keep turning them on while your agent works, or use `conversation.responding(fn)`, which does it for you and always turns typing off. 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` (the channel failed or timed out). Both are safe to ignore: never hold back a reply because of them. On iMessage, marking read marks the whole conversation, and `up_to` has no effect. ## The escape hatch `channel_options` adds channel parameters for how a message looks and notifies, for example `{"parse_mode": "HTML"}` on Telegram. Each channel has an allowlist: keys not on it are dropped, never passed to the channel, and the typed request wins where both set the same thing. Who receives the message and what it says always come from the typed request; keys starting with `_flow_` are refused with `invalid_request`. Flow does not validate the values, and they may break when a channel changes. Prefer typed content. | Channel | Allowed keys | | - | - | | Telegram | `parse_mode`, `entities`, `caption_entities`, `link_preview_options`, `disable_web_page_preview`, `show_caption_above_media`, `disable_notification`, `protect_content`, `allow_paid_broadcast`, `message_effect_id`, `has_spoiler`, `supports_streaming`, `duration`, `width`, `height`, `performer`, `horizontal_accuracy`, `foursquare_id`, `foursquare_type`, `google_place_id`, `google_place_type`, `vcard`, `is_big` (reactions) | | iMessage | messages: `subject`, `effect`, `preview`, `reply_to_id`, `contact_file`; `typing` content: `typing` (1 to 60 seconds). Reactions, read receipts, edits and unsends take none. | | WhatsApp | none yet (not live) | ## Related * [Streaming replies](/concepts/streaming-replies): turning model output into chat bubbles. * API: [Send a message](/api-reference/messages/send-a-message-into-a-conversation), [Get capabilities](/api-reference/capabilities/get-a-conversations-capabilities), [Upload a file](/api-reference/files/upload-a-file). # Conversations, contacts and channels Source: https://docs.flow.engineer/concepts/conversations How Flow Messaging models a chat: a contact is a person on one channel, a conversation is one sender talking with one contact, and your agent replies into the conversation instead of choosing a channel per message. A **conversation** is one sender talking with one contact; your agent always replies into a conversation, so the channel is already decided. ```ts TypeScript theme={null} // From an event: the conversation is attached and ready to use. await event.conversation.reply("Thanks! Your order is on its way."); // From an ID you stored earlier: await flow.conversation("conv_01JB8ZC3K5M7P9R1T3V5X7Z9B1").send("Your order has shipped."); ``` ```bash curl theme={null} curl https://api.flow.engineer/v1/conversations/conv_01JB8ZC3K5M7P9R1T3V5X7Z9B1/messages \ -H "Authorization: Bearer $FLOW_MESSAGING_KEY" \ -H "Content-Type: application/json" \ -d '{"content": {"type": "text", "text": "Your order has shipped."}}' ``` ## The objects | Object | ID | What it is | | - | - | - | | Sender | `snd_` | What your agent talks from: a Telegram bot or an iMessage line (a WhatsApp number once WhatsApp is available). See [Senders](/concepts/senders). | | Contact | `ct_` | A person on **one** channel: a phone number, a WhatsApp username, a Telegram user or an iMessage handle. | | Conversation | `conv_` | One sender with one contact. Holds the window state and whether the contact opted in. | | Message | `msg_` | One message in or out, with typed [content](/concepts/content-types) and a status. | | Event | `evt_` | One entry in your app's ordered log. See [Events and webhooks](/concepts/events-and-webhooks). | IDs are a prefix plus a ULID, so they sort by creation time. ## One channel per conversation * **There is no cross-channel identity.** The same person on Telegram and on WhatsApp is two contacts and two conversations. Flow never guesses that they are the same person, and never moves a conversation to another channel. * **Never assume a phone number.** A Telegram contact has a user ID, a WhatsApp contact may have only a username, and an iMessage handle may be an email address. Read `contact.address` for what the channel gives. * **Reply where they wrote.** Each event carries `conversation.channel`, so one agent can serve every channel with the same code. ## What a conversation tells you ```json theme={null} { "id": "conv_01JB8ZC3K5M7P9R1T3V5X7Z9B1", "channel": "telegram", "sender": "snd_01JB8ZC3K5M7P9R1T3V5X7Z9B1", "contact": "ct_01JB8ZC3K5M7P9R1T3V5X7Z9B1", "livemode": true, "last_inbound_at": "2026-11-03T10:15:00Z", "opted_in": true, "created_at": "2026-11-01T08:00:00Z" } ``` * `window_open_until` (WhatsApp only, once available; absent on Telegram and iMessage): until when free-form messages may be sent. After it, only a template. Every event repeats it in `conversation.window_open_until`. * `opted_in`: the contact wrote first, joined the sandbox, or you recorded their consent. * `metadata`: up to 20 string pairs of your own (for example your user ID), kept with the conversation. ## Starting a conversation Most conversations start when the person writes first. To write first, use `POST /v1/messages` with a sender and an address on that sender's channel. On Telegram that works only for people who already started your bot; Flow's iMessage lines are reply-only, so they cannot write first. ```bash curl theme={null} curl https://api.flow.engineer/v1/messages \ -H "Authorization: Bearer $FLOW_MESSAGING_KEY" \ -H "Content-Type: application/json" \ -d '{ "sender": "snd_01JB8ZC3K5M7P9R1T3V5X7Z9B1", "to": { "telegram_user_id": "123456789" }, "content": { "type": "text", "text": "Your order has shipped." } }' ``` `to` takes exactly one of `contact`, `phone`, `telegram_user_id` or `handle`. Starting a conversation spends the sender's new-contact budget, and each channel has its own rule for who may be messaged first. See [The send gate](/concepts/send-gate). ## Related * [Capabilities](/api-reference/capabilities/get-a-conversations-capabilities): what the conversation's channel can show right now. * [List conversations](/api-reference/conversations/list-conversations) and [list a conversation's messages](/api-reference/conversations/list-a-conversations-messages). # Events and webhooks: receive Telegram and iMessage messages Source: https://docs.flow.engineer/concepts/events-and-webhooks Everything your agent receives is an event in one ordered log, delivered by signed webhook, by a live WebSocket stream, or by polling. Verify signatures, handle retries and ordering, and reply straight from the webhook response. Everything your agent receives (messages, delivery updates, reactions, sender changes) is an **event** in your app's ordered log, and this page covers the three ways to get them and how to answer. ```ts TypeScript (Next.js route handler) theme={null} // app/api/flow/route.ts import { FlowMessaging, contentText } from "@flow-engineer/messaging"; const flow = new FlowMessaging(); // Verifies Flow-Signature with FLOW_MESSAGING_WEBHOOK_SECRET, then calls onEvent. // For message.received, what onEvent returns is sent as the reply. export const POST = flow.webhooks.handler({ onEvent: async (event) => { if (event.type !== "message.received") return; return `You said: ${contentText(event.data.message.content)}`; }, }); ``` ## Three ways to receive events | Way | Use it when | How | | - | - | - | | **Webhook** | Production; serverless | Register an HTTPS URL with `POST /v1/webhook_endpoints`. Flow `POST`s each event, signed. | | **Live stream** | Long-running workers, local scripts | `GET /v1/stream` (WebSocket) or `flow.events.stream()` in TypeScript. Resumable with `after`. | | **Polling** | Catch-up, replay, debugging | `GET /v1/events?after=evt_...`, oldest first. | All three read the same log, so you can mix them: for example a webhook in production and `GET /v1/events` to recover after downtime. ## The event shape ```json theme={null} { "id": "evt_01JB8ZC3K5M7P9R1T3V5X7Z9B1", "type": "message.received", "created_at": "2026-11-03T10:15:00Z", "app": "app_01JB8ZC3K5M7P9R1T3V5X7Z9B1", "livemode": true, "conversation": { "id": "conv_01JB8ZC3K5M7P9R1T3V5X7Z9B1", "channel": "telegram", "sender": "snd_01JB8ZC3K5M7P9R1T3V5X7Z9B1", "contact": "ct_01JB8ZC3K5M7P9R1T3V5X7Z9B1" }, "data": { "message": { "id": "msg_01JB8ZC3K5M7P9R1T3V5X7Z9B1", "direction": "in", "status": "received", "content": { "type": "text", "text": "Do you deliver to 560103?" } } }, "timing": { "received_at": "2026-11-03T10:15:00.012Z", "stored_at": "2026-11-03T10:15:00.020Z", "delivered_at": "2026-11-03T10:15:00.041Z" } } ``` `timing` shows Flow's own overhead: `delivered_at` minus `received_at`. ## Event types | `type` | When | | - | - | | `message.received` | The contact sent something. A button tap arrives as `button_reply` content. | | `message.sent`, `message.delivered`, `message.read` | Your message's progress, where the channel reports it. | | `message.failed` | Your message could not be sent; `data.message.error` says why, with an [error type](/errors/channel_error). | | `reaction.added`, `reaction.removed` | The contact reacted to a message. | | `typing.started`, `typing.stopped` | The contact is typing, where the channel reports it. | | `conversation.started` | A new contact wrote first, joined the sandbox, or you started a conversation (`via`). | | `conversation.window_closing` | WhatsApp only (not available yet), opt-in: the 24-hour window closes in 1 hour. | | `sender.status_changed` | A sender was throttled, flagged, banned or restored (or, once WhatsApp is available, its quality changed). | | `template.status_changed` | A WhatsApp template was approved, rejected or paused (WhatsApp is not available yet). | Status events are high volume. Each webhook endpoint subscribes to the types it lists, so subscribe only to what you use. ## Register a webhook endpoint ```bash curl theme={null} curl https://api.flow.engineer/v1/webhook_endpoints \ -H "Authorization: Bearer $FLOW_MESSAGING_KEY" \ -H "Content-Type: application/json" \ -d '{"url": "https://example.com/api/flow", "events": ["message.received", "message.failed"]}' ``` The answer includes the endpoint's signing `secret` (`whsec_...`), shown only this once. Store it as `FLOW_MESSAGING_WEBHOOK_SECRET`. Endpoints belong to the mode of the key that created them: a test key's endpoint receives test events. ## Verify the signature Every delivery carries these headers: ```text theme={null} Flow-Signature: t=1791763200,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd Flow-Event-Id: evt_01JB8ZC3K5M7P9R1T3V5X7Z9B1 Flow-Event-Type: message.received Flow-Version: 2026-11-01 ``` `v1` is the lowercase hex HMAC-SHA256, keyed with the endpoint secret, of the string `t.{t}.{body}`: the letter `t`, a dot, the timestamp from the header, a dot, and the **raw** request body exactly as received. Compare in constant time and reject timestamps more than 5 minutes from your clock. While a secret is being rotated the header carries one `v1` per active secret; accept the request if any matches. ### Rotate the signing secret `POST /v1/webhook_endpoints/{webhook_endpoint_id}/rotate_secret` makes a new secret and returns it in `secret`, shown only this once. The old secret keeps signing for `overlap_seconds` (default 86400, one day; `0` retires it at once), so every delivery during the overlap carries two `v1` values and verifies with either secret while you deploy the new one. `previous_secret_expires_at` says when the old one stops. Rotating again during an overlap retires the older secret at once: at most two are ever active. So send an `Idempotency-Key` and retry with the same key; a retry without one is a second rotation, which retires the secret you still have deployed. A repeat of a rotation that went through answers `409 idempotency_conflict` (the new secret is shown only once). ```ts theme={null} const { secret } = await flow.webhookEndpoints.rotateSecret("we_...", { overlap_seconds: 3600 }); // Store secret as FLOW_MESSAGING_WEBHOOK_SECRET and deploy within the hour. ``` ```ts TypeScript (Express) theme={null} import express from "express"; import { FlowMessaging, contentText } from "@flow-engineer/messaging"; const flow = new FlowMessaging(); const app = express(); // The raw body is required: a parsed and re-serialized body will not match. app.post("/flow", express.raw({ type: "application/json" }), async (req, res) => { let event; try { event = await flow.webhooks.constructEvent(req.body, req.header("Flow-Signature"), process.env.FLOW_MESSAGING_WEBHOOK_SECRET!); } catch { return res.status(400).end(); } if (event.type === "message.received") { return res.json(flow.webhooks.reply(`You said: ${contentText(event.data.message.content)}`)); } res.json({}); }); app.listen(3000); ``` ```python Python (FastAPI) theme={null} # The Python SDK is not published yet, so this checks the signature by hand. import hashlib, hmac, json, os, time from fastapi import FastAPI, Request, Response app = FastAPI() SECRET = os.environ["FLOW_MESSAGING_WEBHOOK_SECRET"] def verify(body: bytes, header: str, secret: str, tolerance: int = 300) -> bool: fields = [part.strip().split("=", 1) for part in header.split(",") if "=" in part] t = next((v for k, v in fields if k == "t"), None) signatures = [v for k, v in fields if k == "v1"] if t is None or not t.isdigit() or abs(time.time() - int(t)) > tolerance: return False expected = hmac.new(secret.encode(), b"t." + t.encode() + b"." + body, hashlib.sha256).hexdigest() return any(hmac.compare_digest(expected, s) for s in signatures) @app.post("/flow") async def flow_webhook(request: Request): body = await request.body() if not verify(body, request.headers.get("Flow-Signature", ""), SECRET): return Response(status_code=400) event = json.loads(body) if event["type"] == "message.received": text = event["data"]["message"]["content"].get("text", "") return {"reply": {"type": "text", "text": f"You said: {text}"}} return {} ``` ```go Go (net/http) theme={null} // The Go SDK is not published yet, so this checks the signature by hand. package main import ( "crypto/hmac" "crypto/sha256" "encoding/hex" "encoding/json" "io" "net/http" "os" "strconv" "strings" "time" ) func verify(body []byte, header, secret string) bool { var ts string var sigs []string for _, part := range strings.Split(header, ",") { k, v, _ := strings.Cut(strings.TrimSpace(part), "=") switch k { case "t": ts = v case "v1": sigs = append(sigs, v) } } t, err := strconv.ParseInt(ts, 10, 64) if err != nil { return false } if d := time.Now().Unix() - t; d > 300 || d < -300 { return false } mac := hmac.New(sha256.New, []byte(secret)) mac.Write([]byte("t." + ts + ".")) mac.Write(body) want := hex.EncodeToString(mac.Sum(nil)) for _, s := range sigs { if hmac.Equal([]byte(want), []byte(s)) { return true } } return false } func main() { secret := os.Getenv("FLOW_MESSAGING_WEBHOOK_SECRET") http.HandleFunc("/flow", func(w http.ResponseWriter, r *http.Request) { body, _ := io.ReadAll(r.Body) if !verify(body, r.Header.Get("Flow-Signature"), secret) { w.WriteHeader(http.StatusBadRequest) return } var ev struct { Type string `json:"type"` Data struct { Message struct { Content struct { Text string `json:"text"` } `json:"content"` } `json:"message"` } `json:"data"` } _ = json.Unmarshal(body, &ev) w.Header().Set("Content-Type", "application/json") if ev.Type != "message.received" { w.Write([]byte("{}")) return } json.NewEncoder(w).Encode(map[string]any{ "reply": map[string]any{"type": "text", "text": "You said: " + ev.Data.Message.Content.Text}, }) }) http.ListenAndServe(":3000", nil) } ``` ## Reply in the webhook response To answer a `message.received` at once, respond `200` with a reply body. Flow sends it into the event's conversation through the [send gate](/concepts/send-gate), exactly as if you had called `POST /v1/conversations/{conversation_id}/messages` with the event's `id` as the idempotency key. This saves a whole round trip. ```json theme={null} { "reply": { "type": "text", "text": "Yes, we deliver to 560103." } } ``` `reply` may also be a list of up to 10 pieces of content, sent in order. Add `fallback` (`"auto"` or your own content) to say what to send where the channel cannot show a piece, as on HTTP sends; it applies to every piece: ```json theme={null} { "reply": [{ "type": "text", "text": "Which size?" }, { "type": "buttons", "text": "Pick one", "buttons": [{ "id": "s", "label": "Small" }, { "id": "m", "label": "Medium" }] }], "fallback": "auto" } ``` * An empty body, `{}`, `{"reply": null}` or a body that is not a JSON object (plain text such as `OK`) sends nothing, and is not an error. * A JSON object with a `reply` that is not valid, or a body that starts with `{` but is not valid JSON (a `reply` that is not content or a list of content, an empty list, more than 10 pieces, an unknown `fallback`) sends **nothing at all**, not even the valid pieces. It is recorded on the delivery as an [`invalid_request`](/errors/invalid_request) error with the reason, which the MCP tool `get_webhook_deliveries` shows. The delivery counts as delivered and is not retried. * A piece the send gate refuses arrives later as a `message.failed` event. **Slow agents answer `200 {}` at once** and send later with `POST /v1/conversations/{conversation_id}/messages`. Keep your handler under 10 seconds; anything that takes longer belongs after the response. ## Delivery rules * **Answer `2xx` within 10 seconds.** Anything else, or no answer, is a failed delivery. * **Retries:** failed deliveries are retried with backoff for 3 days. After that the event stays in the log, marked failed, and you can read it again with `GET /v1/events`. * **At least once:** a delivery can repeat. Deduplicate on the event's `id` (also in `Flow-Event-Id`). * **Ordered per conversation:** events in one conversation arrive in order, one at a time. Later events in that conversation wait behind a failing one, so a slow endpoint holds back only its own conversations. * **Ordering in the log:** events are ordered by commit, not by `created_at`. Reading forward with `after` never skips an event. * **Disabled endpoints** keep their place: nothing is lost from the log. ## The live stream `GET /v1/stream` upgrades to a WebSocket. Every frame is one JSON object: the server sends `event`, `ack`, `error` and `reconnect` frames, and you may send `send` and `start` frames with the same bodies as the HTTP send endpoints (`ref` is your idempotency key, echoed in the `ack` or `error`). Pass `after` to resume: the stream first replays everything after that event, then goes live. When the server is about to restart it sends `reconnect`; reconnect with `after` set to the last event you received. The TypeScript SDK does all of this for you in `flow.events.stream()`, and polls `GET /v1/events` where the runtime has no WebSocket. Authenticate with the `Authorization` header. Where your WebSocket cannot set headers (a browser's `WebSocket`, and Node's global `WebSocket` on a server), offer the key as a subprotocol instead: offer both `flow` and `flow.key.`. This is as valid from server-side code as from a browser. The server selects `flow` and never echoes the key; offering the key protocol without `flow` is refused with `400 invalid_request`, and an `Authorization` header, when present, takes precedence. Keys are never accepted in the query string, since URLs end up in logs. The TypeScript SDK offers the subprotocol by itself where the WebSocket cannot send headers. ```js theme={null} const ws = new WebSocket("wss://api.flow.engineer/v1/stream", ["flow", "flow.key." + key]); ``` A refused stream (a bad, revoked or expired key, a bad parameter, too many streams for the key) is still upgraded, because most WebSocket clients cannot read an HTTP status: the server sends one `error` frame with the usual error body, then closes with 4000 plus the HTTP status. Stop reconnecting on `4401` (`authentication`), `4403` (`permission`) and `4400` (`invalid_request`); reconnect after `retry_after` on `4429` and `4503`. An open stream whose key is revoked also gets an `authentication` error frame and close `4401`. A key used in a browser is visible to whoever uses that page. Do this only for internal tools or with test keys (`fk_test_`); keep live keys on your server. ## Related * [Local development](/guides/local-development): receive events on your laptop through the live stream, with no public URL. * API: [The webhook event](/api-reference/webhook-endpoints/an-event-delivered-to-your-endpoint), [Create a webhook endpoint](/api-reference/webhook-endpoints/create-a-webhook-endpoint), [List events](/api-reference/events/list-events), [Open the live stream](/api-reference/stream/open-the-live-event-stream-websocket). # Idempotency: retry sends without double messages Source: https://docs.flow.engineer/concepts/idempotency Send an Idempotency-Key with every POST to Flow Messaging so a retried request never sends a WhatsApp, Telegram or iMessage message twice. Keys are kept 24 hours per app and mode. An `Idempotency-Key` header makes a `POST` safe to retry: a repeat with the same key returns the first answer and does not act twice, so a person never gets the same message twice. ```bash theme={null} curl https://api.flow.engineer/v1/conversations/conv_01JB8ZC3K5M7P9R1T3V5X7Z9B1/messages \ -H "Authorization: Bearer $FLOW_MESSAGING_KEY" \ -H "Idempotency-Key: evt_01JB8ZC3K5M7P9R1T3V5X7Z9B1" \ -H "Content-Type: application/json" \ -d '{"content": {"type": "text", "text": "Got it!"}}' ``` ## Rules * Any unique string up to 255 characters works; a UUID or ULID is a good choice. Every `POST` accepts one. * Keys are kept for **24 hours** per app and mode. * A repeat returns the first answer with the header `Idempotent-Replayed: true`. * Reusing a key with a different method, path or body fails with `409` [`idempotency_conflict`](/errors/idempotency_conflict). So does a repeat that arrives while the first request is still running, and a repeat of a request that made a secret shown only once (creating a webhook endpoint, rotating its secret): Flow does not keep that answer, so it cannot show the secret again. * Answers worth retrying (`429`, `500`, `502`, `503`, `504`) are **not** kept, so a retry with the same key runs again. ## Good keys for agents * **Replying to an event:** use the event's `id`, plus the bubble index when you send several messages (`evt_...:0`, `evt_...:1`). A redelivered webhook then cannot make your agent answer twice. * **Webhook replies** already use the event's `id` as their key. * **Stream frames:** the `ref` of a `send` or `start` frame is its idempotency key. * **The TypeScript SDK** generates a key for every `POST` and reuses it across its own retries; pass `idempotencyKey` to choose it yourself. # Rate limits Source: https://docs.flow.engineer/concepts/rate-limits Flow Messaging limits requests per API key and reports the budget in RateLimit headers. A 429 rate_limited answer, for the per-key limit or a sender's sending rate, carries Retry-After. New-contact budgets per sender come from the send gate. Requests are limited per API key, and every answer tells you where you stand, so a client can slow down before it is refused. ```http theme={null} RateLimit-Limit: 100 RateLimit-Remaining: 97 RateLimit-Reset: 12 ``` ## Rules * `RateLimit-Limit`, `RateLimit-Remaining` and `RateLimit-Reset` (seconds) come with every answer. * Over the limit, the API answers `429` with type [`rate_limited`](/errors/rate_limited), a `Retry-After` header and `error.retry_after` in seconds. Wait that long, then retry with the **same** idempotency key. * The TypeScript SDK retries `429` and `5xx` answers for you (2 retries by default, `maxRetries` to change it), honouring `Retry-After`. * **Each sender also has a sending rate (pacing).** [The send gate](/concepts/send-gate) lets a sender send only so many messages a second; a send over it is also refused with `429 rate_limited`, `Retry-After` and `error.retry_after`, and `error.sender` names the sender. Retry the same way, with the same idempotency key. The `RateLimit-*` headers describe only the per-key limit. * **New-contact budgets are separate.** How many new conversations a sender may start is decided by the send gate (`new_contact_limit`, `sender_throttled`), not by these headers. * Use webhooks or the live stream instead of polling `GET /v1/events` in a tight loop. # The send gate: channel rules, new-contact limits and warm-up Source: https://docs.flow.engineer/concepts/send-gate Every send passes one gate that applies each channel's rules: who may be messaged first on Telegram and iMessage, WhatsApp's 24-hour window and templates (once WhatsApp is available), new-contact budgets, warm-up, pacing and abuse protection. Every send, whether over HTTP, the live stream or a webhook reply, passes one **send gate** that applies the channel's rules before anything leaves Flow; this page lists those rules. ```ts TypeScript theme={null} import { OutsideWindowError, template } from "@flow-engineer/messaging"; // WhatsApp (not available yet; shown for when it is): try { await conversation.send("Your table is ready."); } catch (err) { if (err instanceof OutsideWindowError) { // More than 24 hours since they last wrote. Only a template may go. await conversation.send(template("tpl_01JB8ZC3K5M7P9R1T3V5X7Z9B1", "en", { body: ["Table 12"] })); } else throw err; } ``` ## Rule 1: is the conversation open? | Channel | You may send free-form content when... | Otherwise | | - | - | - | | WhatsApp (not available yet) | the contact's last message is less than 24 hours old (the **window**) | only an approved `template`; anything else gets `409` [`outside_window`](/errors/outside_window) | | iMessage | the contact has written to this line before | a new contact is refused with `403` [`permission`](/errors/permission): Flow's iMessage lines are reply-only today | | Telegram | the contact has started your bot | Telegram refuses the message itself | | Sandbox senders | the contact joined through your app with its join code | refused | On WhatsApp, `conversation.window_open_until` (on the conversation and on every event) says when the window closes. Subscribe to `conversation.window_closing` to hear about it 1 hour before. ## Rule 2: starting a conversation spends budget Writing to someone first (`POST /v1/messages`) spends the sender's **new-contact budget**: * **Per sender**: a number of new contacts per day and per hour, in `sender.limits`. * **Warm-up**: a new sender that writes first starts with a small daily budget that grows day by day while it is `warming_up`. * **WhatsApp** (once available): also the number's messaging tier (`sender.limits.whatsapp_tier`). * **Telegram**: a bot can write only to people who started it, so in practice its budget is not the limit. * **iMessage**: Flow's lines are reply-only today, so writing first gets `403` [`permission`](/errors/permission). When the budget is used up the send fails with `429` [`new_contact_limit`](/errors/new_contact_limit) and `retry_after` in seconds. A message to someone who already has an open conversation with that sender goes into it and spends nothing. ## Rule 3: abuse protection The gate watches for signs that a sender is being used for unwanted messages: * the same text going to many new contacts; * a high share of new conversations that get no reply; * people blocking the sender; * a drop in WhatsApp's quality rating (once WhatsApp is available). When one trips, the sender is **throttled**: until `throttled_until`, starting conversations fails with `429` [`sender_throttled`](/errors/sender_throttled), while replies into existing conversations still go. You receive `sender.status_changed` with a one-sentence `reason`, and another when the sender recovers by itself. ## Rule 4: pacing and order * Each sender has a sending rate (pacing): only so many messages a second, from all your conversations together. A send over it is refused with `429` [`rate_limited`](/errors/rate_limited) and `retry_after`; wait that long and retry with the same idempotency key. * At most one message per conversation is in flight at a time, and messages go out in the order you sent them. ## Write agents that respect the gate * **Reply, don't broadcast.** Agents that answer people who wrote first never meet rules 2 and 3. * **Check before you send.** `GET /v1/capabilities?conversation=conv_...` returns `window.open` and what each content type would do. * **Retry only what clears by itself.** `new_contact_limit`, `sender_throttled` and `rate_limited` carry `retry_after`; `outside_window` will not clear until the person writes again, so wait for them to message the line (iMessage), or send a template instead (WhatsApp, once available). * **Watch `sender.status_changed`.** It is the early warning before a channel acts against a number. ## Related * [Senders](/concepts/senders): statuses and limits. * [Going live](/guides/going-live): templates and dedicated numbers. * [Rate limits](/concepts/rate-limits): request limits per API key, which are separate from the gate. # Senders: the shared sandbox and dedicated numbers, bots and lines Source: https://docs.flow.engineer/concepts/senders A sender is what your AI agent talks from: a Telegram bot or an iMessage line (WhatsApp numbers are coming). Use Flow's shared Telegram sandbox bot while building and a dedicated sender of your own to go live. A **sender** is what your agent talks from: a Telegram bot or an iMessage line (and a WhatsApp number once WhatsApp is available). Flow hosts every sender, so you never manage channel credentials. | Channel | Shared sandbox (test key) | Dedicated (live key) | | - | - | - | | Telegram | Flow's sandbox bot | Your own bot: connect its token with `POST /v1/senders` | | iMessage | None | A line the Flow team connects to your app; replies only | | WhatsApp | Not available yet | Not available yet (waiting on Meta's approval) | ```bash theme={null} curl https://api.flow.engineer/v1/senders \ -H "Authorization: Bearer $FLOW_MESSAGING_KEY" ``` ```json theme={null} { "data": [ { "id": "snd_01JB8ZC3K5M7P9R1T3V5X7Z9B1", "channel": "telegram", "kind": "shared", "livemode": false, "status": "active", "address": { "username": "flow_sandbox_bot", "link": "https://t.me/flow_sandbox_bot?start=wild-otter-04508705" }, "join_code": "join wild-otter-04508705", "limits": { "new_contacts_per_day": 50, "new_contacts_per_hour": 10 }, "created_at": "2026-11-01T09:00:00Z" } ], "has_more": false } ``` `address.link` opens a chat with the sender: `https://t.me/...` for a Telegram bot (and `https://wa.me/...` for a WhatsApp number, once available). On the Telegram sandbox bot the link carries your join code (`https://t.me/?start=`), so opening it and tapping **Start** joins your app. For an iMessage line it is the line's opt-in link, which opens Messages with the line and a prefilled text the person sends to start the conversation; it is set only when the line has one configured. ## Two kinds of sender | | Shared (sandbox) | Dedicated | | - | - | - | | Who uses it | Many apps, in test mode | Your app only, in live mode | | Key | `fk_test_...` | `fk_live_...` | | Who it can talk to | Only people who sent your app's join code | Anyone the channel's rules allow | | Name people see | Flow's sandbox name | Your brand | | How you get it | Already there | Telegram: `POST /v1/senders` with your bot's token. iMessage: ask the Flow team. See [Going live](/guides/going-live) | ## The sandbox and join codes A shared sender serves many apps at once. A person joins **your** app by sending its join code, such as `join wild-otter-04508705`, to the sandbox sender. On Telegram, opening the sender's `address.link` and tapping **Start** does the same; typing `join ` is the fallback when the link can't be used. From then on their messages reach your app, and your app can message them. * A join code is two words and eight digits. Your app's code is `sandbox_join_code` in `GET /v1/app` (the bare code, for example `wild-otter-04508705`); each shared sender shows what a person sends as `join_code` (`join wild-otter-04508705`). * A join starts a conversation and sends you `conversation.started` with `via: "sandbox_join"`. * Your test key cannot message anyone who has not joined. That is what keeps the sandbox safe for everyone. ## Status and limits Each sender has its own status and budget for starting conversations: | `status` | Meaning | | - | - | | `pending` | Requested; Flow is provisioning it (for senders Flow provisions, such as WhatsApp numbers once available). | | `active` | Sending normally. | | `warming_up` | Active, with a new-contact budget that grows day by day. | | `throttled` | Slowed after an abuse signal; recovers by itself at `throttled_until`. Replies still go. | | `flagged` | Flagged by the channel or by Flow; starting conversations is paused. A Telegram bot whose token Telegram rejected is `flagged` and holds its queued messages until you send the new token (`POST /v1/senders`); held messages fail with `outside_window` (`channel_code` `queued_too_long`) after 72 hours. | | `banned` | It cannot send or receive: the channel banned it, you disconnected it (`DELETE /v1/senders/{sender_id}`), or its bot or line was connected to another app. | `limits.new_contacts_per_day` and `limits.new_contacts_per_hour` are the sender's current budget for starting conversations; WhatsApp senders (once available) also show `quality_rating` and `limits.whatsapp_tier`. Every change arrives as a `sender.status_changed` event. The rules behind them are on [The send gate](/concepts/send-gate). ## Related * [Conversations and channels](/concepts/conversations) * [Test and live mode](/concepts/test-and-live-mode) * [Going live](/guides/going-live) * API: [List senders](/api-reference/senders/list-senders), [Request a dedicated sender](/api-reference/senders/request-a-dedicated-sender) # Streaming LLM replies as chat bubbles on Telegram and iMessage Source: https://docs.flow.engineer/concepts/streaming-replies Send a streamed LLM answer to Telegram or iMessage as natural chat bubbles: pass an OpenAI, Anthropic, Vercel AI SDK, LangChain or Mastra stream to reply(), or follow the bubble rule over plain HTTP. Messaging apps have no streaming text box, so a streamed model answer is best sent as a few natural **bubbles**, each sent as soon as it is complete, with the typing indicator on in between. ```ts TypeScript theme={null} import Anthropic from "@anthropic-ai/sdk"; import { FlowMessaging, contentText } from "@flow-engineer/messaging"; const flow = new FlowMessaging(); const anthropic = new Anthropic(); for await (const event of flow.events.stream({ types: ["message.received"] })) { const stream = anthropic.messages.stream({ model: "claude-sonnet-4-5", max_tokens: 1024, messages: [{ role: "user", content: contentText(event.data.message.content) }], }); const sent = await event.conversation.reply(stream); // typing on, bubbles out, typing off console.log(`sent ${sent.length} bubbles`); } ``` ## What `reply()` does `conversation.reply(input)` accepts a string, a piece of content, a list of content, or a stream. With a stream it: 1. Turns the typing indicator on, and keeps it on (channels clear it after about 5 seconds). 2. Reads text out of the stream as it arrives. 3. Cuts a bubble at each natural break (below) and sends it while the model is still writing. Bubbles go out in order. 4. Turns typing off when the stream ends, even if it failed. It reads these streams as they are, with no adapter: | Source | Pass | | - | - | | OpenAI Chat Completions | `openai.chat.completions.create({ ..., stream: true })` | | OpenAI Responses | `openai.responses.create({ ..., stream: true })` | | Anthropic Messages | `anthropic.messages.stream(...)` or `create({ ..., stream: true })` | | Vercel AI SDK | the `streamText(...)` result | | OpenAI Agents SDK | the streamed `run(agent, input, { stream: true })` result | | Claude Agent SDK | the `query(...)` iterator | | LangChain | `model.stream(...)` or a runnable's `.stream(...)` | | Mastra | `agent.stream(...)` | | Anything else | any `AsyncIterable` or `ReadableStream` | Text goes out as markdown with `fallback: "auto"`, so channels without formatting get clean plain text. ```ts theme={null} await event.conversation.reply(stream, { idempotencyKey: event.id, // bubbles get event.id:0, event.id:1, ... so retrying the reply is safe split: true, // false: one message, cut only at the channel's length limit onBubble: (message, i) => console.log(i, message.id), }); ``` ## The bubble rule (for any language) Without the SDK, apply this rule to the model's text and send each bubble with `POST /v1/conversations/{conversation_id}/messages`, one after another, turning typing on (`POST .../typing`) before you start and every 4 seconds until you finish. 1. A bubble ends at a paragraph break (a blank line), except after a lead-in line that ends with `:` and between items of one list, unless the bubble is already past the channel's soft length. 2. Past the soft length, a bubble ends at the next sentence end (`. `, `! `, `? ` or `…` followed by a space). 3. Never cut inside a fenced code block, unless the bubble would pass the channel's hard limit; then cut at the last line break or space before it. | Channel | Soft length | Hard limit | | - | - | - | | Telegram | 900 characters | 4096 | | WhatsApp (coming) | 700 characters | 4096 | | iMessage | 400 characters | 9999 | Use the event's ID plus the bubble's index as each send's `Idempotency-Key` (`evt_...:0`, `evt_...:1`), so retrying a failed reply never sends a bubble twice. ## Showing work while the agent thinks For tool calls, retrieval or anything slow before the first word: ```ts theme={null} const answer = await event.conversation.responding(() => agent.run(question)); // typing stays on await event.conversation.reply(answer); ``` Messages in one conversation go out one at a time, in the order they were accepted, so bubbles never arrive out of order even when you send them quickly. ## Related * [Content types](/concepts/content-types): typing, read receipts and markdown per channel. * [Agent frameworks](/frameworks/vercel-ai-sdk): full examples per framework. # Test mode and live mode: API keys and the sandbox Source: https://docs.flow.engineer/concepts/test-and-live-mode Flow Messaging API keys come in two modes. fk_test_ keys reach only the shared sandbox and people who joined it; fk_live_ keys reach real contacts through your dedicated senders. Data never crosses between modes. Every API key belongs to one app and one **mode**, and the mode decides who your agent can reach. ```bash theme={null} curl https://api.flow.engineer/v1/app -H "Authorization: Bearer $FLOW_MESSAGING_KEY" # "livemode": false -> a test key ``` | | Test mode | Live mode | | - | - | - | | Key | `fk_test_...` | `fk_live_...` | | Senders | The shared sandbox senders | Your dedicated senders | | Who can be messaged | Only people who sent your app's join code | Anyone the channel's rules allow | | Data | Test conversations, events, files and webhook endpoints | Live ones | ## Rules * **Nothing in test mode reaches a real contact** unless that person joined the sandbox through your app. Build and test freely. * **The modes never mix.** Every object has `livemode`. A test key cannot see live data or use a live sender (`403` [`permission`](/errors/permission)), and webhook endpoints receive only their own mode's events. * **A key is shown once**, when it is issued, and stored only as a hash. Get a test key with no account in one call (`curl -X POST https://api.flow.engineer/v1/sandbox/keys`, or `npx @flow-engineer/messaging init`); signed in, you make live keys (`fk_live_...`) in the dashboard for your own Telegram bot (iMessage lines are arranged with the Flow team; WhatsApp is not available yet). Signed-in people create and revoke keys in the [dashboard](https://api.flow.engineer/admin); otherwise ask the Flow team to revoke a key if it leaks. * **Test mode has a sandbox allowance.** Without an account: 1 contact, 50 messages in total, and the key expires after 7 days (`api_key.expires_at`). Signed in with GitHub: 3 contacts, 100 messages each, no expiry, one allowance per person shared by all their apps. It covers the Telegram sandbox (and WhatsApp when its sandbox opens), not iMessage, and counts only messages your agent sends. `GET /v1/app` returns what is left (`allowance`). See [Keys and sign-in](/get-a-key). * **Keep live keys on your server.** Never ship a key in a web page or a mobile app. ## Going from test to live Your code does not change. Connect your own Telegram bot (or have the Flow team connect an iMessage line), switch `FLOW_MESSAGING_KEY` to a live key, and register your webhook endpoint again with the live key. See [Going live](/guides/going-live). # API versioning with the Flow-Version header Source: https://docs.flow.engineer/concepts/versioning Flow Messaging is versioned by date. Send Flow-Version to choose a version; without it your app's pinned version is used. New error, event and content types arrive only with a new version. The API is versioned by date, and the `Flow-Version` header chooses which version answers a request. ```bash theme={null} curl https://api.flow.engineer/v1/conversations \ -H "Authorization: Bearer $FLOW_MESSAGING_KEY" \ -H "Flow-Version: 2026-11-01" ``` ## Rules * **The current version is `2026-11-01`.** * **Without the header**, the version pinned to your app when it was created is used (`app.api_version` in `GET /v1/app`). * **Every answer echoes** the version that served it in `Flow-Version`, and webhook deliveries carry it too. * **The SDKs send the version they were built for**, so their types always match the answers. * **Closed lists:** error types, event types and content types change only with a new dated version. Within a version you can switch on them exhaustively. * **Beta:** the API is labelled beta while its shape settles. Breaking changes ship as a new dated version, never inside an existing one. # api_error Source: https://docs.flow.engineer/errors/api_error Something went wrong on Flow's side. # api\_error HTTP 500, 503. Something went wrong on Flow's side. ## What it means The failure is Flow's, not your request's. Nothing needs to change in what you sent. ## Why it happens * A short outage or a restart (`503` with `retry_after`). ## How to fix it Retry with the same `Idempotency-Key` after `retry_after` seconds (or with backoff); the key makes sure nothing is sent twice. If it keeps failing, quote `error.request_id` when you contact us. ```ts theme={null} for (let i = 0; i < 5; i++) { const res = await send(body, { idempotencyKey }); // same key every time if (res.status < 500) break; await sleep(2 ** i * 500); } ``` Every error also carries `hint`, one sentence specific to your request, and `doc_url`, this page. Coding agents connected to Flow's MCP server (`https://api.flow.engineer/mcp`) can call `explain_error` with the type to read this page. # authentication Source: https://docs.flow.engineer/errors/authentication The API key is missing, malformed, unknown, revoked or expired. # authentication HTTP 401. The API key is missing, malformed, unknown, revoked or expired. ## What it means The request carried no API key Flow recognises, so nothing was done. ## Why it happens * No `Authorization` header, or not in the form `Bearer `. * The key was copied with quotes, spaces or a line break. * The key was revoked or belongs to a deleted app. * The environment variable holding the key is empty in this process. * The key came from `POST /v1/sandbox/keys` and passed its `expires_at`, 7 days after it was made (`channel_code` `sandbox_key_expired`). ## How to fix it Send `Authorization: Bearer fk_test_...` (or `fk_live_...`) with a current key. Print the first characters of the key your process actually uses to check it is set. No key at all? Get a test key in one call, without an account. Save `key` (as `FLOW_MESSAGING_KEY`) and `claim_token`; both are shown once. ```bash theme={null} curl -X POST https://api.flow.engineer/v1/sandbox/keys curl https://api.flow.engineer/v1/app -H "Authorization: Bearer $FLOW_MESSAGING_KEY" ``` A sandbox key that expired can be revived by claiming its app within 30 days of its `expires_at`: a person signs in with GitHub through the device flow (`npx @flow-engineer/messaging login`, or `POST /v1/device/authorizations` with the `claim_token` or the expired key), which keeps the app and gives a new key that replaces the expired one (a claim through the `claim_url` in a browser gives no new key and makes the expired key work again). After those 30 days the expired key stays revoked: claim with the `claim_token` (the sign-in gives a new key), or get a new key as above. A key someone revoked is never revived. Keys of signed-in people are created and revoked in the dashboard (`https://api.flow.engineer/admin`). Every error also carries `hint`, one sentence specific to your request, and `doc_url`, this page. Coding agents connected to Flow's MCP server (`https://api.flow.engineer/mcp`) can call `explain_error` with the type to read this page. # channel_error Source: https://docs.flow.engineer/errors/channel_error The channel refused or failed the message. # channel\_error HTTP 502, or in message.failed. The channel refused or failed the message. ## What it means Telegram, WhatsApp or iMessage did not take the message. `error.channel_code` carries the channel's own code and `error.message` its text. Errors after the message was queued arrive as a `message.failed` event. ## Why it happens * The contact blocked the bot, or never pressed Start on a Telegram bot. * Malformed markdown, or text over the channel's limit. * The bot token was revoked. * The channel failed or timed out on a typing indicator or a read receipt. These call the channel at once (`POST /v1/conversations/{id}/typing` and `/read`), so they answer `502` directly. ## How to fix it Read `error.hint`: it maps the channel's reason to the change to make. Fix that and send again; retrying unchanged only helps when the hint says the channel failed for now. From typing or a read receipt it is safe to ignore: never hold back a reply because the indicator failed. ```ts theme={null} if (event.type === "message.failed") console.log(event.data.message.error.hint); ``` Every error also carries `hint`, one sentence specific to your request, and `doc_url`, this page. Coding agents connected to Flow's MCP server (`https://api.flow.engineer/mcp`) can call `explain_error` with the type to read this page. # file_blocked Source: https://docs.flow.engineer/errors/file_blocked The file failed the malware scan and was not stored. # file\_blocked HTTP 422. The file failed the malware scan and was not stored. ## What it means Documents (PDF, Office files, archives) are to be malware-scanned before Flow keeps or sends them. This one was flagged, so it was neither stored nor sent. Malware scanning is not on yet, so uploads do not get this error today: until it is, uploads of documents and archives are refused with `unsupported_content` instead, and documents people send you arrive unscanned. (A file a person sent that Flow did not keep arrives as `file_blocked` content in `message.received`, which is not this error.) ## Why it happens * The file contains malware, or macros the scanner flags. ## How to fix it Send a different file. If you believe it is clean, export it again from its source (for example print to PDF) and upload that. Until malware scanning is on, send documents as a link in text instead of uploading them. ```bash theme={null} curl https://api.flow.engineer/v1/files -H "Authorization: Bearer $FLOW_MESSAGING_KEY" -F file=@receipt.png ``` Every error also carries `hint`, one sentence specific to your request, and `doc_url`, this page. Coding agents connected to Flow's MCP server (`https://api.flow.engineer/mcp`) can call `explain_error` with the type to read this page. # idempotency_conflict Source: https://docs.flow.engineer/errors/idempotency_conflict The idempotency key was used for a different request, or that request is still running. # idempotency\_conflict HTTP 409. The idempotency key was used for a different request, or that request is still running. ## What it means Flow keeps each `Idempotency-Key` for 24 hours with the request it came with. A repeat of the same request returns the first answer; anything else with that key is refused. ## Why it happens `channel_code` names the case: | `channel_code` | Why | What to do | | - | - | - | | `body_mismatch` | The key was already used for a different request: another method, path or body (often a constant key instead of a fresh UUID). | Make a new key for the new request. Retrying with this key never works. | | `in_progress` | A repeat arrived while the first request with this key was still running. | Wait `retry_after` seconds, then repeat the identical request with the same key and body to get its answer. | | `secret_not_kept` | The first request went through and its answer carried a secret that is shown only once (`POST /v1/webhook_endpoints`, `POST /v1/webhook_endpoints/{id}/rotate_secret`). Flow does not keep that answer. | Find the endpoint with `GET /v1/webhook_endpoints`; if you lost its secret, rotate it again with a new key. | ## How to fix it Make a new key per logical request (a UUID or ULID) and reuse it only to retry that exact request. Switch on `channel_code`: only `in_progress` is worth retrying with the same key. ```ts theme={null} const key = crypto.randomUUID(); // one per message you mean to send await fetch("https://api.flow.engineer/v1/conversations/" + conv + "/messages", { method: "POST", headers: { Authorization: `Bearer ${process.env.FLOW_MESSAGING_KEY}`, "Content-Type": "application/json", "Idempotency-Key": key }, body: JSON.stringify({ content: { type: "text", text: "Hi" } }), }); // On 409: error.channel_code === "in_progress" -> wait error.retry_after seconds and // repeat with the same key; "body_mismatch" -> a bug: this key was used for another request. ``` Every error also carries `hint`, one sentence specific to your request, and `doc_url`, this page. Coding agents connected to Flow's MCP server (`https://api.flow.engineer/mcp`) can call `explain_error` with the type to read this page. # invalid_request Source: https://docs.flow.engineer/errors/invalid_request The request is malformed or a parameter is invalid. # invalid\_request HTTP 400. The request is malformed or a parameter is invalid. ## What it means Flow could not accept the request as sent: the body is not valid JSON for the endpoint, a required field is missing, or a value is outside its range. `error.param` names the field as a dotted path (`content.text`, `to.telegram_user_id`, `limit`). ## Why it happens * A required field is missing (`content`, `to`, `content.type`). * A value is the wrong shape: a `telegram_user_id` that is a username instead of digits, an ID with the wrong prefix, `limit` above 100. * Text longer than the conversation's channel takes (`max_text_length` in `GET /v1/capabilities`: 4096 characters on Telegram and WhatsApp, 9999 on iMessage; 1024 for captions, also when you edit a media message's caption). Telegram counts UTF-16 code units, so an emoji such as 😀 counts as 2. * Your answer to a `message.received` webhook delivery is a JSON object with an invalid `reply` (a `reply` that is not content or a list of 1 to 10 pieces, an unknown `fallback`). Nothing is sent; the error is recorded on the delivery (the MCP tool `get_webhook_deliveries` shows it) and the delivery is not retried. ## How to fix it Read `error.param` and `error.hint`: the hint says what the field must look like. Fix that field and send again. Retrying unchanged never helps. ```bash theme={null} # Wrong: telegram_user_id must be the numeric user ID, not @username curl https://api.flow.engineer/v1/messages \ -H "Authorization: Bearer $FLOW_MESSAGING_KEY" -H "Content-Type: application/json" \ -d '{"sender":"snd_...","to":{"telegram_user_id":"123456789"},"content":{"type":"text","text":"Hi"}}' ``` Every error also carries `hint`, one sentence specific to your request, and `doc_url`, this page. Coding agents connected to Flow's MCP server (`https://api.flow.engineer/mcp`) can call `explain_error` with the type to read this page. # new_contact_limit Source: https://docs.flow.engineer/errors/new_contact_limit The sender has used its budget for starting conversations. # new\_contact\_limit HTTP 429. The sender has used its budget for starting conversations. ## What it means Each sender may start a limited number of new conversations per hour and per day; new lines start low and warm up over days. Replies into existing conversations are not limited this way. ## Why it happens * Many `POST /v1/messages` starts in a short time. * A new sender that is still warming up. ## How to fix it Wait `retry_after` seconds before starting more conversations. Spread starts out, and prefer replying to people who wrote first. ```ts theme={null} if (err.type === "new_contact_limit") await sleep(err.retry_after * 1000); ``` Every error also carries `hint`, one sentence specific to your request, and `doc_url`, this page. Coding agents connected to Flow's MCP server (`https://api.flow.engineer/mcp`) can call `explain_error` with the type to read this page. # not_found Source: https://docs.flow.engineer/errors/not_found No such object for this app and mode. # not\_found HTTP 404. No such object for this app and mode. ## What it means The ID does not name an object this key can see. ## Why it happens * The ID was made with a key of the other mode: test and live data are separate. * A typo or the wrong prefix (`conv_` where `msg_` is expected). * A message older than the retention period, or an expired file. * The path itself does not exist (for example a trailing slash). ## How to fix it Take IDs from the API's own answers with the same key: `GET /v1/conversations`, `GET /v1/events`, the event your webhook received. If the object was made in the other mode, use that mode's key. ```bash theme={null} curl "https://api.flow.engineer/v1/conversations?limit=5" -H "Authorization: Bearer $FLOW_MESSAGING_KEY" ``` Every error also carries `hint`, one sentence specific to your request, and `doc_url`, this page. Coding agents connected to Flow's MCP server (`https://api.flow.engineer/mcp`) can call `explain_error` with the type to read this page. # not_implemented Source: https://docs.flow.engineer/errors/not_implemented This endpoint or channel is not live yet during the beta. # not\_implemented HTTP 501. This endpoint or channel is not live yet during the beta. ## What it means Flow Messaging is in beta. Some endpoints and channels are in the spec before they are live, and answer `not_implemented` until then. ## Why it happens * Using WhatsApp before it is live (it waits on Meta's approval; Telegram and iMessage are live). * Asking for an iMessage line with `POST /v1/senders`: iMessage lines are connected by the Flow team, not by API. * An endpoint that arrives in a later release. ## How to fix it Build on what is live now (Telegram, iMessage replies, and the endpoints that answer). For an iMessage line, ask the Flow team; it then appears in `GET /v1/senders` with your live key. ```bash theme={null} curl https://api.flow.engineer/v1/senders -H "Authorization: Bearer $FLOW_MESSAGING_KEY" # what you can send from today ``` Every error also carries `hint`, one sentence specific to your request, and `doc_url`, this page. Coding agents connected to Flow's MCP server (`https://api.flow.engineer/mcp`) can call `explain_error` with the type to read this page. # outside_window Source: https://docs.flow.engineer/errors/outside_window The channel will not deliver outside its conversation window: WhatsApp's 24 hours, or an iMessage contact who has not messaged the line. # outside\_window HTTP 409, or `error.type` in a `message.failed` event. The channel will not deliver outside its conversation window. ## What it means On WhatsApp you may send free-form messages only within 24 hours of the contact's last message. After that, WhatsApp accepts only approved templates; the send is refused with HTTP 409. On iMessage a line can reach only contacts who have messaged it (or opted in). The send is accepted, and the refusal arrives later as a `message.failed` event with `outside_window`. Typing and read receipts go to the channel at once, so they answer HTTP 409 `outside_window` directly when the channel's window is closed: on iMessage, typing works only within 5 minutes of the contact's last message. ## Why it happens * WhatsApp: the contact last wrote more than 24 hours ago. * WhatsApp: you are starting a conversation with someone who never wrote to the number. * iMessage: the contact has never messaged the line, or has not opted in to it. * iMessage: you turned typing on more than 5 minutes after the contact's last message. ## How to fix it On WhatsApp, send an approved template (`content.type=template`), or wait for the contact to write again; their message reopens the window. `GET /v1/capabilities?conversation=...` shows `window.open` and `window.open_until`. ```bash theme={null} curl https://api.flow.engineer/v1/messages \ -H "Authorization: Bearer $FLOW_MESSAGING_KEY" -H "Content-Type: application/json" \ -d '{"sender":"snd_...","to":{"contact":"ct_..."},"content":{"type":"template","template_id":"tpl_...","language":"en","params":{"body":["Asha"]}}}' ``` On iMessage, ask the contact to message the line first (share its handle, or its opt-in link from the sender's `address.link` when the line has one, for example in your app or website), then reply in the conversation their message opens. From typing or a read receipt, ignore it and send your reply: the indicator is a courtesy, and the reply itself is not affected. Every error also carries `hint`, one sentence specific to your request, and `doc_url`, this page. Coding agents connected to Flow's MCP server (`https://api.flow.engineer/mcp`) can call `explain_error` with the type to read this page. # permission Source: https://docs.flow.engineer/errors/permission The key may not do this. # permission HTTP 403. The key may not do this. ## What it means The key is valid but this action is not allowed for it. ## Why it happens * A test key (`fk_test_`) used a dedicated sender, or a live key (`fk_live_`) used a shared sandbox sender. * On the sandbox, the contact has not joined your app (or has since joined another app by sending its join code). * The sender is pending, flagged or banned. A Telegram bot is flagged when Telegram rejected its token (revoked in @BotFather), and banned once disconnected or connected to another app. * A dedicated sender was requested or disconnected with a test key, or the Telegram bot you connected is one of Flow's sandbox senders. * On iMessage, you started a new conversation from a line that may only reply. The contact must message the line first. * The app's sandbox allowance is used up (`channel_code` `sandbox_allowance_used`). Apps made with `POST /v1/sandbox/keys` may send 50 messages in total to 1 contact; people who signed in, 100 messages to each of 3 contacts, one allowance per person shared by every app they own or claim. Only messages your agent sends count. * The allowance has no room for this contact (`sandbox_contact_limit`), or the send used a sandbox channel the allowance does not cover, such as iMessage (`sandbox_channel_not_included`; iMessage lines are arranged with the Flow team and used with a live key). * An app made without an account tried something that needs a person signed in, such as connecting its own Telegram bot, or uploading more than 20 files in a day (`sign_in_required`). ## How to fix it Use the key of the right mode. On the sandbox, have the contact join your app: on Telegram they open the sandbox sender's link (its `address.link`) and tap **Start**, which joins them; if the link can't be used, they send the bot its `join_code` (for example `join wild-otter-04508705`). The MCP tool `sandbox_join` gives the link (and a QR code with `include_qr: true`). Then send again. If Telegram rejected your bot's token, get a new token from @BotFather (`/mybots`, API Token) and connect the bot again with `POST /v1/senders` and your live key: the same sender becomes `active`, and its queued messages go out (those queued for more than 72 hours fail with `outside_window`, `channel_code` `queued_too_long`, instead of going out late). When the sandbox allowance is used up, or the app was made without an account, have a person sign in with GitHub to claim the app: run `npx @flow-engineer/messaging login` (it uses the `claim_token` saved by `init`), or start the device flow yourself with `POST /v1/device/authorizations` and your `claim_token`, show the person the link, and poll `POST /v1/device/token` for the new key. Claiming keeps the app and moves it under the person's signed-in allowance: 3 contacts and 100 messages each, one allowance per person shared by all their apps (a person may claim up to 10 apps). `GET /v1/app` shows what is left (`allowance`). ```bash theme={null} curl -X POST https://api.flow.engineer/v1/device/authorizations \ -H "Content-Type: application/json" -d '{"claim_token": "'"$FLOW_CLAIM_TOKEN"'"}' # Show verification_uri_complete to the person, then poll every `interval` seconds: curl -X POST https://api.flow.engineer/v1/device/token \ -H "Content-Type: application/json" -d '{"device_code": "fdc_..."}' ``` On an iMessage line that may only reply, wait for the contact to message it, then reply in that conversation (`POST /v1/conversations/{conversation_id}/messages`). ```bash theme={null} # Your join code (app.sandbox_join_code) and the sandbox senders (address.link, join_code) curl https://api.flow.engineer/v1/app -H "Authorization: Bearer $FLOW_MESSAGING_KEY" curl https://api.flow.engineer/v1/senders -H "Authorization: Bearer $FLOW_MESSAGING_KEY" ``` Every error also carries `hint`, one sentence specific to your request, and `doc_url`, this page. Coding agents connected to Flow's MCP server (`https://api.flow.engineer/mcp`) can call `explain_error` with the type to read this page. # rate_limited Source: https://docs.flow.engineer/errors/rate_limited Too many requests for this key, or sends faster than the sender's sending rate (pacing). # rate\_limited HTTP 429. Too many requests for this key, or sends faster than the sender's sending rate (pacing). ## What it means Two limits answer with this type: * **The per-key request limit.** Every answer carries `RateLimit-Limit`, `RateLimit-Remaining` and `RateLimit-Reset`; over the limit, any request is refused. * **The sender's sending rate (pacing).** The send gate lets each sender send only so many messages a second, whichever key or conversation they come from. Over it, a send is refused and `error.sender` names the sender. Both carry a `Retry-After` header and `error.retry_after` in seconds. ## Why it happens * Polling `GET /v1/events` or `GET /v1/conversations` in a tight loop. * Sending many messages from one sender at once, for example a reply split into many short bubbles, or a burst of starts. * Asking for many sandbox keys (`POST /v1/sandbox/keys`) or device sign-ins from one address or network. Keep the key you got and reuse it; a person can sign in to lift its allowance instead. * Polling `POST /v1/device/token` faster than its `interval`. ## How to fix it Wait `Retry-After` seconds (also `error.retry_after`) and retry with the same `Idempotency-Key`, so a send that did go through is not sent twice. Receive events by webhook or `GET /v1/stream` instead of polling, and send long answers as fewer, longer messages. The TypeScript SDK does the wait and the retry for you. ```ts theme={null} const res = await fetch(url, init); if (res.status === 429) await sleep(Number(res.headers.get("Retry-After")) * 1000); // then send the same request again with the same Idempotency-Key header ``` Every error also carries `hint`, one sentence specific to your request, and `doc_url`, this page. Coding agents connected to Flow's MCP server (`https://api.flow.engineer/mcp`) can call `explain_error` with the type to read this page. # sender_throttled Source: https://docs.flow.engineer/errors/sender_throttled Abuse signals tripped, so the sender may not start conversations for a while. # sender\_throttled HTTP 429. Abuse signals tripped, so the sender may not start conversations for a while. ## What it means Flow protects senders from being banned by the channels. When a sender sends the same text to many new contacts, gets many starts with no reply, or gets blocked, it is throttled and you receive `sender.status_changed`. Replies into existing conversations still go. ## Why it happens * The same first message to many new contacts. * Many conversations started that nobody answered. * Contacts blocking the sender. ## How to fix it Wait `retry_after` seconds; the sender recovers by itself. Personalise first messages and start only conversations people expect. ```json theme={null} {"error": {"type": "sender_throttled", "retry_after": 3600, "sender": "snd_...", "hint": "Wait 3600 seconds before starting conversations from snd_...; meanwhile reply only into existing conversations."}} ``` Every error also carries `hint`, one sentence specific to your request, and `doc_url`, this page. Coding agents connected to Flow's MCP server (`https://api.flow.engineer/mcp`) can call `explain_error` with the type to read this page. # unsupported_content Source: https://docs.flow.engineer/errors/unsupported_content The channel cannot show this content and no fallback was set. # unsupported\_content HTTP 422. The channel cannot show this content and no fallback was set. ## What it means Flow never converts content silently. When a channel cannot show what you sent (an effect on Telegram, buttons on iMessage, a template outside WhatsApp, a file above the channel's size cap), the send is refused unless you said what to send instead. ## Why it happens * The content type is not supported on the conversation's channel. * A file is larger than the channel takes, or of a type Flow does not send. * A document or archive (PDF, Office files, zip and the like) was uploaded or sent by `file_id` while malware scanning is not on yet. Send a link to it as text, or an image of it. ## How to fix it Set `fallback`: `"auto"` lets Flow send the nearest thing the channel shows (and report it in `delivered_as`), or give your own content. Check `GET /v1/capabilities?conversation=...` before sending rich content. ```json theme={null} {"content": {"type": "buttons", "text": "Pick one", "buttons": [{"id": "a", "label": "A"}]}, "fallback": "auto"} ``` Every error also carries `hint`, one sentence specific to your request, and `doc_url`, this page. Coding agents connected to Flow's MCP server (`https://api.flow.engineer/mcp`) can call `explain_error` with the type to read this page. # Claude Agent SDK on Telegram and iMessage Source: https://docs.flow.engineer/frameworks/claude-agent-sdk Give a Claude Agent SDK agent Telegram and iMessage with Flow Messaging: pass the query() stream to reply() in TypeScript, or send the agent's text over HTTP from Python. This page connects a Claude Agent SDK agent to Telegram and iMessage: each message becomes a `query()`, and the agent's text is sent back as chat bubbles. iMessage is for replies only, on a line the Flow team sets up for your app; WhatsApp is coming (it waits on Meta's approval) and will use the same code. ```ts TypeScript theme={null} // npm install @flow-engineer/messaging @anthropic-ai/claude-agent-sdk import { query } from "@anthropic-ai/claude-agent-sdk"; import { FlowMessaging, contentText } from "@flow-engineer/messaging"; const flow = new FlowMessaging(); for await (const event of flow.events.stream({ types: ["message.received"] })) { const run = query({ prompt: contentText(event.data.message.content), options: { systemPrompt: "You are the assistant for Asha's Bakery. Answer customers in short messages.", tools: [], // no built-in tools (Bash, file access); list the ones your agent may use includePartialMessages: true, // stream text as it is written }, }); await event.conversation.reply(run); // sends the agent's text, skips tool and system messages } ``` ```python Python (HTTP) theme={null} # pip install claude-agent-sdk requests # There is no Python SDK yet (only TypeScript is published); this calls the HTTP API. import os, requests from claude_agent_sdk import query, ClaudeAgentOptions, AssistantMessage, TextBlock API = "https://api.flow.engineer" HEADERS = {"Authorization": f"Bearer {os.environ['FLOW_MESSAGING_KEY']}"} async def answer(event: dict): conversation = event["conversation"]["id"] text = event["data"]["message"]["content"].get("text", "") options = ClaudeAgentOptions( system_prompt="You are the assistant for Asha's Bakery. Answer in short messages.", tools=[], # no built-in tools; list the ones your agent may use ) i = 0 async for message in query(prompt=text, options=options): if isinstance(message, AssistantMessage): for block in message.content: if isinstance(block, TextBlock) and block.text.strip(): requests.post( f"{API}/v1/conversations/{conversation}/messages", headers={**HEADERS, "Idempotency-Key": f"{event['id']}:{i}"}, json={"content": {"type": "text", "text": block.text, "format": "markdown"}, "fallback": "auto"}, timeout=30, ) i += 1 ``` ## How it fits * `reply()` reads the `query()` iterator: with `includePartialMessages` it sends text as it streams, otherwise each assistant message's text. Tool calls, results and system messages are not sent to the person. * Each assistant turn becomes its own bubble, which reads naturally in chat ("Let me check that..." then the answer). * `tools: []` turns off Claude Code's built-in tools, which a customer-facing agent should not have by default; `allowedTools` only pre-approves tools and does not restrict them. * Keep one Claude session per Flow conversation (`conversation.id`) to carry context between messages: remember each run's `session_id` and pass it as `resume` next time (the full example does this). * Your production agent talks to Flow through the SDK or HTTP. The [hosted MCP server](/mcp) is a separate build-time tool for coding agents; whether a project adds it is the project owner's decision. ## Full example [examples/claude-agent-sdk](https://github.com/flow-engineer/sdk/tree/main/examples/claude-agent-sdk) on GitHub. # LangChain agents on Telegram and iMessage Source: https://docs.flow.engineer/frameworks/langchain Connect a LangChain chat model or chain to Telegram and iMessage with Flow Messaging: pass model.stream() to reply() in TypeScript, or answer from the webhook in Python. This page connects a [LangChain](https://js.langchain.com) model or chain to Telegram and iMessage: Flow delivers each message and `reply()` sends the streamed chunks as chat bubbles. iMessage is for replies only, on a line the Flow team sets up for your app; WhatsApp is coming (it waits on Meta's approval) and will use the same code. ```ts TypeScript theme={null} // npm install @flow-engineer/messaging @langchain/openai import { ChatOpenAI } from "@langchain/openai"; import { FlowMessaging, contentText } from "@flow-engineer/messaging"; const flow = new FlowMessaging(); const model = new ChatOpenAI({ model: "gpt-4.1-mini" }); for await (const event of flow.events.stream({ types: ["message.received"] })) { const stream = await model.stream([ ["system", "You are the assistant for Asha's Bakery. Answer in short messages."], ["human", contentText(event.data.message.content)], ]); await event.conversation.reply(stream); // reads each chunk's content } ``` ```python Python (webhook reply) theme={null} # pip install langchain-openai fastapi # There is no Python SDK yet (only TypeScript is published); this answers in the webhook response. from fastapi import FastAPI, Request from langchain_openai import ChatOpenAI model = ChatOpenAI(model="gpt-4.1-mini") app = FastAPI() @app.post("/flow") async def flow_webhook(request: Request): event = await request.json() # verify Flow-Signature first: see Events and webhooks if event["type"] != "message.received": return {} text = event["data"]["message"]["content"].get("text", "") answer = await model.ainvoke([("system", "You are the assistant for Asha's Bakery."), ("human", text)]) return {"reply": {"type": "text", "text": answer.content, "format": "markdown"}, "fallback": "auto"} ``` ## How it fits * Any runnable whose `.stream()` yields message chunks works with `reply()`: chat models, `prompt.pipe(model)` chains, and chains ending in a string output parser. * The Python version answers in the webhook response (one round trip). Flow waits 10 seconds for the answer, so this suits fast chains; for slower ones answer `{}` at once and send later with `POST /v1/conversations/{conversation_id}/messages`. `fallback: "auto"` sends the markdown as plain text where a channel has no formatting, and the reply goes out as one message, so keep it within the channel's text limit (4096 characters on Telegram). See [Events and webhooks](/concepts/events-and-webhooks#reply-in-the-webhook-response). * Keep chat history per `conversation.id`, or read it from `GET /v1/conversations/{conversation_id}/messages`. ## Full example [examples/langchain](https://github.com/flow-engineer/sdk/tree/main/examples/langchain) on GitHub. # Mastra agents on Telegram and iMessage Source: https://docs.flow.engineer/frameworks/mastra Put a Mastra agent on Telegram and iMessage with Flow Messaging: pass agent.stream() to reply() and the answer is sent as chat bubbles with the typing indicator on. This page puts a [Mastra](https://mastra.ai) agent on Telegram and iMessage: Flow delivers each message, and `reply()` sends the agent's stream back as chat bubbles. iMessage is for replies only, on a line the Flow team sets up for your app; WhatsApp is coming (it waits on Meta's approval) and will use the same code. ```ts TypeScript theme={null} // npm install @flow-engineer/messaging @mastra/core @ai-sdk/openai import { Agent } from "@mastra/core/agent"; import { openai } from "@ai-sdk/openai"; import { FlowMessaging, contentText } from "@flow-engineer/messaging"; const flow = new FlowMessaging(); const agent = new Agent({ id: "bakery-assistant", name: "Bakery assistant", instructions: "You help customers of Asha's Bakery with orders and delivery. Keep answers short.", model: openai("gpt-4.1-mini"), }); for await (const event of flow.events.stream({ types: ["message.received"] })) { const stream = await agent.stream(contentText(event.data.message.content)); await event.conversation.reply(stream); // reads stream.textStream } ``` ## How it fits * `reply()` reads the stream's `textStream`, keeps typing on and sends a bubble at each paragraph break. * Mastra memory: use the Flow `conversation.id` as the thread ID and `conversation.contact` (the contact's ID) as the resource ID, so each person keeps their own history. * In a Mastra server, receive Flow webhooks on a custom route and call the same code; verify signatures with `flow.webhooks.constructEvent(rawBody, signatureHeader, secret)`, which returns the typed event with `event.conversation` ready to `reply` on. See [Events and webhooks](/concepts/events-and-webhooks). ## Full example [examples/mastra](https://github.com/flow-engineer/sdk/tree/main/examples/mastra) on GitHub. # OpenAI Agents SDK on Telegram and iMessage Source: https://docs.flow.engineer/frameworks/openai-agents-sdk Run an OpenAI Agents SDK agent on Telegram and iMessage with Flow Messaging: stream the run into reply() in TypeScript, or send the final output over HTTP from Python. This page puts an [OpenAI Agents SDK](https://openai.github.io/openai-agents-js/) agent on Telegram and iMessage: each message runs the agent, and its streamed answer is sent back as chat bubbles. iMessage is for replies only, on a line the Flow team sets up for your app; WhatsApp is coming (it waits on Meta's approval) and will use the same code. ```ts TypeScript theme={null} // npm install @flow-engineer/messaging @openai/agents import { Agent, run } from "@openai/agents"; import { FlowMessaging, contentText } from "@flow-engineer/messaging"; const flow = new FlowMessaging(); const agent = new Agent({ name: "Bakery assistant", instructions: "You help customers of Asha's Bakery with orders and delivery. Keep answers short.", model: "gpt-4.1-mini", }); for await (const event of flow.events.stream({ types: ["message.received"] })) { const result = await run(agent, contentText(event.data.message.content), { stream: true }); await event.conversation.reply(result); // reads result.toTextStream() } ``` ```python Python (HTTP) theme={null} # pip install openai-agents fastapi requests # There is no Python SDK yet (only TypeScript is published); this calls the HTTP API. import os, requests from fastapi import FastAPI, Request, BackgroundTasks from agents import Agent, Runner API = "https://api.flow.engineer" HEADERS = {"Authorization": f"Bearer {os.environ['FLOW_MESSAGING_KEY']}"} agent = Agent(name="Bakery assistant", instructions="You help customers of Asha's Bakery. Keep answers short.") app = FastAPI() async def answer(event: dict): text = event["data"]["message"]["content"].get("text", "") result = await Runner.run(agent, text) requests.post( f"{API}/v1/conversations/{event['conversation']['id']}/messages", headers={**HEADERS, "Idempotency-Key": event["id"]}, json={"content": {"type": "text", "text": result.final_output, "format": "markdown"}, "fallback": "auto"}, timeout=30, ) @app.post("/flow") async def flow_webhook(request: Request, tasks: BackgroundTasks): event = await request.json() # verify Flow-Signature first: see Events and webhooks if event["type"] == "message.received": tasks.add_task(answer, event) return {} ``` ## How it fits * In TypeScript, `run(agent, input, { stream: true })` returns a streamed result; `reply()` reads its text stream and sends bubbles while the agent runs. * In Python, answer the webhook with `{}` at once and send the agent's `final_output` afterwards. Text longer than the channel's limit (4096 characters on Telegram) is refused, so split long answers at paragraph breaks ([the bubble rule](/concepts/streaming-replies#the-bubble-rule-for-any-language)) and send each part with its own `Idempotency-Key`. * Give the agent the conversation's history from `GET /v1/conversations/{conversation_id}/messages`, or keep your own session keyed by `conversation.id`. * Verify webhook signatures as shown in [Events and webhooks](/concepts/events-and-webhooks#verify-the-signature). ## Full example [examples/openai-agents](https://github.com/flow-engineer/sdk/tree/main/examples/openai-agents) on GitHub. # Vercel AI SDK on Telegram and iMessage Source: https://docs.flow.engineer/frameworks/vercel-ai-sdk Connect a Vercel AI SDK agent to Telegram and iMessage with Flow Messaging: pass the streamText result to reply() and it is sent as chat bubbles, with conversation history and tools. This page connects a [Vercel AI SDK](https://ai-sdk.dev) agent to Telegram and iMessage: Flow delivers each message, and `reply()` sends the `streamText` result back as chat bubbles. iMessage is for replies only, on a line the Flow team sets up for your app; WhatsApp is coming (it waits on Meta's approval) and will use the same code. ```ts TypeScript theme={null} // app/api/flow/route.ts (Next.js) // npm install @flow-engineer/messaging ai @ai-sdk/openai import { after } from "next/server"; import { FlowMessaging, contentText } from "@flow-engineer/messaging"; import { streamText, type ModelMessage } from "ai"; import { openai } from "@ai-sdk/openai"; const flow = new FlowMessaging(); export const POST = flow.webhooks.handler({ onEvent: async (event) => { if (event.type !== "message.received") return; after(async () => { // The last 20 messages of this conversation, oldest first (the new one included). const page = await event.conversation.messages({ limit: 20 }); const messages: ModelMessage[] = page.data .reverse() .map((m) => ({ role: m.direction === "in" ? "user" : "assistant", content: contentText(m.content) }) as ModelMessage) .filter((m) => m.content); const result = streamText({ model: openai("gpt-4.1-mini"), system: "You are the assistant for Asha's Bakery. Answer in short messages.", messages, }); await event.conversation.reply(result, { idempotencyKey: event.id }); }); }, }); ``` Set `FLOW_MESSAGING_KEY`, `FLOW_MESSAGING_WEBHOOK_SECRET` and `OPENAI_API_KEY`, then register `https:///api/flow` as a webhook endpoint for `message.received`. During development, with no public URL, read events from the live stream (`GET /v1/stream`) instead; `npx @flow-engineer/messaging listen --forward-to http://localhost:3000/api/flow` wraps it for this route. See [Local development](/guides/local-development). ## How it fits * `reply(result)` reads `result.textStream`, keeps the typing indicator on, and sends a bubble at each paragraph break while the model writes. See [Streaming replies](/concepts/streaming-replies). * Tools work as usual (`tools`, `stopWhen`); only the model's text is sent to the person, never tool calls or their results (text the model writes between tool steps is sent too). To show progress during long tool calls, wrap the work in `event.conversation.responding(...)`. * The webhook answers at once and `after()` runs the reply, so slow models never cause a timed-out delivery. * One handler serves Telegram and iMessage alike; `event.conversation.channel` tells you which one if you want to adapt the tone. ## Full example [examples/vercel-ai-sdk](https://github.com/flow-engineer/sdk/tree/main/examples/vercel-ai-sdk) on GitHub. ## Related * [Build a Telegram agent](/guides/telegram-agent) * [Build an iMessage agent](/guides/imessage-agent) * [Events and webhooks](/concepts/events-and-webhooks) # Get a test key, sign in and the sandbox allowance Source: https://docs.flow.engineer/get-a-key Get a Flow Messaging test key in one unauthenticated call, with no account. What the sandbox allowance covers, how a person signs in with GitHub (device flow) to keep the app, and what each allowance error means. Anyone, including an AI coding agent, can get a test key without an account. A person signs in later, with GitHub, to keep the app and get more room in the sandbox. ## 1. Get a key in one call First check whether you already have one: if `FLOW_MESSAGING_KEY` is set (in the environment or `.env`), use it and skip this step. ```bash curl theme={null} curl -X POST https://api.flow.engineer/v1/sandbox/keys # Optional name, shown in the dashboard once the app is claimed: # -H "Content-Type: application/json" -d '{"name": "Support agent"}' ``` ```bash CLI theme={null} npx @flow-engineer/messaging init ``` ```ts TypeScript theme={null} import { FlowMessaging } from "@flow-engineer/messaging"; const flow = new FlowMessaging(); // no key needed for this call const sandbox = await flow.sandbox.createKey({ name: "Support agent" }); // save sandbox.key as FLOW_MESSAGING_KEY and sandbox.claim_token as FLOW_CLAIM_TOKEN ``` The answer (`201`, a `SandboxKey`, shortened here) is a new app of its own: ```json theme={null} { "key": "fk_test_...", "claim_token": "fct_...", "claim_url": "https://api.flow.engineer/admin/claim#token=fct_...", "api_key": { "id": "key_...", "mode": "test", "expires_at": "2026-10-17T09:30:00Z" }, "app": { "id": "app_...", "name": "Sandbox app", "sandbox_join_code": "wild-otter-04508705" }, "allowance": { "tier": "anonymous", "contacts": { "limit": 1, "used": 0 }, "messages": { "limit": 50, "used": 0, "remaining": 50 } }, "senders": [ { "id": "snd_...", "channel": "telegram", "kind": "shared", "address": { "link": "https://t.me/..." }, "join_code": "join wild-otter-04508705" } ] } ``` * **`key` and `claim_token` are shown once.** Save `key` as `FLOW_MESSAGING_KEY` and `claim_token` as `FLOW_CLAIM_TOKEN`, in the environment or a git-ignored `.env`. The claim token (and `claim_url`, which holds it) lets a person claim the app, so treat it like the key. * **Join from a phone:** open a sender's `address.link` (on Telegram, tap **Start**), or send it the `join_code`. * **Don't call it again** when you already have a key. Calls are limited per client address and network; going over answers `429 rate_limited` with `retry_after`. `init` does all of this: with no key in the environment or `.env`, it gets one itself, writes `FLOW_MESSAGING_KEY` and `FLOW_CLAIM_TOKEN` to `.env`, and prints the sandbox link and join code, the allowance and the expiry. To use a key you already have, pass `init --key fk_test_...`. ## 2. The sandbox allowance Test keys send only on Flow's shared sandbox senders, and only to people who joined your app there. What an app may send there for free: | | No account (key from `POST /v1/sandbox/keys`) | Signed in (GitHub) | | - | - | - | | Contacts | 1 | 3 | | Messages | 50 in total | 100 per contact | | Channels | Telegram sandbox (WhatsApp when its sandbox opens) | Telegram sandbox (WhatsApp when its sandbox opens) | | Key expiry | 7 days (`api_key.expires_at`) | none | * Only messages your agent sends count. Inbound messages are free. * A contact counts once it joins your app, and keeps counting after it leaves. * iMessage is not part of either allowance: iMessage lines are arranged with the Flow team and used with a live key. * The WhatsApp sandbox is not open yet; when it opens, the allowance covers it. * When a key from `POST /v1/sandbox/keys` expires, its keys stop working and its contact is removed from the sandbox. A person can still sign in and claim the app. * Going live: signed in, you make `fk_live_` keys in the dashboard ([Keys, Live](https://api.flow.engineer/admin/keys?mode=live)) and connect your own Telegram bot; iMessage lines are arranged with the Flow team. WhatsApp is not available yet. See [Going live](/guides/going-live). See what is left with `GET /v1/app` (shortened): ```bash theme={null} curl https://api.flow.engineer/v1/app -H "Authorization: Bearer $FLOW_MESSAGING_KEY" ``` ```json theme={null} { "allowance": { "tier": "anonymous", "channels": ["telegram", "whatsapp"], "contacts": { "limit": 1, "used": 1 }, "messages_per_contact": 50, "messages": { "limit": 50, "used": 12, "remaining": 38 }, "expires_at": "2026-10-17T09:30:00Z", "upgrade": "Sign in to keep this app and send 100 messages to each of 3 contacts: npx @flow-engineer/messaging login" } } ``` In TypeScript: `(await flow.app.retrieve()).allowance`. ## 3. Sign in to keep the app A person signs in with GitHub through the device flow (OAuth 2.0 device authorization, RFC 8628). It **claims** the app: its data and keys are kept, the expiry is removed, and the app moves under the person's signed-in allowance of 3 contacts with 100 messages each. That allowance is **one per person**, shared by every app they own or claim (`allowance.scope` is `person`), and a person may claim up to 10 apps. The new key replaces the sandbox key, which **stops working** as soon as the new key is handed out. A claim through `claim_url` in a browser hands out no key, and it **revokes the sandbox key** unless the person ticks "Keep my agent's current key working" (so whoever sent a person someone else's claim link is left with no working key on their allowance). Kept, the key works with its expiry removed (an expired one works again when the app is claimed within 30 days of its expiry); revoked, the person makes a new key on the dashboard's Keys page. ### With the CLI ```bash theme={null} npx @flow-engineer/messaging login ``` It reads `FLOW_CLAIM_TOKEN` from `.env`, prints a link and a short code (for example `WDJB-MJHT`), opens the browser and waits. Once the person approves, it replaces `FLOW_MESSAGING_KEY` in `.env` with the new key and removes `FLOW_CLAIM_TOKEN`. `--no-browser` skips opening the browser. **Coding agents that cannot wait** on a command: run `npx @flow-engineer/messaging login --no-wait`. It prints the link and code and exits. Show them to the person, and once they say they approved, run `npx @flow-engineer/messaging login` again to collect the key. ### With curl 1. Start the sign-in with the claim token. `client_name` is shown to the person on the approval page. ```bash theme={null} curl -X POST https://api.flow.engineer/v1/device/authorizations \ -H "Content-Type: application/json" \ -d '{"claim_token": "'"$FLOW_CLAIM_TOKEN"'", "client_name": "Claude Code"}' ``` ```json theme={null} { "device_code": "fdc_...", "user_code": "WDJB-MJHT", "verification_uri": "https://api.flow.engineer/admin/device", "verification_uri_complete": "https://api.flow.engineer/admin/device?code=WDJB-MJHT", "expires_in": 900, "interval": 5 } ``` Without a claim token, send the app's test key instead (`-H "Authorization: Bearer $FLOW_MESSAGING_KEY"`). 2. Show the person `verification_uri` and `user_code`: they open the page, sign in and type the code you show them (a link alone never approves; `verification_uri_complete` opens the same page). Never show `device_code`. 3. Poll every `interval` seconds: ```bash theme={null} curl -X POST https://api.flow.engineer/v1/device/token \ -H "Content-Type: application/json" -d '{"device_code": "fdc_..."}' ``` * `pending`: wait `interval` seconds and poll again. `429 rate_limited` means slow down: wait `retry_after` seconds. * `approved`: the answer holds `key` (shown once). Save it as `FLOW_MESSAGING_KEY` in place of the sandbox key, and drop `FLOW_CLAIM_TOKEN`. * `denied`: the person refused. Stop. * `expired`: the codes ran out (after 15 minutes) or were already used. Start again. ### With TypeScript ```ts theme={null} import { DeviceSignInError, FlowMessaging } from "@flow-engineer/messaging"; const flow = new FlowMessaging(); try { const token = await flow.device.signIn({ claimToken: process.env.FLOW_CLAIM_TOKEN, clientName: "My agent", prompt: (auth) => console.log(`Open ${auth.verification_uri_complete} and check the code ${auth.user_code}`), }); // token.key replaces FLOW_MESSAGING_KEY } catch (err) { if (err instanceof DeviceSignInError) console.log(`Sign-in ${err.reason}`); // "denied" or "expired" else throw err; } ``` `signIn` polls for you, honouring `interval` and `retry_after`. For your own loop, use `flow.device.authorize({ claimToken, clientName })` and `flow.device.poll(deviceCode)`. ### In a browser `claim_url` opens a page where the person signs in and claims the app without the CLI. ## The dashboard Signed-in people manage their apps and keys at [api.flow.engineer/admin](https://api.flow.engineer/admin) (sign in with GitHub), including creating and revoking test and live keys. Google sign-in is not available yet. ## Errors | Error | `channel_code` | What it means | What to do | | - | - | - | - | | `403 permission` | `sandbox_allowance_used` | The app sent all the messages its allowance allows. | Sign in (`login`) for 100 per contact for 3 contacts, shared by all your apps. Signed in already: go live. | | `403 permission` | `sandbox_contact_limit` | The allowance has no room for another contact. | Sign in for 3 contacts, or keep testing with the contact who joined. | | `403 permission` | `sandbox_channel_not_included` | The send used a sandbox channel the allowance does not cover, such as iMessage. | Use the Telegram sandbox sender. | | `403 permission` | `sign_in_required` | An app made without an account tried something that needs a person signed in, such as connecting its own Telegram bot. | Sign in (`login`), then retry. | | `401 authentication` | `sandbox_key_expired` | The key from `POST /v1/sandbox/keys` is past its `expires_at` (7 days). | Sign in to claim the app within 30 days of the expiry (the key works again, and you get a new one), or get a new key with `POST /v1/sandbox/keys`. | Switch on `error.type` and read `error.channel_code`; each error also carries a `hint` and a `doc_url` ([permission](/errors/permission), [authentication](/errors/authentication), [rate\_limited](/errors/rate_limited)). # Going live: your own Telegram bot, iMessage lines and live keys Source: https://docs.flow.engineer/guides/going-live Move your AI agent from the sandbox to production: make a live key, connect your own Telegram bot, get an iMessage line from the Flow team, and switch your key. WhatsApp numbers and templates are coming. Going live means sending from a **dedicated sender** of your own with a **live key**; your agent's code does not change. The examples below assume `FLOW_MESSAGING_KEY` holds your live key (`fk_live_...`). ```bash curl theme={null} export TELEGRAM_BOT_TOKEN=... # the token from @BotFather; never commit it curl https://api.flow.engineer/v1/senders \ -H "Authorization: Bearer $FLOW_MESSAGING_KEY" \ -H "Content-Type: application/json" \ -d "{\"channel\": \"telegram\", \"telegram_bot_token\": \"$TELEGRAM_BOT_TOKEN\"}" ``` | Channel | How you go live today | | - | - | | Telegram | Self-serve: connect your own bot with a live key (below). | | iMessage | Ask the Flow team: they connect a line to your app. Replies only. | | WhatsApp | Not available yet (waiting on Meta's approval). | ## Checklist Sign in to the [dashboard](https://api.flow.engineer/admin) with GitHub, switch to **Live**, and make an `fk_live_...` key on the [Keys page](https://api.flow.engineer/admin/keys?mode=live) (test keys take one call, see [Keys and sign-in](/get-a-key)). Keep it on your server only, in its own variable (for example `FLOW_MESSAGING_LIVE_KEY`) so it does not replace your test key. Live Telegram with your own bot is self-serve; iMessage lines are arranged with the Flow team. Telegram: `POST /v1/senders` with your bot's token; it is `active` at once. iMessage: the Flow team connects a line to your app, and it then appears in `GET /v1/senders` with your live key (`POST /v1/senders` with `"channel": "imessage"` answers `501 not_implemented`). Webhook endpoints belong to one mode. `POST /v1/webhook_endpoints` again with the live key and store the new `whsec_...` secret. Set `FLOW_MESSAGING_KEY` to the live key and deploy. Watch `message.failed` and `sender.status_changed` for the first days. ## WhatsApp: coming WhatsApp is not available yet: Flow is waiting on Meta's approval, and `POST /v1/senders` with `"channel": "whatsapp"` answers `501 not_implemented`. When it ships: * **Numbers:** Flow buys and hosts a dedicated number for you. * **Verify your business:** WhatsApp requires the business behind a number to be verified through Meta; the Flow team will send you the link. * **Quality and tiers:** the sender shows WhatsApp's `quality_rating` (`green`, `yellow`, `red`) and its messaging tier in `limits.whatsapp_tier`. Replies to people who wrote first are not limited by the tier. * **Fees:** WhatsApp's own per-message fees (charged by Meta) are passed through. See [Pricing](/pricing). ### WhatsApp templates A template is a message WhatsApp approved in advance. On WhatsApp you need one to message someone first, or to write more than 24 hours after their last message. Templates need a WhatsApp number, so this is how it will work once WhatsApp ships: ```bash curl theme={null} curl https://api.flow.engineer/v1/templates \ -H "Authorization: Bearer $FLOW_MESSAGING_KEY" \ -H "Content-Type: application/json" \ -d '{ "sender": "snd_01JB8ZC3K5M7P9R1T3V5X7Z9B1", "name": "order_ready", "language": "en", "category": "utility", "components": [ { "type": "body", "text": "Hi {{1}}, your {{2}} is ready for pickup." } ] }' ``` * Names use lowercase letters, digits and underscores. A name and language pair is unique per sender. * `category` is `utility` (updates about something the person asked for), `marketing` or `authentication`; it sets WhatsApp's fee. * A new template is `pending`. You receive `template.status_changed` when WhatsApp approves, rejects (with `rejection_reason`) or pauses it. * Send it as `template` content with values for its placeholders: ```json theme={null} { "content": { "type": "template", "template_id": "tpl_01JB8ZC3K5M7P9R1T3V5X7Z9B1", "language": "en", "params": { "body": ["Asha", "cake"] } } } ``` ## iMessage: a line from the Flow team * **Get one:** ask the Flow team. They connect a dedicated line to your app; it is listed in `GET /v1/senders?channel=imessage` with your live key. * **Replies only:** the person always writes first. Starting a conversation with a new contact is refused with `403 permission`. * **Get people to write:** the sender's `address.link` is the line's opt-in link (when it has one): it opens Messages with the line and a prefilled text. Publish it on your site, receipts or QR codes. See [Build an iMessage agent](/guides/imessage-agent). ## Telegram: your own bot Create a bot with **@BotFather** and send its token: `POST /v1/senders` with `channel: "telegram"` and `telegram_bot_token`. It is connected and `active` at once. See [Build a Telegram agent](/guides/telegram-agent#2-your-own-telegram-bot). * **New token:** after revoking a token in @BotFather, send the new one the same way. The bot stays the same sender; one Telegram rejected meanwhile is `flagged` until you do. Messages queued while it is flagged wait at most 72 hours, then fail with `outside_window` (`channel_code` `queued_too_long`). * **Disconnect:** `DELETE /v1/senders/{sender_id}` removes the bot's webhook and token and retires the sender. * **One app per bot:** connecting a bot to another app moves it there and retires the old sender. ## Before you launch * Handle `outside_window`, `new_contact_limit` and `sender_throttled` (see [Errors](/errors/outside_window)). * Deduplicate webhook deliveries on the event `id`, and use event IDs as idempotency keys. * Subscribe your webhook only to the event types you use. * Message content and media are kept for your plan's retention period (30 days by default); store anything you need longer yourself. ## Related * [Senders](/concepts/senders) and [Test and live mode](/concepts/test-and-live-mode) * API: [Request a dedicated sender](/api-reference/senders/request-a-dedicated-sender), [Create a template](/api-reference/templates/create-a-template) # Build an iMessage AI agent with the Flow Messaging API Source: https://docs.flow.engineer/guides/imessage-agent Let your AI agent answer people on iMessage from a line the Flow team connects to your app: receive iMessages by webhook or stream, reply with streamed LLM answers, tapbacks and effects, and use fallbacks for buttons. Replies only: the person writes first. This guide builds an AI agent that people text on iMessage, using the same code as on Telegram, plus the iMessage-specific parts: tapbacks, effects and the replies-only rule. iMessage is live for **replies only**, on lines the **Flow team** connects to your app: the person always writes first, and your agent answers. It is not part of the sandbox. To get a line, ask the Flow team; build on the [Telegram sandbox bot](/guides/telegram-agent) in the meantime, with the same code. ```ts TypeScript theme={null} // npm install @flow-engineer/messaging import { FlowMessaging, contentText } from "@flow-engineer/messaging"; import { askMyAgent } from "./agent"; // returns a string, or any LLM stream const flow = new FlowMessaging(); for await (const event of flow.events.stream({ types: ["message.received"] })) { const text = contentText(event.data.message.content); await event.conversation.react(event.data.message.id, "👍"); // shows as the Like tapback await event.conversation.reply(await askMyAgent(text)); } ``` ## 1. Get a line 1. Sign in to the [dashboard](https://api.flow.engineer/admin) with GitHub and make a live key (`fk_live_...`) on the [Keys page](https://api.flow.engineer/admin/keys?mode=live). See [Going live](/guides/going-live). 2. Ask the Flow team to connect an iMessage line to your app. iMessage lines are not self-serve: `POST /v1/senders` with `"channel": "imessage"` answers `501 not_implemented`. 3. With the live key, `GET /v1/senders?channel=imessage` lists the line. Its `address.link`, when set, is the line's opt-in link: it opens Messages with the line and a prefilled text the person sends to start. 4. Run the code above, text the line from an iPhone or Mac, and your agent answers. ## 2. Bubbles fit iMessage People read iMessage as short lines. `reply()` uses shorter bubbles here (about 400 characters before it cuts at a sentence end) than on Telegram. See [Streaming replies](/concepts/streaming-replies). ## 3. Tapbacks and effects * **Reactions** become tapbacks. iMessage has a fixed set, so with `fallback: "auto"` (which `react()` sets) Flow uses the closest tapback, or skips the reaction and says so in `delivered_as`. * **Effects** send text with an iMessage effect: ```ts TypeScript theme={null} import { effect } from "@flow-engineer/messaging"; await event.conversation.send({ content: effect("Your order is confirmed!", "confetti"), fallback: "auto" }); ``` Effects are `slam`, `loud`, `gentle`, `invisible_ink`, `echo`, `spotlight`, `balloons`, `confetti`, `love`, `lasers`, `fireworks` and `celebration`. Which ones a line can send may vary, so keep `fallback: "auto"` (plain text) on. ## 4. No buttons: use the fallback iMessage has no buttons. Send `buttons` content with `fallback: "auto"` and Flow sends numbered text instead. When the person answers "2", you still receive a `button_reply` with the second button's `id`, so your code is the same on every channel. ```ts TypeScript theme={null} import { buttons } from "@flow-engineer/messaging"; await event.conversation.send({ content: buttons("Which time works?", [{ id: "t10", label: "10:00" }, { id: "t14", label: "14:00" }]), fallback: "auto", }); ``` ## 5. Replies only iMessage lines are personal-style numbers, and iMessage watches closely for unwanted messages. Flow protects your line: * **The person writes first.** Flow's iMessage lines are reply-only: starting a conversation with a new contact (`POST /v1/messages`) is refused with `403 permission`. Someone who has written to the line before is an open conversation, and replies to them are not budgeted. * **Get people to write with the opt-in link.** When the line has one, the sender's `address.link` opens Messages with the line and a prefilled text the person sends to start. Put it on your site, receipts or a QR code. * **No cold outreach.** Sending the same text to many people, or messages that get no reply, can throttle the line (`sender_throttled`). See [The send gate](/concepts/send-gate). ## 6. What iMessage supports | Content | iMessage | | - | - | | Text | yes, up to 9999 characters (markdown becomes plain text with `fallback: "auto"`) | | Images, video, audio | yes | | Documents | received (not malware-scanned yet); sending uploaded documents is refused with `422 unsupported_content` until scanning is on | | Tapbacks | yes, fixed set | | Typing | yes, within 5 minutes of the contact's last message (otherwise `409 outside_window`, safe to ignore) | | Read receipts | yes, for the whole conversation (`up_to` has no effect) | | Effects | yes | | Buttons | no (numbered-text fallback) | | Edit | yes, within 15 minutes of sending (not the message that started the conversation) | | Unsend | yes, within 2 minutes of sending | | Voice notes | yes, from an mp3, wav, m4a, caf or aac file | | Locations, contact cards | no: `fallback: "auto"` sends a maps link or text | Use `GET /v1/capabilities?conversation=conv_...` for the live answer for a conversation. ## 7. Contacts An iMessage contact's `address.handle` is a phone number **or an email address**. Do not assume a phone number. Reply into the conversation the person started rather than addressing them by handle. ## Related * [Going live](/guides/going-live): live keys and iMessage lines. * [Content types](/concepts/content-types) # Local development: receive Telegram and iMessage messages on localhost Source: https://docs.flow.engineer/guides/local-development Build your messaging agent on your laptop with no public URL: read events from the live event stream, GET /v1/stream (a WebSocket, resumable with after), and reply over the API. No tunnel needed. On your laptop you have no public URL for a webhook, and you do not need one: open the **live event stream**, `GET /v1/stream`, and your app's events arrive over a WebSocket as they happen. It is resumable with `after`, so a restart of your process never loses an event. ```ts TypeScript theme={null} // npm install @flow-engineer/messaging import { FlowMessaging, contentText } from "@flow-engineer/messaging"; const flow = new FlowMessaging(); // reads FLOW_MESSAGING_KEY for await (const event of flow.events.stream({ types: ["message.received"] })) { await event.conversation.reply(`You said: ${contentText(event.data.message.content)}`, { idempotencyKey: event.id, }); } ``` ## How it works 1. Connect to `wss://api.flow.engineer/v1/stream` with `Authorization: Bearer $FLOW_MESSAGING_KEY`. Filter with `type` (repeat it for several types). 2. Every text frame is one JSON object. `event` frames carry the same event a webhook would receive. 3. Reply over the API (`POST /v1/conversations/{conversation_id}/messages`), or send a `send` frame on the same socket; each `send` gets one `ack` or `error` frame back, matched by `ref` (your idempotency key). 4. Keep the `id` of the last event you handled. When you reconnect, or when the server sends a `reconnect` frame before it restarts, connect again with `after` set to that ID: the stream replays everything after it, then goes live. The TypeScript SDK's `flow.events.stream()` does the reconnecting and resuming for you. In other languages use any WebSocket client that can set a header: ```python Python theme={null} # pip install websockets import asyncio, json, os, websockets async def main(): url = "wss://api.flow.engineer/v1/stream?type=message.received" headers = {"Authorization": f"Bearer {os.environ['FLOW_MESSAGING_KEY']}"} async with websockets.connect(url, additional_headers=headers) as ws: async for raw in ws: frame = json.loads(raw) print(frame) # an "event" frame; reply with POST /v1/conversations/{id}/messages asyncio.run(main()) ``` See [The live stream](/concepts/events-and-webhooks#the-live-stream) for the frame types and browser authentication. ## Testing your webhook handler locally If your production code is a webhook handler, you can still develop it on localhost: read the stream and pass each event to your handler function directly, then deploy the same handler behind a real webhook endpoint. The stream and your webhook endpoints read the same log and do not interfere: both receive events. The CLI wraps the stream for webhook handlers: `listen --forward-to` reads `GET /v1/stream` and `POST`s each event to your local URL, signed like a real delivery. ```bash theme={null} npx @flow-engineer/messaging listen --forward-to http://localhost:3000/api/flow ``` * Each event is `POST`ed with the same headers and signature as a real webhook delivery (`Flow-Signature`, `Flow-Event-Id`, `Flow-Event-Type`, `Flow-Version`), signed with `FLOW_MESSAGING_WEBHOOK_SECRET` (made and saved to `.env` if it is not set). * If your handler answers a `message.received` with `{"reply": ...}`, `listen` sends the reply into the conversation, with the event's ID as the idempotency key, as the API does for real webhooks. * Flags: `--events a,b` (only these event types), `--after evt_...` (replay from this event first, then go live), `--secret whsec_...` (the signing secret to use). Without `--forward-to`, `listen` only prints events. ## Tips * Use a **test key** while developing: only people who joined your sandbox can reach your agent. * To run past events through new code, find the event just before them with `GET /v1/events` and pass its ID as `after`. * The stream is for long-running processes. In production, serverless apps usually use [webhooks](/concepts/events-and-webhooks). ## Related * [Events and webhooks](/concepts/events-and-webhooks) * [For AI coding agents](/coding-agents): the MCP server can wait for events and replay them too. * API: [Open the live stream](/api-reference/stream/open-the-live-event-stream-websocket) # Build a Telegram AI agent with the Flow Messaging API Source: https://docs.flow.engineer/guides/telegram-agent Connect an AI agent to Telegram: use the sandbox bot or your own BotFather token, receive messages by webhook or stream, reply with streamed LLM answers, inline keyboards and media, and edit or unsend messages. This guide builds an AI agent on Telegram, first on Flow's sandbox bot and then on your own bot, with the same code. ```ts TypeScript theme={null} // npm install @flow-engineer/messaging openai import OpenAI from "openai"; import { FlowMessaging, contentText } from "@flow-engineer/messaging"; const flow = new FlowMessaging(); const openai = new OpenAI(); for await (const event of flow.events.stream({ types: ["message.received"] })) { await event.conversation.reply( openai.chat.completions.create({ model: "gpt-4.1-mini", stream: true, messages: [{ role: "user", content: contentText(event.data.message.content) }], }), ); } ``` ## 1. Try it on the sandbox bot 1. Get a test key, with no account: `curl -X POST https://api.flow.engineer/v1/sandbox/keys` (or `npx @flow-engineer/messaging init`). Set its `key` as `FLOW_MESSAGING_KEY`. It allows 1 contact and 50 messages for 7 days; sign in to get more ([Keys and sign-in](/get-a-key)). 2. `GET /v1/senders?channel=telegram` lists the sandbox bot. Its `address.link` is `https://t.me/?start=`, with your join code in it. (`npx @flow-engineer/messaging init` prints it too.) 3. Open the link and tap **Start**. That's it, you've joined. 4. Run the code above and send the bot a message. If the link can't be used (for example you found the bot by searching for it), send the bot the sender's `join_code` instead, for example `join wild-otter-04508705`. ## 2. Your own Telegram bot Telegram is the fastest channel to go live on: bring a bot token and it is connected at once. 1. In Telegram, open **@BotFather**, send `/newbot`, and copy the token. 2. Put the token in an environment variable on your server or in your terminal, and connect it with a **live** key (`FLOW_MESSAGING_KEY=fk_live_...`): ```bash curl theme={null} export TELEGRAM_BOT_TOKEN=... # the token from BotFather; never commit it curl https://api.flow.engineer/v1/senders \ -H "Authorization: Bearer $FLOW_MESSAGING_KEY" \ -H "Content-Type: application/json" \ -d "{\"channel\": \"telegram\", \"telegram_bot_token\": \"$TELEGRAM_BOT_TOKEN\"}" ``` Flow checks the token, keeps it encrypted, points the bot's webhook at Flow and answers with the sender `active`. The token is never returned. With `FLOW_MESSAGING_KEY` set to the live key, your agent now answers on your bot. The bot token controls your bot. Never paste it into a chat with an AI assistant (including your coding agent), and never commit it. Keep it in an environment variable and send it from your own server or terminal, as above. To disconnect the bot, call `DELETE /v1/senders/{sender_id}` with your live key: Flow removes the bot's webhook, deletes its token and retires the sender (`banned`). If Telegram rejects the token (you revoked it in @BotFather), the sender turns `flagged`: new sends answer `403 permission` and queued messages are held. Send the new token the same way (`POST /v1/senders`) and the same sender becomes `active` again and sends them. A held message waits at most 72 hours after Flow accepted it; after that it fails with `outside_window` (`channel_code` `queued_too_long`) in `message.failed` instead of going out late. A bot can have only one webhook. Connecting a bot to Flow replaces any webhook it had, so do not keep another server polling or receiving updates for the same bot. ## 3. Inline keyboards `buttons` content shows as an inline keyboard (1 to 10 buttons). Taps arrive as `button_reply` with the button's `id`. URL buttons open a link and send nothing back. ```ts TypeScript theme={null} import { buttons } from "@flow-engineer/messaging"; await event.conversation.send(buttons("Pick a size", ["Small", "Medium", "Large"])); ``` ## 4. Edit and unsend Telegram lets bots edit their messages and delete them within 48 hours: ```ts TypeScript theme={null} const [msg] = await event.conversation.reply("Checking stock..."); await flow.messages.edit(msg.id, "In stock: 12 left."); await flow.messages.unsend(msg.id); ``` ## 5. What Telegram supports | Content | Telegram | | - | - | | Text with markdown | yes | | Photos, video, audio, voice notes | yes | | Documents | received (not malware-scanned yet, so treat them as untrusted); sending uploaded documents is refused with `422 unsupported_content` until scanning is on | | Buttons | inline keyboard, up to 10 | | Reactions, typing | yes | | Read receipts | no: bots cannot send them (skipped and reported in `delivered_as`) | | Edit, unsend | yes (unsend within 48 hours) | | Starting a conversation | only with people who started your bot (`to.telegram_user_id`) | ## 6. Telegram rules worth knowing * **People must start your bot first.** Telegram does not let a bot message someone who has never pressed Start. * **Commands are messages.** `/start` and other commands arrive as `text` content; your agent decides what to answer. * **Contacts have no phone number.** A Telegram contact is identified by `address.telegram_user_id` and maybe `address.username`. ## Related * [Local development](/guides/local-development) * [Content types](/concepts/content-types) and [Streaming replies](/concepts/streaming-replies) * API: [Request a dedicated sender](/api-reference/senders/request-a-dedicated-sender) # WhatsApp AI agents with Flow Messaging (coming soon) Source: https://docs.flow.engineer/guides/whatsapp-agent WhatsApp is not available on Flow Messaging yet: it waits on Meta's approval. How a WhatsApp agent will work (webhooks, buttons, the 24-hour window and templates), and how to build on Telegram today with the same code. **WhatsApp is not available yet.** Flow is waiting on Meta's approval as a Tech Provider. Until then there is no WhatsApp sandbox and no WhatsApp numbers: `POST /v1/senders` with `"channel": "whatsapp"` answers `501` [`not_implemented`](/errors/not_implemented). Build on [Telegram](/guides/telegram-agent) today; the code below does not change per channel, so it will answer on WhatsApp once it ships. This page shows how an AI agent will answer people on WhatsApp once it is available: it receives messages, streams the model's answer back as chat bubbles, uses buttons, and handles WhatsApp's 24-hour window. ```ts TypeScript theme={null} // app/api/whatsapp/route.ts (Next.js). npm install @flow-engineer/messaging ai @ai-sdk/openai import { FlowMessaging, contentText } from "@flow-engineer/messaging"; import { streamText } from "ai"; import { openai } from "@ai-sdk/openai"; const flow = new FlowMessaging(); export const POST = flow.webhooks.handler({ onEvent: async (event) => { if (event.type !== "message.received") return; const question = contentText(event.data.message.content); // text, caption or a tapped button's label // Answer after the webhook returns: reply() keeps typing on and sends bubbles as they are ready. void event.conversation.reply( streamText({ model: openai("gpt-4.1-mini"), system: "You are the assistant for Asha's Bakery.", prompt: question }), { idempotencyKey: event.id }, ); }, }); ``` On serverless platforms that stop work when the response is sent, run the reply with your platform's background helper (for example `after()` in Next.js or `waitUntil()`), or answer in the webhook response itself as shown below. ## 1. Build it on Telegram today There is no WhatsApp sandbox yet. Run the same code on the Telegram sandbox bot: 1. Get a test key, with no account: `curl -X POST https://api.flow.engineer/v1/sandbox/keys` (or `npx @flow-engineer/messaging init`). Set its `key` as `FLOW_MESSAGING_KEY`. See [Keys and sign-in](/get-a-key). 2. Join the Telegram sandbox from your phone with the bot's `address.link` (see [Build a Telegram agent](/guides/telegram-agent)). 3. Register your webhook, or on your laptop read events from the live stream (`GET /v1/stream`, see [Local development](/guides/local-development)), and send a message. When the WhatsApp sandbox opens, the sandbox allowance will cover it. ## 2. Answer fast: reply in the webhook response For short answers, skip the extra API call and return the reply from the webhook handler: ```ts TypeScript theme={null} export const POST = flow.webhooks.handler({ onEvent: async (event) => { if (event.type !== "message.received") return; return await answer(contentText(event.data.message.content)); // a string, content, or a list of up to 10 }, }); ``` ```json HTTP response body theme={null} { "reply": { "type": "text", "text": "Yes, we deliver to 560103. Delivery takes about 40 minutes." } } ``` Answer within 10 seconds; for anything slower, answer `200 {}` at once and send with `POST /v1/conversations/{conversation_id}/messages`. ## 3. Voice notes WhatsApp users send a lot of voice notes. Each one will arrive as `voice` content with the audio file (`url`, `file_id`): ```json theme={null} { "type": "voice", "url": "https://api.flow.engineer/v1/files/file_01JB8ZC3K5M7P9R1T3V5X7Z9B1", "duration_seconds": 4.2 } ``` Flow does not transcribe voice notes yet, so fetch the audio and transcribe it yourself if your agent needs the words. To send a voice note back, send `voice` content with an audio `url` or `file_id`. ## 4. Buttons and lists WhatsApp will show up to 3 reply buttons, and a list for 4 to 10: ```ts TypeScript theme={null} import { buttons } from "@flow-engineer/messaging"; await event.conversation.send(buttons("How would you like to pay?", [ { id: "upi", label: "UPI" }, { id: "card", label: "Card" }, { id: "cod", label: "Cash on delivery" }, ])); ``` A tap arrives as `message.received` with `button_reply` content: `{ "type": "button_reply", "button_id": "upi", "label": "UPI" }`. Labels are at most 20 characters. ## 5. The 24-hour window and templates WhatsApp allows free-form messages only within **24 hours of the person's last message**. After that, and to message someone first, you must send an approved **template**. * Every event carries `conversation.window_open_until`. * A free-form send outside the window fails with `409` [`outside_window`](/errors/outside_window). * Subscribe to `conversation.window_closing` to get a warning 1 hour before it closes. ```ts TypeScript theme={null} import { template } from "@flow-engineer/messaging"; // Follow up two days later: only a template may go. await flow.conversation(conversationId).send( template("tpl_01JB8ZC3K5M7P9R1T3V5X7Z9B1", "en", { body: ["Asha", "order 1042"] }), ); ``` Once WhatsApp numbers are available, templates are created on your dedicated number with `POST /v1/templates` and reviewed by WhatsApp; you receive `template.status_changed` when one is approved, rejected or paused. See [Going live](/guides/going-live#whatsapp-templates). ## 6. What WhatsApp will support | Content | WhatsApp | | - | - | | Text with markdown | yes (rendered in WhatsApp's own formatting) | | Images, video, documents, audio | yes | | Voice notes | yes, both ways | | Buttons | up to 3, else a list (up to 10) | | Reactions, read receipts, typing | yes | | Locations, contact cards | yes | | Edit or unsend | no (`422 unsupported_content`) | ## 7. Going live on WhatsApp Not possible yet. When WhatsApp ships, Flow will host a dedicated number for you, your business will be verified through Meta, and your agent's code stays the same. Until then, go live on [your own Telegram bot](/guides/going-live), or ask the Flow team about an iMessage line. WhatsApp's business policy does not allow general-purpose AI assistants. Your agent must serve your business's own customers (support, orders, bookings, updates). Agents that only reply to people who wrote first, about your business, are the safe pattern. ## Related * [The send gate](/concepts/send-gate): windows, new-contact limits and quality. * [Streaming replies](/concepts/streaming-replies) and [Content types](/concepts/content-types). * Frameworks: [Vercel AI SDK](/frameworks/vercel-ai-sdk), [OpenAI Agents SDK](/frameworks/openai-agents-sdk), [LangChain](/frameworks/langchain). # Flow Messaging: WhatsApp, Telegram and iMessage API for AI agents Source: https://docs.flow.engineer/index One API and SDK to give your AI agent Telegram and iMessage, with WhatsApp coming. Receive messages by webhook or live stream, reply with text, media, buttons and streamed LLM answers, and send through one send gate. Flow Messaging is a two-way messaging API that lets an AI agent talk with people on **Telegram** and **iMessage** through one HTTP API, one event format and one SDK. **WhatsApp** is coming (it waits on Meta's approval) and uses the same API. ```ts TypeScript theme={null} import { FlowMessaging, contentText } from "@flow-engineer/messaging"; const flow = new FlowMessaging(); // reads FLOW_MESSAGING_KEY for await (const event of flow.events.stream({ types: ["message.received"] })) { const said = contentText(event.data.message.content); await event.conversation.reply(`You said: ${said}`); } ``` That loop is a working agent on every live channel, with no per-channel code. Swap the reply for your model's answer and you are done: [Quickstart](/quickstart) takes about 5 minutes. No account needed to start: one call gets a test key (`curl -X POST https://api.flow.engineer/v1/sandbox/keys`, or `npx @flow-engineer/messaging init`). Sign in with GitHub later to keep the app. See [Keys and sign-in](/get-a-key). ## How it works 1. Flow hosts the **senders**: a Telegram bot or an iMessage line (and WhatsApp numbers once WhatsApp is available). Start on the shared Telegram sandbox bot; go live with your own Telegram bot, or an iMessage line the Flow team connects to your app. 2. A person messages your sender. Flow stores the message and delivers it to you as an **event**: by signed webhook, by a live WebSocket stream, or by polling the event log. 3. Your agent replies into the **conversation** with typed content (text, media, voice notes, buttons, reactions). Streamed LLM answers become natural chat bubbles. 4. Every send passes one **send gate** that applies each channel's rules (who may be messaged, new-contact limits, pacing), so your sender stays healthy. 5. Delivery updates (`message.sent`, `delivered`, `read`, `failed`) come back as events in the same ordered log. You never handle channel credentials, channel webhooks or per-channel payload formats. ## Why it is built for AI agents * **Replies that read like a person typed them.** Pass an OpenAI, Anthropic, Vercel AI SDK, LangChain or Mastra stream to `reply()` and the SDK keeps the typing indicator on and sends paragraph-sized bubbles as they are ready. See [Streaming replies](/concepts/streaming-replies). * **Reply in the webhook response.** Answer a `message.received` delivery with `{"reply": ...}` and save a round trip. See [Events and webhooks](/concepts/events-and-webhooks). * **Nothing converted silently.** If a channel cannot show something (buttons on iMessage), the API says so, or sends the `fallback` you chose and reports what was shown. See [Content types](/concepts/content-types). * **Errors an agent can act on.** Every error has a closed `type`, a one-line `hint` and a `doc_url`. See [Errors](/errors/outside_window). * **Works from coding agents.** A Claude Code skill and an `AGENTS.md` snippet let Claude Code, Codex and Cursor integrate Flow for you, and an optional [MCP server](/mcp) you can add to them helps test it while you build. See [For AI coding agents](/coding-agents). * **Language-neutral.** Everything is plain HTTP and JSON with an OpenAPI 3.1 spec, so any language works today. ## Packages | Language | Package | Status | | - | - | - | | TypeScript / JavaScript | `npm install @flow-engineer/messaging` | Published (0.1.0), beta | | Python | Not published yet | Use the HTTP API | | Go | Not published yet | Use the HTTP API | | Any language | HTTP at `https://api.flow.engineer` ([OpenAPI spec](https://github.com/flow-engineer/sdk/blob/main/openapi/openapi.yaml)) | Beta | The SDKs are open source (Apache-2.0) at [github.com/flow-engineer/sdk](https://github.com/flow-engineer/sdk). ## Channels | Channel | Status | What your agent talks from | Notes | | - | - | - | - | | Telegram | Live | Flow's sandbox bot (test key), or your own bot (live key) | Bring a bot token to go live in minutes. | | iMessage | Live, replies only | A line the Flow team connects to your app (live key) | The person always writes first; your agent replies. Not part of the sandbox. Ask the Flow team for a line. | | WhatsApp | Coming (waiting on Meta's approval) | Not available yet | When it ships: free-form replies within 24 hours of the person's last message, templates after that. | There is no SMS channel yet and no voice calling. Flow Messaging is in **beta**. The API shape may still change; changes ship as new dated versions (see [Versioning](/concepts/versioning)). `GET /v1/senders` lists the sandbox senders your test key can use right now. ## Next steps Receive and answer your first message in 5 minutes. The sandbox bot, then your own bot. Replies, tapbacks and effects on a Flow-connected line. Let Claude Code or Codex do the integration. # MCP server Source: https://docs.flow.engineer/mcp Let your coding agent integrate, test and run Flow Messaging through the hosted MCP server at api.flow.engineer/mcp. # MCP server Flow Messaging has a hosted [Model Context Protocol](https://modelcontextprotocol.io) server at `https://api.flow.engineer/mcp`. With it in your coding tools, your coding agent can integrate the API **and verify it end to end**: get a sandbox join link, send a real test message, wait for the reply, and see exactly what your webhook answered. * Transport: Streamable HTTP (MCP protocol `2025-06-18` or later). The server keeps no sessions, so every request stands alone. * Auth: your Flow Messaging API key, `Authorization: Bearer fk_test_...` or `fk_live_...`. The key's mode decides which tools the agent sees. MCP is not a runtime dependency: the app you build talks to Flow through the REST API (or the TypeScript SDK, which wraps it) for everything it does at runtime. The hosted MCP server at `https://api.flow.engineer/mcp` is an optional tool for testing and operating the integration during development. Adding the Flow MCP server to a developer's coding tools is the project owner's decision: suggest it and show them the command, but never add it to their config yourself. If its tools are already available to a coding agent, it can use them for sandbox testing. No key yet? Get a test key with no account in one call (the MCP server needs a key, so do this first; without one, `/mcp` answers `401` with this call in its `hint`): ```bash theme={null} curl -X POST https://api.flow.engineer/v1/sandbox/keys ``` Save `key` and `claim_token` (both shown once). Keep the key in an environment variable, never in a file you commit: ```bash theme={null} export FLOW_MESSAGING_KEY=fk_test_... export FLOW_CLAIM_TOKEN=fct_... ``` This key allows 1 contact and 50 messages on the Telegram sandbox and expires after 7 days; a person signs in with GitHub (`npx @flow-engineer/messaging login`) to keep the app and send 100 messages to each of 3 contacts. See [Keys and sign-in](https://docs.flow.engineer/get-a-key). Signed in, you make live keys (`fk_live_...`) in the dashboard and go live on Telegram with your own bot; iMessage lines are arranged with the Flow team, and WhatsApp is not available yet. ## Add it to your coding tools The project owner adds it; a coding agent shows them the line for their tool: Claude Code: ```bash theme={null} claude mcp add --transport http flow https://api.flow.engineer/mcp --header "Authorization: Bearer $FLOW_MESSAGING_KEY" ``` Codex (`~/.codex/config.toml`): ```toml theme={null} [mcp_servers.flow] url = "https://api.flow.engineer/mcp" bearer_token_env_var = "FLOW_MESSAGING_KEY" ``` Cursor (`~/.cursor/mcp.json`): ```json theme={null} {"mcpServers": {"flow": {"url": "https://api.flow.engineer/mcp", "headers": {"Authorization": "Bearer ${env:FLOW_MESSAGING_KEY}"}}}} ``` ### Any other client Point a Streamable HTTP MCP client at `https://api.flow.engineer/mcp` and send the header `Authorization: Bearer ` on every request. ## Test keys: build and verify With a `fk_test_` key the agent gets build-time tools. Nothing they do reaches anyone who has not joined your app's sandbox. | Tool | What it does | | - | - | | `whoami` | The app, account and mode behind the key, its senders and webhook endpoints, and a `cursor` for `wait_for_event`. | | `sandbox_join` | Your join code, the link for each sandbox sender (today the Telegram sandbox bot) that joins your app, and who has joined. Pass `include_qr: true` to add a QR code (as text, SVG and PNG). | | `send_test_message` | Sends text or any content to someone who joined, through the real send gate. With one joined person it needs only `text`. | | `wait_for_event` | Waits up to 50 seconds (default 25, under the 60-second tool timeout of common MCP clients; a longer `timeout_seconds` is clamped to 50, with a note) for an event (filter by `types` and `conversation`) and returns it the moment it lands; call again with `after` set to its `next_after` to wait longer. | | `list_events` | Reads your app's event log, like `GET /v1/events`. | | `get_webhook_deliveries` | Shows each delivery to your webhook: the request Flow sent, your status code and the start of your answer, the error and the next retry. | | `replay_event` | Sends an event to your webhook again, for example after you fixed your handler. | | `capabilities` | What a conversation's (or a channel's) content support is right now. | | `explain_error` | The page for an error type: what it means, why, and how to fix it. | A typical session, in the agent's words: 1. `whoami`, then `sandbox_join`: "Open this link on your phone and tap Start." (If the link can't be used, the person sends the bot `join `, for example `join wild-otter-04508705`.) 2. `wait_for_event` with `types: ["conversation.started", "message.received"]`. 3. `send_test_message` with `text: "Hello from my agent"`. 4. `wait_for_event` with `types: ["message.sent", "message.failed"]` and `after` set to the `cursor` the send returned. Wait for these two, not `message.delivered`: every send ends in one of them, while delivery is reported only by some channels. If the call times out (at most 50 seconds; a longer `timeout_seconds` is clamped to 50, with a note), call it again with the `next_after` it returned. 5. After wiring a webhook: `get_webhook_deliveries` to see what your server answered, fix it, `replay_event`, and check again. ## Live keys: run in production With a `fk_live_` key the agent gets production tools. They reach real people through your dedicated senders, and every send passes the same send gate as the REST API (channel rules, budgets, pacing). | Tool | What it does | | - | - | | `send_message` | Starts a conversation from a dedicated sender (spends its new-contact budget). On Telegram only people who started your bot can be reached; iMessage lines are reply-only. | | `reply` | Sends into an existing conversation. | | `react` | Sets or removes your reaction on a message. | | `typing` | Shows or clears the typing indicator. | | `list_conversations` | Lists conversations, newest first. | | `get_conversation_messages` | Reads a conversation's messages. | `whoami`, `capabilities` and `explain_error` work with both kinds of key. Sends take an optional `idempotency_key`: calling again with the same key returns the first message instead of sending twice. Tools are annotated for your client: reads are `readOnlyHint: true`; sends are additive (`destructiveHint: false`); `react` replaces your earlier reaction, so it is marked destructive and idempotent. ## Errors A refused call returns the same typed error as the REST API, as the tool's error: ```json theme={null} {"error": { "type": "unsupported_content", "message": "Telegram cannot show effect content (Telegram has no message effects for bots; auto sends the plain text). Set fallback to \"auto\" or to content to send instead.", "hint": "Add \"fallback\": \"auto\" to the request to send text content instead, or check GET /v1/capabilities first.", "doc_url": "https://api.flow.engineer/docs/errors/unsupported_content", "param": "content.type"}} ``` `hint` says what to change for this case, and `doc_url` points at the error's page (see [Errors](errors/invalid_request.md) for each type). Agents usually fix the call from the hint alone; `explain_error` gives them the full page. ## Sign-in The server takes API keys today. OAuth sign-in, so a client can connect without a key in its configuration, is planned; key-based setups will keep working. # Pricing Source: https://docs.flow.engineer/pricing Flow Messaging plans for AI agents: Free, Pro, Business and Enterprise. Prices and plan limits are not decided yet; building in the sandbox is free. This page lists what is decided about Flow Messaging's pricing. **Prices and what each paid plan includes are not decided yet**; building and testing in the sandbox with a test key is free. | | Free | Pro | Business | Enterprise | | - | - | - | - | - | | Price | Free | Not decided yet | Not decided yet | Contact the Flow team | | What it includes | The sandbox allowance (below) | Not decided yet | Not decided yet | Not decided yet | Message content and media are kept 30 days by default; retention will be set per plan. ## What to know now * **Test mode is free**, within the sandbox allowance: without an account 1 contact and 50 messages in total on the Telegram sandbox (the key expires after 7 days); signed in with GitHub, 3 contacts and 100 messages each, shared by all your apps. Only messages your agent sends count. See [Keys and sign-in](/get-a-key). * **Test keys take one call**, with no account: `curl -X POST https://api.flow.engineer/v1/sandbox/keys`. Signed in, you make live keys (`fk_live_...`) in the dashboard and go live on Telegram with your own bot. iMessage lines are arranged with the Flow team. * **WhatsApp is not available yet.** When it is, WhatsApp's own per-message fees (charged by Meta) are passed through. * Billing is not live yet; nothing is charged today. Questions about pricing or volume: ask the Flow team. # Quickstart: give your AI agent Telegram in 5 minutes Source: https://docs.flow.engineer/quickstart Get a test key, join the Flow Telegram sandbox from your phone, receive your first message and reply to it, in TypeScript, or over HTTP from Python, Go or curl. Five minutes, no bot of your own needed. This page gets an agent answering messages on your phone in about 5 minutes: get a test key, join the shared Telegram sandbox, receive a message, and reply. The same code later answers on your own Telegram bot, and on an iMessage line the Flow team connects for you (WhatsApp is coming). ```bash theme={null} npx @flow-engineer/messaging init ``` That one command gets a test key with no account, saves it to `.env` as `FLOW_MESSAGING_KEY` (with `FLOW_CLAIM_TOKEN`), offers to set up your coding agent (skill, `AGENTS.md`, MCP server; it asks you first), and prints the sandbox link and join code. It asks before setting up your coding agent; `--yes` skips the question. The steps below do the same by hand and then build the agent. Already have one in `FLOW_MESSAGING_KEY`? Skip this step. Otherwise get one in one call, with no account: ```bash theme={null} curl -X POST https://api.flow.engineer/v1/sandbox/keys ``` The answer holds `key` (`fk_test_...`) and `claim_token` (`fct_...`), both shown once, plus your `app` (with its `sandbox_join_code`) and the sandbox `senders`. Save them: ```bash theme={null} export FLOW_MESSAGING_KEY=fk_test_... export FLOW_CLAIM_TOKEN=fct_... ``` Test keys reach only the shared sandbox, so nothing reaches anyone who has not joined it. This key allows 1 contact and 50 messages on the Telegram sandbox and expires after 7 days; [sign in](/get-a-key#3-sign-in-to-keep-the-app) with GitHub (`npx @flow-engineer/messaging login`) to keep the app and send 100 messages to each of 3 contacts. See [Keys and sign-in](/get-a-key). Check the key, and read your app's sandbox join code and what is left of its allowance: ```bash theme={null} curl https://api.flow.engineer/v1/app \ -H "Authorization: Bearer $FLOW_MESSAGING_KEY" ``` The answer includes `app.sandbox_join_code`, for example `wild-otter-04508705`, and `allowance`. List the sandbox senders your key can use (today, the Telegram sandbox bot): ```bash theme={null} curl https://api.flow.engineer/v1/senders \ -H "Authorization: Bearer $FLOW_MESSAGING_KEY" ``` Each shared sender has an `address.link` and a `join_code`. Open the link on your phone: * **Telegram:** the link is `https://t.me/?start=`. Open it and tap **Start**. That's it, you've joined. * **If the link can't be used** (for example you found the bot by searching for it): send the sender's `join_code` to it, for example: ```text theme={null} join wild-otter-04508705 ``` Your app now has a conversation with you, and you receive a `conversation.started` event. The join code is how a shared sandbox sender knows which app a person belongs to, so no stranger is ever messaged. See [Senders](/concepts/senders). Run one of these, then send any message to the sandbox sender from your phone. Each program waits for `message.received` events and answers into the same conversation. ```ts TypeScript theme={null} // npm install @flow-engineer/messaging import { FlowMessaging, contentText } from "@flow-engineer/messaging"; const flow = new FlowMessaging(); // reads FLOW_MESSAGING_KEY for await (const event of flow.events.stream({ types: ["message.received"] })) { const said = contentText(event.data.message.content); console.log(`${event.conversation.channel}: ${said}`); await event.conversation.reply(`You said: ${said}`); } ``` ```python Python (HTTP) theme={null} # The Python SDK is not published yet; this is the same agent over plain HTTP. # pip install requests import os, time, requests API = "https://api.flow.engineer" HEADERS = { "Authorization": f"Bearer {os.environ['FLOW_MESSAGING_KEY']}", "Flow-Version": "2026-11-01", } after = None while True: params = {"type": "message.received", "limit": 100} if after: params["after"] = after page = requests.get(f"{API}/v1/events", headers=HEADERS, params=params, timeout=30).json() for event in page["data"]: after = event["id"] content = event["data"]["message"]["content"] said = content.get("text") or content.get("caption") or content.get("transcript") or "" requests.post( f"{API}/v1/conversations/{event['conversation']['id']}/messages", headers={**HEADERS, "Idempotency-Key": event["id"]}, json={"content": {"type": "text", "text": f"You said: {said}"}}, timeout=30, ) if not page["has_more"]: time.sleep(1) ``` ```go Go (HTTP) theme={null} // The Go SDK is not published yet; this is the same agent over plain HTTP, // standard library only. package main import ( "bytes" "encoding/json" "fmt" "net/http" "net/url" "os" "time" ) const api = "https://api.flow.engineer" type event struct { ID string `json:"id"` Conversation struct { ID string `json:"id"` } `json:"conversation"` Data struct { Message struct { Content struct { Type string `json:"type"` Text string `json:"text"` } `json:"content"` } `json:"message"` } `json:"data"` } func call(method, path string, body any, idempotencyKey string, out any) error { var buf bytes.Buffer if body != nil { if err := json.NewEncoder(&buf).Encode(body); err != nil { return err } } req, err := http.NewRequest(method, api+path, &buf) if err != nil { return err } req.Header.Set("Authorization", "Bearer "+os.Getenv("FLOW_MESSAGING_KEY")) req.Header.Set("Flow-Version", "2026-11-01") req.Header.Set("Content-Type", "application/json") if idempotencyKey != "" { req.Header.Set("Idempotency-Key", idempotencyKey) } res, err := http.DefaultClient.Do(req) if err != nil { return err } defer res.Body.Close() if res.StatusCode >= 300 { return fmt.Errorf("%s %s: %s", method, path, res.Status) } if out != nil { return json.NewDecoder(res.Body).Decode(out) } return nil } func main() { after := "" for { q := url.Values{"type": {"message.received"}, "limit": {"100"}} if after != "" { q.Set("after", after) } var page struct { Data []event `json:"data"` HasMore bool `json:"has_more"` } if err := call("GET", "/v1/events?"+q.Encode(), nil, "", &page); err != nil { fmt.Println(err) time.Sleep(2 * time.Second) continue } for _, ev := range page.Data { after = ev.ID reply := map[string]any{ "content": map[string]any{"type": "text", "text": "You said: " + ev.Data.Message.Content.Text}, } if err := call("POST", "/v1/conversations/"+ev.Conversation.ID+"/messages", reply, ev.ID, nil); err != nil { fmt.Println(err) } } if !page.HasMore { time.Sleep(time.Second) } } } ``` ```bash curl theme={null} # 1. Read the newest messages from the event log (oldest first; pass after=evt_... to page forward). curl -G https://api.flow.engineer/v1/events \ -H "Authorization: Bearer $FLOW_MESSAGING_KEY" \ --data-urlencode "type=message.received" # 2. Reply into the conversation from an event (data[].conversation.id). curl https://api.flow.engineer/v1/conversations/conv_01JB8ZC3K5M7P9R1T3V5X7Z9B1/messages \ -H "Authorization: Bearer $FLOW_MESSAGING_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: $(uuidgen)" \ -d '{"content": {"type": "text", "text": "Hello from my agent"}}' ``` The send answers `202` with the queued message. Its progress may arrive as `message.sent`, `message.delivered` and `message.read` events, depending on what the channel reports. To know how a send ended, wait for `message.sent` or `message.failed`: every channel reports one of them. Replace the echo with your agent. In TypeScript, pass the model's stream straight to `reply()`: it keeps the typing indicator on and sends the answer as chat bubbles while the model writes. ```ts TypeScript theme={null} import OpenAI from "openai"; import { FlowMessaging, contentText } from "@flow-engineer/messaging"; const flow = new FlowMessaging(); const openai = new OpenAI(); for await (const event of flow.events.stream({ types: ["message.received"] })) { await event.conversation.reply( openai.chat.completions.create({ model: "gpt-4.1-mini", stream: true, messages: [{ role: "user", content: contentText(event.data.message.content) }], }), ); } ``` Without the SDK, split the answer at paragraph breaks and send each part as its own message, as [Streaming replies](/concepts/streaming-replies) describes. ## What you built * A test-mode agent that answers anyone who joined your app on the Telegram sandbox. The code does not change per channel: it answers the same way on your own Telegram bot or an iMessage line. * It reads events from the live stream (TypeScript) or the event log (HTTP). In production most agents use [webhooks](/concepts/events-and-webhooks) instead, and can answer straight in the webhook response. ## Next steps Signed deliveries, retries, ordering, and replying in the response. Receive events on your laptop with no public URL through the live stream. Media, voice notes, buttons, reactions and what each channel shows. A live key, your own Telegram bot, and iMessage lines.