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

# Write as the simulated contact

> Test keys only. Posts one inbound turn from the simulated contact of a
simulated conversation. It goes through the same inbound path as a real
channel's message: Flow stores it, then delivers it as a `message.received`
event by webhook, on the stream and in the event log, in order with the
conversation's other events. A webhook answer's `reply` works as usual. The
answer comes before delivery; the `message.received` event carries the
`message` ID given here.

`content` is what the contact sends, as a kind object with `kind`: `basic`
(`text` and/or `media`), `voice`, `location`, `contact_card` or
`button_reply` (a tap on a reply button of a buttons or carousel message
your agent sent, or on a row of a list, given as its `message_id` and
`button_id`: Flow fills in the `label`). Media and voice take the
`file_id` of a file uploaded with `POST /v0/files` (no `url`); the contact's
message refers to that same file (its `file_id`, `url`, `mime_type` and
`size_bytes`): Flow does not copy it again. The channel's rules apply:
what it cannot carry from a person (a document over SMS, a location over
SMS, a file larger than the channel fetches from a person) answers
`422 unsupported_content`, and text over the channel's limit answers
`400 invalid_request`.

On SMS, the keywords work as from a real phone, matched against the whole
text. STOP (or STOPALL, UNSUBSCRIBE, CANCEL, END, QUIT, REVOKE, OPT OUT,
OPTOUT) opts the simulated contact out against the simulated sender: the
contact's `opted_out_at` is set and `opted_out_via` is `keyword`, and sends
to them answer `403 permission` with `channel_code` `contact_opted_out`.
START (or UNSTOP, YES) lifts it. The turn is still delivered as
`message.received`. A keyword reply is never recorded as a message, so the
transcript shows no automatic reply to STOP, START or HELP.

On WhatsApp, each turn opens the conversation's 24-hour window, as a real
contact's message does.

A conversation that is not simulated answers `403 permission` with
`channel_code` `not_simulated`; a live key answers `403 permission` with
`channel_code` `test_mode_only`. In a HIPAA account (`Account.hipaa`), a
turn on a simulated `telegram`, `whatsapp` or `imessage` conversation (one made before
the flag was set) answers `403 permission` with `channel_code`
`hipaa_channel_not_covered` (`param` `conversation_id`).




## OpenAPI

````yaml unified-messaging-api/openapi.yaml POST /v0/test/conversations/{conversation_id}/inbound
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, and on **SMS and MMS in
    the

    US** (in pilot: dedicated numbers arranged with the Flow team). 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 on lines the Flow team

    connects to your app: it replies to people who write first, in one-to-one

    conversations and in group chats people add the line to; writing first is

    available on request on dedicated lines (`Sender.imessage.can_initiate`).
    WhatsApp numbers are connected by the Flow team

    too, either a number Flow provides or one in your business's own WhatsApp

    Business Account, and the WhatsApp-only parts (templates, the 24-hour
    window)

    apply on them; the shared WhatsApp sandbox is not open yet.


    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,
      an iMessage line or a US SMS number. 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 (`type`
    `direct`),
      or, on iMessage, with a group chat the line was added to (`type` `group`,
      see Groups). You reply into a conversation; you never pick a channel per
      message.
    - A **message** is one **kind**: `basic` (text, or media with a caption),
      `voice`, `buttons`, `list`, `carousel`, `template`, `contact_card`,
      `location`, `effect`, `reaction`, and more (see `MessageKind`). Each kind
      you can send has its own endpoint, named after it
      (`POST /v0/conversations/{conversation_id}/basic`, `/buttons`, ...), whose
      page opens with the channels it works on. The same kind objects describe
      what you receive (`Message.content`, with `kind`).
    - 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 /v0/stream`) and by polling
      (`GET /v0/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 /v0/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/v0/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 message 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 a Flow account (flow.engineer)

    through the device flow: the agent or the CLI (`npx @flow-engineer/messaging
    login`) calls `POST

    /v0/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 /v0/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 /v0/app`
    shows what is left of the allowance

    (`allowance`). When it runs out, sends answer `403 permission` with

    `channel_code` `sandbox_allowance_used`. The apps of your own account get
    the

    free trial instead, started at your first sign-in: 25 people per channel on

    Flow's shared Telegram, WhatsApp and iMessage senders, over all the

    account's apps (their `allowance` is absent).


    ## 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`.


    Every path starts with `/v0`: this is API V0, the shape before the stable

    release. `/v1` is kept for that release, version 1.0, after which the shape
    is

    frozen.


    ## Idempotency


    Every `POST` that takes an API key accepts an `Idempotency-Key` header (any

    unique string up to 255 characters; a UUID or ULID is a good choice); the

    onboarding calls `POST /v0/sandbox/keys`, `POST /v0/device/authorizations`
    and

    `POST /v0/device/token` ignore it. 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,
      connecting a WhatsApp number), 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": "unsupported_content",
      "channel": "telegram",
      "param": "kind",
      "message": "Carousels work on WhatsApp only.",
      "hint": "On Telegram, send a basic message, then one buttons message with an image header per card.",
      "doc_url": "https://api.flow.engineer/docs/errors/unsupported_content",
      "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/<type>`: 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 /v0/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, which also refuses what the channel does not take (see "Exact or
    422"):


    - **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
      (`Sender.imessage.can_initiate`, available on request on dedicated lines); 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, with a
      warm-up curve, and a contact who has never messaged the line (or opted in)
      gets a `message.failed` event with `outside_window`. Group conversations
      always begin with the group writing; a start never makes one.
    - **Telegram**: the contact must have started the bot; Telegram enforces
    this.

    - **SMS (US, in pilot)**: a conversation starts only with a contact who
    texted
      one of your numbers before, or whose consent you record with the start
      (`consent` on `POST /v0/messages`); otherwise `403 permission` with
      `channel_code` `consent_required`. A contact who texts STOP (or STOPALL,
      UNSUBSCRIBE, CANCEL, END, QUIT, REVOKE, OPT OUT, OPTOUT) is opted out of every
      SMS sender of your app at once: sends to them answer `403 permission` with
      `channel_code` `contact_opted_out`, and messages already queued fail the same
      way, until they text START (or UNSTOP, YES) to a number they opted out
      through (carriers and providers block per number, so START to another of
      your numbers changes nothing). HELP (or INFO) gets the sender's
      help reply. Flow answers these keywords itself, and you still receive the
      message as `message.received`. The contact's `opted_out_at` shows the state.
      Messages to a contact who has not texted in the last hour respect the sender's
      quiet hours (`Sender.sms.quiet_hours`, by default 21:00 to 08:00 in the
      contact's time zone, worked out from the area code): inside them a send
      answers `409 outside_window` with `channel_code` `quiet_hours` and
      `retry_after` set to when they end; a message that waited in the queue into
      the quiet hours is held until they end, not sent at night.
    - **HIPAA accounts** (`Account.hipaa`): only channels under a business
      associate agreement carry messages (SMS). Sends, typing and read receipts on
      Telegram, WhatsApp and iMessage, and connecting senders there, answer `403
      permission` with `channel_code` `hipaa_channel_not_covered`; messages
      already queued there fail the same way. Contacts get a fixed refusal from
      the sandbox senders instead of reaching the app, and nothing they send
      there is stored or delivered. Simulated conversations (`POST
      /v0/test/conversations`) follow the same rule: only SMS can be simulated.
    - **Sandbox senders**: only contacts who joined through your app (by sending
    its
      join message, for example `Hi Sandbox app (ref brave-otter-40718263)`, or by
      opening its Telegram link).

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


    ## Groups (iMessage)


    When someone adds one of your dedicated iMessage lines to a group chat
    (Flow's

    shared iMessage line ignores group chats), the group is one

    conversation with `type` `group`. It has no `contact`; `group` lists the

    participants (`participants`, each a Flow contact on the channel, made the
    first

    time Flow sees them) and the group's name when it has one. Every inbound

    message carries `from`, the contact who wrote it. Participants who join or

    leave change the list without an event: read the conversation

    (`GET /v0/conversations/{conversation_id}`) for the current list.


    Reply with the normal sends (`POST
    /v0/conversations/{conversation_id}/basic`

    and the other kinds' endpoints). Basic messages (with `imessage.styles`,

    `imessage.subject` and `link_preview`), voice notes, effects, `reply_to`,
    edits

    and unsends work in groups. Groups take no reactions, typing indicator or
    read

    receipts from your line: each answers `422 unsupported_content` with `param`

    `kind` and `channel_code` `group_unsupported`, and nothing is sent.
    Dedicated

    iMessage lines receive and reply in groups; Flow's shared iMessage line
    ignores

    group chats. A group cannot be started, renamed, or have people added or

    removed through the API.


    ## Exact or 422


    What you send is what the contact gets. Each endpoint's page opens with the

    channels it works on and their limits; a send of a kind, a field, a channel

    block or a value the conversation's channel does not take fails with `422

    unsupported_content` and sends nothing. `param` names what was refused

    (`kind`, a field such as `reply_to` or `telegram.is_big`, or a value's field

    such as `emoji`), `channel` the conversation's channel, and `hint` what to
    send

    instead over plain HTTP (for example, for a carousel on Telegram, a basic

    message, then one buttons message with an image header per card). Flow never

    converts a message into another kind, never drops a field, and never answers

    `2xx` for something it did not send. Fields that only one channel has sit in

    a block named after it (`telegram`, `imessage`), typed per endpoint; an

    unknown key in a block, or an unknown field, is refused with `400

    invalid_request` naming it, and so is a read-only field Flow fills on

    received messages (such as `media.mime_type` or a voice note's
    `transcript`).


    `text` is sent exactly as written on every channel: Flow does not read

    markdown, strip marks or warn. Style it in the channel's own terms:

    `telegram.parse_mode`, `imessage.styles`; WhatsApp's app reads its own

    `*bold*` and `_italic_`. With `telegram.parse_mode`, Telegram's length
    limits

    count the text after its tags or marks are parsed, as Telegram counts them,
    so

    a styled text may be longer than the limit before parsing.


    ## Order and duplicates


    Sends to one conversation go out in the order Flow accepted them, one at a

    time, across every endpoint, webhook replies and the stream. A message sent

    as several (a text and one message per card) arrives in the order you sent

    the calls. Give each call its own `Idempotency-Key` (for example

    `evt_123:card:2` for the second card answering event `evt_123`): a retry
    with

    the same key never sends twice (see Idempotency). A `422` answer is kept

    under its key like any other, so a message you send instead of a refused one

    (a `basic` after a `buttons` came back `422`) is a new request with a new

    key: add what it is to the refused one's key, such as
    `evt_123:card:2:basic`.


    ## Webhooks


    Register endpoints with `POST /v0/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

    /v0/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": <message>}` or `{"reply": [<message>,
    ...]}`

    (up to 11 pieces, each a kind object with `kind`, such as `{"kind":
    "buttons",

    ...}`); the reply is sent into the event's conversation through the send
    gate,

    in order, which saves a round trip. A string is a basic message:

    `{"reply": "Hi"}` sends `{"kind": "basic", "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

    the kinds' endpoints (`POST /v0/conversations/{conversation_id}/basic`,
    ...). 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 /v0/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.


    ## Retention and deleting contacts


    Message content and files are kept for your plan's retention period (30 days
    by

    default). After it, a message's content is removed and the message is no
    longer

    returned, files are deleted, and events older than it leave the log.


    `DELETE /v0/contacts/{contact_id}` erases one contact at once, for example
    when

    the person asks you to delete their data or when you delete them in your own

    product: their name and address, the content of every message in their

    conversations, the files in them, their events and pending webhook
    deliveries,

    and messages still queued to them. It cannot be undone. What stays is only
    IDs,

    timestamps and the counts that bill and pace your senders. If the person
    writes

    again, or you send to their address, they are a new contact with a new ID.


    ## Testing with simulated contacts


    A test key can play the end user's side without a phone or a real channel.
    `POST

    /v0/test/conversations` creates a conversation on `telegram`, `whatsapp`,

    `imessage` or `sms` with a **simulated contact**; `POST
    /v0/test/conversations/{conversation_id}/inbound`

    writes as that contact (text, or media uploaded with `POST /v0/files`). Each
    turn

    reaches your agent exactly like a real message: a `message.received` event
    by

    webhook, on the stream and in the event log, in order. Your agent replies
    with

    its normal sends, which pass the real send gate (the channel's kinds and

    fields, text limits, edit targets) and end `sent` on your app's simulated

    sender, without touching a network. On Telegram, as with a real bot, nothing
    reaches a

    contact who has not written yet: a send before the contact's first turn ends

    `failed` (`channel_error`, `channel_code` `403`). `GET
    /v0/test/conversations/{conversation_id}/transcript`

    shows both sides, oldest first: what was sent is exactly what the contact
    sees.


    Simulated traffic is free: it does not count against the sandbox allowance,

    since nothing leaves Flow and no real person is reached. Two limits bound it

    instead: an app has at most 500 simulated conversations (a new one past that

    answers `403 permission` with `channel_code`
    `simulated_conversation_limit`),

    and a key makes at most 120 requests a minute to the `/v0/test/` endpoints

    (`429 rate_limited`), counted on top of its plan's request rate limit. The
    gate also skips the rules that protect a real number's

    standing with its channel (pacing, the new-contact budget and warm-up, abuse

    throttling), so a test suite can run cases in parallel. Simulated
    conversations,

    contacts and senders carry `simulated: true` (senders `kind: simulated`),
    exist

    only in test mode, are never shown to live keys, and follow your plan's

    retention like other test data.


    The SMS rules that concern the contact hold on a simulated SMS conversation
    as

    on a real number. A start or reply before the contact's first turn needs
    their

    recorded consent (`consent` on `POST /v0/messages`; otherwise `403
    permission`

    with `channel_code` `consent_required`). A turn with the text STOP (or a

    variant) opts the simulated contact out (its `opted_out_at`, `opted_out_via`

    `keyword`; sends answer `403 permission` with `channel_code`

    `contact_opted_out`), and START to the simulated sender lifts it; the turn
    is

    still delivered as `message.received`, and the transcript shows no automatic

    keyword reply. A simulated SMS sender keeps no quiet hours, so a test's
    outcome

    never depends on the time of day, and it sends media as MMS. A HIPAA account

    (`Account.hipaa`) can simulate only SMS: creating a simulated `telegram`,

    `whatsapp` or `imessage` conversation, or posting a turn to one made before
    the

    flag was set, answers `403 permission` with `channel_code`

    `hipaa_channel_not_covered`.


    A simulated WhatsApp conversation keeps WhatsApp's rules. The contact's
    turns go

    through the same inbound path as a real WhatsApp message, so each turn opens
    the

    24-hour window, and the send gate applies WhatsApp's rules: outside the
    window

    (also before the contact's first turn) only a `template` can be sent (`409

    outside_window`); buttons take up to 3 reply buttons (a `list` up to 10
    rows);

    reactions and `reply_to` work; edits and unsends answer `422

    unsupported_content`.


    ## 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/v0/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`, `simulate_inbound`, `get_test_transcript`,

    `wait_for_event`, `list_events`, `get_webhook_deliveries`, `replay_event`

    and `explain_error`. A `fk_live_` key gets

    production tools: `send_message`, `reply`, `react`, `edit_message`,

    `unsend_message`, `typing`, `list_conversations` and

    `get_conversation_messages`, plus `whoami` and

    `explain_error`. The tools that send take a `message` that is a kind object,

    as in a webhook reply (a string is a basic message's text). 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`, `param`, `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. Test and live mode are chosen by the API key.
security:
  - bearerAuth: []
tags:
  - name: Messages
    description: >-
      Send messages into conversations, one endpoint per kind; 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: 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, iMessage lines and SMS numbers 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: Testing
    description: >
      Test keys only. Simulated contacts: create a conversation with an end user
      who

      exists only inside Flow, write as them, and read what your agent sent
      back, with

      no phone and no real channel.
  - name: Onboarding
    description: >
      Get a test key without an account (`POST /v0/sandbox/keys`), and sign in
      with

      a Flow account (flow.engineer) through the device flow to claim the app
      and

      lift its sandbox allowance. These endpoints take no API key.
paths:
  /v0/test/conversations/{conversation_id}/inbound:
    parameters:
      - $ref: '#/components/parameters/ConversationId'
      - $ref: '#/components/parameters/FlowVersion'
    post:
      tags:
        - Testing
      summary: Write as the simulated contact
      description: >
        Test keys only. Posts one inbound turn from the simulated contact of a

        simulated conversation. It goes through the same inbound path as a real

        channel's message: Flow stores it, then delivers it as a
        `message.received`

        event by webhook, on the stream and in the event log, in order with the

        conversation's other events. A webhook answer's `reply` works as usual.
        The

        answer comes before delivery; the `message.received` event carries the

        `message` ID given here.


        `content` is what the contact sends, as a kind object with `kind`:
        `basic`

        (`text` and/or `media`), `voice`, `location`, `contact_card` or

        `button_reply` (a tap on a reply button of a buttons or carousel message

        your agent sent, or on a row of a list, given as its `message_id` and

        `button_id`: Flow fills in the `label`). Media and voice take the

        `file_id` of a file uploaded with `POST /v0/files` (no `url`); the
        contact's

        message refers to that same file (its `file_id`, `url`, `mime_type` and

        `size_bytes`): Flow does not copy it again. The channel's rules apply:

        what it cannot carry from a person (a document over SMS, a location over

        SMS, a file larger than the channel fetches from a person) answers

        `422 unsupported_content`, and text over the channel's limit answers

        `400 invalid_request`.


        On SMS, the keywords work as from a real phone, matched against the
        whole

        text. STOP (or STOPALL, UNSUBSCRIBE, CANCEL, END, QUIT, REVOKE, OPT OUT,

        OPTOUT) opts the simulated contact out against the simulated sender: the

        contact's `opted_out_at` is set and `opted_out_via` is `keyword`, and
        sends

        to them answer `403 permission` with `channel_code` `contact_opted_out`.

        START (or UNSTOP, YES) lifts it. The turn is still delivered as

        `message.received`. A keyword reply is never recorded as a message, so
        the

        transcript shows no automatic reply to STOP, START or HELP.


        On WhatsApp, each turn opens the conversation's 24-hour window, as a
        real

        contact's message does.


        A conversation that is not simulated answers `403 permission` with

        `channel_code` `not_simulated`; a live key answers `403 permission` with

        `channel_code` `test_mode_only`. In a HIPAA account (`Account.hipaa`), a

        turn on a simulated `telegram`, `whatsapp` or `imessage` conversation
        (one made before

        the flag was set) answers `403 permission` with `channel_code`

        `hipaa_channel_not_covered` (`param` `conversation_id`).
      operationId: simulateInbound
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/SimulatedInboundRequest'
            examples:
              text:
                summary: A text from the contact
                value:
                  content:
                    kind: basic
                    text: Do you deliver on Sundays?
              mms:
                summary: A photo with a caption (an MMS on SMS)
                value:
                  content:
                    kind: basic
                    text: Do you have this in blue?
                    media:
                      type: image
                      file_id: file_01JB8ZG7Q9S1V3X5Z7B9D1F3G5
              tap:
                summary: A tap on a reply button of a buttons message your agent sent
                value:
                  content:
                    kind: button_reply
                    message_id: msg_01JB8ZD4M6P8R0T2V4X6Z8B0C2
                    button_id: 'yes'
      responses:
        '202':
          description: The turn was accepted; `message.received` follows.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SimulatedInbound'
        '400':
          $ref: '#/components/responses/InvalidRequest'
        '401':
          $ref: '#/components/responses/Unauthenticated'
        '403':
          description: >
            `permission`: a live key (`channel_code` `test_mode_only`), a

            conversation that is not simulated (`not_simulated`), or, in a HIPAA

            account, a simulated conversation on a channel its business
            associate

            agreement does not cover (`hipaa_channel_not_covered`).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error:
                  type: permission
                  message: >-
                    This account handles health information under a business
                    associate agreement, and Telegram is not a channel Flow
                    holds such an agreement for, so it cannot be simulated here
                    either.
                  hint: >-
                    Simulate an SMS conversation instead: POST
                    /v0/test/conversations with {"channel":"sms"}.
                  doc_url: https://api.flow.engineer/docs/errors/permission
                  param: conversation_id
                  channel_code: hipaa_channel_not_covered
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/IdempotencyConflict'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '422':
          $ref: '#/components/responses/UnsupportedContent'
        '429':
          $ref: '#/components/responses/RateLimited'
        default:
          $ref: '#/components/responses/Error'
components:
  parameters:
    ConversationId:
      name: conversation_id
      in: path
      required: true
      description: The conversation's ID.
      schema:
        $ref: '#/components/schemas/ConversationId'
    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
  schemas:
    SimulatedInboundRequest:
      type: object
      description: One turn from a simulated contact (test keys only).
      required:
        - content
      properties:
        content:
          $ref: '#/components/schemas/SimulatedInboundContent'
        from:
          $ref: '#/components/schemas/ContactId'
          description: >-
            Simulated group conversations only, and required there. The
            participant who writes this turn (a `contact` in the conversation's
            `group.participants`); another contact answers `400 invalid_request`
            with `param` `from`. Refused the same way on one-to-one
            conversations.
        reply_to:
          $ref: '#/components/schemas/MessageId'
          description: >-
            A message of this conversation the contact replies to inline, on
            channels with inline replies (Telegram, WhatsApp, iMessage; on SMS
            it answers `400 invalid_request`). The received message shows it in
            `reply_to`.
    SimulatedInbound:
      type: object
      description: A simulated turn Flow accepted. Its `message.received` event follows.
      required:
        - message
        - conversation
        - channel_message_id
        - status
      properties:
        message:
          $ref: '#/components/schemas/MessageId'
          description: >-
            The ID the received message will have, in its `message.received`
            event and in the transcript.
        conversation:
          $ref: '#/components/schemas/ConversationId'
        channel_message_id:
          type: string
          description: >-
            The simulated channel's ID for the message (the received message's
            `channel_message_id`).
        status:
          type: string
          description: Always `queued`; the message is stored and delivered right after.
          enum:
            - queued
    Error:
      type: object
      description: The body of every error answer.
      required:
        - error
      properties:
        error:
          $ref: '#/components/schemas/ErrorBody'
    ConversationId:
      type: string
      description: A conversation ID, `conv_` and a ULID.
      pattern: ^conv_[0-9A-HJKMNP-TV-Z]{26}$
      examples:
        - conv_01JB8ZC3K5M7P9R1T3V5X7Z9B1
    SimulatedInboundContent:
      type: object
      description: >
        What a simulated contact sends in one turn, as a message kind with
        `kind`

        (required): `basic` (`text` and/or `media` by `file_id`), `voice` (by

        `file_id`), `location`, `contact_card`, or `button_reply` (a tap on a
        reply

        button, or on a list row, of a message your app sent). Fields a contact

        cannot send (`reply_to` and `metadata` inside the kind, channel blocks,

        `link_preview`, `url`) and other kinds answer `400 invalid_request`.
      required:
        - kind
      oneOf:
        - $ref: '#/components/schemas/BasicKind'
        - $ref: '#/components/schemas/VoiceKind'
        - $ref: '#/components/schemas/LocationKind'
        - $ref: '#/components/schemas/ContactCardKind'
        - $ref: '#/components/schemas/SimulatedButtonTap'
      discriminator:
        propertyName: kind
        mapping:
          basic: '#/components/schemas/BasicKind'
          voice: '#/components/schemas/VoiceKind'
          location: '#/components/schemas/LocationKind'
          contact_card: '#/components/schemas/ContactCardKind'
          button_reply: '#/components/schemas/SimulatedButtonTap'
    ContactId:
      type: string
      description: A contact ID, `ct_` and a ULID.
      pattern: ^ct_[0-9A-HJKMNP-TV-Z]{26}$
      examples:
        - ct_01JB8ZB2J4K6N8Q0S2V4W6Y8A0
    MessageId:
      type: string
      description: A message ID, `msg_` and a ULID.
      pattern: ^msg_[0-9A-HJKMNP-TV-Z]{26}$
      examples:
        - msg_01JB8ZD4M6P8R0T2V4X6Z8B0C2
    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

            /v0/conversations/{conversation_id}/template." For
            `unsupported_content`

            it says what to send instead over plain HTTP. Written for developers
            and

            coding agents alike. Do not parse it; it may be reworded at any
            time.
          examples:
            - >-
              Send a template instead: POST
              /v0/conversations/{conversation_id}/template.
        doc_url:
          type: string
          format: uri
          description: >-
            The documentation page for this error's type,
            `https://api.flow.engineer/docs/errors/<type>`. 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 (`kind`,
            `telegram.is_big`, `buttons`, `cards[1].header.type`, `limit`).
        retry_after:
          type: integer
          minimum: 0
          description: >-
            Seconds to wait before retrying, for errors that clear by
            themselves.
        channel:
          $ref: '#/components/schemas/Channel'
          description: >-
            The conversation's channel, on errors about what it takes
            (`unsupported_content`) and its windows (`outside_window`).
        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

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

            SMS: `contact_opted_out` and `consent_required` (`permission`),

            `quiet_hours` (`outside_window`, with `retry_after`), and

            `carrier_filtered`, `unreachable`, `invalid_number`, `landline`,

            `registration_required`, `carrier_error` and
            `provider_not_configured`

            (`channel_error`). HIPAA accounts: `hipaa_channel_not_covered`

            (`permission`). Connecting your own WhatsApp number (`POST

            /v0/senders`): `whatsapp_token_malformed`,
            `whatsapp_token_rejected`,

            `whatsapp_app_secret_malformed` and `whatsapp_number_not_in_account`

            (`invalid_request`), and `whatsapp_number_taken`,

            `whatsapp_account_taken` and `whatsapp_number_managed`
            (`permission`).

            Simulated conversations (`permission`): `test_mode_only`,

            `not_simulated`, `simulated_contact` and
            `simulated_conversation_limit`.

            For a message that failed after it was queued (`message.failed`):

            `sandbox_contact_left` (`permission`), `window_closed` and

            `queued_too_long` (`outside_window`), and

            `channel_timeout_unknown_outcome` (`channel_error`: the channel
            never

            confirmed the send, so it may or may not have arrived). iMessage

            (`unsupported_content`): `group_unsupported` (a reaction, typing or
            a

            read receipt in a group conversation; `param` `kind`) and

            `not_on_sms` (an edit or unsend of a message that went by SMS or
            RCS;

            `param` `kind`). SMS (`unsupported_content`): `no_mms` (media to a

            number without MMS; `param` `media`).

            Edits and unsends (`unsupported_content`): `edit_window_closed` and

            `unsend_window_closed` (the message is older than the channel allows

            for the change) and `first_message_not_editable` (iMessage: the
            message

            that started the conversation). From typing and

            read receipts, `unknown_outcome` (`channel_error`: the call timed
            out after

            it was sent).
        request_id:
          type: string
          description: Flow's ID for this request. Quote it when asking for help.
    BasicKind:
      description: >-
        A `basic` message with `kind` given, as in a message's `content`, a
        webhook reply or a stream frame (`BasicMessage` with `kind` required).
      allOf:
        - $ref: '#/components/schemas/BasicMessage'
        - required:
            - kind
    VoiceKind:
      description: >-
        A `voice` message with `kind` given, as in a message's `content`, a
        webhook reply or a stream frame (`VoiceMessage` with `kind` required).
      allOf:
        - $ref: '#/components/schemas/VoiceMessage'
        - required:
            - kind
    LocationKind:
      description: >-
        A `location` message with `kind` given, as in a message's `content`, a
        webhook reply or a stream frame (`LocationMessage` with `kind`
        required).
      allOf:
        - $ref: '#/components/schemas/LocationMessage'
        - required:
            - kind
    ContactCardKind:
      description: >-
        A `contact_card` message with `kind` given, as in a message's `content`,
        a webhook reply or a stream frame (`ContactCardMessage` with `kind`
        required).
      allOf:
        - $ref: '#/components/schemas/ContactCardMessage'
        - required:
            - kind
    SimulatedButtonTap:
      type: object
      description: >
        The simulated contact taps a reply button of a buttons or carousel
        message

        your app sent in this conversation, or a row of a list. Name the message

        and the button's (or row's) `id`; Flow fills in the label, and the
        received

        message is a `button_reply` with the tapped button's `label` (a row's

        title). A channel that shows no buttons has nothing to tap: the contact

        answers in words, as a `basic` turn.
      required:
        - kind
        - message_id
        - button_id
      properties:
        kind:
          type: string
          const: button_reply
          description: Always `button_reply`.
        message_id:
          $ref: '#/components/schemas/MessageId'
          description: >-
            The buttons, carousel or list message your app sent, which must have
            reached the contact (its `message.sent`).
        button_id:
          type: string
          minLength: 1
          description: The `id` of the tapped reply button or list row.
      examples:
        - kind: button_reply
          message_id: msg_01JB8ZD4M6P8R0T2V4X6Z8B0C2
          button_id: 'yes'
    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/<type>`,
        given in

        the error's `doc_url`; the error's `hint` says what to change for the
        case.


        - `invalid_request` (400, or 413 for a request body over the size
        limit): the request is malformed or a parameter is invalid. Fix the
        parameter named in `param`; the `hint` says what it must look like.
        Every endpoint answers it for a `Flow-Version` header that is not a
        version this API serves.

        - `authentication` (401): the API key is missing, malformed, unknown,
        revoked or expired. An `Authorization: Bearer` header with nothing after
        it usually means the environment variable holding the key was empty when
        the header was built (for an MCP client, when the server was added): the
        `hint` says so. Send `Authorization: Bearer fk_test_...` or
        `fk_live_...` with a current key; with no key at all, get a test key
        with `POST /v0/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 message, wait for the
        contact to message the line first, or sign in through the device flow to
        lift the allowance. On SMS: `contact_opted_out` (the contact texted
        STOP; nothing more goes to them until they text START),
        `consent_required` (a start to someone who never texted your numbers:
        record their consent with `consent` on the start). In a HIPAA account:
        `hipaa_channel_not_covered` (the channel has no business associate
        agreement; use SMS). Simulated conversations: `simulated_contact` (a
        real sender was pointed at a simulated contact; reply in the contact's
        simulated conversation), `not_simulated` (a simulated sender or test
        endpoint was pointed at a real contact), `test_mode_only`,
        `simulated_conversation_limit`. In `message.failed`:
        `sandbox_contact_left` (the contact joined another app on the sandbox
        sender while the message was queued; have them send your join message
        again).

        - `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, connecting a WhatsApp number; a secret that was
        lost must be rotated again, and a WhatsApp number connected again to see
        its verify token).

        - `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
        /v0/conversations/{conversation_id}/template`, or `POST /v0/messages`
        with `kind` `template` to start), 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
        for them is closed (iMessage typing works only within 5 minutes of the
        contact's last message; WhatsApp read receipts go at any time); ignore
        it and send your reply. On SMS, `channel_code` `quiet_hours`: the
        contact has not texted in the last hour and it is inside the sender's
        quiet hours in their time zone; retry after `retry_after` seconds, or
        wait for them to text. In `message.failed`: `window_closed` (WhatsApp's
        24-hour window closed while the message was queued; send a template) and
        `queued_too_long` (the message waited in the queue longer than its
        sender allows, for example while the sender was paused; send it again if
        it still applies).

        - `unsupported_content` (422): the conversation's channel does not take
        this kind, field, block or value, and nothing was sent; Flow never
        converts a message. `param` names what was refused: `kind` (the
        endpoint's kind is not on this channel, such as a carousel on Telegram),
        a field or block (`reply_to` on SMS, `telegram` on WhatsApp,
        `telegram.is_big`), or the field whose value is not taken (`emoji`
        outside Telegram's reaction emoji, `buttons` for more than 3 WhatsApp
        buttons). `channel` is the conversation's channel and `hint` says what
        to send instead over plain HTTP; each endpoint's page lists its
        channels. In an iMessage group conversation, a reaction, typing or a
        read receipt answers it with `param` `kind` and `channel_code`
        `group_unsupported`: leave it out. Media to an SMS number without MMS
        answers it with `param` `media` and `channel_code` `no_mms`. An edit or
        unsend of an iMessage message that went by SMS or RCS (its `network`)
        answers it with `channel_code` `not_on_sms`: send a new message instead.
        An edit or unsend past the channel's time limit answers it with
        `channel_code` `edit_window_closed` or `unsend_window_closed` (iMessage:
        edits within 15 minutes of sending, unsends within 2 minutes; Telegram:
        unsends within 48 hours), and an iMessage edit of the message that
        started the conversation with `first_message_not_editable`; `message`
        states the limit and how long ago the message was sent. Send a new
        message instead. `POST /v0/files` answers it for a type Flow does not
        take (programs, web pages, unknown types). A send by `file_id` of a
        document a contact sent before Flow malware-scanned documents answers it
        too: Flow sends only documents it scanned.

        - `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. (When the scan cannot run at all, the
        upload answers a retryable `500 api_error` with `Retry-After` instead.)

        - `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. SMS codes are Flow's, the same whichever carrier network
        carries the message: `carrier_filtered` (a carrier's spam filter blocked
        it: make it read like a person's message, no shortened links),
        `unreachable`, `invalid_number`, `landline`, `registration_required`
        (the number's 10DLC or toll-free registration is not approved yet),
        `carrier_error`, and `provider_not_configured` (the SMS sender is not
        fully set up on Flow's side, so nothing was sent; contact the Flow
        team). Flow's own codes on every channel:
        `channel_timeout_unknown_outcome` (in `message.failed`: the channel did
        not answer after the message was sent, and the resends its policy allows
        did not settle it, so the message may have arrived or not; check with
        the contact before sending it again) and `unknown_outcome` (from typing
        and read receipts: the call timed out after it was sent).

        - `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
    Channel:
      type: string
      description: >-
        A messaging channel. `sms` is US SMS and MMS (in pilot), from dedicated
        numbers only.
      enum:
        - telegram
        - whatsapp
        - imessage
        - sms
    SenderId:
      type: string
      description: A sender ID, `snd_` and a ULID.
      pattern: ^snd_[0-9A-HJKMNP-TV-Z]{26}$
      examples:
        - snd_01JB8Z4Q3V6W0R2N7C5H1M9K4T
    BasicMessage:
      type: object
      description: >
        Text, or one media file with an optional caption, in either direction:
        the

        kind every channel takes (`POST
        /v0/conversations/{conversation_id}/basic`).

        Give `text`, `media`, or both (then `text` is the caption). A field not

        listed here is refused with `400 invalid_request` naming it in `param`;

        a field or channel block the conversation's channel does not take
        answers

        `422 unsupported_content` naming it.


        Received from a contact: their text and/or one file. Inbound media
        always

        has `url` (a Flow file URL, `GET /v0/files/{file_id}`) and `file_id`, on

        every channel (an SMS contact's MMS files included), and `mime_type` and

        `size_bytes` once Flow kept the file; send a received file again by its

        `file_id`. On SMS Flow always keeps its own copy (the provider's is

        deleted): an MMS file it could not fetch or check arrives as

        `file_blocked` instead.
      properties:
        kind:
          type: string
          const: basic
          default: basic
          description: >-
            Always `basic`. Optional on `POST
            /v0/conversations/{conversation_id}/basic`; required where no URL
            names the kind (webhook replies, the stream, starts, a message's
            `content`).
        text:
          type: string
          minLength: 1
          maxLength: 9999
          description: >
            The text, sent exactly as written: no markdown, no stripping. At
            most

            4096 UTF-16 code units on Telegram (an emoji such as 😀 counts as
            2),

            4096 characters on WhatsApp, 9999 on iMessage, and 10 segments on
            SMS

            (see `SmsSegments`). With `media` it is the caption, at most 1024
            (UTF-16

            code units on Telegram). Optional when `media` is given. Longer text
            is

            refused with `400 invalid_request`.
        media:
          $ref: '#/components/schemas/Media'
        reply_to:
          $ref: '#/components/schemas/ReplyTo'
        link_preview:
          type: boolean
          description: >
            Whether links in the text show a rich preview. Left out, the
            channel's

            default applies (Telegram, WhatsApp and iMessage show previews).

            `false` sends links as plain text. SMS never shows previews, and no

            channel shows one on a media message: `false` is accepted there, and

            `true` on SMS or with `media` answers `422 unsupported_content` with

            `param` `link_preview`.
        telegram:
          $ref: '#/components/schemas/BasicTelegramOptions'
        imessage:
          $ref: '#/components/schemas/BasicImessageOptions'
        metadata:
          $ref: '#/components/schemas/Metadata'
    VoiceMessage:
      type: object
      description: >
        A voice note, in either direction (`POST

        /v0/conversations/{conversation_id}/voice`). When sending, give `url` or

        `file_id` of an audio file. Inbound voice notes always come with the
        audio

        file (`url`, `file_id`). On iMessage, a received voice note also carries

        a `transcript` when iMessage made one of it; no other channel has one.
      properties:
        kind:
          type: string
          const: voice
          default: voice
          description: >-
            Always `voice`. Optional on its endpoint; required where no URL
            names the kind.
        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'
        reply_to:
          $ref: '#/components/schemas/ReplyTo'
        metadata:
          $ref: '#/components/schemas/Metadata'
        duration_seconds:
          type: number
          readOnly: true
          description: Inbound only. The voice note's length.
        transcript:
          type: string
          readOnly: true
          description: >-
            Inbound only, on iMessage, when iMessage made a transcript of the
            voice note. What was said.
    LocationMessage:
      type: object
      description: |
        A place, in either direction (`POST
        /v0/conversations/{conversation_id}/location` sends one; Telegram and
        WhatsApp). Inbound, a location the contact shared.
      required:
        - lat
        - lng
      properties:
        kind:
          type: string
          const: location
          default: location
          description: >-
            Always `location`. Optional on its endpoint; required where no URL
            names the kind.
        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.
        reply_to:
          $ref: '#/components/schemas/ReplyTo'
        metadata:
          $ref: '#/components/schemas/Metadata'
    ContactCardMessage:
      type: object
      description: >
        A contact card, in either direction (`POST

        /v0/conversations/{conversation_id}/contact-card` sends one; Telegram
        and

        WhatsApp). Inbound, a card the contact shared.
      required:
        - name
      properties:
        kind:
          type: string
          const: contact_card
          default: contact_card
          description: >-
            Always `contact_card`. Optional on its endpoint; required where no
            URL names the kind.
        name:
          type: string
          minLength: 1
          description: The person's or business's name.
        phones:
          type: array
          description: >-
            Phone numbers, E.164 where known. Telegram needs at least one (else
            `422 unsupported_content`, `param` `phones`).
          items:
            type: string
        emails:
          type: array
          description: Email addresses.
          items:
            type: string
            format: email
        reply_to:
          $ref: '#/components/schemas/ReplyTo'
        metadata:
          $ref: '#/components/schemas/Metadata'
    Media:
      type: object
      description: >
        One image, video, document or audio file. When sending, give exactly one
        of

        `url` (an HTTPS URL Flow fetches; Flow neither stores nor scans it, and
        on

        Telegram a document by URL works for PDF and ZIP files) or `file_id`
        (from

        `POST /v0/files`). Each channel takes files up to its own size: Telegram

        50 MB, WhatsApp and iMessage 100 MB, SMS (MMS) 5 MB; a larger file
        answers

        `422 unsupported_content` with `param` `media`. SMS carries images,
        audio

        and video, never documents (`param` `media.type`), and only from numbers

        with MMS (`channel_code` `no_mms`).
      required:
        - type
      properties:
        type:
          type: string
          enum:
            - image
            - video
            - document
            - audio
          description: What kind of file it is.
          x-enum-varnames:
            - MediaImage
            - MediaVideo
            - MediaDocument
            - MediaAudio
        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'
        filename:
          type: string
          maxLength: 255
          description: >-
            The file's name, shown for documents and taken only with `type`
            `document` (on another type it answers `422 unsupported_content`
            with `param` `media.filename`). On Telegram it works only with
            `file_id`: Telegram names a document sent by `url` itself, so
            `filename` with `url` answers `422 unsupported_content` with `param`
            `media.filename`.
        mime_type:
          type: string
          readOnly: true
          description: Inbound only. The real type, checked from the file's first bytes.
        size_bytes:
          type: integer
          readOnly: true
          description: Inbound only. The file's size.
    ReplyTo:
      $ref: '#/components/schemas/MessageId'
      description: >
        Send this as an inline reply to an earlier message in the same

        conversation (Telegram, WhatsApp and iMessage show it quoted). SMS has
        no

        inline replies: there it answers `422 unsupported_content` with `param`

        `reply_to`. Refused with `404 not_found` (`param` `reply_to`) when the

        target is not a message in this conversation, and with `400

        invalid_request` (`param` `reply_to`) 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.
    BasicTelegramOptions:
      type: object
      description: >
        Telegram only: how the message looks and notifies on Telegram. On
        another

        channel the block answers `422 unsupported_content` with `param`

        `telegram`; an unknown key `400 invalid_request`.
      additionalProperties: false
      properties:
        parse_mode:
          $ref: '#/components/schemas/TelegramParseMode'
        disable_notification:
          type: boolean
          description: Deliver silently, with no sound.
        protect_content:
          type: boolean
          description: Stop the contact forwarding or saving the message.
        show_caption_above_media:
          type: boolean
          description: >-
            Media only. Show the caption above the photo or video instead of
            below it.
        has_spoiler:
          type: boolean
          description: >-
            Media only (photos and videos). Cover it with a spoiler animation
            until tapped.
    BasicImessageOptions:
      type: object
      description: >
        iMessage only: styles and a subject line. On another channel the block

        answers `422 unsupported_content` with `param` `imessage`; an unknown
        key

        `400 invalid_request`. A message that goes by SMS or RCS (see

        `Message.network`) shows neither.
      additionalProperties: false
      properties:
        styles:
          type: array
          description: >-
            Styled ranges of `text` (see `ImessageStyle`). Each range must lie
            within `text`; one that does not answers `400 invalid_request`
            naming it (`imessage.styles[0]`).
          items:
            $ref: '#/components/schemas/ImessageStyle'
        subject:
          type: string
          minLength: 1
          maxLength: 200
          description: iMessage's subject line, shown in bold above the text.
    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
    FileId:
      type: string
      description: A file ID, `file_` and a ULID.
      pattern: ^file_[0-9A-HJKMNP-TV-Z]{26}$
      examples:
        - file_01JB8ZG7Q9S1V3X5Z7B9D1F3G5
    TelegramParseMode:
      type: string
      description: >
        How Telegram reads the text: `HTML` or `MarkdownV2`, Telegram's own

        formatting (see Telegram's Bot API, "Formatting options"). Without it
        the

        text is plain. Text that Telegram cannot parse is refused by Telegram:

        the message fails (`message.failed`, `channel_error`).
      enum:
        - HTML
        - MarkdownV2
      x-enum-varnames:
        - ParseModeHTML
        - ParseModeMarkdownV2
    ImessageStyle:
      type: object
      description: One styled range of an iMessage text.
      required:
        - start
        - length
        - style
      properties:
        start:
          type: integer
          minimum: 0
          description: >-
            Where the range starts, in UTF-16 code units from the start of
            `text`.
        length:
          type: integer
          minimum: 1
          description: How long the range is, in UTF-16 code units.
        style:
          type: string
          description: >-
            The style. `big` and `small` set the text's size; `shake`, `nod`,
            `explode`, `ripple`, `bloom` and `jitter` animate it.
          enum:
            - bold
            - italic
            - underline
            - strikethrough
            - big
            - small
            - shake
            - nod
            - explode
            - ripple
            - bloom
            - jitter
  responses:
    InvalidRequest:
      description: >
        The request is malformed or a parameter is invalid (`invalid_request`).

        `error.param` names the parameter. Every endpoint answers this for a

        `Flow-Version` header that is not a date this API serves (`param`

        `Flow-Version`), and every `POST` that takes an `Idempotency-Key` for a
        key

        over 255 characters (`param` `Idempotency-Key`).
      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`).
      headers:
        WWW-Authenticate:
          description: >
            The authentication scheme, `Bearer realm="api.flow.engineer"`. When
            the

            request carried a key that is well formed but unknown, revoked or

            expired, it also says `error="invalid_token"`:

            `Bearer realm="api.flow.engineer", error="invalid_token"`. A missing
            or

            malformed key gets the scheme alone.
          schema:
            type: string
            examples:
              - Bearer realm="api.flow.engineer", error="invalid_token"
      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/v0/sandbox/keys
              doc_url: https://api.flow.engineer/docs/errors/authentication
    NotFound:
      description: No such object for this app and mode (`not_found`).
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
    IdempotencyConflict:
      description: >
        The `Idempotency-Key` was already used (`idempotency_conflict`):

        `channel_code` `body_mismatch` (a different method, path or body; use a
        new

        key), `in_progress` (the first request with the key is still running;
        repeat

        it after `retry_after` seconds), or `secret_not_kept` (the first request
        went

        through and its answer carried a secret shown only once). See
        "Idempotency"

        in the introduction.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            error:
              type: idempotency_conflict
              message: >-
                This Idempotency-Key was already used for a different request
                (another method, path or body).
              hint: >-
                Use a new Idempotency-Key for this request; reuse a key only to
                repeat the identical request.
              doc_url: https://api.flow.engineer/docs/errors/idempotency_conflict
              param: Idempotency-Key
              channel_code: body_mismatch
    PayloadTooLarge:
      description: >
        The request body is larger than Flow takes (`invalid_request`): 32 MB,
        or

        101 MB for `POST /v0/files` (a 100 MB file and its multipart framing). A
        body

        that declares a larger `Content-Length` is refused before it is read.
        Send

        media as a `file_id` from `POST /v0/files` or by `url` instead of
        inlining it.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            error:
              type: invalid_request
              message: The request body is larger than 33554432 bytes.
              hint: >-
                Send a smaller body; upload media with POST /v0/files and send
                its file_id instead of inlining it.
              doc_url: https://api.flow.engineer/docs/errors/invalid_request
    UnsupportedContent:
      description: >
        The conversation's channel does not take this kind, field, block or
        value,

        so nothing was sent (`unsupported_content`). `param` names what was

        refused, `channel` the channel, and `hint` what to send instead.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            error:
              type: unsupported_content
              channel: telegram
              param: kind
              message: Carousels work on WhatsApp only.
              hint: >-
                On Telegram, send a basic message, then one buttons message with
                an image header per card.
              doc_url: https://api.flow.engineer/docs/errors/unsupported_content
              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
    Error:
      description: An error. `error.type` says which (see `ErrorType`).
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
  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
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: fk_test_... or fk_live_...
      description: >
        An API key of one app, sent as `Authorization: Bearer <key>`. 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.

````

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