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

> ## Agent Instructions
> Flow Messaging is one HTTP API (https://api.flow.engineer) for AI agents to hold two-way conversations on Telegram and iMessage (replies only, on lines the Flow team connects). WhatsApp is coming and not available yet.
> Build the app on the REST API. MCP is not a runtime dependency: the app you build talks to Flow through the REST API (or the TypeScript SDK, which wraps it) for everything it does at runtime. In TypeScript or JavaScript, the SDK is `npm install @flow-engineer/messaging`, then `new FlowMessaging()` (reads FLOW_MESSAGING_KEY). Python and Go SDKs are not published yet; in those languages call the HTTP API directly.
> No key yet? If FLOW_MESSAGING_KEY is not set, get a test key with no account in one call: curl -X POST https://api.flow.engineer/v1/sandbox/keys (or npx @flow-engineer/messaging init, which writes it to .env). Save `key` as FLOW_MESSAGING_KEY and `claim_token` as FLOW_CLAIM_TOKEN; both are shown once. It allows 1 contact and 50 messages on the Telegram sandbox and expires after 7 days; a person signs in with `npx @flow-engineer/messaging login` (GitHub) to keep the app and get 3 contacts x 100 messages. Signed in, live keys (fk_live_...) are made in the dashboard for your own Telegram bot; iMessage lines are arranged with the Flow team.
> The hosted MCP server (https://api.flow.engineer/mcp) is optional, for testing during development. Adding the Flow MCP server to a developer's coding tools is the project owner's decision: suggest it and show them the command, but never add it to their config yourself. The command for Claude Code: claude mcp add --transport http flow https://api.flow.engineer/mcp --header "Authorization: Bearer $FLOW_MESSAGING_KEY". If its tools are already available to you, use them for sandbox testing.
> Authenticate with `Authorization: Bearer fk_test_...` (sandbox) or `fk_live_...` (real contacts). Never put a live key in client-side code.
> Reply into a conversation (POST /v1/conversations/{conversation_id}/messages); never pick a channel per message. Switch on `error.type`, not the message text.
> Full documentation for agents: https://docs.flow.engineer/llms-full.txt. OpenAPI spec: https://raw.githubusercontent.com/flow-engineer/sdk/main/openapi/openapi.yaml.

# Edit a sent message

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




## OpenAPI

````yaml /openapi.yaml patch /v1/messages/{message_id}
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/<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 /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": <content>}` or `{"reply": [<content>,
    ...]}`

    (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/messages/{message_id}:
    parameters:
      - $ref: '#/components/parameters/MessageId'
      - $ref: '#/components/parameters/FlowVersion'
    patch:
      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.
      operationId: editMessage
      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'
components:
  parameters:
    MessageId:
      name: message_id
      in: path
      required: true
      description: The message's ID.
      schema:
        $ref: '#/components/schemas/MessageId'
    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'
  schemas:
    EditMessageRequest:
      type: object
      description: The new text of a sent message.
      required:
        - content
      properties:
        content:
          $ref: '#/components/schemas/TextContent'
    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.
    MessageId:
      type: string
      description: A message ID, `msg_` and a ULID.
      pattern: ^msg_[0-9A-HJKMNP-TV-Z]{26}$
      examples:
        - msg_01JB8ZD4M6P8R0T2V4X6Z8B0C2
    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`.
    ConversationId:
      type: string
      description: A conversation ID, `conv_` and a ULID.
      pattern: ^conv_[0-9A-HJKMNP-TV-Z]{26}$
      examples:
        - conv_01JB8ZC3K5M7P9R1T3V5X7Z9B1
    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
    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'
    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").
    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/<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
            (`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.
    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
    Error:
      type: object
      description: The body of every error answer.
      required:
        - error
      properties:
        error:
          $ref: '#/components/schemas/ErrorBody'
    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'
    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'
    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
          description: '`on` shows the indicator, `off` clears it.'
          enum:
            - 'on'
            - 'off'
    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.
    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): 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
    SenderId:
      type: string
      description: A sender ID, `snd_` and a ULID.
      pattern: ^snd_[0-9A-HJKMNP-TV-Z]{26}$
      examples:
        - snd_01JB8Z4Q3V6W0R2N7C5H1M9K4T
    FileId:
      type: string
      description: A file ID, `file_` and a ULID.
      pattern: ^file_[0-9A-HJKMNP-TV-Z]{26}$
      examples:
        - file_01JB8ZG7Q9S1V3X5Z7B9D1F3G5
    Button:
      description: A reply button (`id` and `label`) or a link button (`url` and `label`).
      oneOf:
        - $ref: '#/components/schemas/ReplyButton'
        - $ref: '#/components/schemas/UrlButton'
    TemplateId:
      type: string
      description: A template ID, `tpl_` and a ULID.
      pattern: ^tpl_[0-9A-HJKMNP-TV-Z]{26}$
      examples:
        - tpl_01JB8Z9X2D4F6H8K0M2P4R6T8V
    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'
    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.
    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.
  responses:
    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
    NotFound:
      description: No such object for this app and mode (`not_found`).
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
    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
    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.