openapi: 3.1.0 info: title: Flow Messaging API version: "2026-11-01" summary: Two-way messaging for AI agents on WhatsApp, Telegram and iMessage. description: | Flow Messaging lets an AI agent hold conversations with people on **WhatsApp**, **Telegram** and **iMessage** through one HTTP API. Flow hosts the senders (bots, numbers and lines), receives every inbound message, and passes every send through one gate that enforces each channel's rules. You never handle vendor keys or channel webhooks. Channels today: Telegram is live. iMessage is live for replies only, on lines the Flow team connects to your app. WhatsApp is not available yet (it waits on Meta's approval): requesting a WhatsApp sender answers `501 not_implemented` until it ships, and the WhatsApp-only parts (templates, the 24-hour window) apply then. This API is in **beta**: the shape may still change before it is declared stable. Changes are released as new dated versions (see Versioning). ## Concepts - An **app** is one agent integration. It owns API keys, webhook endpoints and settings, and has a test mode and a live mode. - A **sender** is what your agent talks *from*: a Telegram bot, a WhatsApp number or an iMessage line. Shared sandbox senders serve many apps; dedicated senders are yours alone. - A **contact** is a person on one channel. There is no cross-channel identity: a contact on Telegram and a contact on WhatsApp are different contacts, even if they are the same person. Never assume a contact has a phone number. - A **conversation** is one sender talking with one contact. You reply into a conversation; you never pick a channel per message. - A **message** carries one piece of typed **content** (text, media, voice, buttons, a reaction, a template, ...). The same content union is used in both directions. - An **event** is one entry in your app's ordered log. Everything you receive (inbound messages, delivery statuses, reactions, sender changes) is an event, delivered by webhook, by the live stream (`GET /v1/stream`) and by polling (`GET /v1/events`). ## Authentication Every request carries an API key as a bearer token: ``` Authorization: Bearer fk_live_... ``` Keys belong to one app and one mode. `fk_test_` keys see only sandbox senders and test data, and nothing they send reaches a real contact unless that contact joined the sandbox. `fk_live_` keys reach real contacts through your dedicated senders. A key is shown once when it is created and stored only as a hash. Keys are never accepted in the query string. The live stream (`GET /v1/stream`) also takes the key as a WebSocket subprotocol, for browsers, which cannot set headers on a WebSocket (see that endpoint). ### No key yet? Get one in one call Anyone, including an AI coding agent, can get a test key without signing up: ``` curl -X POST https://api.flow.engineer/v1/sandbox/keys ``` The answer (`SandboxKey`) holds a `fk_test_` key for a new app of its own, shown once, and the sandbox senders with the links and join code a person uses to join it. Save the key (for example as `FLOW_MESSAGING_KEY`) and the `claim_token`. This key needs no account and has a **sandbox allowance**: 1 contact and 50 messages sent in total on the Telegram sandbox (and WhatsApp's when it opens), and it **expires after 7 days**. Only messages your agent sends count; inbound messages are free. iMessage is not part of the allowance. To keep the app, a person signs in with GitHub through the device flow: the agent or the CLI (`npx @flow-engineer/messaging login`) calls `POST /v1/device/authorizations` with the `claim_token`, shows the person the `verification_uri` and `user_code` (they type the code on that page), and polls `POST /v1/device/token` until it returns a key. The app is claimed: its data and keys are kept, its keys no longer expire, and it moves under the person's signed-in allowance of 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). `GET /v1/app` shows what is left of the allowance (`allowance`). When it runs out, sends answer `403 permission` with `channel_code` `sandbox_allowance_used`. ## Versioning The API is versioned by date. Send `Flow-Version: 2026-11-01` to choose a version; without the header, the version pinned to your app when it was created is used. Responses echo the version that served them in `Flow-Version`. ## Idempotency Every `POST` accepts an `Idempotency-Key` header (any unique string up to 255 characters; a UUID or ULID is a good choice). Keys are kept for 24 hours per app and mode. Repeating a request with the same key returns the first answer, with the header `Idempotent-Replayed: true`, and does not act twice. Otherwise a key already used answers `409 idempotency_conflict`, and its `channel_code` says which case it is: - `body_mismatch`: the key was used with a different method, path or body. Use a new key for a new request; do not retry. - `in_progress`: the first request with this key is still running. Wait `retry_after` seconds and repeat the identical request with the same key to get its answer. - `secret_not_kept`: the first request went through, but its answer carried a secret shown only once (creating a webhook endpoint, rotating its secret), so that answer was not kept. Answers that are worth retrying (`429`, `500`, `502`, `503`, `504`) are not kept, so a retry with the same key runs again. ## Errors Errors share one shape: ```json {"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_01JB..."}} ``` `error.type` is a closed list (see the `ErrorType` schema); switch on it, not on the message text. Every error carries `hint`, one sentence saying what to change for this case, and `doc_url`, the page for its type (`https://api.flow.engineer/docs/errors/`: what it means, why it happens, how to fix it, with code). Errors that can be retried carry `retry_after` in seconds. Coding agents can act on `hint` directly; the MCP tool `explain_error` returns the page for a type. ## Pagination Lists are paged with cursors. Pass `limit` (1 to 100, default 20) and either `after` or `before`, set to the ID of an item you already have. IDs are prefixed ULIDs, so they sort by creation time. The event log (`GET /v1/events`) is oldest first, so `after` moves forward in time; every other list is newest first, so `after` moves back in time. A list answer says `has_more` when more items lie in that direction. ## Rate limits Requests are limited per key. Every answer carries `RateLimit-Limit`, `RateLimit-Remaining` and `RateLimit-Reset`. Sends are also paced per sender by the send gate (see below): each sender has a sending rate it may not exceed. Going over either limit answers `429` with type `rate_limited`, a `Retry-After` header and `error.retry_after` in seconds; wait that long and retry with the same `Idempotency-Key`. The send gate also answers `429` with type `new_contact_limit` when a sender has used its budget for starting conversations. ## The send gate Every send (HTTP, the stream, or a reply in a webhook response) passes the same gate: - **WhatsApp**: free-form messages only while the contact's last message is less than 24 hours old (the window); otherwise only a `template` (`409 outside_window`). - **iMessage**: a line may only reply unless it can start conversations; a new conversation from a reply-only line is refused (`403 permission`). A line that can start conversations spends the sender's new-contact budget, and a contact who has never messaged the line (or opted in) gets a `message.failed` event with `outside_window`. - **Telegram**: the contact must have started the bot; Telegram enforces this. - **Sandbox senders**: only contacts who joined through your app (by sending its join code, for example `join brave-otter-40718263`). Starting a conversation spends budget per sender (new contacts per day and per hour, with a warm-up curve for new lines) and per plan. When abuse signals trip (the same text to many new contacts, many starts with no reply, blocks, a drop in WhatsApp quality) the sender is throttled and you receive `sender.status_changed`: until the sender's `throttled_until`, starts fail with `429 sender_throttled`, while replies into existing conversations still go. The sender recovers by itself, with another `sender.status_changed`. ## Content a channel cannot show Nothing is converted silently. When a channel cannot show some content, the send fails with `422 unsupported_content`, unless the request set `fallback`: then the fallback is sent and the message reports what was actually shown in `delivered_as`. `GET /v1/capabilities` says what a conversation's channel supports right now. ## Webhooks Register endpoints with `POST /v1/webhook_endpoints`. Each event is `POST`ed as JSON (the `event` webhook below), in order per conversation, and signed: ``` Flow-Signature: t=1791763200,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd ``` `v1` is the lowercase hex HMAC-SHA256, keyed with the endpoint's signing secret (`whsec_...`), 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 (`POST /v1/webhook_endpoints/{webhook_endpoint_id}/rotate_secret`), the header carries one `v1` per active secret; accept the request if any of them matches. Answer `2xx` within 10 seconds. To reply at once, answer a `message.received` event with `200` and a body `{"reply": }` or `{"reply": [, ...]}` (up to 10 pieces), optionally with `fallback` as on HTTP sends; the reply is sent into the event's conversation through the send gate, which saves a round trip. A string is text: `{"reply": "Hi"}` sends `{"type": "text", "text": "Hi"}`. Any other `2xx` answer (an empty body, plain text such as `OK`, or JSON without `reply`) sends nothing and is not an error. A JSON object whose `reply` is not valid `WebhookReply` content, or a body that starts with `{` but is not valid JSON, sends nothing at all and is recorded on the delivery as an `invalid_request` error (the MCP tool `get_webhook_deliveries` shows it); the delivery still counts as delivered and is not retried. Slow agents answer `200 {}` at once and send later with `POST /v1/conversations/{conversation_id}/messages`. Failed deliveries are retried with backoff for 3 days; within a conversation, later events wait behind a failing one. After that the event stays in the log, marked failed, and can be read again with `GET /v1/events`. Inbound messages keep the channel's order within a conversation. iMessage: inbound order is best-effort (arrival order); messages sent within about a second of each other may arrive out of order. ## MCP server `https://api.flow.engineer/mcp` is a Model Context Protocol server (Streamable HTTP transport, protocol version 2025-06-18 or later) for coding agents such as Claude Code, Codex and Cursor. It is not a REST endpoint: clients `POST` JSON-RPC messages to it (and `GET` answers `405`, since the server keeps no sessions). Authenticate with the same API key as the REST API: ``` claude mcp add --transport http flow https://api.flow.engineer/mcp --header "Authorization: Bearer $FLOW_MESSAGING_KEY" ``` Without a key, `/mcp` answers `401` and its `hint` gives the one call that gets a test key (`curl -X POST https://api.flow.engineer/v1/sandbox/keys`). The key's mode chooses the tools. A `fk_test_` key gets build-time tools to integrate and verify an app end to end: `whoami`, `sandbox_join`, `send_test_message`, `wait_for_event`, `list_events`, `get_webhook_deliveries`, `replay_event`, `capabilities` and `explain_error`. A `fk_live_` key gets production tools: `send_message`, `reply`, `react`, `typing`, `list_conversations` and `get_conversation_messages`, plus `whoami`, `capabilities` and `explain_error`. Every send passes the same send gate as the REST API, and a refusal comes back as the tool's error with the same `type`, `hint` and `doc_url`. contact: name: Flow Engineer url: https://docs.flow.engineer license: name: Apache-2.0 url: https://www.apache.org/licenses/LICENSE-2.0 servers: - url: https://api.flow.engineer description: Production (asia-south1). Test and live mode are chosen by the API key. security: - bearerAuth: [] tags: - name: Messages description: Send content into conversations, start conversations, edit and unsend messages. - name: Conversations description: One sender talking with one contact. Read history, show typing, mark as read. - name: Events description: The ordered log of everything that happened in your app. Catch up, replay and debug. - name: Stream description: Live events over a WebSocket, resumable from any event. - name: Capabilities description: What a conversation's channel can show right now, and whether its window is open. - name: Files description: Media in and out. Flow fetches channel media for you and serves it through short-lived URLs. - name: Senders description: The Telegram bots, WhatsApp numbers and iMessage lines your app sends from. - name: Templates description: WhatsApp message templates, mirrored from Meta with their approval status. - name: Webhook endpoints description: Where Flow delivers your app's events. - name: Contacts description: People your app talks with, one per channel. - name: App description: The app and key making the request. - name: Onboarding description: | Get a test key without an account (`POST /v1/sandbox/keys`), and sign in with GitHub through the device flow to claim the app and lift its sandbox allowance. These endpoints take no API key. paths: /v1/conversations/{conversation_id}/messages: parameters: - $ref: "#/components/parameters/ConversationId" - $ref: "#/components/parameters/FlowVersion" post: operationId: sendMessage tags: [Messages] summary: Send a message into a conversation description: | 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. parameters: - $ref: "#/components/parameters/IdempotencyKey" requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/SendMessageRequest" examples: text: summary: A plain text reply value: content: {type: text, text: "Yes, we deliver to 560103. Delivery takes 2 days."} buttons_with_fallback: summary: Buttons, with the channel's default fallback allowed value: content: type: buttons text: "Which size?" buttons: - {id: size_s, label: Small} - {id: size_m, label: Medium} - {id: size_l, label: Large} fallback: auto responses: "202": description: The message passed the gate and is queued for the channel. content: application/json: schema: $ref: "#/components/schemas/Message" "400": $ref: "#/components/responses/InvalidRequest" "401": $ref: "#/components/responses/Unauthenticated" "403": $ref: "#/components/responses/PermissionDenied" "404": $ref: "#/components/responses/NotFound" "409": $ref: "#/components/responses/Conflict" "422": $ref: "#/components/responses/UnsupportedContent" "429": $ref: "#/components/responses/RateLimited" default: $ref: "#/components/responses/Error" get: operationId: listConversationMessages tags: [Conversations] summary: List a conversation's messages description: | 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. parameters: - $ref: "#/components/parameters/After" - $ref: "#/components/parameters/Before" - $ref: "#/components/parameters/Limit" responses: "200": description: A page of messages. content: application/json: schema: $ref: "#/components/schemas/MessageList" "400": $ref: "#/components/responses/InvalidRequest" "401": $ref: "#/components/responses/Unauthenticated" "404": $ref: "#/components/responses/NotFound" "429": $ref: "#/components/responses/RateLimited" default: $ref: "#/components/responses/Error" /v1/messages: parameters: - $ref: "#/components/parameters/FlowVersion" post: operationId: startConversation tags: [Messages] summary: Start a conversation description: | 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. parameters: - $ref: "#/components/parameters/IdempotencyKey" requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/StartConversationRequest" examples: whatsapp_template: summary: A WhatsApp template to a phone number value: sender: snd_01JB8Z4Q3V6W0R2N7C5H1M9K4T to: {phone: "+919812345678"} content: type: template template_id: tpl_01JB8Z9X2D4F6H8K0M2P4R6T8V language: en params: {body: ["Asha", "order 1042"]} responses: "202": description: The message passed the gate and is queued. Its `conversation` is the conversation it went into. content: application/json: schema: $ref: "#/components/schemas/Message" "400": $ref: "#/components/responses/InvalidRequest" "401": $ref: "#/components/responses/Unauthenticated" "403": $ref: "#/components/responses/PermissionDenied" "404": $ref: "#/components/responses/NotFound" "409": $ref: "#/components/responses/Conflict" "422": $ref: "#/components/responses/UnsupportedContent" "429": $ref: "#/components/responses/RateLimited" default: $ref: "#/components/responses/Error" /v1/messages/{message_id}: parameters: - $ref: "#/components/parameters/MessageId" - $ref: "#/components/parameters/FlowVersion" get: operationId: getMessage tags: [Messages] summary: Get a message description: Returns one message, inbound or outbound, with its current status. responses: "200": description: The message. content: application/json: schema: $ref: "#/components/schemas/Message" "401": $ref: "#/components/responses/Unauthenticated" "404": $ref: "#/components/responses/NotFound" "429": $ref: "#/components/responses/RateLimited" default: $ref: "#/components/responses/Error" patch: operationId: editMessage tags: [Messages] summary: Edit a sent message description: | 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. requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/EditMessageRequest" responses: "200": description: The edit was accepted. The message shows its new content. content: application/json: schema: $ref: "#/components/schemas/Message" "400": $ref: "#/components/responses/InvalidRequest" "401": $ref: "#/components/responses/Unauthenticated" "404": $ref: "#/components/responses/NotFound" "422": $ref: "#/components/responses/UnsupportedContent" "429": $ref: "#/components/responses/RateLimited" default: $ref: "#/components/responses/Error" delete: operationId: unsendMessage tags: [Messages] summary: Unsend a message description: | 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. responses: "200": description: The message was unsent. content: application/json: schema: $ref: "#/components/schemas/Message" "401": $ref: "#/components/responses/Unauthenticated" "404": $ref: "#/components/responses/NotFound" "422": $ref: "#/components/responses/UnsupportedContent" "429": $ref: "#/components/responses/RateLimited" default: $ref: "#/components/responses/Error" /v1/conversations/{conversation_id}/typing: parameters: - $ref: "#/components/parameters/ConversationId" - $ref: "#/components/parameters/FlowVersion" post: operationId: setTyping tags: [Conversations] summary: Show or stop the typing indicator description: | 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. parameters: - $ref: "#/components/parameters/IdempotencyKey" requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/TypingRequest" responses: "202": description: The channel showed or cleared the indicator, or `delivered_as` says it was skipped. content: application/json: schema: $ref: "#/components/schemas/ConversationAction" "400": $ref: "#/components/responses/InvalidRequest" "401": $ref: "#/components/responses/Unauthenticated" "403": $ref: "#/components/responses/PermissionDenied" "404": $ref: "#/components/responses/NotFound" "409": $ref: "#/components/responses/Conflict" "429": $ref: "#/components/responses/RateLimited" "502": $ref: "#/components/responses/ChannelError" default: $ref: "#/components/responses/Error" /v1/conversations/{conversation_id}/read: parameters: - $ref: "#/components/parameters/ConversationId" - $ref: "#/components/parameters/FlowVersion" post: operationId: markRead tags: [Conversations] summary: Mark messages as read description: | 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. parameters: - $ref: "#/components/parameters/IdempotencyKey" requestBody: required: false content: application/json: schema: $ref: "#/components/schemas/ReadRequest" responses: "202": description: The channel took the read receipt, or `delivered_as` says it was skipped. content: application/json: schema: $ref: "#/components/schemas/ConversationAction" "400": $ref: "#/components/responses/InvalidRequest" "401": $ref: "#/components/responses/Unauthenticated" "403": $ref: "#/components/responses/PermissionDenied" "404": $ref: "#/components/responses/NotFound" "409": $ref: "#/components/responses/Conflict" "429": $ref: "#/components/responses/RateLimited" "502": $ref: "#/components/responses/ChannelError" default: $ref: "#/components/responses/Error" /v1/conversations: parameters: - $ref: "#/components/parameters/FlowVersion" get: operationId: listConversations tags: [Conversations] summary: List conversations description: Lists your app's conversations in this mode, newest first. parameters: - $ref: "#/components/parameters/After" - $ref: "#/components/parameters/Before" - $ref: "#/components/parameters/Limit" - name: sender in: query required: false description: Only conversations on this sender. schema: $ref: "#/components/schemas/SenderId" - name: contact in: query required: false description: Only conversations with this contact. schema: $ref: "#/components/schemas/ContactId" - name: channel in: query required: false description: Only conversations on this channel. schema: $ref: "#/components/schemas/Channel" responses: "200": description: A page of conversations. content: application/json: schema: $ref: "#/components/schemas/ConversationList" "400": $ref: "#/components/responses/InvalidRequest" "401": $ref: "#/components/responses/Unauthenticated" "429": $ref: "#/components/responses/RateLimited" default: $ref: "#/components/responses/Error" /v1/conversations/{conversation_id}: parameters: - $ref: "#/components/parameters/ConversationId" - $ref: "#/components/parameters/FlowVersion" get: operationId: getConversation tags: [Conversations] summary: Get a conversation description: Returns one conversation with its window state. responses: "200": description: The conversation. content: application/json: schema: $ref: "#/components/schemas/Conversation" "401": $ref: "#/components/responses/Unauthenticated" "404": $ref: "#/components/responses/NotFound" "429": $ref: "#/components/responses/RateLimited" default: $ref: "#/components/responses/Error" /v1/events: parameters: - $ref: "#/components/parameters/FlowVersion" get: operationId: listEvents tags: [Events] summary: List events description: | 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. parameters: - $ref: "#/components/parameters/After" - $ref: "#/components/parameters/Before" - $ref: "#/components/parameters/Limit" - name: type in: query required: false description: Only events of these types. Repeat the parameter for several (`type=message.received&type=reaction.added`). style: form explode: true schema: type: array maxItems: 20 items: $ref: "#/components/schemas/EventType" - name: conversation in: query required: false description: Only events in this conversation. An ID with no conversation of this app and mode answers `404 not_found`. schema: $ref: "#/components/schemas/ConversationId" responses: "200": description: A page of events, oldest first. content: application/json: schema: $ref: "#/components/schemas/EventList" "400": $ref: "#/components/responses/InvalidRequest" "401": $ref: "#/components/responses/Unauthenticated" "404": $ref: "#/components/responses/NotFound" "429": $ref: "#/components/responses/RateLimited" default: $ref: "#/components/responses/Error" /v1/events/{event_id}: parameters: - $ref: "#/components/parameters/EventId" - $ref: "#/components/parameters/FlowVersion" get: operationId: getEvent tags: [Events] summary: Get an event description: Returns one event from the log. responses: "200": description: The event. content: application/json: schema: $ref: "#/components/schemas/Event" "401": $ref: "#/components/responses/Unauthenticated" "404": $ref: "#/components/responses/NotFound" "429": $ref: "#/components/responses/RateLimited" default: $ref: "#/components/responses/Error" /v1/stream: parameters: - $ref: "#/components/parameters/FlowVersion" get: operationId: openStream tags: [Stream] summary: Open the live event stream (WebSocket) description: | 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. parameters: - $ref: "#/components/parameters/After" - name: type in: query required: false description: Only events of these types. Repeat the parameter for several. style: form explode: true schema: type: array maxItems: 20 items: $ref: "#/components/schemas/EventType" responses: "101": description: | Switching to the WebSocket protocol. The schema below describes each JSON frame on the socket, in either direction. A refused WebSocket request is upgraded too, then answered with one `error` frame and a 4000-range close code (see the description); the 4xx answers below are what a request without an upgrade gets. content: application/json: schema: $ref: "#/components/schemas/StreamFrame" "400": $ref: "#/components/responses/InvalidRequest" "401": $ref: "#/components/responses/Unauthenticated" "429": $ref: "#/components/responses/RateLimited" default: $ref: "#/components/responses/Error" /v1/capabilities: parameters: - $ref: "#/components/parameters/FlowVersion" get: operationId: getCapabilities tags: [Capabilities] summary: Get a conversation's capabilities description: | 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. parameters: - name: conversation in: query required: true description: The conversation to describe. schema: $ref: "#/components/schemas/ConversationId" responses: "200": description: The conversation's capabilities. content: application/json: schema: $ref: "#/components/schemas/Capabilities" "400": $ref: "#/components/responses/InvalidRequest" "401": $ref: "#/components/responses/Unauthenticated" "404": $ref: "#/components/responses/NotFound" "429": $ref: "#/components/responses/RateLimited" default: $ref: "#/components/responses/Error" /v1/files: parameters: - $ref: "#/components/parameters/FlowVersion" post: operationId: uploadFile tags: [Files] summary: Upload a file description: | 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). parameters: - $ref: "#/components/parameters/IdempotencyKey" requestBody: required: true content: multipart/form-data: schema: $ref: "#/components/schemas/FileUpload" responses: "201": description: The file was stored. content: application/json: schema: $ref: "#/components/schemas/File" "400": $ref: "#/components/responses/InvalidRequest" "401": $ref: "#/components/responses/Unauthenticated" "422": $ref: "#/components/responses/UnsupportedContent" "429": $ref: "#/components/responses/RateLimited" default: $ref: "#/components/responses/Error" /v1/files/{file_id}: parameters: - $ref: "#/components/parameters/FileId" - $ref: "#/components/parameters/FlowVersion" get: operationId: getFile tags: [Files] summary: Download a file description: | 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. responses: "200": description: | The file's bytes, served directly. Used where file storage has no separate file domain (local development) and for media received before Flow kept copies, which is fetched from the channel when asked for. content: application/octet-stream: schema: type: string format: binary "302": description: A redirect to the file's bytes. The `Location` URL expires after 5 minutes. headers: Location: description: The signed URL of the file's bytes. schema: type: string format: uri "401": $ref: "#/components/responses/Unauthenticated" "404": $ref: "#/components/responses/NotFound" "429": $ref: "#/components/responses/RateLimited" default: $ref: "#/components/responses/Error" /v1/senders: parameters: - $ref: "#/components/parameters/FlowVersion" get: operationId: listSenders tags: [Senders] summary: List senders description: 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. parameters: - $ref: "#/components/parameters/After" - $ref: "#/components/parameters/Before" - $ref: "#/components/parameters/Limit" - name: channel in: query required: false description: Only senders on this channel. schema: $ref: "#/components/schemas/Channel" responses: "200": description: A page of senders. content: application/json: schema: $ref: "#/components/schemas/SenderList" "400": $ref: "#/components/responses/InvalidRequest" "401": $ref: "#/components/responses/Unauthenticated" "429": $ref: "#/components/responses/RateLimited" default: $ref: "#/components/responses/Error" post: operationId: requestSender tags: [Senders] summary: Request a dedicated sender description: | 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}`. parameters: - $ref: "#/components/parameters/IdempotencyKey" requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/SenderRequest" responses: "200": description: The sender is connected and `active` (Telegram bots), or an already connected bot's token was updated in place. content: application/json: schema: $ref: "#/components/schemas/Sender" "202": description: The request was accepted. The sender is `pending` (WhatsApp numbers, once WhatsApp is available); you receive `sender.status_changed` when it is ready. content: application/json: schema: $ref: "#/components/schemas/Sender" "400": $ref: "#/components/responses/InvalidRequest" "401": $ref: "#/components/responses/Unauthenticated" "403": $ref: "#/components/responses/PermissionDenied" "429": $ref: "#/components/responses/RateLimited" default: $ref: "#/components/responses/Error" /v1/senders/{sender_id}: parameters: - $ref: "#/components/parameters/SenderId" - $ref: "#/components/parameters/FlowVersion" get: operationId: getSender tags: [Senders] summary: Get a sender description: Returns one sender with its status, limits and, on WhatsApp, its quality rating. responses: "200": description: The sender. content: application/json: schema: $ref: "#/components/schemas/Sender" "401": $ref: "#/components/responses/Unauthenticated" "404": $ref: "#/components/responses/NotFound" "429": $ref: "#/components/responses/RateLimited" default: $ref: "#/components/responses/Error" delete: operationId: disconnectSender tags: [Senders] summary: Disconnect a sender description: | Disconnects one of your dedicated Telegram bots from Flow. Flow removes the bot's webhook (best effort), deletes its stored token, and retires the sender: its status becomes `banned`, it no longer sends or receives, and messages still queued from it fail. You receive `sender.status_changed`. The sender, its conversations and its messages stay readable. Repeating the call is safe and answers with the retired sender. Live keys only. Shared sandbox senders belong to Flow and cannot be disconnected (`403 permission` with a test key). iMessage lines and WhatsApp numbers are disconnected by Flow, not by API (`501 not_implemented`). To use the bot again, connect it with `POST /v1/senders`; it becomes a new sender. responses: "200": description: The sender is disconnected; it is returned with status `banned`. content: application/json: schema: $ref: "#/components/schemas/Sender" "401": $ref: "#/components/responses/Unauthenticated" "403": $ref: "#/components/responses/PermissionDenied" "404": $ref: "#/components/responses/NotFound" "429": $ref: "#/components/responses/RateLimited" default: $ref: "#/components/responses/Error" /v1/templates: parameters: - $ref: "#/components/parameters/FlowVersion" get: operationId: listTemplates tags: [Templates] summary: List templates description: Lists WhatsApp message templates for your senders, newest first. parameters: - $ref: "#/components/parameters/After" - $ref: "#/components/parameters/Before" - $ref: "#/components/parameters/Limit" - name: sender in: query required: false description: Only templates of this sender. schema: $ref: "#/components/schemas/SenderId" - name: status in: query required: false description: Only templates in this status. schema: $ref: "#/components/schemas/TemplateStatus" responses: "200": description: A page of templates. content: application/json: schema: $ref: "#/components/schemas/TemplateList" "400": $ref: "#/components/responses/InvalidRequest" "401": $ref: "#/components/responses/Unauthenticated" "429": $ref: "#/components/responses/RateLimited" default: $ref: "#/components/responses/Error" post: operationId: createTemplate tags: [Templates] summary: Create a template description: | 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`. parameters: - $ref: "#/components/parameters/IdempotencyKey" requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/TemplateCreateRequest" responses: "201": description: The template was submitted. content: application/json: schema: $ref: "#/components/schemas/Template" "400": $ref: "#/components/responses/InvalidRequest" "401": $ref: "#/components/responses/Unauthenticated" "403": $ref: "#/components/responses/PermissionDenied" "404": $ref: "#/components/responses/NotFound" "422": $ref: "#/components/responses/UnsupportedContent" "429": $ref: "#/components/responses/RateLimited" default: $ref: "#/components/responses/Error" /v1/templates/{template_id}: parameters: - $ref: "#/components/parameters/TemplateId" - $ref: "#/components/parameters/FlowVersion" get: operationId: getTemplate tags: [Templates] summary: Get a template description: Returns one template with its approval status. responses: "200": description: The template. content: application/json: schema: $ref: "#/components/schemas/Template" "401": $ref: "#/components/responses/Unauthenticated" "404": $ref: "#/components/responses/NotFound" "429": $ref: "#/components/responses/RateLimited" default: $ref: "#/components/responses/Error" delete: operationId: deleteTemplate tags: [Templates] summary: Delete a template description: Deletes the template at Meta and here. Messages already sent with it are unaffected. responses: "200": description: The template was deleted. content: application/json: schema: $ref: "#/components/schemas/Deleted" "401": $ref: "#/components/responses/Unauthenticated" "404": $ref: "#/components/responses/NotFound" "429": $ref: "#/components/responses/RateLimited" default: $ref: "#/components/responses/Error" /v1/webhook_endpoints: parameters: - $ref: "#/components/parameters/FlowVersion" get: operationId: listWebhookEndpoints tags: [Webhook endpoints] summary: List webhook endpoints description: Lists your app's webhook endpoints in this mode, newest first. Signing secrets are not included. parameters: - $ref: "#/components/parameters/After" - $ref: "#/components/parameters/Before" - $ref: "#/components/parameters/Limit" responses: "200": description: A page of webhook endpoints. content: application/json: schema: $ref: "#/components/schemas/WebhookEndpointList" "400": $ref: "#/components/responses/InvalidRequest" "401": $ref: "#/components/responses/Unauthenticated" "429": $ref: "#/components/responses/RateLimited" default: $ref: "#/components/responses/Error" post: operationId: createWebhookEndpoint tags: [Webhook endpoints] summary: Create a webhook endpoint description: | 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. parameters: - $ref: "#/components/parameters/IdempotencyKey" requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/WebhookEndpointCreateRequest" responses: "201": description: The endpoint was created. `secret` is included this once. content: application/json: schema: $ref: "#/components/schemas/WebhookEndpoint" "400": $ref: "#/components/responses/InvalidRequest" "401": $ref: "#/components/responses/Unauthenticated" "429": $ref: "#/components/responses/RateLimited" default: $ref: "#/components/responses/Error" /v1/webhook_endpoints/{webhook_endpoint_id}: parameters: - $ref: "#/components/parameters/WebhookEndpointId" - $ref: "#/components/parameters/FlowVersion" get: operationId: getWebhookEndpoint tags: [Webhook endpoints] summary: Get a webhook endpoint description: Returns one webhook endpoint. The signing secret is not included. responses: "200": description: The webhook endpoint. content: application/json: schema: $ref: "#/components/schemas/WebhookEndpoint" "401": $ref: "#/components/responses/Unauthenticated" "404": $ref: "#/components/responses/NotFound" "429": $ref: "#/components/responses/RateLimited" default: $ref: "#/components/responses/Error" patch: operationId: updateWebhookEndpoint tags: [Webhook endpoints] summary: Update a webhook endpoint description: 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`. requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/WebhookEndpointUpdateRequest" responses: "200": description: The updated webhook endpoint. content: application/json: schema: $ref: "#/components/schemas/WebhookEndpoint" "400": $ref: "#/components/responses/InvalidRequest" "401": $ref: "#/components/responses/Unauthenticated" "404": $ref: "#/components/responses/NotFound" "429": $ref: "#/components/responses/RateLimited" default: $ref: "#/components/responses/Error" delete: operationId: deleteWebhookEndpoint tags: [Webhook endpoints] summary: Delete a webhook endpoint description: Stops deliveries to the endpoint and deletes it. Events stay in the log. responses: "200": description: The endpoint was deleted. content: application/json: schema: $ref: "#/components/schemas/Deleted" "401": $ref: "#/components/responses/Unauthenticated" "404": $ref: "#/components/responses/NotFound" "429": $ref: "#/components/responses/RateLimited" default: $ref: "#/components/responses/Error" /v1/webhook_endpoints/{webhook_endpoint_id}/rotate_secret: parameters: - $ref: "#/components/parameters/WebhookEndpointId" - $ref: "#/components/parameters/FlowVersion" post: operationId: rotateWebhookEndpointSecret tags: [Webhook endpoints] summary: Rotate a webhook endpoint's signing secret description: | Makes a new signing secret for the endpoint and returns it in `secret`, shown only this once. The previous secret keeps signing for `overlap_seconds` (default 86400, one day; `0` retires it at once): during the overlap every delivery's `Flow-Signature` carries two `v1` values, one per secret, so you can deploy the new secret while the old one still verifies. When the overlap ends only the new secret signs. Rotating again during an overlap retires the older previous secret at once: at most two secrets 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. The new secret is never stored with the key, so a repeat of a rotation that went through answers `409 idempotency_conflict` instead of showing the secret again. parameters: - $ref: "#/components/parameters/IdempotencyKey" requestBody: required: false content: application/json: schema: $ref: "#/components/schemas/WebhookSecretRotateRequest" examples: default_overlap: summary: Keep the old secret for one day value: {} one_hour: summary: Keep the old secret for one hour value: {overlap_seconds: 3600} responses: "200": description: The secret was rotated. `secret` is the new secret, included this once; `previous_secret_expires_at` says when the old one stops signing. content: application/json: schema: $ref: "#/components/schemas/WebhookEndpoint" "400": $ref: "#/components/responses/InvalidRequest" "401": $ref: "#/components/responses/Unauthenticated" "404": $ref: "#/components/responses/NotFound" "429": $ref: "#/components/responses/RateLimited" default: $ref: "#/components/responses/Error" /v1/contacts: parameters: - $ref: "#/components/parameters/FlowVersion" get: operationId: listContacts tags: [Contacts] summary: List contacts description: Lists the contacts your app has talked with in this mode, newest first. parameters: - $ref: "#/components/parameters/After" - $ref: "#/components/parameters/Before" - $ref: "#/components/parameters/Limit" - name: channel in: query required: false description: Only contacts on this channel. schema: $ref: "#/components/schemas/Channel" responses: "200": description: A page of contacts. content: application/json: schema: $ref: "#/components/schemas/ContactList" "400": $ref: "#/components/responses/InvalidRequest" "401": $ref: "#/components/responses/Unauthenticated" "429": $ref: "#/components/responses/RateLimited" default: $ref: "#/components/responses/Error" /v1/contacts/{contact_id}: parameters: - $ref: "#/components/parameters/ContactId" - $ref: "#/components/parameters/FlowVersion" get: operationId: getContact tags: [Contacts] summary: Get a contact description: Returns one contact and the address they are reached at on their channel. responses: "200": description: The contact. content: application/json: schema: $ref: "#/components/schemas/Contact" "401": $ref: "#/components/responses/Unauthenticated" "404": $ref: "#/components/responses/NotFound" "429": $ref: "#/components/responses/RateLimited" default: $ref: "#/components/responses/Error" /v1/app: parameters: - $ref: "#/components/parameters/FlowVersion" get: operationId: getApp tags: [App] summary: Get the calling app description: | 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. responses: "200": description: The calling app. content: application/json: schema: $ref: "#/components/schemas/AppContext" "401": $ref: "#/components/responses/Unauthenticated" "429": $ref: "#/components/responses/RateLimited" default: $ref: "#/components/responses/Error" /v1/sandbox/keys: post: operationId: createSandboxKey tags: [Onboarding] security: [] summary: Get a test key without an account description: | 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. requestBody: required: false content: application/json: schema: $ref: "#/components/schemas/SandboxKeyRequest" responses: "201": description: The new app and its key. The key and the claim token are not shown again. content: application/json: schema: $ref: "#/components/schemas/SandboxKey" "400": $ref: "#/components/responses/InvalidRequest" "429": $ref: "#/components/responses/RateLimited" default: $ref: "#/components/responses/Error" /v1/device/authorizations: post: operationId: createDeviceAuthorization tags: [Onboarding] security: - {} - bearerAuth: [] summary: Start a sign-in from an agent or CLI (device flow) description: | 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. requestBody: required: false content: application/json: schema: $ref: "#/components/schemas/DeviceAuthorizationRequest" responses: "201": description: The sign-in was started. Show the person the link and code, then poll for the key. content: application/json: schema: $ref: "#/components/schemas/DeviceAuthorization" "400": $ref: "#/components/responses/InvalidRequest" "401": $ref: "#/components/responses/Unauthenticated" "429": $ref: "#/components/responses/RateLimited" default: $ref: "#/components/responses/Error" /v1/device/token: post: operationId: pollDeviceToken tags: [Onboarding] security: [] summary: Poll a device sign-in for its key description: | 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. requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/DeviceTokenRequest" responses: "200": description: The sign-in's state, with the key once it is approved. content: application/json: schema: $ref: "#/components/schemas/DeviceToken" "400": $ref: "#/components/responses/InvalidRequest" "404": $ref: "#/components/responses/NotFound" "429": $ref: "#/components/responses/RateLimited" default: $ref: "#/components/responses/Error" webhooks: event: post: operationId: receiveEvent tags: [Webhook endpoints] summary: An event delivered to your endpoint description: | 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. parameters: - name: Flow-Signature in: header required: true description: "`t=,v1=`, with one `v1` per active signing secret." schema: type: string examples: ["t=1791763200,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd"] - name: Flow-Event-Id in: header required: true description: The event's `id`, also in the body. Use it to deduplicate. schema: $ref: "#/components/schemas/EventId" - name: Flow-Event-Type in: header required: true description: The event's `type`, also in the body. schema: $ref: "#/components/schemas/EventType" - name: Flow-Version in: header required: true description: The API version the event body is shaped by (your app's pinned version). schema: type: string format: date requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/Event" responses: "200": description: The event was received. Optionally carries a reply to send into the event's conversation. content: application/json: schema: $ref: "#/components/schemas/WebhookReply" examples: reply: summary: A text reply value: reply: {type: text, text: "Yes, we deliver to 560103."} string_reply: summary: A text reply given as a string value: reply: "Yes, we deliver to 560103." no_reply: summary: No reply now value: {} "4XX": description: Any other answer, or no answer within 10 seconds, is a failed delivery and is retried with backoff for 3 days. components: securitySchemes: bearerAuth: type: http scheme: bearer bearerFormat: fk_test_... or fk_live_... description: | An API key of one app, sent as `Authorization: Bearer `. Keys start with `fk_test_` (test mode: sandbox senders and test data only) or `fk_live_` (live mode). Keep live keys on your server; never ship them in an app or page. parameters: FlowVersion: name: Flow-Version in: header required: false description: The API version to use, as a date. Without it, the version pinned to your app when it was created is used. schema: type: string format: date examples: ["2026-11-01"] IdempotencyKey: name: Idempotency-Key in: header required: false description: | A unique string (up to 255 characters) that makes this request safe to retry. A repeat with the same key within 24 hours returns the first answer instead of acting again. See "Idempotency" in the introduction. schema: type: string minLength: 1 maxLength: 255 After: name: after in: query required: false description: An item ID. Returns the items that come after it in the list's order. schema: type: string maxLength: 64 Before: name: before in: query required: false description: An item ID. Returns the items that come before it in the list's order. schema: type: string maxLength: 64 Limit: name: limit in: query required: false description: How many items to return, 1 to 100. schema: type: integer minimum: 1 maximum: 100 default: 20 ConversationId: name: conversation_id in: path required: true description: The conversation's ID. schema: $ref: "#/components/schemas/ConversationId" MessageId: name: message_id in: path required: true description: The message's ID. schema: $ref: "#/components/schemas/MessageId" EventId: name: event_id in: path required: true description: The event's ID. schema: $ref: "#/components/schemas/EventId" FileId: name: file_id in: path required: true description: The file's ID. schema: $ref: "#/components/schemas/FileId" SenderId: name: sender_id in: path required: true description: The sender's ID. schema: $ref: "#/components/schemas/SenderId" TemplateId: name: template_id in: path required: true description: The template's ID. schema: $ref: "#/components/schemas/TemplateId" WebhookEndpointId: name: webhook_endpoint_id in: path required: true description: The webhook endpoint's ID. schema: $ref: "#/components/schemas/WebhookEndpointId" ContactId: name: contact_id in: path required: true description: The contact's ID. schema: $ref: "#/components/schemas/ContactId" headers: RetryAfter: description: Seconds to wait before retrying. schema: type: integer RateLimitLimit: description: Requests allowed in the current window for this key. schema: type: integer RateLimitRemaining: description: Requests left in the current window for this key. schema: type: integer RateLimitReset: description: Seconds until the current window resets. schema: type: integer responses: Error: description: An error. `error.type` says which (see `ErrorType`). content: application/json: schema: $ref: "#/components/schemas/Error" InvalidRequest: description: The request is malformed or a parameter is invalid (`invalid_request`). `error.param` names the parameter. content: application/json: schema: $ref: "#/components/schemas/Error" example: error: {type: invalid_request, message: "limit must be between 1 and 100.", hint: "Pass limit between 1 and 100 (default 20), and page with after or before.", doc_url: "https://api.flow.engineer/docs/errors/invalid_request", param: limit} Unauthenticated: description: The API key is missing, malformed, unknown, revoked or expired (`authentication`). content: application/json: schema: $ref: "#/components/schemas/Error" example: error: {type: authentication, message: "No valid API key was given.", hint: "Send the header Authorization: Bearer fk_test_... (or fk_live_...); no key yet? Get a test key with curl -X POST https://api.flow.engineer/v1/sandbox/keys", doc_url: "https://api.flow.engineer/docs/errors/authentication"} PermissionDenied: description: The key may not do this, for example a test key using a live sender (`permission`). content: application/json: schema: $ref: "#/components/schemas/Error" NotFound: description: No such object for this app and mode (`not_found`). content: application/json: schema: $ref: "#/components/schemas/Error" Conflict: description: | The request conflicts with the current state: the channel's window is closed (`outside_window`: WhatsApp's 24 hours for free-form messages, iMessage's 5 minutes after the contact's last message for typing), or the idempotency key was used for a different request or is still in use (`idempotency_conflict`). content: application/json: schema: $ref: "#/components/schemas/Error" example: 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} UnsupportedContent: description: The channel cannot show this content and the request set no `fallback` (`unsupported_content`). content: application/json: schema: $ref: "#/components/schemas/Error" example: error: {type: unsupported_content, message: "iMessage cannot show buttons.", hint: "Set \"fallback\": \"auto\" to send numbered text instead, or send text.", doc_url: "https://api.flow.engineer/docs/errors/unsupported_content", param: content.type} ChannelError: description: | The channel refused the call, failed or timed out (`channel_error`). `error.channel_code` carries the channel's own code where it gave one. content: application/json: schema: $ref: "#/components/schemas/Error" example: error: {type: channel_error, message: "The channel refused: the request timed out.", hint: "Typing and read receipts are safe to ignore; carry on and send your reply.", doc_url: "https://api.flow.engineer/docs/errors/channel_error", conversation: conv_01JB8ZC3K5M7P9R1T3V5X7Z9B1} RateLimited: description: | Too many requests (`rate_limited`): either this key's request limit (see the `RateLimit-*` headers) or the sender's sending rate (pacing) was exceeded; retry after `Retry-After` seconds (also `error.retry_after`) with the same `Idempotency-Key`. Or the sender has used its budget for starting conversations (`new_contact_limit`), or the sender is throttled after abuse signals (`sender_throttled`); wait `retry_after` seconds. headers: Retry-After: $ref: "#/components/headers/RetryAfter" RateLimit-Limit: $ref: "#/components/headers/RateLimitLimit" RateLimit-Remaining: $ref: "#/components/headers/RateLimitRemaining" RateLimit-Reset: $ref: "#/components/headers/RateLimitReset" content: application/json: schema: $ref: "#/components/schemas/Error" example: error: {type: new_contact_limit, message: "This sender has started its 15 new conversations for today.", hint: "Retry after 3600 seconds; replies into existing conversations still go.", doc_url: "https://api.flow.engineer/docs/errors/new_contact_limit", retry_after: 3600, sender: snd_01JB8Z4Q3V6W0R2N7C5H1M9K4T} schemas: # ---------------------------------------------------------------- IDs AccountId: type: string description: An account ID, `acct_` and a ULID. pattern: "^acct_[0-9A-HJKMNP-TV-Z]{26}$" examples: ["acct_01JB8YZ3N2Q4R6T8V0X2Z4B6D8"] AppId: type: string description: An app ID, `app_` and a ULID. pattern: "^app_[0-9A-HJKMNP-TV-Z]{26}$" examples: ["app_01JB8Z0A1C3E5G7J9K1M3P5R7T"] ApiKeyId: type: string description: An API key's ID, `key_` and a ULID. Not the secret key itself. pattern: "^key_[0-9A-HJKMNP-TV-Z]{26}$" examples: ["key_01JB8Z1B2D4F6H8K0M2P4R6T8W"] SenderId: type: string description: A sender ID, `snd_` and a ULID. pattern: "^snd_[0-9A-HJKMNP-TV-Z]{26}$" examples: ["snd_01JB8Z4Q3V6W0R2N7C5H1M9K4T"] ContactId: type: string description: A contact ID, `ct_` and a ULID. pattern: "^ct_[0-9A-HJKMNP-TV-Z]{26}$" examples: ["ct_01JB8ZB2J4K6N8Q0S2V4W6Y8A0"] ConversationId: type: string description: A conversation ID, `conv_` and a ULID. pattern: "^conv_[0-9A-HJKMNP-TV-Z]{26}$" examples: ["conv_01JB8ZC3K5M7P9R1T3V5X7Z9B1"] MessageId: type: string description: A message ID, `msg_` and a ULID. pattern: "^msg_[0-9A-HJKMNP-TV-Z]{26}$" examples: ["msg_01JB8ZD4M6P8R0T2V4X6Z8B0C2"] EventId: type: string description: An event ID, `evt_` and a ULID. pattern: "^evt_[0-9A-HJKMNP-TV-Z]{26}$" examples: ["evt_01JB8ZE5N7Q9S1V3X5Z7B9D1E3"] WebhookEndpointId: type: string description: A webhook endpoint ID, `we_` and a ULID. pattern: "^we_[0-9A-HJKMNP-TV-Z]{26}$" examples: ["we_01JB8ZF6P8R0T2W4Y6A8C0E2F4"] TemplateId: type: string description: A template ID, `tpl_` and a ULID. pattern: "^tpl_[0-9A-HJKMNP-TV-Z]{26}$" examples: ["tpl_01JB8Z9X2D4F6H8K0M2P4R6T8V"] FileId: type: string description: A file ID, `file_` and a ULID. pattern: "^file_[0-9A-HJKMNP-TV-Z]{26}$" examples: ["file_01JB8ZG7Q9S1V3X5Z7B9D1F3G5"] # ---------------------------------------------------------------- shared Channel: type: string description: A messaging channel. enum: [telegram, whatsapp, imessage] Mode: type: string description: "`test` (sandbox senders and test data only) or `live`." enum: [test, live] Metadata: type: object description: Up to 20 string pairs of your own, kept with the object and returned unchanged. Keys up to 40 characters, values up to 500. maxProperties: 20 additionalProperties: type: string maxLength: 500 Deleted: type: object description: Confirms that an object was deleted. required: [id, deleted] properties: id: type: string description: The deleted object's ID. deleted: type: boolean description: Always `true`. # ---------------------------------------------------------------- errors ErrorType: type: string description: | What went wrong. A closed list: new types arrive only with a new API version. Each type has a page at `https://api.flow.engineer/docs/errors/`, given in the error's `doc_url`; the error's `hint` says what to change for the case. - `invalid_request` (400): the request is malformed or a parameter is invalid. Fix the parameter named in `param`; the `hint` says what it must look like. - `authentication` (401): the API key is missing, malformed, unknown, revoked or expired. Send `Authorization: Bearer fk_test_...` or `fk_live_...` with a current key; with no key at all, get a test key with `POST /v1/sandbox/keys`. A sandbox key past its `expires_at` has `channel_code` `sandbox_key_expired`: sign in to claim the app, or get a new key. - `permission` (403): the key may not do this, for example a test key using a live sender, a sandbox contact who joined another app, a new conversation from an iMessage line that may only reply, or a send past the app's sandbox allowance (`channel_code` `sandbox_allowance_used`, `sandbox_contact_limit` or `sandbox_channel_not_included`). Use the key of the right mode, have the contact send your join code, wait for the contact to message the line first, or sign in through the device flow to lift the allowance. - `not_found` (404): no such object for this app and mode. Check the ID's prefix and that it was made with a key of the same mode (test and live data are separate). - `idempotency_conflict` (409): the idempotency key was used for a different request (`channel_code` `body_mismatch`: use a new key), or that request is still running (`in_progress`: wait `retry_after` seconds and repeat it with the same key), or it already created a secret that is shown only once (`secret_not_kept`: creating a webhook endpoint, rotating its secret; a secret that was lost must be rotated again). - `outside_window` (409, or in `message.failed`): the channel will not deliver outside its conversation window. On WhatsApp the 24-hour window is closed: send a `template` (`POST /v1/messages` with `content.type=template`), or wait for the contact to write. On iMessage the contact has not messaged the line (or opted in) yet, or not recently, so the failure arrives as a `message.failed` event: wait for the contact to write, then reply in that conversation. Typing and read receipts answer `409 outside_window` directly when the channel's window is closed (iMessage typing works only within 5 minutes of the contact's last message); ignore it and send your reply. - `unsupported_content` (422): the channel cannot show this content and no `fallback` was set. Set `fallback` (`"auto"` or your own content), or check `GET /v1/capabilities` first. - `new_contact_limit` (429): the sender has used its budget for starting conversations. Wait `retry_after` seconds; replies into existing conversations still go. - `sender_throttled` (429): abuse signals tripped (the same text to many new contacts, many starts with no reply, blocks), so the sender may not start conversations until `retry_after`; replies into existing conversations still go. Personalise first messages and start only conversations people expect. - `file_blocked` (422): the file failed the malware scan and was not stored. Send a different file. - `rate_limited` (429): either too many requests for this key (the per-key request limit, reported in the `RateLimit-*` headers) or sends faster than the sender's sending rate (pacing). Wait `Retry-After` (`retry_after`) seconds and retry with the same `Idempotency-Key`. - `channel_error` (502, or in `message.failed`): the channel refused or failed the message, or timed out; `channel_code` carries its own code. Read `message`, change what the channel objected to, and send again. From typing and read receipts, which call the channel at once, it is safe to ignore. - `not_implemented` (501): this endpoint or channel is not live yet during the beta. Use a channel that is live (Telegram, iMessage), or check the changelog. - `api_error` (500, 503): something went wrong on Flow's side. Retry with the same idempotency key after `retry_after` seconds, and quote `request_id` if it persists. enum: - invalid_request - authentication - permission - not_found - idempotency_conflict - outside_window - unsupported_content - new_contact_limit - sender_throttled - file_blocked - rate_limited - channel_error - not_implemented - api_error ErrorBody: type: object description: The details of an error. required: [type, message, hint, doc_url] properties: type: $ref: "#/components/schemas/ErrorType" message: type: string description: A sentence for a developer saying what went wrong. Do not parse it; switch on `type`. hint: type: string description: | One sentence saying what to change to make the request succeed, specific to this case, for example "Send a template instead: POST /v1/messages with content.type=template." Written for developers and coding agents alike. Do not parse it; it may be reworded at any time. examples: ["Send a template instead: POST /v1/messages with content.type=template."] doc_url: type: string format: uri description: The documentation page for this error's type, `https://api.flow.engineer/docs/errors/`. It says what the error means, why it happens and how to fix it, with code. examples: ["https://api.flow.engineer/docs/errors/outside_window"] param: type: string description: The request parameter the error is about, as a dotted path (`content.buttons`, `limit`). retry_after: type: integer minimum: 0 description: Seconds to wait before retrying, for errors that clear by themselves. conversation: $ref: "#/components/schemas/ConversationId" sender: $ref: "#/components/schemas/SenderId" channel_code: type: string description: | For `channel_error`, the channel's own error code, as given by the channel. For some other errors, Flow's own code naming the case, for example `sandbox_allowance_used` (`permission`: the sandbox allowance is used up), `sandbox_contact_limit` (`permission`: the allowance has no room for another contact), `sandbox_channel_not_included` (`permission`: the allowance does not cover this sandbox channel), `sign_in_required` (`permission`: an app made without an account cannot do this) or `sandbox_key_expired` (`authentication`: a key from `POST /v1/sandbox/keys` passed its `expires_at`; `permission`, for a send that carries no key, such as a reply in a webhook answer, from such an app). For `idempotency_conflict`, always one of `body_mismatch` (the key was used for a different request), `in_progress` (the first request is still running; `retry_after` says when to repeat it) or `secret_not_kept` (its answer carried a secret shown only once). request_id: type: string description: Flow's ID for this request. Quote it when asking for help. Error: type: object description: The body of every error answer. required: [error] properties: error: $ref: "#/components/schemas/ErrorBody" # ---------------------------------------------------------------- account, app, key Account: type: object description: A customer company. Holds the plan, billing and members. Every app belongs to one account. required: [id, name, plan, created_at] properties: id: $ref: "#/components/schemas/AccountId" name: type: string description: The company's name. plan: type: string description: The account's plan. enum: [free, pro, business, enterprise] created_at: type: string format: date-time description: When the account was created. App: type: object description: One agent integration. Owns API keys (per mode), webhook endpoints and settings. required: [id, account, name, api_version, settings, created_at] properties: id: $ref: "#/components/schemas/AppId" account: $ref: "#/components/schemas/AccountId" name: type: string description: The app's name. api_version: type: string format: date description: The API version pinned to the app when it was created, used when a request sends no `Flow-Version`. settings: $ref: "#/components/schemas/AppSettings" sandbox_join_code: type: string description: | The app's sandbox join code on its own, without the word `join`: two words and eight digits, for example `brave-otter-40718263`. A contact joins this app by sending `join ` followed by it to a shared sandbox sender (`join brave-otter-40718263`); each shared sender's `join_code` gives that whole message. examples: ["brave-otter-40718263"] created_at: type: string format: date-time description: When the app was created. AppSettings: type: object description: Per-app behaviour. The Flow team sets it for now; ask them to change it. required: [transcription, auto_read] properties: transcription: type: boolean description: When on, inbound voice notes carry a `transcript` (adds about 1 to 2 seconds to voice notes only). auto_read: type: boolean description: When on, Flow marks inbound messages as read as soon as your endpoint accepts them. ApiKey: type: object description: An API key's record. The key itself is shown once, at creation, and stored only as a hash. required: [id, mode, last4, created_at] properties: id: $ref: "#/components/schemas/ApiKeyId" mode: $ref: "#/components/schemas/Mode" last4: type: string description: The key's last four characters, to tell keys apart. minLength: 4 maxLength: 4 created_at: type: string format: date-time description: When the key was created. expires_at: type: string format: date-time description: | When the key stops working. Set only on keys of an unclaimed app made with `POST /v1/sandbox/keys` (7 days after it was made); signing in through the device flow claims the app and removes it. An expired key can still start that sign-in for 30 days after this time, and claiming the app within those 30 days makes the key work again. AppContext: type: object description: Who is calling, as worked out from the API key. required: [account, app, api_key, livemode] properties: account: $ref: "#/components/schemas/Account" app: $ref: "#/components/schemas/App" api_key: $ref: "#/components/schemas/ApiKey" livemode: type: boolean description: "`true` for a live key, `false` for a test key." allowance: $ref: "#/components/schemas/SandboxAllowance" # ---------------------------------------------------------------- onboarding SandboxAllowance: type: object description: | What the app may still send on the shared sandbox senders for free. Present only on apps that have one: apps made with `POST /v1/sandbox/keys` (`anonymous`, one allowance per app), and apps of people who signed in (`signed_in`, one allowance per person: every app the person owns or claimed draws on the same contacts and messages, so the counts here are the person's, over all those apps). Only messages your agent sends count, on the channels in `channels`; inbound messages are free. A contact counts once it joins an app on a sandbox sender, and keeps counting after it leaves. Sends past the allowance answer `403 permission` with `channel_code` `sandbox_allowance_used`; a join past `contacts.limit` is refused in the chat. required: [tier, scope, channels, contacts, messages_per_contact, messages, upgrade] properties: tier: type: string description: "`anonymous`: an app made without an account, whose keys expire. `signed_in`: an app of a person who signed in (GitHub; Google sign-in is not enabled yet)." enum: [anonymous, signed_in] scope: type: string description: | Who the counts belong to. `app`: this app alone (`anonymous`). `person`: the signed-in person, shared by every app they own or claimed, so sends from their other apps use the same contacts and messages. enum: [app, person] channels: type: array description: The sandbox channels the allowance covers whose shared sandbox is open now (Telegram today; WhatsApp when its sandbox opens). Shared senders of other channels (iMessage) are not part of it and refuse its sends. items: $ref: "#/components/schemas/Channel" contacts: $ref: "#/components/schemas/AllowanceCount" messages_per_contact: type: integer minimum: 0 description: How many messages may be sent to each contact (50 anonymous, so 50 in total; 100 signed in). messages: $ref: "#/components/schemas/AllowanceMessages" expires_at: type: string format: date-time description: "`anonymous` only: when the app's keys expire. Signing in removes it." upgrade: type: string description: One sentence saying how to raise the allowance, for example by signing in with `npx @flow-engineer/messaging login`. AllowanceCount: type: object description: Contacts the allowance has room for, and how many have joined (with `scope` `person`, over all the person's apps). required: [limit, used] properties: limit: type: integer minimum: 0 description: How many contacts may join on the sandbox (1 for an anonymous app; 3 for a signed-in person, over all their apps). used: type: integer minimum: 0 description: How many have joined so far. AllowanceMessages: type: object description: Messages sent against the allowance, over all its contacts (with `scope` `person`, from all the person's apps). required: [limit, used, remaining] properties: limit: type: integer minimum: 0 description: "`contacts.limit` times `messages_per_contact`." used: type: integer minimum: 0 description: Messages sent so far on the allowance's sandbox senders. remaining: type: integer minimum: 0 description: Messages still allowed, counting each contact's own cap and the contacts that may still join. SandboxKeyRequest: type: object description: Optional details for a new sandbox app. properties: name: type: string maxLength: 80 description: The app's name, shown in the dashboard once it is claimed. Defaults to "Sandbox app". SandboxKey: type: object description: A new app made without an account, with its test key. `key` and `claim_token` are shown only here. required: [key, api_key, account, app, allowance, claim_token, claim_url, senders] properties: key: type: string description: The API key itself (`fk_test_...`). Shown once; store it as `FLOW_MESSAGING_KEY`. examples: ["fk_test_..."] api_key: $ref: "#/components/schemas/ApiKey" account: $ref: "#/components/schemas/Account" app: $ref: "#/components/schemas/App" allowance: $ref: "#/components/schemas/SandboxAllowance" claim_token: type: string description: | Proves you hold this app when a person signs in to claim it: pass it to `POST /v1/device/authorizations`. Shown once; keep it with the key. It stops working once the app is claimed. examples: ["fct_..."] claim_url: type: string format: uri description: A page where a person signs in with GitHub and claims the app in the browser, without the CLI. It holds the claim token, so treat it like one. The claim hands out no key and revokes the app's keys unless the person ticks "Keep my agent's current key working"; afterwards they make keys on the dashboard's Keys page. examples: ["https://api.flow.engineer/admin/claim#token=fct_..."] senders: type: array description: The shared sandbox senders the key can use, each with the link a person opens to join the app (`address.link`) and the join message (`join_code`). items: $ref: "#/components/schemas/Sender" DeviceAuthorizationRequest: type: object description: Optional details for a device sign-in. properties: claim_token: type: string description: The `claim_token` from `POST /v1/sandbox/keys`, to claim that app when the person approves. client_name: type: string maxLength: 80 description: What is asking, shown to the person on the approval page, for example "flow CLI" or "Claude Code". DeviceAuthorization: type: object description: A device sign-in waiting for a person to approve it in a browser. required: [device_code, user_code, verification_uri, verification_uri_complete, expires_in, expires_at, interval] properties: device_code: type: string description: The secret you poll `POST /v1/device/token` with. Never show it to the person. examples: ["fdc_..."] user_code: type: string description: The code the person types on the approval page, eight letters in two groups. Always show it to the person, also when you show `verification_uri_complete`. examples: ["WDJB-MJHT"] verification_uri: type: string format: uri description: The page where the person signs in and enters `user_code`. examples: ["https://api.flow.engineer/admin/device"] verification_uri_complete: type: string format: uri description: The same page for this sign-in. Show this link (or a QR code of it) to the person together with `user_code`; the page asks them to type the code they see from you and checks it against the link, so a link on its own cannot approve a sign-in. examples: ["https://api.flow.engineer/admin/device?code=WDJB-MJHT"] expires_in: type: integer minimum: 0 description: Seconds until the codes expire (15 minutes). expires_at: type: string format: date-time description: When the codes expire. interval: type: integer minimum: 1 description: Seconds to wait between polls of `POST /v1/device/token`. DeviceTokenRequest: type: object description: The device sign-in to poll. required: [device_code] properties: device_code: type: string description: The `device_code` from `POST /v1/device/authorizations`. DeviceToken: type: object description: | A device sign-in's state. With `approved`, it carries a new test key (shown once), the app it belongs to and whether a sandbox app was claimed. required: [status, interval] properties: status: type: string description: | - `pending`: the person has not finished; poll again after `interval` seconds. - `approved`: signed in; `key` is set. The device code is now used up. - `denied`: the person refused. Stop polling. - `expired`: the codes ran out or were already used. Start again. enum: [pending, approved, denied, expired] interval: type: integer minimum: 1 description: Seconds to wait before the next poll. key: type: string description: "`approved` only. A new API key (`fk_test_...`), shown once; store it as `FLOW_MESSAGING_KEY` in place of the sandbox key. When the sign-in claimed a sandbox app, that app's sandbox keys stop working as this key is handed out." api_key: $ref: "#/components/schemas/ApiKey" account: $ref: "#/components/schemas/Account" app: $ref: "#/components/schemas/App" claimed: type: boolean description: "`approved` only. `true` when the sign-in claimed the app of the `claim_token` or key it was started with." allowance: $ref: "#/components/schemas/SandboxAllowance" user: $ref: "#/components/schemas/SignedInUser" SignedInUser: type: object description: The person who approved a device sign-in. required: [name, provider] properties: name: type: string description: Their name or login at the provider. email: type: string format: email description: Their verified email address, where the provider gave one. provider: type: string description: Where they signed in. enum: [github, google] # ---------------------------------------------------------------- senders Sender: type: object description: | What your agent talks from: a Telegram bot, a WhatsApp number or an iMessage line. `shared` senders are Flow's sandbox, used by many apps in test mode; `dedicated` senders are yours alone. Each sender has its own limits and warm-up state. required: [id, channel, kind, livemode, status, address, limits, created_at] properties: id: $ref: "#/components/schemas/SenderId" channel: $ref: "#/components/schemas/Channel" kind: type: string description: "`shared` (the sandbox) or `dedicated` (yours)." enum: [shared, dedicated] livemode: type: boolean description: "`false` for sandbox senders, `true` for dedicated senders used in live mode." display_name: type: string description: The name contacts see, where the channel shows one. status: $ref: "#/components/schemas/SenderStatus" throttled_until: type: string format: date-time description: With status `throttled`, when starts are allowed again. Recovery is automatic. address: $ref: "#/components/schemas/SenderAddress" limits: $ref: "#/components/schemas/SenderLimits" quality_rating: type: string description: WhatsApp only. Meta's quality rating for the number. enum: [green, yellow, red, unknown] join_code: type: string description: | Shared senders only. The whole message a contact sends to this sender to join your app: `join `, a space, then the app's `sandbox_join_code` (from `GET /v1/app`), for example `join brave-otter-40718263`. Show it to testers as is. examples: ["join brave-otter-40718263"] created_at: type: string format: date-time description: When the sender was created. SenderStatus: type: string description: | - `pending`: requested, being provisioned. - `active`: sending normally. - `warming_up`: active, with a new-contact budget that grows day by day. - `throttled`: the gate slowed it after an abuse signal; it recovers by itself. - `flagged`: the channel or Flow flagged it; starts are paused. A Telegram bot is also `flagged` when Telegram rejects its token (revoked in @BotFather): it then sends nothing, new sends answer `403 permission`, and queued messages wait until you connect the bot again with its new token (`POST /v1/senders`). They wait at most 72 hours after Flow accepted them; older ones fail with `outside_window` (`channel_code` `queued_too_long`) in `message.failed` instead of going out late. - `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. enum: [pending, active, warming_up, throttled, flagged, banned] SenderAddress: type: object description: How contacts reach the sender. Which fields are set depends on the channel. properties: phone: type: string description: WhatsApp and iMessage. E.164 phone number. username: type: string description: Telegram. The bot's username, without `@`. handle: type: string description: iMessage. The line's handle (phone number or email address). link: type: string format: uri description: | A link that opens a chat with the sender: `https://t.me/...` for a Telegram bot, `https://wa.me/...` for a WhatsApp number. 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. SenderLimits: type: object description: The sender's current budget for starting conversations. required: [new_contacts_per_day, new_contacts_per_hour] properties: new_contacts_per_day: type: integer description: New conversations the sender may start per day today (grows during warm-up). new_contacts_per_hour: type: integer description: New conversations the sender may start per hour. whatsapp_tier: type: string description: WhatsApp only. Meta's messaging tier for the number. SenderRequest: type: object description: A request for a dedicated sender. required: [channel] properties: channel: $ref: "#/components/schemas/Channel" display_name: type: string maxLength: 64 description: The name contacts should see. country: type: string description: WhatsApp and iMessage. ISO 3166-1 alpha-2 country for the number, for example `IN`. pattern: "^[A-Z]{2}$" telegram_bot_token: type: string writeOnly: true maxLength: 128 pattern: "^[0-9]{1,20}:[A-Za-z0-9_-]{20,100}$" description: Telegram only, and required there. The bot token from BotFather. Stored encrypted; never returned. SenderList: type: object description: A page of senders. required: [data, has_more] properties: data: type: array items: $ref: "#/components/schemas/Sender" has_more: type: boolean description: Whether more items lie beyond this page in the direction you paged. # ---------------------------------------------------------------- contacts Contact: type: object description: A person on one channel. There is no cross-channel identity, and a contact may have no phone number at all (a Telegram user, a WhatsApp username, an iMessage email handle). required: [id, channel, livemode, address, created_at] properties: id: $ref: "#/components/schemas/ContactId" channel: $ref: "#/components/schemas/Channel" livemode: type: boolean description: Whether the contact belongs to live mode. address: $ref: "#/components/schemas/ContactAddress" name: type: string description: The contact's display name, as the channel reports it. May change or be missing. created_at: type: string format: date-time description: When Flow first saw the contact. ContactAddress: type: object description: How the contact is reached on their channel. Which fields are set depends on the channel; do not assume `phone`. properties: phone: type: string description: E.164 phone number (WhatsApp; iMessage when the handle is a number). username: type: string description: A username without `@` (Telegram, WhatsApp usernames). telegram_user_id: type: string description: Telegram's numeric user ID, as a string. handle: type: string description: iMessage handle (a phone number or an email address). ContactList: type: object description: A page of contacts. required: [data, has_more] properties: data: type: array items: $ref: "#/components/schemas/Contact" has_more: type: boolean description: Whether more items lie beyond this page in the direction you paged. Recipient: type: object description: | Who to start a conversation with: an existing `contact`, or an address on the sender's channel. Give exactly one field. minProperties: 1 maxProperties: 1 properties: contact: $ref: "#/components/schemas/ContactId" phone: type: string description: WhatsApp or iMessage. E.164 phone number. pattern: "^\\+[1-9][0-9]{6,14}$" telegram_user_id: type: string description: Telegram. The user's numeric ID; they must have started the bot. handle: type: string description: iMessage. A phone number or an email address. # ---------------------------------------------------------------- conversations Conversation: type: object description: One sender talking with one contact. Holds the window state and whether the contact opted in. required: [id, app, channel, sender, contact, livemode, opted_in, created_at] properties: id: $ref: "#/components/schemas/ConversationId" app: $ref: "#/components/schemas/AppId" channel: $ref: "#/components/schemas/Channel" sender: $ref: "#/components/schemas/SenderId" contact: $ref: "#/components/schemas/ContactId" livemode: type: boolean description: Whether the conversation belongs to live mode. last_inbound_at: type: string format: date-time description: When the contact last wrote. Absent if they never have. window_open_until: type: string format: date-time description: WhatsApp only. Until when free-form messages may be sent; after it, only templates. Absent when the window is closed or the channel has none. opted_in: type: boolean description: Whether the contact has agreed to receive messages (wrote first, joined the sandbox, or you recorded consent). metadata: $ref: "#/components/schemas/Metadata" created_at: type: string format: date-time description: When the conversation began. ConversationRef: type: object description: The conversation an event happened in, inlined so most handlers need no extra call. required: [id, channel, sender, contact] properties: id: $ref: "#/components/schemas/ConversationId" channel: $ref: "#/components/schemas/Channel" sender: $ref: "#/components/schemas/SenderId" contact: $ref: "#/components/schemas/ContactId" window_open_until: type: string format: date-time description: WhatsApp only. Until when free-form messages may be sent. Absent when closed or not applicable. ConversationList: type: object description: A page of conversations. required: [data, has_more] properties: data: type: array items: $ref: "#/components/schemas/Conversation" has_more: type: boolean description: Whether more items lie beyond this page in the direction you paged. ConversationAction: type: object description: The result of a typing or read call. required: [conversation, action] properties: conversation: $ref: "#/components/schemas/ConversationId" action: type: string description: Which action was taken. enum: [typing_on, typing_off, read] delivered_as: $ref: "#/components/schemas/DeliveredAs" TypingRequest: type: object description: Turn the typing indicator on or off. required: [state] properties: state: type: string description: "`on` shows the indicator, `off` clears it." enum: ["on", "off"] ReadRequest: type: object description: Which messages to mark as read. properties: up_to: $ref: "#/components/schemas/MessageId" description: The latest inbound message to mark read (default the latest inbound message). On iMessage it has no effect, since the whole conversation is marked read. # ---------------------------------------------------------------- messages Message: type: object description: One message in or out of a conversation, with its typed content and delivery status. required: [id, conversation, direction, status, content, livemode, created_at] properties: id: $ref: "#/components/schemas/MessageId" conversation: $ref: "#/components/schemas/ConversationId" direction: type: string description: "`in` from the contact, `out` from your app." enum: [in, out] status: $ref: "#/components/schemas/MessageStatus" content: $ref: "#/components/schemas/Content" delivered_as: $ref: "#/components/schemas/DeliveredAs" error: $ref: "#/components/schemas/ErrorBody" channel_message_id: type: string description: The channel's own ID for the message, once the channel accepted it. reply_to: $ref: "#/components/schemas/MessageId" readOnly: true description: | The Flow ID of the message this one replies to. For a sent message, the message the send named in `reply_to`. For a received message, the message it replies to inline (a Telegram reply; on iMessage, the thread's root message). Absent when the message is not a reply or the quoted message is not known to Flow. livemode: type: boolean description: Whether the message belongs to live mode. metadata: $ref: "#/components/schemas/Metadata" created_at: type: string format: date-time description: When Flow received (inbound) or accepted (outbound) the message. updated_at: type: string format: date-time description: When the status last changed. MessageStatus: type: string description: | - `received`: inbound from the contact. - `queued`: accepted by the gate, waiting for its turn in the conversation. - `sent`: the channel accepted it. - `delivered`: it reached the contact's device, where the channel reports this. - `read`: the contact read it, where the channel reports this. - `failed`: it could not be sent; see `error`. - `unsent`: you unsent it. enum: [received, queued, sent, delivered, read, failed, unsent] DeliveredAs: type: object description: | Present when what the contact sees differs from what you sent: the `fallback` was used, or the action was skipped because the channel has no equivalent. Absent when the content was shown as sent. required: [type, reason] properties: type: type: string description: The content type actually shown, or `none` when nothing was shown. enum: [text, media, voice, buttons, reaction, template, location, contact_card, effect, typing, read, edit, unsend, none] reason: type: string description: Why, in one sentence (for example "iMessage cannot show buttons; sent numbered text"). MessageList: type: object description: A page of messages. required: [data, has_more] properties: data: type: array items: $ref: "#/components/schemas/Message" has_more: type: boolean description: Whether more items lie beyond this page in the direction you paged. Fallback: description: | What to send when the channel cannot show `content`. Without it, such a send fails with `unsupported_content`; nothing is converted silently. - `"auto"`: use the documented default for the content type (markdown becomes plain text, buttons become numbered text whose replies are matched back to the button IDs, a voice note becomes an audio file, a location becomes a maps link, a contact card becomes text, an effect becomes plain text, a reaction becomes the closest tapback or is skipped). - A `Content` object: send exactly this instead. The message's `delivered_as` reports what was used. oneOf: - $ref: "#/components/schemas/FallbackAuto" - $ref: "#/components/schemas/Content" FallbackAuto: type: string description: Use the content type's default fallback. enum: [auto] ChannelOptions: type: object description: | **Unstable escape hatch.** Extra 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 always wins over a key that sets the same thing. Who receives the message and what it says always come from the typed request. Keys starting with `_flow_` are reserved and refused with `invalid_request`. Flow does not validate the values, and they may break when a channel changes. Prefer typed content. - **Telegram** (Bot API parameters, on every send): `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**: on messages, `subject`, `effect`, `preview`, `reply_to_id` and `contact_file`; on `typing` content, `typing` (how long to show it, 1 to 60 seconds). Reactions, read receipts, edits and unsends take none. - **WhatsApp**: none yet; the channel is not live. examples: - {parse_mode: HTML, disable_notification: true} additionalProperties: true SendMessageRequest: type: object description: One piece of content to send into a conversation. required: [content] properties: content: $ref: "#/components/schemas/Content" reply_to: $ref: "#/components/schemas/MessageId" description: | Send this as a reply to an earlier message in the same conversation. Telegram and iMessage show it as an inline reply; channels without inline replies send it as a normal message. Refused with `not_found` (`param` `reply_to`) when the target is not a message in this conversation, and with `invalid_request` (`param` `reply_to`) when the content is `typing`, `read`, `reaction`, `edit` or `unsend`, or when the target never reached the channel (for example, it failed). If the target fails after the send was accepted, the reply goes out as a normal message. fallback: $ref: "#/components/schemas/Fallback" channel_options: $ref: "#/components/schemas/ChannelOptions" metadata: $ref: "#/components/schemas/Metadata" StartConversationRequest: type: object description: The first message from one of your senders to a contact. required: [sender, to, content] properties: sender: $ref: "#/components/schemas/SenderId" to: $ref: "#/components/schemas/Recipient" content: $ref: "#/components/schemas/Content" fallback: $ref: "#/components/schemas/Fallback" channel_options: $ref: "#/components/schemas/ChannelOptions" metadata: $ref: "#/components/schemas/Metadata" EditMessageRequest: type: object description: The new text of a sent message. required: [content] properties: content: $ref: "#/components/schemas/TextContent" # ---------------------------------------------------------------- content ContentType: type: string description: The kinds of content. Each is described in its own schema, with the direction it travels in. enum: [text, media, voice, buttons, button_reply, reaction, template, location, contact_card, effect, typing, read, edit, unsend, file_blocked] Content: type: object description: | One piece of content, in either direction. `type` selects the shape. Inbound messages use `text`, `media`, `voice`, `button_reply`, `reaction`, `location`, `contact_card` and `file_blocked`; sends use every type except `button_reply` and `file_blocked`. | type | Telegram | WhatsApp | iMessage | `fallback: "auto"` | |---|---|---|---|---| | text | yes | yes | yes | markdown to plain | | media | yes | yes | yes | none | | voice | yes | yes | yes | audio file | | buttons | inline keyboard | up to 3 buttons, else a list | no | numbered text | | reaction | yes | yes | tapbacks, other emoji as emoji reactions | closest tapback, else skipped | | template | no | yes | no | none | | location | yes | yes | no | 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 | oneOf: - $ref: "#/components/schemas/TextContent" - $ref: "#/components/schemas/MediaContent" - $ref: "#/components/schemas/VoiceContent" - $ref: "#/components/schemas/ButtonsContent" - $ref: "#/components/schemas/ButtonReplyContent" - $ref: "#/components/schemas/ReactionContent" - $ref: "#/components/schemas/TemplateContent" - $ref: "#/components/schemas/LocationContent" - $ref: "#/components/schemas/ContactCardContent" - $ref: "#/components/schemas/EffectContent" - $ref: "#/components/schemas/TypingContent" - $ref: "#/components/schemas/ReadContent" - $ref: "#/components/schemas/EditContent" - $ref: "#/components/schemas/UnsendContent" - $ref: "#/components/schemas/FileBlockedContent" discriminator: propertyName: type mapping: text: "#/components/schemas/TextContent" media: "#/components/schemas/MediaContent" voice: "#/components/schemas/VoiceContent" buttons: "#/components/schemas/ButtonsContent" button_reply: "#/components/schemas/ButtonReplyContent" reaction: "#/components/schemas/ReactionContent" template: "#/components/schemas/TemplateContent" location: "#/components/schemas/LocationContent" contact_card: "#/components/schemas/ContactCardContent" effect: "#/components/schemas/EffectContent" typing: "#/components/schemas/TypingContent" read: "#/components/schemas/ReadContent" edit: "#/components/schemas/EditContent" unsend: "#/components/schemas/UnsendContent" file_blocked: "#/components/schemas/FileBlockedContent" TextContent: type: object description: Text, both ways. With `format` `markdown`, Flow renders it in each channel's own formatting; a channel without formatting needs `fallback`. required: [type, text] properties: type: type: string enum: [text] description: Always `text`. text: type: string minLength: 1 maxLength: 9999 description: The text, at most the channel's `max_text_length` characters (4096 on Telegram and WhatsApp, 9999 on iMessage; see `GET /v1/capabilities`). Telegram counts UTF-16 code units of the text as shown (after markdown), so an emoji such as 😀 counts as 2. In an edit of a media message the text replaces its caption, and the caption limit applies (1024). Longer text is refused with `invalid_request`. format: type: string enum: [plain, markdown] default: plain description: How to read `text`. Inbound text is always `plain`. MediaContent: type: object description: | An image, video, document or audio file, both ways. When sending, give either `url` (Flow fetches it) or `file_id` (from `POST /v1/files`). Inbound media always has `url`, a Flow file URL (`GET /v1/files/{file_id}`), and `file_id`. required: [type, kind] properties: type: type: string enum: [media] description: Always `media`. kind: type: string enum: [image, video, document, audio] description: What kind of media it is. url: type: string format: uri description: Where the bytes are. Sending, an HTTPS URL Flow can fetch; inbound, a Flow file URL. file_id: $ref: "#/components/schemas/FileId" caption: type: string maxLength: 1024 description: Text shown with the media, at most 1024 characters. Telegram counts UTF-16 code units, so an emoji such as 😀 counts as 2. Longer captions are refused with `invalid_request`. filename: type: string maxLength: 255 description: The file's name, shown for documents. mime_type: type: string readOnly: true description: The real type, checked from the file's first bytes. size_bytes: type: integer readOnly: true description: The file's size. VoiceContent: type: object description: | A voice note, both ways. Inbound voice notes always come with the audio file (`url`, `file_id`) and, when the app has transcription on, a `transcript`. When sending, give `url` or `file_id` of an audio file; channels without voice notes need `fallback` (`auto` sends it as an audio file). required: [type] properties: type: type: string enum: [voice] description: Always `voice`. url: type: string format: uri description: Where the audio is. Sending, an HTTPS URL Flow can fetch; inbound, a Flow file URL. file_id: $ref: "#/components/schemas/FileId" duration_seconds: type: number readOnly: true description: The voice note's length. transcript: type: string readOnly: true description: Inbound only, when transcription is on. What was said. ButtonsContent: type: object description: | Text with buttons, sent only. Telegram shows an inline keyboard; WhatsApp shows up to 3 reply buttons, or a list for more; iMessage has no buttons (use `fallback`). A tap arrives as a `message.received` event with `button_reply` content carrying the button's `id`. URL buttons open a link and send nothing back. required: [type, text, buttons] properties: type: type: string enum: [buttons] description: Always `buttons`. text: type: string minLength: 1 maxLength: 4096 description: | The text above the buttons: at most 4096 characters on Telegram (the same as a text message, in UTF-16 code units; Telegram's 1024 limit is for media captions only) and 1024 on WhatsApp (its limit for messages with buttons). On iMessage, `fallback: auto` sends the text and the numbered choices as one text message, which must fit the channel's `max_text_length`. Longer text is refused with `invalid_request`. buttons: type: array minItems: 1 maxItems: 10 description: 1 to 10 buttons, in order. items: $ref: "#/components/schemas/Button" Button: description: A reply button (`id` and `label`) or a link button (`url` and `label`). oneOf: - $ref: "#/components/schemas/ReplyButton" - $ref: "#/components/schemas/UrlButton" ReplyButton: type: object description: A button that sends its `id` back as a `button_reply` when tapped. required: [id, label] properties: id: type: string minLength: 1 maxLength: 64 description: Your ID for the button, returned in `button_reply`. label: type: string minLength: 1 maxLength: 20 description: The button's text. WhatsApp shows at most 20 characters. UrlButton: type: object description: A button that opens a link. required: [url, label] properties: url: type: string format: uri description: The HTTPS link to open. label: type: string minLength: 1 maxLength: 20 description: The button's text. ButtonReplyContent: type: object description: Inbound only. The contact tapped a reply button (or, where buttons fell back to numbered text, answered with its number). required: [type, button_id, label] properties: type: type: string enum: [button_reply] description: Always `button_reply`. button_id: type: string description: The `id` of the tapped button. label: type: string description: The tapped button's label. message_id: $ref: "#/components/schemas/MessageId" ReactionContent: type: object description: | A reaction to a message, both ways. Set `emoji` to `null` to remove your reaction. iMessage has a fixed set of tapbacks; with `fallback: "auto"` the closest tapback is used, or the reaction is skipped and reported in `delivered_as`. Inbound reactions also arrive as `reaction.added` and `reaction.removed` events. required: [type, message_id, emoji] properties: type: type: string enum: [reaction] description: Always `reaction`. message_id: $ref: "#/components/schemas/MessageId" emoji: type: [string, "null"] maxLength: 16 description: One emoji, or `null` to remove the reaction. TemplateContent: type: object description: | A WhatsApp message template, sent only. Required to start a WhatsApp conversation or to write after the 24-hour window closed. The template must be `approved`. required: [type, template_id, language] properties: type: type: string enum: [template] description: Always `template`. template_id: $ref: "#/components/schemas/TemplateId" language: type: string description: The template language to use, as Meta names it (`en`, `en_US`, `hi`). params: $ref: "#/components/schemas/TemplateParams" TemplateParams: type: object description: Values for the template's placeholders. properties: header: $ref: "#/components/schemas/TemplateHeaderParam" body: type: array description: Values for the body's `{{1}}`, `{{2}}`, ... in order. items: type: string buttons: type: array description: Values for buttons that take one (URL suffixes, quick-reply payloads). items: $ref: "#/components/schemas/TemplateButtonParam" TemplateHeaderParam: type: object description: The value of a template's header, when it has a placeholder or media. required: [kind] properties: kind: type: string enum: [text, image, video, document] description: What the header holds. text: type: string description: For `text` headers, the placeholder's value. url: type: string format: uri description: For media headers, an HTTPS URL of the media. file_id: $ref: "#/components/schemas/FileId" TemplateButtonParam: type: object description: The value for one of the template's buttons. required: [index, value] properties: index: type: integer minimum: 0 maximum: 9 description: The button's position in the template, from 0. value: type: string description: The URL suffix or the quick-reply payload. LocationContent: type: object description: A place, both ways. Inbound, a location the contact shared. Channels without locations need `fallback` (`auto` sends a maps link). required: [type, lat, lng] properties: type: type: string enum: [location] description: Always `location`. lat: type: number minimum: -90 maximum: 90 description: Latitude in degrees. lng: type: number minimum: -180 maximum: 180 description: Longitude in degrees. name: type: string description: The place's name. address: type: string description: The place's address. ContactCardContent: type: object description: A contact card, both ways. Channels without cards need `fallback` (`auto` sends it as text). required: [type, name] properties: type: type: string enum: [contact_card] description: Always `contact_card`. name: type: string description: The person's or business's name. phones: type: array description: Phone numbers, E.164 where known. items: type: string emails: type: array description: Email addresses. items: type: string format: email EffectContent: type: object description: Text sent with an iMessage effect, sent only. Other channels need `fallback` (`auto` sends the plain text). Which effects are available may vary. required: [type, text, effect] properties: type: type: string enum: [effect] description: Always `effect`. text: type: string minLength: 1 maxLength: 9999 description: The text, at most the channel's `max_text_length` characters (4096 on Telegram and WhatsApp, 9999 on iMessage; see `GET /v1/capabilities`). Telegram counts UTF-16 code units, so an emoji such as 😀 counts as 2. Longer text is refused with `invalid_request`. effect: type: string description: The effect. enum: [slam, loud, gentle, invisible_ink, echo, spotlight, balloons, confetti, love, lasers, fireworks, celebration] TypingContent: type: object description: The typing indicator, sent only (the stream's and webhook replies' way to type; over HTTP use `POST /v1/conversations/{conversation_id}/typing`). Skipped where the channel has none. required: [type, state] properties: type: type: string enum: [typing] description: Always `typing`. state: type: string enum: ["on", "off"] description: "`on` shows the indicator, `off` clears it." ReadContent: type: object description: A read receipt, sent only (over HTTP use `POST /v1/conversations/{conversation_id}/read`). Skipped on Telegram, where bots cannot send them. On iMessage the whole conversation is marked read. required: [type] properties: type: type: string enum: [read] description: Always `read`. up_to: $ref: "#/components/schemas/MessageId" description: The latest inbound message to mark read (default the latest inbound message). No effect on iMessage. EditContent: type: object description: Replaces the text of one of your sent messages, sent only (over HTTP use `PATCH /v1/messages/{message_id}`). Telegram and iMessage; WhatsApp refuses it with `unsupported_content`. required: [type, message_id, content] properties: type: type: string enum: [edit] description: Always `edit`. message_id: $ref: "#/components/schemas/MessageId" content: $ref: "#/components/schemas/TextContent" UnsendContent: type: object description: Removes one of your sent messages from the contact's chat, sent only (over HTTP use `DELETE /v1/messages/{message_id}`). Telegram and iMessage; WhatsApp refuses it with `unsupported_content`. required: [type, message_id] properties: type: type: string enum: [unsend] description: Always `unsend`. message_id: $ref: "#/components/schemas/MessageId" FileBlockedContent: type: object description: | Inbound only. The contact sent a file that Flow did not keep: it failed the malware scan, its real type is one Flow does not pass on (programs, web pages), it could not be scanned, or it is larger than the channel lets Flow download. The file is not stored and has no `url`; what is known about it is here. Images, audio and video are not scanned; documents (PDF, Office, archives) are. required: [type, reason] properties: type: type: string enum: [file_blocked] description: Always `file_blocked`. reason: type: string enum: [malware, type_not_allowed, scan_failed, too_large] description: "Why the file was not kept: `malware` (the scan found something), `type_not_allowed`, `scan_failed` (the scanner could not check it; ask the contact to send it again), or `too_large`." kind: type: string enum: [image, video, document, audio] description: What kind of media the channel said it was. filename: type: string description: The file's name, if it had one. mime_type: type: string description: The real type, read from the file's first bytes, when Flow read them. size_bytes: type: integer description: The file's size, when known. caption: type: string description: Text the contact sent with the file. # ---------------------------------------------------------------- events EventType: type: string description: | - `message.received`: the contact sent something with content (a button tap arrives as `button_reply` content). - `message.sent`, `message.delivered`, `message.read`, `message.failed`: the status of your outbound messages. `message.failed` carries the error in `data.message.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`: the first inbound message from a new contact, or a sandbox join (Flow itself answers the join; the join message is not a `message.received`). - `conversation.window_closing`: WhatsApp only, opt-in. The 24-hour window closes in 1 hour. - `sender.status_changed`: a sender was throttled, flagged, banned or restored, or its WhatsApp quality rating changed. - `template.status_changed`: Meta approved, rejected or paused a template. enum: - message.received - message.sent - message.delivered - message.read - message.failed - reaction.added - reaction.removed - typing.started - typing.stopped - conversation.started - conversation.window_closing - sender.status_changed - template.status_changed Timing: type: object description: | When the event moved through Flow, to measure Flow's overhead (`delivered_at` minus `received_at`). required: [received_at, stored_at] properties: received_at: type: string format: date-time description: When Flow received it from the channel, or when it happened inside Flow. stored_at: type: string format: date-time description: When it was committed to the log. delivered_at: type: string format: date-time description: When an endpoint first accepted it. Absent until then. EventBase: type: object description: The fields every event has. required: [id, created_at, app, livemode, timing] properties: id: $ref: "#/components/schemas/EventId" created_at: type: string format: date-time description: When the event was created. Events are ordered in the log by commit, not by this time. app: $ref: "#/components/schemas/AppId" livemode: type: boolean description: Whether the event belongs to live mode. conversation: $ref: "#/components/schemas/ConversationRef" timing: $ref: "#/components/schemas/Timing" Event: description: | One entry in your app's log. `type` says what happened and selects the shape of `data`. `conversation` is set for every event that happened in a conversation (all but `sender.status_changed` and `template.status_changed`). oneOf: - $ref: "#/components/schemas/MessageReceivedEvent" - $ref: "#/components/schemas/MessageSentEvent" - $ref: "#/components/schemas/MessageDeliveredEvent" - $ref: "#/components/schemas/MessageReadEvent" - $ref: "#/components/schemas/MessageFailedEvent" - $ref: "#/components/schemas/ReactionAddedEvent" - $ref: "#/components/schemas/ReactionRemovedEvent" - $ref: "#/components/schemas/TypingStartedEvent" - $ref: "#/components/schemas/TypingStoppedEvent" - $ref: "#/components/schemas/ConversationStartedEvent" - $ref: "#/components/schemas/ConversationWindowClosingEvent" - $ref: "#/components/schemas/SenderStatusChangedEvent" - $ref: "#/components/schemas/TemplateStatusChangedEvent" discriminator: propertyName: type mapping: message.received: "#/components/schemas/MessageReceivedEvent" message.sent: "#/components/schemas/MessageSentEvent" message.delivered: "#/components/schemas/MessageDeliveredEvent" message.read: "#/components/schemas/MessageReadEvent" message.failed: "#/components/schemas/MessageFailedEvent" reaction.added: "#/components/schemas/ReactionAddedEvent" reaction.removed: "#/components/schemas/ReactionRemovedEvent" typing.started: "#/components/schemas/TypingStartedEvent" typing.stopped: "#/components/schemas/TypingStoppedEvent" conversation.started: "#/components/schemas/ConversationStartedEvent" conversation.window_closing: "#/components/schemas/ConversationWindowClosingEvent" sender.status_changed: "#/components/schemas/SenderStatusChangedEvent" template.status_changed: "#/components/schemas/TemplateStatusChangedEvent" MessageEventData: type: object description: The message the event is about. required: [message] properties: message: $ref: "#/components/schemas/Message" MessageReceivedEvent: description: The contact sent a message. `data.message.content` is what they sent. allOf: - $ref: "#/components/schemas/EventBase" - type: object required: [type, data] properties: type: type: string enum: [message.received] description: Always `message.received`. data: $ref: "#/components/schemas/MessageEventData" MessageSentEvent: description: The channel accepted one of your messages. allOf: - $ref: "#/components/schemas/EventBase" - type: object required: [type, data] properties: type: type: string enum: [message.sent] description: Always `message.sent`. data: $ref: "#/components/schemas/MessageEventData" MessageDeliveredEvent: description: One of your messages reached the contact's device. allOf: - $ref: "#/components/schemas/EventBase" - type: object required: [type, data] properties: type: type: string enum: [message.delivered] description: Always `message.delivered`. data: $ref: "#/components/schemas/MessageEventData" MessageReadEvent: description: The contact read one of your messages. allOf: - $ref: "#/components/schemas/EventBase" - type: object required: [type, data] properties: type: type: string enum: [message.read] description: Always `message.read`. data: $ref: "#/components/schemas/MessageEventData" MessageFailedEvent: description: One of your messages could not be sent. `data.message.error` says why, mapped to an `ErrorType`. allOf: - $ref: "#/components/schemas/EventBase" - type: object required: [type, data] properties: type: type: string enum: [message.failed] description: Always `message.failed`. data: $ref: "#/components/schemas/MessageEventData" ReactionEventData: type: object description: A reaction the contact added or removed. required: [message_id, emoji] properties: message_id: $ref: "#/components/schemas/MessageId" emoji: type: string description: The emoji (iMessage tapbacks are mapped to their closest emoji). ReactionAddedEvent: description: The contact reacted to a message. allOf: - $ref: "#/components/schemas/EventBase" - type: object required: [type, data] properties: type: type: string enum: [reaction.added] description: Always `reaction.added`. data: $ref: "#/components/schemas/ReactionEventData" ReactionRemovedEvent: description: The contact removed a reaction. allOf: - $ref: "#/components/schemas/EventBase" - type: object required: [type, data] properties: type: type: string enum: [reaction.removed] description: Always `reaction.removed`. data: $ref: "#/components/schemas/ReactionEventData" TypingEventData: type: object description: Who is typing. required: [contact] properties: contact: $ref: "#/components/schemas/ContactId" TypingStartedEvent: description: The contact started typing, where the channel reports it. allOf: - $ref: "#/components/schemas/EventBase" - type: object required: [type, data] properties: type: type: string enum: [typing.started] description: Always `typing.started`. data: $ref: "#/components/schemas/TypingEventData" TypingStoppedEvent: description: The contact stopped typing, where the channel reports it. allOf: - $ref: "#/components/schemas/EventBase" - type: object required: [type, data] properties: type: type: string enum: [typing.stopped] description: Always `typing.stopped`. data: $ref: "#/components/schemas/TypingEventData" ConversationStartedData: type: object description: The new conversation and how it began. required: [conversation, via] properties: conversation: $ref: "#/components/schemas/Conversation" via: type: string description: "`inbound` (the contact wrote first), `sandbox_join` (they sent your app's join code) or `outbound` (you started it)." enum: [inbound, sandbox_join, outbound] ConversationStartedEvent: description: | A new conversation began. Sent before the first `message.received` of a new contact. A sandbox join (`data.via` `sandbox_join`) also starts one: the join message itself is not delivered as `message.received`, and the sandbox bot's confirmation ("You're connected to ") is sent by Flow itself, not by your app, and counts against no allowance. Joining again in a conversation that already exists sends no new `conversation.started`. allOf: - $ref: "#/components/schemas/EventBase" - type: object required: [type, data] properties: type: type: string enum: [conversation.started] description: Always `conversation.started`. data: $ref: "#/components/schemas/ConversationStartedData" WindowClosingData: type: object description: When the window closes. required: [window_open_until] properties: window_open_until: type: string format: date-time description: After this time only templates can be sent. ConversationWindowClosingEvent: description: WhatsApp only, opt-in per endpoint. The conversation's 24-hour window closes in 1 hour. allOf: - $ref: "#/components/schemas/EventBase" - type: object required: [type, data] properties: type: type: string enum: [conversation.window_closing] description: Always `conversation.window_closing`. data: $ref: "#/components/schemas/WindowClosingData" SenderStatusChangedData: type: object description: The sender as it is now, and what changed. required: [sender, previous_status, reason] properties: sender: $ref: "#/components/schemas/Sender" previous_status: $ref: "#/components/schemas/SenderStatus" reason: type: string description: Why, in one sentence (for example "Many new conversations got no reply; starts are slowed for 24 hours"). SenderStatusChangedEvent: description: A sender's status or WhatsApp quality rating changed. allOf: - $ref: "#/components/schemas/EventBase" - type: object required: [type, data] properties: type: type: string enum: [sender.status_changed] description: Always `sender.status_changed`. data: $ref: "#/components/schemas/SenderStatusChangedData" TemplateStatusChangedData: type: object description: The template as it is now, and what changed. required: [template, previous_status] properties: template: $ref: "#/components/schemas/Template" previous_status: $ref: "#/components/schemas/TemplateStatus" reason: type: string description: Meta's reason, when it gives one. TemplateStatusChangedEvent: description: Meta approved, rejected or paused a template. allOf: - $ref: "#/components/schemas/EventBase" - type: object required: [type, data] properties: type: type: string enum: [template.status_changed] description: Always `template.status_changed`. data: $ref: "#/components/schemas/TemplateStatusChangedData" EventList: type: object description: A page of events, oldest first. required: [data, has_more] properties: data: type: array items: $ref: "#/components/schemas/Event" has_more: type: boolean description: Whether more committed events follow this page. # ---------------------------------------------------------------- stream StreamFrame: description: | One JSON frame on the `/v1/stream` WebSocket. The server sends `event`, `ack`, `error` and `reconnect`; the client sends `send` and `start`. oneOf: - $ref: "#/components/schemas/StreamEventFrame" - $ref: "#/components/schemas/StreamAckFrame" - $ref: "#/components/schemas/StreamErrorFrame" - $ref: "#/components/schemas/StreamReconnectFrame" - $ref: "#/components/schemas/StreamSendFrame" - $ref: "#/components/schemas/StreamStartFrame" discriminator: propertyName: type mapping: event: "#/components/schemas/StreamEventFrame" ack: "#/components/schemas/StreamAckFrame" error: "#/components/schemas/StreamErrorFrame" reconnect: "#/components/schemas/StreamReconnectFrame" send: "#/components/schemas/StreamSendFrame" start: "#/components/schemas/StreamStartFrame" StreamEventFrame: type: object description: Server to client. One event from the log. required: [type, event] properties: type: type: string enum: [event] description: Always `event`. event: $ref: "#/components/schemas/Event" StreamAckFrame: type: object description: Server to client. A `send` or `start` frame was accepted. required: [type, ref, message] properties: type: type: string enum: [ack] description: Always `ack`. ref: type: string description: The `ref` of the frame being answered. message: $ref: "#/components/schemas/Message" StreamErrorFrame: type: object description: Server to client. A `send` or `start` frame was refused (with its `ref`), or the stream itself was refused or failed (without `ref`; the server then closes the socket with a 4000-range close code, `4401` for `authentication`, see `GET /v1/stream`). required: [type, error] properties: type: type: string enum: [error] description: Always `error`. ref: type: string description: The `ref` of the frame being answered, if any. error: $ref: "#/components/schemas/ErrorBody" StreamReconnectFrame: type: object description: Server to client. The server is restarting; reconnect with `after` set to the last event you received. The socket closes after this frame. required: [type] properties: type: type: string enum: [reconnect] description: Always `reconnect`. after: $ref: "#/components/schemas/EventId" StreamSendFrame: type: object description: Client to server. Same as `POST /v1/conversations/{conversation_id}/messages`; `ref` is also the idempotency key. required: [type, ref, conversation, message] properties: type: type: string enum: [send] description: Always `send`. ref: type: string minLength: 1 maxLength: 255 description: Your ID for this frame, echoed in the answer and used as the idempotency key. conversation: $ref: "#/components/schemas/ConversationId" message: $ref: "#/components/schemas/SendMessageRequest" StreamStartFrame: type: object description: Client to server. Same as `POST /v1/messages`; `ref` is also the idempotency key. required: [type, ref, message] properties: type: type: string enum: [start] description: Always `start`. ref: type: string minLength: 1 maxLength: 255 description: Your ID for this frame, echoed in the answer and used as the idempotency key. message: $ref: "#/components/schemas/StartConversationRequest" # ---------------------------------------------------------------- capabilities Capabilities: type: object description: What a conversation's channel supports now. required: [conversation, channel, window, content] properties: conversation: $ref: "#/components/schemas/ConversationId" channel: $ref: "#/components/schemas/Channel" window: $ref: "#/components/schemas/WindowState" content: type: array description: One entry per content type that can be sent. items: $ref: "#/components/schemas/ContentSupport" limits: $ref: "#/components/schemas/ChannelLimits" WindowState: type: object description: Whether free-form messages can be sent now. required: [open] properties: open: type: boolean description: "`true` when any content can be sent; `false` when only a `template` can (WhatsApp after 24 hours)." open_until: type: string format: date-time description: WhatsApp only. When the window closes, if it is open. ContentSupport: type: object description: How the channel handles one content type. required: [type, support] properties: type: $ref: "#/components/schemas/ContentType" support: type: string description: "`native` (shown as sent), `fallback` (needs `fallback`; `auto` sends `fallback_type`) or `unsupported` (cannot be sent)." enum: [native, fallback, unsupported] fallback_type: type: string description: 'With `support` `fallback`, what `fallback: "auto"` sends instead, or `none` when it is skipped.' enum: [text, media, none] note: type: string description: A limit worth knowing (for example "up to 3 buttons, else a list"). ChannelLimits: type: object description: Size limits on this channel. properties: max_text_length: type: integer description: The longest text one message may carry, in characters (4096 on Telegram and WhatsApp, 9999 on iMessage). Telegram counts UTF-16 code units, so an emoji such as 😀 counts as 2; the other channels count Unicode characters. A media caption takes at most 1024. Longer text is refused with `invalid_request`. max_buttons: type: integer description: The most buttons one message may carry natively. max_file_bytes: type: integer description: The largest file one message may carry. # ---------------------------------------------------------------- files FileUpload: type: object description: A file to upload, as multipart form data. required: [file] properties: file: type: string format: binary description: The file's bytes. Its real type is read from its first bytes. filename: type: string maxLength: 255 description: The name to show for the file; defaults to the uploaded part's file name. channel: $ref: "#/components/schemas/Channel" File: type: object description: A stored file. Send it as `media` or `voice` content by `file_id`. required: [id, mime_type, size_bytes, url, livemode, created_at, expires_at] properties: id: $ref: "#/components/schemas/FileId" mime_type: type: string description: The real type, checked from the first bytes. size_bytes: type: integer description: The file's size. filename: type: string description: The file's name, if it has one. url: type: string format: uri description: Its Flow URL (`https://api.flow.engineer/v1/files/{file_id}`), which redirects to the bytes for an authenticated request. livemode: type: boolean description: Whether the file belongs to live mode. created_at: type: string format: date-time description: When the file was stored. expires_at: type: string format: date-time description: When the file will be deleted under your plan's retention. # ---------------------------------------------------------------- templates TemplateStatus: type: string description: A template's approval status at Meta. enum: [pending, approved, rejected, paused, disabled] TemplateCategory: type: string description: Meta's category for the template, which sets its price. enum: [marketing, utility, authentication] Template: type: object description: A WhatsApp message template, mirrored from Meta with its approval status. required: [id, sender, name, language, category, status, components, created_at] properties: id: $ref: "#/components/schemas/TemplateId" sender: $ref: "#/components/schemas/SenderId" name: type: string description: The template's name at Meta. language: type: string description: The template's language, as Meta names it. category: $ref: "#/components/schemas/TemplateCategory" status: $ref: "#/components/schemas/TemplateStatus" components: type: array description: The template's parts, as submitted. items: $ref: "#/components/schemas/TemplateComponent" rejection_reason: type: string description: Meta's reason, when the template was rejected. created_at: type: string format: date-time description: When the template was submitted. TemplateComponent: type: object description: One part of a template. Placeholders are written `{{1}}`, `{{2}}`, ... required: [type] properties: type: type: string enum: [header, body, footer, buttons] description: Which part this is. format: type: string enum: [text, image, video, document] description: For headers, what the header holds. text: type: string description: The part's text, with placeholders. buttons: type: array description: For `buttons`, the buttons in order. items: $ref: "#/components/schemas/TemplateButton" TemplateButton: type: object description: One button of a template. required: [type, text] properties: type: type: string enum: [quick_reply, url, phone_number] description: What the button does. text: type: string description: The button's label. url: type: string description: For `url` buttons, the link, optionally ending in a `{{1}}` placeholder. phone: type: string description: For `phone_number` buttons, the E.164 number. TemplateCreateRequest: type: object description: A template to submit to Meta. required: [sender, name, language, category, components] properties: sender: $ref: "#/components/schemas/SenderId" name: type: string pattern: "^[a-z0-9_]{1,512}$" description: Lowercase letters, digits and underscores. language: type: string description: The template's language, as Meta names it (`en`, `en_US`, `hi`). category: $ref: "#/components/schemas/TemplateCategory" components: type: array minItems: 1 description: The template's parts. A body is required. items: $ref: "#/components/schemas/TemplateComponent" TemplateList: type: object description: A page of templates. required: [data, has_more] properties: data: type: array items: $ref: "#/components/schemas/Template" has_more: type: boolean description: Whether more items lie beyond this page in the direction you paged. # ---------------------------------------------------------------- webhook endpoints WebhookEndpoint: type: object description: A URL that receives your app's events of the types it subscribes to. required: [id, url, events, enabled, livemode, created_at] properties: id: $ref: "#/components/schemas/WebhookEndpointId" url: type: string format: uri description: The HTTPS URL events are posted to. events: type: array description: The event types delivered to this endpoint. items: $ref: "#/components/schemas/EventType" description: type: string description: Your note about the endpoint. enabled: type: boolean description: Whether events are delivered. Disabled endpoints keep their place; nothing is lost from the log. livemode: type: boolean description: Whether the endpoint receives live-mode or test-mode events. secret: type: string description: The signing secret (`whsec_...`). Only in the answers to create and to rotate the secret. previous_secret_expires_at: type: string format: date-time description: While a secret rotation overlaps, when the previous secret stops signing. Absent when only one secret is active. created_at: type: string format: date-time description: When the endpoint was created. WebhookEndpointCreateRequest: type: object description: A new webhook endpoint. required: [url, events] properties: url: type: string format: uri maxLength: 2048 description: An HTTPS URL on a public address. events: type: array minItems: 1 description: The event types to deliver. items: $ref: "#/components/schemas/EventType" description: type: string maxLength: 500 description: Your note about the endpoint. WebhookEndpointUpdateRequest: type: object description: Changes to a webhook endpoint. Fields left out are kept. properties: url: type: string format: uri maxLength: 2048 description: An HTTPS URL on a public address. events: type: array minItems: 1 description: The event types to deliver. items: $ref: "#/components/schemas/EventType" description: type: string maxLength: 500 description: Your note about the endpoint. enabled: type: boolean description: Whether to deliver events. WebhookEndpointList: type: object description: A page of webhook endpoints. required: [data, has_more] properties: data: type: array items: $ref: "#/components/schemas/WebhookEndpoint" has_more: type: boolean description: Whether more items lie beyond this page in the direction you paged. WebhookSecretRotateRequest: type: object description: Options for rotating a webhook endpoint's signing secret. properties: overlap_seconds: type: integer minimum: 0 maximum: 604800 default: 86400 description: How long the previous secret keeps signing alongside the new one, in seconds (at most 7 days). `0` retires it at once. WebhookReply: type: object description: | The optional body of your answer to a `message.received` delivery. `reply` is one piece of content or a list of up to 10, sent in order into the event's conversation through the send gate. A non-empty string, as `reply` or as an item of the list, is text: `{"reply": "Hi"}` is short for `{"reply": {"type": "text", "text": "Hi"}}`. Leave `reply` out, set it to `null`, or answer `{}`, an empty body or any body that is not a JSON object (such as `OK`) to send nothing now; that is not an error. A JSON object whose `reply` (or `fallback`, alongside a `reply`) does not match this shape, or a body that starts with `{` but is not valid JSON, sends nothing at all (no piece of a list goes out) and is recorded on the delivery as an `invalid_request` error with the reason; the delivery counts as delivered and is not retried. properties: reply: description: One piece of content, or a list of 1 to 10 pieces sent in order. A non-empty string is text content. `null` sends nothing. oneOf: - $ref: "#/components/schemas/Content" - type: string minLength: 1 description: "Text to send, as `{\"type\": \"text\", \"text\": \"...\"}`." - type: array minItems: 1 maxItems: 10 items: oneOf: - $ref: "#/components/schemas/Content" - type: string minLength: 1 description: "Text to send, as `{\"type\": \"text\", \"text\": \"...\"}`." - type: "null" fallback: $ref: "#/components/schemas/Fallback" description: What to send when the channel cannot show a piece of `reply`, as on HTTP sends. It applies to every piece. examples: - reply: - {type: text, text: "Here are the sizes we have."} - type: buttons text: "Which size?" buttons: - {id: size_s, label: Small} - {id: size_m, label: Medium} fallback: auto