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/conversations/{conversation_id}/basic:
    parameters:
      - $ref: "#/components/parameters/ConversationId"
      - $ref: "#/components/parameters/FlowVersion"
    post:
      operationId: sendBasic
      tags: [Messages]
      summary: Send a basic message (text, or media with a caption)
      description: |
        | Channel | Works | Limits |
        |---|---|---|
        | Telegram | Yes | `text` 4096 UTF-16 code units (an emoji such as 😀 counts as 2); a caption 1024; files up to 50 MB; `media.filename` only with `file_id` (Telegram names a document sent by `url` itself) |
        | WhatsApp | Yes | `text` 4096 characters; a caption 1024 (none on audio: `text` with `media.type` `audio` is refused, send the text as its own message); files up to 100 MB |
        | iMessage | Yes | `text` 9999 characters; a caption 1024; files up to 100 MB |
        | SMS (US, pilot) | Yes | `text` at most 10 segments (1530 GSM-7 characters, 670 with an emoji or other character outside GSM-7; see `SmsSegments`); `media` only from numbers with MMS (`Sender.sms.mms`): images, audio and video up to 5 MB, no documents |

        Sends text, or one image, video, document or audio file with an optional
        caption, into an existing conversation. This is the normal way to reply to
        a contact, and the one kind every channel takes. The send passes the send
        gate (window rules, pacing) and is queued; the answer is the queued
        message. Its progress arrives as `message.sent`, `message.delivered`,
        `message.read` or `message.failed` events. Sends to one conversation go out
        in the order they were accepted (see "Order and duplicates").

        `text` is sent exactly as written: Flow never reads it as markdown, never
        strips or converts marks, and never warns. WhatsApp's app shows its own
        marks (`*bold*`, `_italic_`) as styles by itself. To style text on Telegram
        or iMessage, use that channel's block: `telegram.parse_mode` (Telegram's
        own HTML or MarkdownV2) or `imessage.styles` (ranges of the text). On
        iMessage, text that looks like markup still shows as written.

        A field or block a channel does not take is refused, never ignored: a
        `telegram` block sent to a WhatsApp conversation answers `422
        unsupported_content` with `param` `telegram`, `reply_to` on SMS with
        `param` `reply_to`, `link_preview: true` on SMS with `param`
        `link_preview`. A field this endpoint does not know answers `400
        invalid_request` naming it in `param`. `media` to an SMS number without
        MMS answers `422 unsupported_content` with `param` `media` and
        `channel_code` `no_mms`; a `document` on SMS with `param` `media.type`;
        a file larger than the channel takes with `param` `media`.

        **iMessage lines with the SMS/RCS add-on** (`Sender.imessage.fallback`):
        a message to a contact who cannot get iMessage goes by SMS or RCS. The
        network is chosen for each message when it is sent, never by your app, and
        the message's `network` (also `data.message.network` in its events) says
        which one carried it. A message that went by SMS or RCS shows no
        `imessage.styles`, no `imessage.subject` and no effect, and cannot be
        edited or unsent (`422 unsupported_content`, `channel_code`
        `not_on_sms`).

        There are no scheduled sends: a message goes out as soon as it passes the
        gate, so to send later, wait in your own code and send then.
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/BasicMessage"
            examples:
              text:
                summary: A text reply
                value:
                  text: "Yes, we deliver to 560103. Delivery takes 2 days."
              photo:
                summary: A photo with a caption
                value:
                  text: "Here is the blue one."
                  media: {type: image, url: "https://example.com/shirt-blue.jpg"}
              telegram_html:
                summary: Telegram text styled with its own HTML
                value:
                  text: "<b>Order 1042</b> is on its way."
                  telegram: {parse_mode: HTML}
              imessage_styles:
                summary: iMessage text with a bold range
                value:
                  text: "Order 1042 is on its way."
                  imessage:
                    styles:
                      - {start: 0, length: 10, style: bold}
      responses:
        "202":
          description: The message passed the gate and is queued for the channel.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Message"
        "400":
          $ref: "#/components/responses/InvalidRequest"
        "401":
          $ref: "#/components/responses/Unauthenticated"
        "403":
          $ref: "#/components/responses/PermissionDenied"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "422":
          $ref: "#/components/responses/UnsupportedContent"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Error"

  /v0/conversations/{conversation_id}/voice:
    parameters:
      - $ref: "#/components/parameters/ConversationId"
      - $ref: "#/components/parameters/FlowVersion"
    post:
      operationId: sendVoice
      tags: [Messages]
      summary: Send a voice note
      description: |
        | Channel | Works | Limits |
        |---|---|---|
        | Telegram | Yes | files up to 50 MB |
        | WhatsApp | Yes | files up to 100 MB |
        | iMessage | Yes | files up to 100 MB |
        | SMS (US, pilot) | No | |

        On SMS, send instead: a basic message with `media` of `type` `audio`
        (from a number with MMS).

        Sends an audio file as a voice note, shown with the channel's voice note
        player. Give `url` (an HTTPS URL Flow fetches) or `file_id` (from `POST
        /v0/files`). A send to SMS answers `422 unsupported_content` with `param`
        `kind`. Sends to one conversation go out in the order they were accepted
        (see "Order and duplicates").
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/VoiceMessage"
            examples:
              by_file:
                summary: A voice note uploaded with POST /v0/files
                value:
                  file_id: file_01JB8ZG7Q9S1V3X5Z7B9D1F3G5
      responses:
        "202":
          description: The message passed the gate and is queued for the channel.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Message"
        "400":
          $ref: "#/components/responses/InvalidRequest"
        "401":
          $ref: "#/components/responses/Unauthenticated"
        "403":
          $ref: "#/components/responses/PermissionDenied"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "422":
          $ref: "#/components/responses/UnsupportedContent"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Error"

  /v0/conversations/{conversation_id}/buttons:
    parameters:
      - $ref: "#/components/parameters/ConversationId"
      - $ref: "#/components/parameters/FlowVersion"
    post:
      operationId: sendButtons
      tags: [Messages]
      summary: Send text with buttons
      description: |
        | Channel | Works | Limits |
        |---|---|---|
        | Telegram | Yes | 1 to 10 buttons, reply and URL buttons mixed; `text` 4096 UTF-16 code units, 1024 with a media `header` |
        | WhatsApp | Yes | 1 to 3 reply buttons, or exactly one URL button (a call-to-action link); `text` 1024 characters |
        | iMessage | No | |
        | SMS (US, pilot) | No | |

        On iMessage and SMS, send instead: a basic message whose text lists the
        choices; the contact answers in words, as a basic message.

        Sends text with buttons under it, optionally with a `header` (an image,
        video, document or a short title) above the text. A tap on a reply button
        arrives as a `message.received` event with a `button_reply` message
        carrying the button's `id`; a URL button opens its link and sends nothing
        back.

        On WhatsApp, more than 3 buttons answer `422 unsupported_content` with
        `param` `buttons` (send a `list` instead, up to 10 rows), and so does a
        mix of reply and URL buttons or more than one URL button. A message whose
        only button is one URL button goes as WhatsApp's call-to-action URL
        message: the text, the header if given, and one button that opens the
        link. Footers are not supported.

        On Telegram, a media `header` makes one message: the photo, video or
        document with `text` as its caption and the buttons under it, so `text`
        must fit Telegram's caption limit of 1024 UTF-16 code units. A `text`
        header is the message's first line, in bold. On WhatsApp the header is the
        top of the same message bubble.

        iMessage and SMS answer `422 unsupported_content` with `param` `kind`.
        Sends to one conversation go out in the order they were accepted (see
        "Order and duplicates").
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/ButtonsMessage"
            examples:
              sizes:
                summary: Three reply buttons
                value:
                  text: "Which size?"
                  buttons:
                    - {id: size_s, label: Small}
                    - {id: size_m, label: Medium}
                    - {id: size_l, label: Large}
              with_image:
                summary: An image header and one reply button
                value:
                  header: {type: image, url: "https://example.com/a.jpg"}
                  text: "Linen shirt, 1,499"
                  buttons: [{id: buy_a, label: Buy}]
      responses:
        "202":
          description: The message passed the gate and is queued for the channel.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Message"
        "400":
          $ref: "#/components/responses/InvalidRequest"
        "401":
          $ref: "#/components/responses/Unauthenticated"
        "403":
          $ref: "#/components/responses/PermissionDenied"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "422":
          $ref: "#/components/responses/UnsupportedContent"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Error"

  /v0/conversations/{conversation_id}/list:
    parameters:
      - $ref: "#/components/parameters/ConversationId"
      - $ref: "#/components/parameters/FlowVersion"
    post:
      operationId: sendList
      tags: [Messages]
      summary: Send a list to pick one option from
      description: |
        | Channel | Works | Limits |
        |---|---|---|
        | Telegram | No | |
        | WhatsApp | Yes | 1 to 10 sections, at most 10 rows in all; `text` 4096 characters |
        | iMessage | No | |
        | SMS (US, pilot) | No | |

        On Telegram, send instead: a `buttons` message with one reply button per
        row (up to 10). On iMessage and SMS: a basic message whose text lists the
        options; the contact answers in words, as a basic message.

        Sends WhatsApp's list message: the text, a button (`button`) that opens
        the list, and the rows in their sections. A tap on a row arrives as a
        `message.received` event with a `button_reply` message whose `button_id`
        is the row's `id` and whose `label` is its title. Other channels answer
        `422 unsupported_content` with `param` `kind`. Sends to one conversation go
        out in the order they were accepted (see "Order and duplicates").
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/ListMessage"
            examples:
              slots:
                summary: Delivery slots in two sections
                value:
                  text: "When should we deliver?"
                  button: "Pick a slot"
                  sections:
                    - title: Today
                      rows:
                        - {id: today_pm, title: "This evening", description: "6 to 9 pm"}
                    - title: Tomorrow
                      rows:
                        - {id: tmrw_am, title: "Morning", description: "9 am to 12 pm"}
                        - {id: tmrw_pm, title: "Afternoon", description: "12 to 4 pm"}
      responses:
        "202":
          description: The message passed the gate and is queued for the channel.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Message"
        "400":
          $ref: "#/components/responses/InvalidRequest"
        "401":
          $ref: "#/components/responses/Unauthenticated"
        "403":
          $ref: "#/components/responses/PermissionDenied"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "422":
          $ref: "#/components/responses/UnsupportedContent"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Error"

  /v0/conversations/{conversation_id}/carousel:
    parameters:
      - $ref: "#/components/parameters/ConversationId"
      - $ref: "#/components/parameters/FlowVersion"
    post:
      operationId: sendCarousel
      tags: [Messages]
      summary: Send a carousel of cards
      description: |
        | Channel | Works | Limits |
        |---|---|---|
        | Telegram | No | |
        | WhatsApp | Yes | 2 to 10 cards, 1 to 2 buttons each; `text` 1024 characters |
        | iMessage | No | |
        | SMS (US, pilot) | No | |

        On Telegram, send instead: a basic message with the text, then one
        `buttons` message with an image `header` per card. On iMessage and SMS:
        a basic message with the text, then one basic message per card with its
        image as `media` and its text as the caption.

        Sends a row of cards the contact scrolls sideways, under one text. Each
        card has an image or video, optional text, and its own buttons. A tap on a
        reply button arrives as a `button_reply` message carrying the button's
        `id`; URL buttons open a link and send nothing back. WhatsApp shows one
        interactive carousel message, inside the 24-hour window like any message
        that is not a template (a carousel template is sent with `POST
        /v0/conversations/{conversation_id}/template`). Other channels answer
        `422 unsupported_content` with `param` `kind`.

        Rules, each refused with `400 invalid_request` when broken: every card has
        the same number and kind of buttons; a card has either exactly one URL
        button or 1 to 2 reply buttons; reply button `id`s are unique across the
        carousel. Labels may repeat across cards (every card's "Buy"): the `id`
        says which card's button was tapped.

        When you send the cards one by one instead (the other channels' way), send
        them in order with one `Idempotency-Key` each, such as `evt_123:card:2`:
        sends to one conversation go out in the order they were accepted, one at
        a time (see "Order and duplicates").
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/CarouselMessage"
            examples:
              picks:
                summary: Two cards with a Buy button each
                value:
                  text: "Three picks for you"
                  cards:
                    - header: {type: image, url: "https://example.com/a.jpg"}
                      text: "Linen shirt, 1,499"
                      buttons: [{id: buy_a, label: Buy}]
                    - header: {type: image, url: "https://example.com/b.jpg"}
                      text: "Denim jacket, 2,999"
                      buttons: [{id: buy_b, label: Buy}]
      responses:
        "202":
          description: The message passed the gate and is queued for the channel.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Message"
        "400":
          $ref: "#/components/responses/InvalidRequest"
        "401":
          $ref: "#/components/responses/Unauthenticated"
        "403":
          $ref: "#/components/responses/PermissionDenied"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "422":
          $ref: "#/components/responses/UnsupportedContent"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Error"

  /v0/conversations/{conversation_id}/template:
    parameters:
      - $ref: "#/components/parameters/ConversationId"
      - $ref: "#/components/parameters/FlowVersion"
    post:
      operationId: sendTemplate
      tags: [Messages]
      summary: Send a WhatsApp template
      description: |
        | Channel | Works | Limits |
        |---|---|---|
        | Telegram | No | |
        | WhatsApp | Yes | an `approved` template of the conversation's sender; required outside the 24-hour window |
        | iMessage | No | |
        | SMS (US, pilot) | No | |

        On Telegram, iMessage and SMS, send instead: a basic message (these
        channels have no templates and no 24-hour window).

        Sends an approved WhatsApp message template into an existing
        conversation. Outside the 24-hour window (the contact's last message is
        24 hours old or more, or they never wrote) a template is the only message
        WhatsApp takes; every other kind answers `409 outside_window` there. To
        start a new conversation with a template, use `POST /v0/messages`. Other
        channels answer `422 unsupported_content` with `param` `kind`. Sends to
        one conversation go out in the order they were accepted (see "Order and
        duplicates").

        A media card carousel template (marketing category) is sent the same way,
        with one entry per card in `params.cards`. Inside the 24-hour window a
        carousel needs no template: use `POST
        /v0/conversations/{conversation_id}/carousel`.
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/TemplateMessage"
            examples:
              order_update:
                summary: A template with two body values
                value:
                  template_id: tpl_01JB8Z9X2D4F6H8K0M2P4R6T8V
                  language: en
                  params: {body: ["Asha", "order 1042"]}
      responses:
        "202":
          description: The message passed the gate and is queued for the channel.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Message"
        "400":
          $ref: "#/components/responses/InvalidRequest"
        "401":
          $ref: "#/components/responses/Unauthenticated"
        "403":
          $ref: "#/components/responses/PermissionDenied"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "422":
          $ref: "#/components/responses/UnsupportedContent"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Error"

  /v0/conversations/{conversation_id}/contact-card:
    parameters:
      - $ref: "#/components/parameters/ConversationId"
      - $ref: "#/components/parameters/FlowVersion"
    post:
      operationId: sendContactCard
      tags: [Messages]
      summary: Send a contact card
      description: |
        | Channel | Works | Limits |
        |---|---|---|
        | Telegram | Yes | at least one phone number; every phone number and email address goes on the card, which Telegram caps at 2048 bytes (a longer one answers `400 invalid_request` with `param` `phones`) |
        | WhatsApp | Yes | |
        | iMessage | No | |
        | SMS (US, pilot) | No | |

        On iMessage and SMS, send instead: a basic message with the name, phone
        numbers and email addresses in its text.

        Sends a person's or business's contact card, which the contact can save.
        On Telegram a card without a phone number answers `422
        unsupported_content` with `param` `phones`. iMessage and SMS answer `422
        unsupported_content` with `param` `kind`. Sends to one conversation go out
        in the order they were accepted (see "Order and duplicates").
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/ContactCardMessage"
            examples:
              support:
                summary: The support desk's card
                value:
                  name: "Acme Support"
                  phones: ["+15555550123"]
                  emails: ["support@example.com"]
      responses:
        "202":
          description: The message passed the gate and is queued for the channel.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Message"
        "400":
          $ref: "#/components/responses/InvalidRequest"
        "401":
          $ref: "#/components/responses/Unauthenticated"
        "403":
          $ref: "#/components/responses/PermissionDenied"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "422":
          $ref: "#/components/responses/UnsupportedContent"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Error"

  /v0/conversations/{conversation_id}/location:
    parameters:
      - $ref: "#/components/parameters/ConversationId"
      - $ref: "#/components/parameters/FlowVersion"
    post:
      operationId: sendLocation
      tags: [Messages]
      summary: Send a place
      description: |
        | Channel | Works | Limits |
        |---|---|---|
        | Telegram | Yes | `name` and `address` together, or neither |
        | WhatsApp | Yes | |
        | iMessage | No | |
        | SMS (US, pilot) | No | |

        On iMessage and SMS, send instead: a basic message with a maps link (for
        example `https://maps.google.com/?q=12.9716,77.5946`) and the place's name
        and address in its text.

        Sends a place on a map, with its name and address when given. Telegram
        shows a name only with an address: a `name` without `address`, or an
        `address` without `name`, answers `422 unsupported_content` with `param`
        naming the one given. iMessage and SMS answer `422 unsupported_content` with `param` `kind`. Sends to one
        conversation go out in the order they were accepted (see "Order and
        duplicates").
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/LocationMessage"
            examples:
              store:
                summary: A store's location
                value:
                  lat: 12.9716
                  lng: 77.5946
                  name: "Acme, MG Road"
                  address: "12 MG Road, Bengaluru 560001"
      responses:
        "202":
          description: The message passed the gate and is queued for the channel.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Message"
        "400":
          $ref: "#/components/responses/InvalidRequest"
        "401":
          $ref: "#/components/responses/Unauthenticated"
        "403":
          $ref: "#/components/responses/PermissionDenied"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "422":
          $ref: "#/components/responses/UnsupportedContent"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Error"

  /v0/conversations/{conversation_id}/effect:
    parameters:
      - $ref: "#/components/parameters/ConversationId"
      - $ref: "#/components/parameters/FlowVersion"
    post:
      operationId: sendEffect
      tags: [Messages]
      summary: Send text with an iMessage effect
      description: |
        | Channel | Works | Limits |
        |---|---|---|
        | Telegram | No | |
        | WhatsApp | No | |
        | iMessage | Yes | `text` 9999 characters; not shown when the message goes by SMS or RCS |
        | SMS (US, pilot) | No | |

        On Telegram, WhatsApp and SMS, send instead: a basic message with the
        text.

        Sends text with one of iMessage's screen or bubble effects. On a line with
        the SMS/RCS add-on, a message that goes by SMS or RCS shows the text
        without the effect (its `network` says so; see `POST
        /v0/conversations/{conversation_id}/basic`). Which effects a contact's
        device shows may vary. Other channels answer `422 unsupported_content`
        with `param` `kind`. Sends to one conversation go out in the order they
        were accepted (see "Order and duplicates").
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/EffectMessage"
            examples:
              confetti:
                summary: Confetti for a delivered order
                value:
                  text: "Your order arrived!"
                  effect: confetti
      responses:
        "202":
          description: The message passed the gate and is queued for the channel.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Message"
        "400":
          $ref: "#/components/responses/InvalidRequest"
        "401":
          $ref: "#/components/responses/Unauthenticated"
        "403":
          $ref: "#/components/responses/PermissionDenied"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "422":
          $ref: "#/components/responses/UnsupportedContent"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Error"

  /v0/conversations/{conversation_id}/reaction:
    parameters:
      - $ref: "#/components/parameters/ConversationId"
      - $ref: "#/components/parameters/FlowVersion"
    post:
      operationId: sendReaction
      tags: [Messages]
      summary: React to a message
      description: |
        | Channel | Works | Limits |
        |---|---|---|
        | Telegram | Yes | only Telegram's reaction emoji (listed below); `telegram.is_big` for a large animation |
        | WhatsApp | Yes | any one emoji |
        | iMessage | Yes | any one emoji; not in group conversations |
        | SMS (US, pilot) | No | |

        On SMS, send instead: nothing (SMS has no reactions), or a basic message
        if the reaction carries meaning.

        Reacts to a message in the conversation, or removes your reaction with
        `emoji` `null`. On iMessage, ❤️ 👍 👎 😂 ‼️ ❓ show as tapbacks and other
        emoji as emoji reactions. An iMessage group conversation answers `422
        unsupported_content` with `param` `kind` and `channel_code`
        `group_unsupported`; SMS answers it with `param` `kind`.

        Telegram takes only these reaction emoji; any other answers `422
        unsupported_content` with `param` `emoji`: ❤ 👍 👎 🔥 🥰 👏 😁 🤔 🤯 😱 🤬
        😢 🎉 🤩 🤮 💩 🙏 👌 🕊 🤡 🥱 🥴 😍 🐳 ❤‍🔥 🌚 🌭 💯 🤣 ⚡ 🍌 🏆 💔 🤨 😐 🍓 🍾 💋
        🖕 😈 😴 😭 🤓 👻 👨‍💻 👀 🎃 🙈 😇 😨 🤝 ✍ 🤗 🫡 🎅 🎄 ☃ 💅 🤪 🗿 🆒 💘 🙉 🦄 😘 💊
        🙊 😎 👾 🤷‍♂ 🤷 🤷‍♀ 😡 (Telegram's list, which it may extend). `telegram.is_big`
        to another channel answers `422 unsupported_content` with `param`
        `telegram`.

        Sends to one conversation go out in the order they were accepted (see
        "Order and duplicates"). The contact's own reactions arrive as
        `reaction.added` and `reaction.removed` events.
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/ReactionMessage"
            examples:
              fire:
                summary: A large 🔥 on Telegram
                value:
                  message_id: msg_01JB8ZD4M6P8R0T2V4X6Z8B0C2
                  emoji: "🔥"
                  telegram: {is_big: true}
              remove:
                summary: Remove your reaction
                value:
                  message_id: msg_01JB8ZD4M6P8R0T2V4X6Z8B0C2
                  emoji: null
      responses:
        "202":
          description: The reaction passed the gate and is queued for the channel.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Message"
        "400":
          $ref: "#/components/responses/InvalidRequest"
        "401":
          $ref: "#/components/responses/Unauthenticated"
        "403":
          $ref: "#/components/responses/PermissionDenied"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "422":
          $ref: "#/components/responses/UnsupportedContent"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Error"

  /v0/conversations/{conversation_id}/messages:
    parameters:
      - $ref: "#/components/parameters/ConversationId"
      - $ref: "#/components/parameters/FlowVersion"
    get:
      operationId: listConversationMessages
      tags: [Conversations]
      summary: List a conversation's messages
      description: |
        Lists the messages in a conversation, inbound and outbound, newest first.
        Message content is kept for your plan's retention period (30 days by
        default); older messages are not returned.
      parameters:
        - $ref: "#/components/parameters/After"
        - $ref: "#/components/parameters/Before"
        - $ref: "#/components/parameters/Limit"
      responses:
        "200":
          description: A page of messages.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/MessageList"
        "400":
          $ref: "#/components/responses/InvalidRequest"
        "401":
          $ref: "#/components/responses/Unauthenticated"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Error"

  /v0/messages:
    parameters:
      - $ref: "#/components/parameters/FlowVersion"
    post:
      operationId: startConversation
      tags: [Messages]
      summary: Start a conversation
      description: |
        Sends the first message from one of your senders to a contact, creating the
        conversation if it does not exist. The body is a `basic` or `template`
        message, with `kind` (required here) saying which, plus where to send it:
        `sender`, `to` and, on SMS, `consent`. Any other `kind` answers `422
        unsupported_content` with `param` `kind`: start with a basic message or a
        template, then send the other kinds into the conversation.

        | Kind | Telegram | WhatsApp | iMessage | SMS (US, pilot) |
        |---|---|---|---|---|
        | `basic` | Yes (the contact must have started the bot) | Only inside the 24-hour window | Yes, from lines that can write first (`Sender.imessage.can_initiate`) | Yes, to contacts who texted you or with `consent` |
        | `template` | No | Yes | No | No |

        Starting a conversation spends the sender's new-contact budget. If the
        contact already has an open conversation with this sender, the message
        goes into it and spends no budget. The rules of the message's kind apply
        as on its own endpoint (`POST /v0/conversations/{conversation_id}/basic`
        or `/template`), and a field the channel does not take answers `422
        unsupported_content` naming it (`consent` on any channel but SMS
        included).

        In test mode, `to` must be a contact who joined the sandbox through your app.
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/StartConversationRequest"
            examples:
              whatsapp_template:
                summary: A WhatsApp template to a phone number
                value:
                  sender: snd_01JB8Z4Q3V6W0R2N7C5H1M9K4T
                  to: {phone: "+919812345678"}
                  kind: template
                  template_id: tpl_01JB8Z9X2D4F6H8K0M2P4R6T8V
                  language: en
                  params: {body: ["Asha", "order 1042"]}
              sms_basic:
                summary: A basic message to an SMS contact who agreed to it
                value:
                  sender: snd_01JB8Z4Q3V6W0R2N7C5H1M9K4T
                  to: {phone: "+15555550123"}
                  kind: basic
                  text: "Hi Asha, your table for 4 at 7 pm is booked."
                  consent: {method: web_form, obtained_at: "2026-10-20T14:03:00Z"}
      responses:
        "202":
          description: The message passed the gate and is queued. Its `conversation` is the conversation it went into.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Message"
        "400":
          $ref: "#/components/responses/InvalidRequest"
        "401":
          $ref: "#/components/responses/Unauthenticated"
        "403":
          $ref: "#/components/responses/PermissionDenied"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "422":
          $ref: "#/components/responses/UnsupportedContent"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Error"

  /v0/messages/{message_id}:
    parameters:
      - $ref: "#/components/parameters/MessageId"
      - $ref: "#/components/parameters/FlowVersion"
    get:
      operationId: getMessage
      tags: [Messages]
      summary: Get a message
      description: Returns one message, inbound or outbound, with its current status.
      responses:
        "200":
          description: The message.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Message"
        "400":
          $ref: "#/components/responses/InvalidRequest"
        "401":
          $ref: "#/components/responses/Unauthenticated"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Error"
    patch:
      operationId: editMessage
      tags: [Messages]
      summary: Edit a sent message
      description: |
        | Channel | Works | Limits |
        |---|---|---|
        | Telegram | Yes | no time limit; a caption 1024 UTF-16 code units, text 4096 |
        | WhatsApp | No | |
        | iMessage | Yes | within 15 minutes of sending; not the message that started the conversation; not a message that went by SMS or RCS |
        | SMS (US, pilot) | No | |

        On WhatsApp and SMS, send instead: a new basic message with the corrected
        text.

        Replaces the text of one of your outbound basic messages (on Telegram,
        also the caption of a media message). The text is sent exactly as written,
        as on `POST /v0/conversations/{conversation_id}/basic`; style it with
        `telegram.parse_mode` or `imessage.styles`. WhatsApp and SMS answer `422
        unsupported_content` with `param` `kind`, as does an iMessage message that
        went by SMS or RCS (its `network`), with `channel_code` `not_on_sms`.

        Flow checks the channel's limits when you call, from its own record of
        when the message was sent: an edit past the limit answers `422
        unsupported_content` with `channel_code` `edit_window_closed`, and an
        iMessage edit of the message that started the conversation with
        `first_message_not_editable`. The error's `message` states the limit and
        how long ago the message was sent, for example "iMessage allows edits
        within 15 minutes of sending; this message was sent 22 minutes ago." Send
        the corrected text as a new basic message instead. Flow gives a few seconds'
        allowance at the limit, so a call the channel would still take is never
        refused.

        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 after Flow accepted the call (for example when the edit waited behind the
        conversation's earlier messages until the limit passed), you receive
        `message.failed` for a message of kind `edit` naming this one (its
        `content.message_id`), and the message keeps its earlier text.
        Only your app's outbound messages can be edited: an inbound message's ID
        answers `400 invalid_request` (`param` `message_id`). The gate's sender and
        contact checks apply as for a send, so the edit can answer `403 permission`
        (for example a sandbox contact who has since joined another app).
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/EditMessageRequest"
            examples:
              fix_time:
                summary: Correct the text
                value:
                  text: "Your table for 4 is at 7:30 pm."
      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"
        "403":
          $ref: "#/components/responses/PermissionDenied"
        "404":
          $ref: "#/components/responses/NotFound"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "422":
          $ref: "#/components/responses/UnsupportedContent"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Error"
    delete:
      operationId: unsendMessage
      tags: [Messages]
      summary: Unsend a message
      description: |
        | Channel | Works | Limits |
        |---|---|---|
        | Telegram | Yes | within 48 hours of sending |
        | WhatsApp | No | |
        | iMessage | Yes | within 2 minutes of sending; not a message that went by SMS or RCS |
        | SMS (US, pilot) | No | |

        On WhatsApp and SMS, send instead: a new basic message that corrects the
        earlier one (the earlier one stays in the contact's chat).

        Removes one of your outbound messages from the contact's chat. Flow never
        sends anything in its place. WhatsApp and SMS answer `422
        unsupported_content` with `param` `kind`, as does an iMessage message that
        went by SMS or RCS (its `network`), with `channel_code` `not_on_sms`.

        Flow checks the channel's limit when you call, from its own record of when
        the message was sent: an unsend past it answers `422 unsupported_content`
        with `channel_code` `unsend_window_closed`, and the error's `message`
        states the limit and how long ago the message was sent, for example
        "iMessage allows unsends within 2 minutes of sending; this message was sent
        6 minutes ago." Flow gives a few seconds' allowance at the limit, so a call
        the channel would still take is never refused.

        A `200` means Flow accepted the unsend, not that the message is gone yet.
        The unsend passes the send gate and goes out in order with the
        conversation's other messages. When the channel removed it, you receive
        `message.sent` for a message of kind `unsend` (its `content.message_id`
        names this one), and this message takes status `unsent`. If the channel
        refuses it after Flow accepted the call (for example when the unsend waited
        behind the conversation's earlier messages until the limit passed), you
        receive `message.failed` for the `unsend` message, with the channel's reason in
        `error`, and this message keeps its status: it stays in the contact's chat.
        Repeating the call is safe.
        Only your app's outbound messages can be unsent: an inbound message's ID
        answers `400 invalid_request` (`param` `message_id`). The gate's sender and
        contact checks apply as for a send, so the unsend can answer `403
        permission` (for example a sandbox contact who has since joined another app).
      responses:
        "200":
          description: |
            The unsend was accepted. The message is answered as it is now: its
            `status` becomes `unsent` once the channel removed it (a message that
            is already `unsent` is answered as it is).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Message"
        "400":
          $ref: "#/components/responses/InvalidRequest"
        "401":
          $ref: "#/components/responses/Unauthenticated"
        "403":
          $ref: "#/components/responses/PermissionDenied"
        "404":
          $ref: "#/components/responses/NotFound"
        "422":
          $ref: "#/components/responses/UnsupportedContent"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Error"

  /v0/conversations/{conversation_id}/typing:
    parameters:
      - $ref: "#/components/parameters/ConversationId"
      - $ref: "#/components/parameters/FlowVersion"
    post:
      operationId: setTyping
      tags: [Conversations]
      summary: Show the typing indicator
      description: |
        | Channel | Works | Limits |
        |---|---|---|
        | Telegram | Yes | |
        | WhatsApp | Yes | within 24 hours of the contact's last message, after they wrote at least once |
        | iMessage | Yes | within 5 minutes of the contact's last message; not in group conversations; `imessage.duration_seconds` 1 to 60 |
        | SMS (US, pilot) | No | |

        On SMS, send instead: nothing (SMS has no typing indicator); send your
        reply when it is ready.

        Shows the typing indicator in a conversation. The body is optional: no
        body or `{}` shows it. No channel lets it be cleared on request: it clears
        itself when your next message goes, or after a few seconds, so call again
        every few seconds while your agent works. `Idempotency-Key` works as on
        every `POST`: a repeat with the same key within 24 hours answers the first
        call's result and does not show the indicator again, so give each call that
        should show it its own key (or none). SMS answers `422 unsupported_content` with `param` `kind`, and an iMessage
        group conversation with `param` `kind` and `channel_code`
        `group_unsupported`: nothing is sent, so leave typing out there.

        The call goes to the channel at once, not behind the conversation's queued
        messages, so the channel's answer is the call's answer. It fails with
        `409 outside_window` when the channel allows typing only inside a window
        (iMessage: within 5 minutes of the contact's last message; WhatsApp: within
        24 hours of it, and only after the contact wrote at least once), and with
        `502 channel_error` when the channel failed or timed out. Both are safe to
        ignore: typing is a courtesy, so never hold back a reply because of it.
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: false
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/TypingMessage"
            examples:
              show:
                summary: Show the indicator
                value: {}
              imessage_longer:
                summary: Show it for 30 seconds on iMessage
                value: {imessage: {duration_seconds: 30}}
      responses:
        "202":
          description: The channel showed the indicator.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ConversationAction"
        "400":
          $ref: "#/components/responses/InvalidRequest"
        "401":
          $ref: "#/components/responses/Unauthenticated"
        "403":
          $ref: "#/components/responses/PermissionDenied"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "422":
          $ref: "#/components/responses/UnsupportedContent"
        "429":
          $ref: "#/components/responses/RateLimited"
        "502":
          $ref: "#/components/responses/ChannelError"
        default:
          $ref: "#/components/responses/Error"

  /v0/conversations/{conversation_id}/read:
    parameters:
      - $ref: "#/components/parameters/ConversationId"
      - $ref: "#/components/parameters/FlowVersion"
    post:
      operationId: markRead
      tags: [Conversations]
      summary: Mark messages as read
      description: |
        | Channel | Works | Limits |
        |---|---|---|
        | Telegram | No | |
        | WhatsApp | Yes | up to any inbound message (`up_to`) |
        | iMessage | Yes | the whole conversation (no `up_to`); not in group conversations |
        | SMS (US, pilot) | No | |

        On Telegram and SMS, send instead: nothing (bots on Telegram and SMS have
        no read receipts).

        Shows the contact that their messages were read, up to and including
        `up_to` (default: the latest inbound message). iMessage has no
        per-message read receipt: the whole conversation is marked read, so `up_to`
        there answers `422 unsupported_content` with `param` `up_to`. Telegram and
        SMS answer `422 unsupported_content` with `param` `kind`, and an iMessage
        group conversation with `param` `kind` and `channel_code`
        `group_unsupported`: nothing is sent, so leave read receipts out there.

        The call goes to the channel at once, so the channel's answer is the call's
        answer. On iMessage it fails with `409 outside_window` before the contact
        has written in the conversation (there is nothing to mark read yet); on
        WhatsApp read receipts go at any time, also outside the 24-hour window. It
        fails with `502 channel_error` when the channel failed or timed out. Both
        are safe to ignore; never hold back a reply because of them.
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: false
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/ReadMessage"
            examples:
              latest:
                summary: Mark everything up to the latest inbound message read
                value: {}
              up_to:
                summary: Mark up to one message read (WhatsApp)
                value: {up_to: msg_01JB8ZD4M6P8R0T2V4X6Z8B0C2}
      responses:
        "202":
          description: The channel took the read receipt.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ConversationAction"
        "400":
          $ref: "#/components/responses/InvalidRequest"
        "401":
          $ref: "#/components/responses/Unauthenticated"
        "403":
          $ref: "#/components/responses/PermissionDenied"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "422":
          $ref: "#/components/responses/UnsupportedContent"
        "429":
          $ref: "#/components/responses/RateLimited"
        "502":
          $ref: "#/components/responses/ChannelError"
        default:
          $ref: "#/components/responses/Error"

  /v0/conversations:
    parameters:
      - $ref: "#/components/parameters/FlowVersion"
    get:
      operationId: listConversations
      tags: [Conversations]
      summary: List conversations
      description: Lists your app's conversations in this mode, newest first.
      parameters:
        - $ref: "#/components/parameters/After"
        - $ref: "#/components/parameters/Before"
        - $ref: "#/components/parameters/Limit"
        - name: sender
          in: query
          required: false
          description: Only conversations on this sender.
          schema:
            $ref: "#/components/schemas/SenderId"
        - name: contact
          in: query
          required: false
          description: Only conversations with this contact.
          schema:
            $ref: "#/components/schemas/ContactId"
        - name: channel
          in: query
          required: false
          description: Only conversations on this channel.
          schema:
            $ref: "#/components/schemas/Channel"
      responses:
        "200":
          description: A page of conversations.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ConversationList"
        "400":
          $ref: "#/components/responses/InvalidRequest"
        "401":
          $ref: "#/components/responses/Unauthenticated"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Error"

  /v0/conversations/{conversation_id}:
    parameters:
      - $ref: "#/components/parameters/ConversationId"
      - $ref: "#/components/parameters/FlowVersion"
    get:
      operationId: getConversation
      tags: [Conversations]
      summary: Get a conversation
      description: Returns one conversation with its window state.
      responses:
        "200":
          description: The conversation.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Conversation"
        "400":
          $ref: "#/components/responses/InvalidRequest"
        "401":
          $ref: "#/components/responses/Unauthenticated"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Error"

  /v0/events:
    parameters:
      - $ref: "#/components/parameters/FlowVersion"
    get:
      operationId: listEvents
      tags: [Events]
      summary: List events
      description: |
        Reads your app's event log in this mode, **oldest first**. Pass the ID of the
        last event you processed as `after` to catch up after downtime, or to replay
        from any point. The log is the same one webhooks and the stream deliver from,
        so it is also how you recover events whose webhook deliveries failed.

        The log only returns events once every event before them is committed, so
        reading forward with `after` never skips an event.
      parameters:
        - $ref: "#/components/parameters/After"
        - $ref: "#/components/parameters/Before"
        - $ref: "#/components/parameters/Limit"
        - name: type
          in: query
          required: false
          description: Only events of these types. Repeat the parameter for several (`type=message.received&type=reaction.added`).
          style: form
          explode: true
          schema:
            type: array
            maxItems: 20
            items:
              $ref: "#/components/schemas/EventType"
        - name: conversation
          in: query
          required: false
          description: Only events in this conversation. An ID with no conversation of this app and mode answers `404 not_found`.
          schema:
            $ref: "#/components/schemas/ConversationId"
      responses:
        "200":
          description: A page of events, oldest first.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/EventList"
        "400":
          $ref: "#/components/responses/InvalidRequest"
        "401":
          $ref: "#/components/responses/Unauthenticated"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Error"

  /v0/events/{event_id}:
    parameters:
      - $ref: "#/components/parameters/EventId"
      - $ref: "#/components/parameters/FlowVersion"
    get:
      operationId: getEvent
      tags: [Events]
      summary: Get an event
      description: Returns one event from the log.
      responses:
        "200":
          description: The event.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Event"
        "400":
          $ref: "#/components/responses/InvalidRequest"
        "401":
          $ref: "#/components/responses/Unauthenticated"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Error"

  /v0/stream:
    parameters:
      - $ref: "#/components/parameters/FlowVersion"
    get:
      operationId: openStream
      tags: [Stream]
      summary: Open the live event stream (WebSocket)
      description: |
        Upgrades to a WebSocket that pushes your app's events as they happen, in log
        order. Pass `after` to resume from the last event you saw: the stream first
        replays everything after it, then goes live, so a reconnect never loses an
        event.

        Every WebSocket text frame is one JSON object (`StreamFrame`). The server
        sends `event` frames; the client may send `send` frames with the same bodies
        as the HTTP send endpoints, and gets one `ack` or `error` frame back for each,
        matched by `ref`. When the server is about to restart it sends a `reconnect`
        frame; reconnect with `after` set to the last event you received.

        Authenticate with the `Authorization` header where your WebSocket client can
        set headers. Where it cannot (a browser's `WebSocket`, and Node's global
        `WebSocket` on the server), offer the key as a WebSocket subprotocol instead:
        offer both `flow` and `flow.key.<api key>`, for example
        `new WebSocket("wss://api.flow.engineer/v0/stream", ["flow", "flow.key." + key])`.
        This works from server-side clients as well as browsers. The server selects
        `flow` and never echoes the key. Offering the key protocol without `flow` is
        refused with a plain `400 invalid_request` answer, without an upgrade. When an `Authorization` header is present it
        takes precedence. Keys are never accepted in the query string, since URLs end
        up in logs. A key used in a browser is visible to whoever uses that page: do
        this only for internal tools or with test keys (`fk_test_`).

        **Refusals arrive on the socket.** Many WebSocket clients (Node's and
        browsers' among them) cannot read the HTTP status of a refused upgrade, so
        a WebSocket request that Flow refuses (a missing, unknown, revoked or
        expired key, a bad parameter, too many streams for the key, a restart) is
        still upgraded: the server sends one `error` frame with the usual error
        body (`type`, `message`, `hint`, `retry_after`, `channel_code`, ...), then
        closes with an application close code of 4000 plus the HTTP status the
        error has elsewhere, and a short reason:

        - `4401`: `authentication`. The key is missing, malformed, unknown, revoked
          or expired (`channel_code` `sandbox_key_expired`). Stop reconnecting
          until you have a working key.
        - `4403`: `permission`. The key may not open this stream. Stop
          reconnecting.
        - `4400`: `invalid_request`, for example a bad `after` or `type`. Fix the
          request; reconnecting unchanged fails again.
        - `4429`: `rate_limited`, for example more open streams than the key may
          hold. Reconnect after `retry_after` seconds.
        - `4500`, `4503`: Flow could not open the stream just now, or is
          restarting. Reconnect after `retry_after` seconds with the same `after`.

        An open stream re-checks its key about once a minute: when the key is
        revoked, the stream sends an `authentication` error frame and closes with
        `4401` too. Other closes: `1012` after a `reconnect` frame (reconnect at
        once with `after`), `1008` after too many rate-limited `send` frames in a
        row, `1001` when pings go unanswered, `1011` when the event log is
        unavailable; reconnect with `after` after any of these. A request without
        a WebSocket upgrade gets the same errors as plain HTTP answers.
      parameters:
        - $ref: "#/components/parameters/After"
        - name: type
          in: query
          required: false
          description: Only events of these types. Repeat the parameter for several.
          style: form
          explode: true
          schema:
            type: array
            maxItems: 20
            items:
              $ref: "#/components/schemas/EventType"
      responses:
        "101":
          description: |
            Switching to the WebSocket protocol. The schema below describes each JSON
            frame on the socket, in either direction. A refused WebSocket request is
            upgraded too, then answered with one `error` frame and a 4000-range close
            code (see the description); the 4xx answers below are what a request
            without an upgrade gets.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/StreamFrame"
        "400":
          $ref: "#/components/responses/InvalidRequest"
        "401":
          $ref: "#/components/responses/Unauthenticated"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Error"

  /v0/files:
    parameters:
      - $ref: "#/components/parameters/FlowVersion"
    post:
      operationId: uploadFile
      tags: [Files]
      summary: Upload a file
      description: |
        Uploads a file to send later (by `file_id`) as a basic message's `media`, a
        voice note, or a header.
        The real type is checked from the file's first bytes: programs, web pages and
        unknown types are refused with `422 unsupported_content`. Images, audio and
        video are not scanned and are taken. Documents (PDF, Office documents,
        archives) are stored only after a malware scan: one that fails the scan is
        refused with `422 file_blocked` and not stored. If the scan cannot run just
        then, the upload answers `500 api_error` with `Retry-After` and nothing is
        stored; retry it with the same `Idempotency-Key`. A document can also go
        without an upload: send a basic message whose `media` has `type` `document`
        and a public HTTPS `url` in place of `file_id`, and the channel fetches it from that URL
        (Flow neither stores nor scans it; on Telegram this works for PDF and ZIP
        files).
        Files up to 100 MB are taken; give `channel` to check the file
        against that channel's own size limit now (otherwise it is checked when you
        send it, with `422 unsupported_content`). Files are kept for your plan's
        retention period (30 days by default).

        An app made without an account (`POST /v0/sandbox/keys`) that is not claimed
        yet uploads at most 20 files in any 24 hours; past that an upload answers
        `403 permission` with `channel_code` `sign_in_required`. Reuse a `file_id`
        you already have, wait for the oldest upload to age out, or sign in to lift
        the limit.
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              $ref: "#/components/schemas/FileUpload"
      responses:
        "201":
          description: The file was stored.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/File"
        "400":
          $ref: "#/components/responses/InvalidRequest"
        "401":
          $ref: "#/components/responses/Unauthenticated"
        "403":
          description: |
            `permission`: an app made without an account, not claimed yet, past its
            20 uploads in any 24 hours (`channel_code` `sign_in_required`, `param`
            `file`).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
              example:
                error: {type: permission, message: "This app was made without an account (POST /v0/sandbox/keys) and uploaded its 20 files for the last 24 hours.", hint: "Reuse a file_id you already uploaded, or wait for the oldest upload of the last 24 hours to age out; signing in lifts the limit.", doc_url: "https://api.flow.engineer/docs/errors/permission", param: file, channel_code: sign_in_required}
        "409":
          $ref: "#/components/responses/IdempotencyConflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "422":
          description: |
            `file_blocked`: a document failed the malware scan and was not stored.
            `unsupported_content`: the file's real type is one Flow does not take
            (programs, web pages, unknown types), or it is larger than the `channel`
            given takes.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
              example:
                error: {type: file_blocked, message: "The file failed the malware scan and was not stored.", hint: "Send a different file; if you believe this one is clean, recreate it from its source and upload that.", doc_url: "https://api.flow.engineer/docs/errors/file_blocked", param: file}
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Error"

  /v0/files/{file_id}:
    parameters:
      - $ref: "#/components/parameters/FileId"
      - $ref: "#/components/parameters/FlowVersion"
    get:
      operationId: getFile
      tags: [Files]
      summary: Download a file
      description: |
        Redirects to a short-lived signed URL for the file's bytes, on Flow's
        separate file domain. Inbound media (photos, documents, voice notes) is
        fetched from the channel once, before its event is delivered, and served this
        way; event payloads link to this endpoint.
      responses:
        "200":
          description: |
            The file's bytes, served directly. Used where file storage has no separate
            file domain (local development) and for media received before Flow kept
            copies, which is fetched from the channel when asked for.
          content:
            application/octet-stream:
              schema:
                type: string
                format: binary
        "302":
          description: A redirect to the file's bytes. The `Location` URL expires after 5 minutes.
          headers:
            Location:
              description: The signed URL of the file's bytes.
              schema:
                type: string
                format: uri
        "400":
          $ref: "#/components/responses/InvalidRequest"
        "401":
          $ref: "#/components/responses/Unauthenticated"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
        "502":
          $ref: "#/components/responses/ChannelError"
        default:
          $ref: "#/components/responses/Error"

  /v0/senders:
    parameters:
      - $ref: "#/components/parameters/FlowVersion"
    get:
      operationId: listSenders
      tags: [Senders]
      summary: List senders
      description: |
        Lists the senders your app can use in this mode, newest first. Test keys see
        the shared sandbox senders (a sandbox sender Flow has replaced is left out;
        `GET /v0/senders/{sender_id}` still shows it, retired); live keys see your
        dedicated senders. Your app's
        simulated senders (test mode only) are listed only when asked for with
        `kind=simulated`, so code that picks a sender from this list never picks one.
      parameters:
        - $ref: "#/components/parameters/After"
        - $ref: "#/components/parameters/Before"
        - $ref: "#/components/parameters/Limit"
        - name: channel
          in: query
          required: false
          description: Only senders on this channel.
          schema:
            $ref: "#/components/schemas/Channel"
        - name: kind
          in: query
          required: false
          description: Only senders of this kind. `simulated` lists your app's simulated senders (test keys only), which are otherwise left out.
          schema:
            $ref: "#/components/schemas/SenderKind"
      responses:
        "200":
          description: A page of senders.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/SenderList"
        "400":
          $ref: "#/components/responses/InvalidRequest"
        "401":
          $ref: "#/components/responses/Unauthenticated"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Error"
    post:
      operationId: requestSender
      tags: [Senders]
      summary: Request a dedicated sender
      description: |
        Connects a dedicated sender to your app. Telegram bots and WhatsApp numbers
        in your own WhatsApp Business Account are connected here, self-serve, with
        a live key (below). iMessage lines and US SMS numbers are connected by the
        Flow team, not by API: a request with `channel` `imessage` or `sms` answers
        `501 not_implemented` before any other check, with any key (test or live,
        claimed app or not): ask the Flow team, and the sender appears in
        `GET /v0/senders` once it is connected. Its `hint` says what to build with
        meanwhile: a Telegram bot, or simulated conversations of that channel
        (`POST /v0/test/conversations`, test keys). Never send API keys, tokens or
        passwords in a chat or email, to the Flow team or anyone else.

        - iMessage: a dedicated line attached to your app.
        - US SMS (in pilot): carriers require each number's brand and campaign to be
          registered first, so the Flow team registers and connects the number.

        To get a live key (`fk_live_...`), a person signs in to the dashboard at
        `https://api.flow.engineer/admin` (with a Flow account), switches to **Live**,
        and clicks **Create live key** on the Keys page
        (`https://api.flow.engineer/admin/keys?mode=live`). An app made without an
        account (`POST /v0/sandbox/keys`) is claimed first, by signing in through the
        device flow or its `claim_url`. For a Telegram bot or a WhatsApp number, an
        unclaimed app's key gets `403 permission` (`channel_code`
        `sign_in_required`) and a test key gets `403 permission`, each with a `hint`
        naming these steps. Telegram bots and your own WhatsApp numbers are
        self-serve; iMessage lines and SMS numbers are arranged with the Flow team,
        and the Flow team can also help you connect a WhatsApp number.

        A Telegram bot is connected at once, with a live key: give the token
        BotFather issued as `telegram_bot_token`. Flow checks it, keeps it encrypted, points the bot's
        webhook at Flow, and answers `200` with the sender `active`. The token is
        never returned. One bot is one sender:

        - Connecting a bot that is already a sender of this app updates its token in
          place and answers with the same sender (use it after revoking a token in
          @BotFather; a sender `flagged` because Telegram rejected its old token
          becomes `active` again, or `throttled` while an abuse throttle still
          runs, and its queued messages go out).
        - Connecting a bot that is a sender of another app moves it here: holding
          the token proves control of the bot. The old sender is retired (`banned`)
          and its app receives `sender.status_changed`.
        - A bot that is one of Flow's sandbox senders is refused with
          `403 permission`.

        To disconnect a bot, call `DELETE /v0/senders/{sender_id}`.

        A WhatsApp number in your own WhatsApp Business Account, under your own
        Meta app, is connected at once with a live key. In Meta Business Manager,
        first: create a Meta app of type Business with the WhatsApp product, and
        switch it to Live; add the phone number to your WhatsApp Business Account
        with an approved display name, and a payment method (Meta bills its fees
        to you directly); create a system user (Admin) with your WhatsApp
        Business Account (full control) and the app assigned, and generate a
        token that never expires with the permissions
        `whatsapp_business_messaging` and `whatsapp_business_management`. Then
        send `whatsapp_phone_number_id`, `whatsapp_business_account_id`,
        `whatsapp_access_token` (that system user token) and
        `whatsapp_app_secret` (App settings > Basic), and `display_name` if you
        like. Add `whatsapp_registration_pin` only when the number is not
        registered for the Cloud API yet: Flow then registers it with that
        six-digit two-step verification PIN (Meta allows 10 registrations per
        number in 72 hours).

        Flow checks that the number belongs to that business account, subscribes
        your app to the business account's webhooks (an existing subscription is
        left as it is), and points only this number's webhooks at Flow (Meta's
        phone-number-level override; your other numbers keep theirs). It keeps
        the token and the app secret encrypted and never returns them. The answer
        is `200` with the sender `active` and, only in this answer, `setup`: the
        callback URL and verify token to enter in your Meta app (App Dashboard >
        WhatsApp > Configuration) and the webhook fields to subscribe there, with
        the steps. One number is one sender:

        - Connecting a number that is already a sender of this app updates its
          token and app secret in place and answers with the same sender and the
          same verify token (use it to rotate the token; a sender `flagged`
          because Meta rejected its old token becomes `active` again, and its
          queued messages go out).
        - A number that is a sender of another app, or in a WhatsApp Business
          Account whose numbers are connected to another account, is refused
          with `403 permission` (`channel_code` `whatsapp_number_taken` or
          `whatsapp_account_taken`): disconnect it there first.
        - A number the Flow team connected for you is updated by the Flow team
          (`403 permission`, `channel_code` `whatsapp_number_managed`).
        - A token that is not a Meta access token answers `400 invalid_request`
          (`channel_code` `whatsapp_token_malformed`), a token Meta refuses
          (expired, revoked, or without the permissions or assets above) `400
          invalid_request` (`channel_code` `whatsapp_token_rejected`), and a
          number that is not in that business account `400 invalid_request`
          (`channel_code` `whatsapp_number_not_in_account`). Other refusals from
          Meta answer `502 channel_error` with Meta's code as `channel_code`.

        To disconnect a number, call `DELETE /v0/senders/{sender_id}`.
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/SenderRequest"
      responses:
        "200":
          description: The sender is connected and `active`, or an already connected bot's token, or number's token and app secret, were updated in place. A WhatsApp number's answer carries `setup`.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Sender"
        "202":
          description: "The request was accepted. The sender is `pending`, and you receive `sender.status_changed` when it is ready. No request answers this way today: Telegram bots and WhatsApp numbers answer `200`, and senders of other channels are connected by the Flow team."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Sender"
        "400":
          $ref: "#/components/responses/InvalidRequest"
        "401":
          $ref: "#/components/responses/Unauthenticated"
        "403":
          $ref: "#/components/responses/PermissionDenied"
        "409":
          $ref: "#/components/responses/IdempotencyConflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "429":
          $ref: "#/components/responses/RateLimited"
        "501":
          description: |
            `channel` is `imessage` or `sms`: the Flow team connects these senders
            (`not_implemented`, with any key). The `hint` says how to get one and
            what to build with meanwhile.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
              example:
                error: {type: not_implemented, message: "US SMS numbers are registered and connected by the Flow team, not by API: carriers require each number's brand and campaign to be registered first. Once connected, it appears in GET /v0/senders.", hint: "Ask the Flow team to connect a US SMS number to your app; it then appears in GET /v0/senders. Meanwhile, build with a Telegram bot (POST /v0/senders with telegram_bot_token and a live key) or with simulated SMS conversations (POST /v0/test/conversations with a test key). Never send API keys, tokens or passwords in a chat or email.", doc_url: "https://api.flow.engineer/docs/errors/not_implemented", param: channel}
        "502":
          $ref: "#/components/responses/ChannelError"
        default:
          $ref: "#/components/responses/Error"

  /v0/senders/{sender_id}:
    parameters:
      - $ref: "#/components/parameters/SenderId"
      - $ref: "#/components/parameters/FlowVersion"
    get:
      operationId: getSender
      tags: [Senders]
      summary: Get a sender
      description: Returns one sender with its status, limits and, on WhatsApp, its quality rating.
      responses:
        "200":
          description: The sender.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Sender"
        "400":
          $ref: "#/components/responses/InvalidRequest"
        "401":
          $ref: "#/components/responses/Unauthenticated"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Error"
    delete:
      operationId: disconnectSender
      tags: [Senders]
      summary: Disconnect a sender
      description: |
        Disconnects one of your dedicated Telegram bots or WhatsApp numbers from
        Flow. For a bot, Flow removes its webhook (best effort) and deletes its
        stored token; for a WhatsApp number, Flow deletes its stored token and app
        secret and stops taking its webhooks. Either way the sender is retired: its
        status becomes `banned`, it no longer sends or receives, and messages still
        queued from it fail. You receive `sender.status_changed`. The sender, its
        conversations and its messages stay readable. Repeating the call is safe and
        answers with the retired sender. Nothing changes in your Meta app: remove
        Flow's callback URL there, or point the number elsewhere, yourself.

        Live keys only. Shared sandbox senders belong to Flow and cannot be
        disconnected (`403 permission` with a test key). iMessage lines and SMS
        numbers, and WhatsApp numbers the Flow team manages for you, are
        disconnected by Flow, not by API (`501 not_implemented`).

        To use the bot or number again, connect it with `POST /v0/senders`; it
        becomes a new sender.
      responses:
        "200":
          description: The sender is disconnected; it is returned with status `banned`.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Sender"
        "400":
          $ref: "#/components/responses/InvalidRequest"
        "401":
          $ref: "#/components/responses/Unauthenticated"
        "403":
          $ref: "#/components/responses/PermissionDenied"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
        "501":
          $ref: "#/components/responses/NotImplemented"
        default:
          $ref: "#/components/responses/Error"

  /v0/templates:
    parameters:
      - $ref: "#/components/parameters/FlowVersion"
    get:
      operationId: listTemplates
      tags: [Templates]
      summary: List templates
      description: Lists WhatsApp message templates for your senders, newest first.
      parameters:
        - $ref: "#/components/parameters/After"
        - $ref: "#/components/parameters/Before"
        - $ref: "#/components/parameters/Limit"
        - name: sender
          in: query
          required: false
          description: Only templates of this sender.
          schema:
            $ref: "#/components/schemas/SenderId"
        - name: status
          in: query
          required: false
          description: Only templates in this status.
          schema:
            $ref: "#/components/schemas/TemplateStatus"
      responses:
        "200":
          description: A page of templates.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/TemplateList"
        "400":
          $ref: "#/components/responses/InvalidRequest"
        "401":
          $ref: "#/components/responses/Unauthenticated"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Error"
    post:
      operationId: createTemplate
      tags: [Templates]
      summary: Create a template
      description: |
        Submits a WhatsApp message template for review for one of your dedicated
        WhatsApp senders. It starts as `pending`; you receive
        `template.status_changed` when WhatsApp approves, rejects or pauses it. Templates
        are WhatsApp only: a Telegram or iMessage sender gets
        `422 unsupported_content`, and a name and language the sender already has
        gets `400 invalid_request`. Give a realistic sample for every placeholder
        (`examples` on the body and a text header, `example` on a `url` button
        whose link ends in `{{1}}`): WhatsApp reviews the template with them.
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/TemplateCreateRequest"
            examples:
              order_update:
                summary: A utility template with samples for its placeholders
                value:
                  sender: snd_01JB8Z4Q3V6W0R2N7C5H1M9K4T
                  name: order_update
                  language: en
                  category: utility
                  components:
                    - type: header
                      format: text
                      text: "Order {{1}}"
                      examples: ["A-1042"]
                    - type: body
                      text: "Hi {{1}}, your order {{2}} has shipped."
                      examples: ["Maria", "A-1042"]
                    - type: buttons
                      buttons:
                        - type: url
                          text: Track it
                          url: "https://shop.example.com/track/{{1}}"
                          example: A-1042
      responses:
        "201":
          description: The template was submitted.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Template"
        "400":
          $ref: "#/components/responses/InvalidRequest"
        "401":
          $ref: "#/components/responses/Unauthenticated"
        "403":
          $ref: "#/components/responses/PermissionDenied"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/IdempotencyConflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "422":
          $ref: "#/components/responses/UnsupportedContent"
        "429":
          $ref: "#/components/responses/RateLimited"
        "502":
          $ref: "#/components/responses/ChannelError"
        default:
          $ref: "#/components/responses/Error"

  /v0/templates/{template_id}:
    parameters:
      - $ref: "#/components/parameters/TemplateId"
      - $ref: "#/components/parameters/FlowVersion"
    get:
      operationId: getTemplate
      tags: [Templates]
      summary: Get a template
      description: Returns one template with its approval status.
      responses:
        "200":
          description: The template.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Template"
        "400":
          $ref: "#/components/responses/InvalidRequest"
        "401":
          $ref: "#/components/responses/Unauthenticated"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Error"
    delete:
      operationId: deleteTemplate
      tags: [Templates]
      summary: Delete a template
      description: |
        Deletes the template at Meta and here. Messages already sent with it are
        unaffected. Templates of a shared sandbox sender belong to Flow and cannot
        be deleted (`403 permission`).
      responses:
        "200":
          description: The template was deleted.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Deleted"
        "400":
          $ref: "#/components/responses/InvalidRequest"
        "401":
          $ref: "#/components/responses/Unauthenticated"
        "403":
          description: "`permission`: the template belongs to a shared sandbox sender, whose templates are Flow's (`param` `template_id`)."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
              example:
                error: {type: permission, message: "This template belongs to the sandbox's sender; the sandbox's templates are Flow's.", hint: "Delete only templates of your own senders: your dedicated WhatsApp senders' (fk_live_ key) or your simulated sender's (fk_test_ key).", doc_url: "https://api.flow.engineer/docs/errors/permission", param: template_id}
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
        "502":
          $ref: "#/components/responses/ChannelError"
        default:
          $ref: "#/components/responses/Error"

  /v0/webhook_endpoints:
    parameters:
      - $ref: "#/components/parameters/FlowVersion"
    get:
      operationId: listWebhookEndpoints
      tags: [Webhook endpoints]
      summary: List webhook endpoints
      description: Lists your app's webhook endpoints in this mode, newest first. Signing secrets are not included.
      parameters:
        - $ref: "#/components/parameters/After"
        - $ref: "#/components/parameters/Before"
        - $ref: "#/components/parameters/Limit"
      responses:
        "200":
          description: A page of webhook endpoints.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/WebhookEndpointList"
        "400":
          $ref: "#/components/responses/InvalidRequest"
        "401":
          $ref: "#/components/responses/Unauthenticated"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Error"
    post:
      operationId: createWebhookEndpoint
      tags: [Webhook endpoints]
      summary: Create a webhook endpoint
      description: |
        Registers an HTTPS URL to receive your app's events of the listed types. The
        answer includes the endpoint's signing `secret`, shown only this once.
        Status events (`message.sent`, `message.delivered`, ...) are high volume;
        subscribe only to the types you use.

        A URL is registered once per app and mode: a URL another endpoint already
        has (scheme and host compared in any case) answers `400 invalid_request`
        with `param` `url`, naming that endpoint. Change its event types with
        `PATCH /v0/webhook_endpoints/{webhook_endpoint_id}` instead; one endpoint
        can receive every event type.
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/WebhookEndpointCreateRequest"
      responses:
        "201":
          description: The endpoint was created. `secret` is included this once.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/WebhookEndpoint"
        "400":
          $ref: "#/components/responses/InvalidRequest"
        "401":
          $ref: "#/components/responses/Unauthenticated"
        "409":
          $ref: "#/components/responses/IdempotencyConflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Error"

  /v0/webhook_endpoints/{webhook_endpoint_id}:
    parameters:
      - $ref: "#/components/parameters/WebhookEndpointId"
      - $ref: "#/components/parameters/FlowVersion"
    get:
      operationId: getWebhookEndpoint
      tags: [Webhook endpoints]
      summary: Get a webhook endpoint
      description: Returns one webhook endpoint. The signing secret is not included.
      responses:
        "200":
          description: The webhook endpoint.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/WebhookEndpoint"
        "400":
          $ref: "#/components/responses/InvalidRequest"
        "401":
          $ref: "#/components/responses/Unauthenticated"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Error"
    patch:
      operationId: updateWebhookEndpoint
      tags: [Webhook endpoints]
      summary: Update a webhook endpoint
      description: Changes an endpoint's URL, event types, description, or whether it is enabled. Fields you leave out are kept. A `url` another endpoint of the app already has in this mode answers `400 invalid_request` with `param` `url`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/WebhookEndpointUpdateRequest"
      responses:
        "200":
          description: The updated webhook endpoint.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/WebhookEndpoint"
        "400":
          $ref: "#/components/responses/InvalidRequest"
        "401":
          $ref: "#/components/responses/Unauthenticated"
        "404":
          $ref: "#/components/responses/NotFound"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Error"
    delete:
      operationId: deleteWebhookEndpoint
      tags: [Webhook endpoints]
      summary: Delete a webhook endpoint
      description: Stops deliveries to the endpoint and deletes it. Events stay in the log.
      responses:
        "200":
          description: The endpoint was deleted.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Deleted"
        "400":
          $ref: "#/components/responses/InvalidRequest"
        "401":
          $ref: "#/components/responses/Unauthenticated"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Error"

  /v0/webhook_endpoints/{webhook_endpoint_id}/rotate_secret:
    parameters:
      - $ref: "#/components/parameters/WebhookEndpointId"
      - $ref: "#/components/parameters/FlowVersion"
    post:
      operationId: rotateWebhookEndpointSecret
      tags: [Webhook endpoints]
      summary: Rotate a webhook endpoint's signing secret
      description: |
        Makes a new signing secret for the endpoint and returns it in `secret`, shown
        only this once. The previous secret keeps signing for `overlap_seconds`
        (default 86400, one day; `0` retires it at once): during the overlap every
        delivery's `Flow-Signature` carries two `v1` values, one per secret, so you
        can deploy the new secret while the old one still verifies. When the overlap
        ends only the new secret signs. Rotating again during an overlap retires the
        older previous secret at once: at most two secrets are ever active. So send an
        `Idempotency-Key` and retry with the same key: a retry without one is a second
        rotation, which retires the secret you still have deployed. The new secret is
        never stored with the key, so a repeat of a rotation that went through answers
        `409 idempotency_conflict` instead of showing the secret again.
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: false
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/WebhookSecretRotateRequest"
            examples:
              default_overlap:
                summary: Keep the old secret for one day
                value: {}
              one_hour:
                summary: Keep the old secret for one hour
                value: {overlap_seconds: 3600}
      responses:
        "200":
          description: The secret was rotated. `secret` is the new secret, included this once; `previous_secret_expires_at` says when the old one stops signing.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/WebhookEndpoint"
        "400":
          $ref: "#/components/responses/InvalidRequest"
        "401":
          $ref: "#/components/responses/Unauthenticated"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/IdempotencyConflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Error"

  /v0/contacts:
    parameters:
      - $ref: "#/components/parameters/FlowVersion"
    get:
      operationId: listContacts
      tags: [Contacts]
      summary: List contacts
      description: Lists the contacts your app has talked with in this mode, newest first.
      parameters:
        - $ref: "#/components/parameters/After"
        - $ref: "#/components/parameters/Before"
        - $ref: "#/components/parameters/Limit"
        - name: channel
          in: query
          required: false
          description: Only contacts on this channel.
          schema:
            $ref: "#/components/schemas/Channel"
      responses:
        "200":
          description: A page of contacts.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ContactList"
        "400":
          $ref: "#/components/responses/InvalidRequest"
        "401":
          $ref: "#/components/responses/Unauthenticated"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Error"

  /v0/contacts/{contact_id}:
    parameters:
      - $ref: "#/components/parameters/ContactId"
      - $ref: "#/components/parameters/FlowVersion"
    get:
      operationId: getContact
      tags: [Contacts]
      summary: Get a contact
      description: Returns one contact and the address they are reached at on their channel. A deleted contact answers `404 not_found`.
      responses:
        "200":
          description: The contact.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Contact"
        "400":
          $ref: "#/components/responses/InvalidRequest"
        "401":
          $ref: "#/components/responses/Unauthenticated"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Error"
    delete:
      operationId: deleteContact
      tags: [Contacts]
      summary: Delete a contact and their data
      description: |
        Erases the contact and what Flow keeps about them in this app and mode. It
        cannot be undone. Use it when the person asks you to delete their data, or
        when you delete them in your own product.

        Erased at once:

        - the contact's name and address (phone, username, Telegram user ID, iMessage
          handle) and, on SMS, their recorded consent and opt-out;
        - the content of every message in their conversations, inbound and outbound,
          with reply quotes, metadata and what the channel reported back;
        - the files sent or received in those conversations, including uploads you
          sent them (a `file_id` sent to a deleted contact is not found afterwards);
        - the events of those conversations: they are no longer returned by
          `GET /v0/events`, the stream or webhooks (an event ID you hold still works
          as an `after` or `before` cursor);
        - webhook deliveries of those events still pending (they are not sent), and
          messages still queued to the contact (they are not sent; a message already
          handed to the channel may still arrive);
        - saved `Idempotency-Key` answers that held their data: repeating such a
          request answers `404 not_found`.

        Kept: IDs, timestamps, and the counts that bill and pace your senders
        (allowances, new-contact budgets, warm-up). The contact and their
        conversations answer `404 not_found` from then on and are not listed. Flow
        cannot delete what the channel itself keeps, such as the chat on the
        person's own device.

        On a shared sandbox sender the person leaves your app and must send the join
        message again to talk to it. On SMS the opt-out is erased with the contact:
        carriers keep blocking a number that texted STOP, so do not text it again
        unless the person opts in again. If the person writes again, or you send to their address, they are a
        new contact with a new ID and a new conversation (`conversation.started`),
        and the channel's usual rules apply, such as the WhatsApp 24-hour window.

        The first delete emits `contact.deleted`, which carries only the contact's ID.
        Deleting a contact that is already deleted also answers `204`, so the call is
        safe to retry. An ID this app and mode never had (including a contact of your
        other mode) answers `404 not_found` with `param` `contact_id`, as `GET` does.
      responses:
        "204":
          description: The contact is deleted (or was already).
        "400":
          $ref: "#/components/responses/InvalidRequest"
        "401":
          $ref: "#/components/responses/Unauthenticated"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Error"

  /v0/app:
    parameters:
      - $ref: "#/components/parameters/FlowVersion"
    get:
      operationId: getApp
      tags: [App]
      summary: Get the calling app
      description: |
        Returns the app, account and API key that made the request, and the mode it
        runs in. Useful to check a key and to read the app's pinned API version.
      responses:
        "200":
          description: The calling app.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AppContext"
        "400":
          $ref: "#/components/responses/InvalidRequest"
        "401":
          $ref: "#/components/responses/Unauthenticated"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Error"

  /v0/sandbox/keys:
    post:
      operationId: createSandboxKey
      tags: [Onboarding]
      security: []
      summary: Get a test key without an account
      description: |
        Creates a new app with a `fk_test_` key, without an account or sign-in. This
        is the first call for an AI coding agent that has no key: no API key is
        sent, and the answer holds everything needed to start (the key, the
        sandbox senders with their links and the app's join message).

        The key and the `claim_token` are shown **once**: save both. The app has a
        sandbox allowance of 1 contact and 50 messages sent in total on the
        Telegram sandbox, and WhatsApp's when it opens (inbound messages are free;
        iMessage is not included), and its keys **expire after 7 days**. After expiry the keys
        stop working and the contact is removed from the sandbox; a person can
        still claim the app.

        To keep the app, a person signs in through the device flow with the
        `claim_token` (`POST /v0/device/authorizations`), or opens `claim_url` in a
        browser. The app then shares the person's signed-in allowance (3 contacts
        and 100 messages each, one allowance per person over all their apps). A
        claim through `claim_url` revokes this key unless the person chooses to
        keep their agent's key working; a device sign-in replaces it with a new
        key.

        Calls are limited per client address, per network (/24, /64) and wider
        network (/16, /48), and service-wide per day; going over answers `429
        rate_limited` with `retry_after`. Do not call this when you already
        have a key: check `FLOW_MESSAGING_KEY` first, and reuse the key you saved.
      requestBody:
        required: false
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/SandboxKeyRequest"
      responses:
        "201":
          description: The new app and its key. The key and the claim token are not shown again.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/SandboxKey"
        "400":
          $ref: "#/components/responses/InvalidRequest"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Error"
  /v0/device/authorizations:
    post:
      operationId: createDeviceAuthorization
      tags: [Onboarding]
      security:
        - {}
        - bearerAuth: []
      summary: Start a sign-in from an agent or CLI (device flow)
      description: |
        Starts a sign-in that a person finishes in a browser with a Flow account
        (flow.engineer): the OAuth 2.0 device authorization grant (RFC 8628) in
        Flow's JSON shape.
        Show the person `verification_uri` and `user_code`: they open the page,
        sign in and type the code you show them. Then poll `POST /v0/device/token`
        with `device_code` every `interval` seconds until it returns a key.

        To claim an app made with `POST /v0/sandbox/keys`, pass its `claim_token`,
        or send that app's test key as `Authorization: Bearer fk_test_...` (a key
        of an app that is already claimed is ignored). When
        the person approves, the app joins their account: its data and keys are
        kept, its keys no longer expire (a key that expired less than 30 days ago
        works again), and the app moves under the person's signed-in allowance: 3
        contacts and 100 messages each, one allowance per person, shared by every
        app they own or claim. A person may claim up to 10 apps; past that the
        approval page refuses the claim. An expired key claims its app only for 30
        days after its `expires_at`; after that, use the `claim_token`. Without
        either, approving gives a new test key for the person's own app (made at
        their first sign-in).

        The approval page asks the person to type `user_code` as the agent or CLI
        shows it, so a link alone cannot approve a sign-in someone else started.

        No API key is needed. Calls are limited per client address and network.
      requestBody:
        required: false
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/DeviceAuthorizationRequest"
      responses:
        "201":
          description: The sign-in was started. Show the person the link and code, then poll for the key.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DeviceAuthorization"
        "400":
          $ref: "#/components/responses/InvalidRequest"
        "401":
          $ref: "#/components/responses/Unauthenticated"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Error"
  /v0/device/token:
    post:
      operationId: pollDeviceToken
      tags: [Onboarding]
      security: []
      summary: Poll a device sign-in for its key
      description: |
        Answers the state of a sign-in started with `POST /v0/device/authorizations`.
        While the person has not finished, `status` is `pending`: wait `interval`
        seconds and poll again. Polling faster answers `429 rate_limited` with
        `retry_after`. Once they approve, `status` is `approved` and the answer
        holds a new `fk_test_` key, shown once; the device code is then used up,
        and later polls answer `expired`. If the sign-in claimed a sandbox app,
        that app's sandbox keys stop working when this key is handed out: replace
        `FLOW_MESSAGING_KEY` with it. (A claim through `claim_url` in a browser
        hands out no key, and revokes the sandbox key unless the person chooses to
        keep their agent's key working.) `denied` means the person refused, and
        `expired` that the code ran out (after `expires_in` seconds): start again.
        Polls are also limited per client network, unknown codes included.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/DeviceTokenRequest"
      responses:
        "200":
          description: The sign-in's state, with the key once it is approved.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DeviceToken"
        "400":
          $ref: "#/components/responses/InvalidRequest"
        "404":
          $ref: "#/components/responses/NotFound"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Error"

  /v0/test/conversations:
    parameters:
      - $ref: "#/components/parameters/FlowVersion"
    post:
      operationId: createTestConversation
      tags: [Testing]
      summary: Create a simulated conversation
      description: |
        Test keys only. Creates a conversation with a **simulated contact**, an end
        user that exists only inside Flow, so an agent or a test suite can play the
        person's side with no phone and no real channel. Write as the contact with
        `POST /v0/test/conversations/{conversation_id}/inbound`; your agent receives
        each turn exactly like a real `message.received` and replies with its normal
        send. Read both sides with `GET /v0/test/conversations/{conversation_id}/transcript`.

        The conversation is on your app's **simulated sender** for the channel
        (`kind` `simulated`), which Flow creates on first use. It never touches a
        network: sends to the simulated contact pass the real send gate and end
        `sent`. Simulated channels: `telegram`, `whatsapp`, `imessage` and `sms`.
        The channel's rules hold as on a real sender: a simulated iMessage line,
        like Flow's lines today, only replies, so the contact writes first; a
        simulated Telegram bot, like a real one, cannot write to a contact who has
        not written to it, so a send before the contact's first turn is accepted
        (`202`) and then fails as it does on real Telegram (`message.failed`,
        `channel_error`, `channel_code` `403`); on SMS
        and iMessage a buttons message answers `422 unsupported_content`, as on a
        real line.

        On WhatsApp, the contact's turns go through the real WhatsApp inbound path,
        so each turn opens the 24-hour window. Outside it, including 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`.

        On SMS, the rules that concern the contact hold too. Before the contact's
        first turn, a send to them is a start that needs their recorded consent:
        without `consent` (on `POST /v0/messages` with `to.contact`), it answers
        `403 permission` with `channel_code` `consent_required`, as a real SMS start
        does. A turn with the text STOP opts the contact out and START lifts it (see
        `POST /v0/test/conversations/{conversation_id}/inbound`). A simulated SMS
        sender keeps no quiet hours, so a test's outcome never depends on the time of
        day, and it sends media and voice as MMS.

        Creating the conversation sends no event. The conversation starts with its
        first message: the contact's first turn (`via` `inbound`), or your first send
        to the contact, such as a WhatsApp template or an SMS send with consent
        (`via` `outbound`). `conversation.started` comes before that message's
        `message.received` or `message.sent`.

        On iMessage, `group` makes a simulated group conversation instead (`type`
        `group`, with the participants as simulated contacts), so you can test how
        your agent handles groups: each turn names its writer in `from`, and the
        gate applies the group rules (reactions refused with `channel_code`
        `group_unsupported`, as are typing and read receipts). Each call with
        `group` makes a new group.

        Give `contact` to choose the simulated contact's address; giving an address
        that already has a simulated conversation returns that conversation (`200`).
        Without `contact`, Flow makes a new contact with a made-up address (phone
        numbers start `+1555`), so each call is a fresh conversation, for example one
        per test case. Simulated contacts never match real ones, even with the same
        address.

        Simulated traffic is free: it does not count against the sandbox allowance.
        An app has at most 500 simulated conversations: past that, a new one answers
        `403 permission` with `channel_code` `simulated_conversation_limit` (an
        address that has one still returns it with `200`), so reuse conversations
        across test runs. The `/v0/test/` endpoints take at most 120 requests a
        minute per key (`429 rate_limited`), on top of the plan's request rate
        limit. A live key answers `403 permission` with `channel_code`
        `test_mode_only`.

        A HIPAA account (`Account.hipaa`) can simulate only the channels its
        business associate agreement covers (SMS), as it can connect senders only
        there: `telegram`, `whatsapp` or `imessage` answers `403 permission` with
        `channel_code` `hipaa_channel_not_covered` (`param` `channel`).
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/TestConversationRequest"
            examples:
              sms:
                summary: A new SMS contact with a made-up number
                value: {channel: sms, name: "Test contact"}
              telegram:
                summary: A Telegram contact with a chosen user ID
                value: {channel: telegram, contact: {telegram_user_id: "700100200"}, name: Asha}
              imessage_group:
                summary: An iMessage group chat with two people
                value: {channel: imessage, group: {name: "Weekend trip", participants: [{name: Asha}, {name: Ben, address: {phone: "+15555550142"}}]}}
      responses:
        "201":
          description: The simulated conversation was created.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Conversation"
        "200":
          description: The contact's simulated conversation already existed; it is returned as is.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Conversation"
        "400":
          $ref: "#/components/responses/InvalidRequest"
        "401":
          $ref: "#/components/responses/Unauthenticated"
        "403":
          description: |
            `permission`: a live key (`channel_code` `test_mode_only`), the app's
            500 simulated conversations reached (`simulated_conversation_limit`), or
            a HIPAA account asking for a channel its business associate agreement
            does not cover (`hipaa_channel_not_covered`; only `sms` can be simulated).
          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: channel, channel_code: hipaa_channel_not_covered}
        "409":
          $ref: "#/components/responses/IdempotencyConflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Error"
  /v0/test/conversations/{conversation_id}/inbound:
    parameters:
      - $ref: "#/components/parameters/ConversationId"
      - $ref: "#/components/parameters/FlowVersion"
    post:
      operationId: simulateInbound
      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`).
      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"
  /v0/test/conversations/{conversation_id}/transcript:
    parameters:
      - $ref: "#/components/parameters/ConversationId"
      - $ref: "#/components/parameters/FlowVersion"
    get:
      operationId: getTestTranscript
      tags: [Testing]
      summary: Read a simulated conversation, both ways
      description: |
        Test keys only. Lists a simulated conversation's messages, both directions,
        **oldest first**, so a test can page forward with `after` (the last message
        ID it has) to see what your agent sent back to the simulated contact. Each
        entry is a `Message`; a sent message's `content` is exactly what the
        simulated contact's phone shows, since Flow converts nothing.

        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`.
      parameters:
        - $ref: "#/components/parameters/After"
        - $ref: "#/components/parameters/Limit"
      responses:
        "200":
          description: A page of the transcript, oldest first.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/TestTranscript"
        "400":
          $ref: "#/components/responses/InvalidRequest"
        "401":
          $ref: "#/components/responses/Unauthenticated"
        "403":
          $ref: "#/components/responses/PermissionDenied"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Error"

webhooks:
  event:
    post:
      operationId: receiveEvent
      tags: [Webhook endpoints]
      summary: An event delivered to your endpoint
      description: |
        Flow `POST`s each event your endpoint subscribes to, one event per request,
        in order per conversation. Verify `Flow-Signature` before trusting the body
        (see "Webhooks" in the introduction). Deliveries are at least once:
        deduplicate on the event's `id`.

        To reply at once to a `message.received` event, answer `200` with a
        `WebhookReply` body; the reply goes into the event's conversation through the
        send gate, piece by piece in order, as if you had called each piece's
        kind's endpoint (`POST /v0/conversations/{conversation_id}/basic`, ...)
        with the event's `id` as the idempotency key. For any other event, or to
        reply later, answer `200` with
        an empty body, `{}`, `{"reply": null}` or any body that is not a JSON object
        with `reply` (plain text such as `OK` included): nothing is sent and it is not
        an error.

        An answer to a `message.received` delivery that is a JSON object with a
        `reply` that is not valid (a `reply` that is not a kind object, a string
        or a list of them, an unknown `kind`, an empty list, more than 11
        pieces), or a
        body that starts with `{` but is not valid JSON, sends nothing at all, not
        even the valid pieces. It is recorded
        on the delivery as an `invalid_request` error with the reason, which the MCP
        tool `get_webhook_deliveries` shows; the delivery counts as delivered and is
        not retried. A piece that the send gate refuses is reported as a
        `message.failed` event.
      parameters:
        - name: Flow-Signature
          in: header
          required: true
          description: "`t=<unix seconds>,v1=<hex HMAC-SHA256 of \"t.{t}.{body}\">`, with one `v1` per active signing secret."
          schema:
            type: string
            examples: ["t=1791763200,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd"]
        - name: Flow-Event-Id
          in: header
          required: true
          description: The event's `id`, also in the body. Use it to deduplicate.
          schema:
            $ref: "#/components/schemas/EventId"
        - name: Flow-Event-Type
          in: header
          required: true
          description: The event's `type`, also in the body.
          schema:
            $ref: "#/components/schemas/EventType"
        - name: Flow-Version
          in: header
          required: true
          description: The API version the event body is shaped by (your app's pinned version).
          schema:
            type: string
            format: date
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/Event"
      responses:
        "200":
          description: The event was received. Optionally carries a reply to send into the event's conversation.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/WebhookReply"
              examples:
                reply:
                  summary: A basic reply
                  value:
                    reply: {kind: basic, text: "Yes, we deliver to 560103."}
                string_reply:
                  summary: A basic reply given as a string
                  value:
                    reply: "Yes, we deliver to 560103."
                text_and_cards:
                  summary: A text, then one buttons message per card (Telegram has no carousels)
                  value:
                    reply:
                      - "Three picks for you"
                      - kind: buttons
                        header: {type: image, url: "https://example.com/a.jpg"}
                        text: "Linen shirt, 1,499"
                        buttons: [{id: buy_a, label: Buy}]
                      - kind: buttons
                        header: {type: image, url: "https://example.com/b.jpg"}
                        text: "Denim jacket, 2,999"
                        buttons: [{id: buy_b, label: Buy}]
                no_reply:
                  summary: No reply now
                  value: {}
        "4XX":
          description: Any other answer, or no answer within 10 seconds, is a failed delivery and is retried with backoff for 3 days.

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: fk_test_... or fk_live_...
      description: |
        An API key of one app, sent as `Authorization: Bearer <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.

  parameters:
    FlowVersion:
      name: Flow-Version
      in: header
      required: false
      description: The API version to use, as a date. Without it, the version pinned to your app when it was created is used.
      schema:
        type: string
        format: date
        examples: ["2026-11-01"]
    IdempotencyKey:
      name: Idempotency-Key
      in: header
      required: false
      description: |
        A unique string (up to 255 characters) that makes this request safe to retry.
        A repeat with the same key within 24 hours returns the first answer instead of
        acting again. See "Idempotency" in the introduction.
      schema:
        type: string
        minLength: 1
        maxLength: 255
    After:
      name: after
      in: query
      required: false
      description: An item ID. Returns the items that come after it in the list's order.
      schema:
        type: string
        maxLength: 64
    Before:
      name: before
      in: query
      required: false
      description: An item ID. Returns the items that come before it in the list's order.
      schema:
        type: string
        maxLength: 64
    Limit:
      name: limit
      in: query
      required: false
      description: How many items to return, 1 to 100.
      schema:
        type: integer
        minimum: 1
        maximum: 100
        default: 20
    ConversationId:
      name: conversation_id
      in: path
      required: true
      description: The conversation's ID.
      schema:
        $ref: "#/components/schemas/ConversationId"
    MessageId:
      name: message_id
      in: path
      required: true
      description: The message's ID.
      schema:
        $ref: "#/components/schemas/MessageId"
    EventId:
      name: event_id
      in: path
      required: true
      description: The event's ID.
      schema:
        $ref: "#/components/schemas/EventId"
    FileId:
      name: file_id
      in: path
      required: true
      description: The file's ID.
      schema:
        $ref: "#/components/schemas/FileId"
    SenderId:
      name: sender_id
      in: path
      required: true
      description: The sender's ID.
      schema:
        $ref: "#/components/schemas/SenderId"
    TemplateId:
      name: template_id
      in: path
      required: true
      description: The template's ID.
      schema:
        $ref: "#/components/schemas/TemplateId"
    WebhookEndpointId:
      name: webhook_endpoint_id
      in: path
      required: true
      description: The webhook endpoint's ID.
      schema:
        $ref: "#/components/schemas/WebhookEndpointId"
    ContactId:
      name: contact_id
      in: path
      required: true
      description: The contact's ID.
      schema:
        $ref: "#/components/schemas/ContactId"

  headers:
    RetryAfter:
      description: Seconds to wait before retrying.
      schema:
        type: integer
    RateLimitLimit:
      description: Requests allowed in the current window for this key.
      schema:
        type: integer
    RateLimitRemaining:
      description: Requests left in the current window for this key.
      schema:
        type: integer
    RateLimitReset:
      description: Seconds until the current window resets.
      schema:
        type: integer

  responses:
    Error:
      description: An error. `error.type` says which (see `ErrorType`).
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
    InvalidRequest:
      description: |
        The request is malformed or a parameter is invalid (`invalid_request`).
        `error.param` names the parameter. 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"}
    PermissionDenied:
      description: The key may not do this, for example a test key using a live sender (`permission`).
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
    NotFound:
      description: No such object for this app and mode (`not_found`).
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
    Conflict:
      description: |
        The request conflicts with the current state: the channel's window is closed
        (`outside_window`: WhatsApp's 24 hours for free-form messages, iMessage's
        5 minutes after the contact's last message for typing), or the idempotency
        key was used for a different request or is still in use
        (`idempotency_conflict`).
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
          example:
            error: {type: outside_window, message: "Last message from the contact was 31h ago; WhatsApp allows only templates now.", hint: "Send a template instead: POST /v0/conversations/{conversation_id}/template.", doc_url: "https://api.flow.engineer/docs/errors/outside_window", channel: whatsapp, conversation: conv_01JB8ZC3K5M7P9R1T3V5X7Z9B1}
    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"}
    NotImplemented:
      description: |
        Not available through the API during the beta (`not_implemented`), for
        example a sender of a channel the Flow team connects and disconnects for
        you. The `hint` says what to do instead.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
    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}
    ChannelError:
      description: |
        The channel refused the call, failed or timed out (`channel_error`).
        `error.channel_code` carries the channel's own code where it gave one.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
          example:
            error: {type: channel_error, message: "The channel refused: the request timed out.", hint: "Typing and read receipts are safe to ignore; carry on and send your reply.", doc_url: "https://api.flow.engineer/docs/errors/channel_error", conversation: conv_01JB8ZC3K5M7P9R1T3V5X7Z9B1}
    RateLimited:
      description: |
        Too many requests (`rate_limited`): either this key's request limit (see the
        `RateLimit-*` headers) or the sender's sending rate (pacing) was exceeded;
        retry after `Retry-After` seconds (also `error.retry_after`) with the same
        `Idempotency-Key`. Or the sender has used its budget for starting
        conversations (`new_contact_limit`), or the sender is throttled after abuse
        signals (`sender_throttled`); wait `retry_after` seconds.
      headers:
        Retry-After:
          $ref: "#/components/headers/RetryAfter"
        RateLimit-Limit:
          $ref: "#/components/headers/RateLimitLimit"
        RateLimit-Remaining:
          $ref: "#/components/headers/RateLimitRemaining"
        RateLimit-Reset:
          $ref: "#/components/headers/RateLimitReset"
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
          example:
            error: {type: new_contact_limit, message: "This sender has started its 15 new conversations for today.", hint: "Retry after 3600 seconds; replies into existing conversations still go.", doc_url: "https://api.flow.engineer/docs/errors/new_contact_limit", retry_after: 3600, sender: snd_01JB8Z4Q3V6W0R2N7C5H1M9K4T}

  schemas:
    # ---------------------------------------------------------------- IDs
    AccountId:
      type: string
      description: An account ID, `acct_` and a ULID.
      pattern: "^acct_[0-9A-HJKMNP-TV-Z]{26}$"
      examples: ["acct_01JB8YZ3N2Q4R6T8V0X2Z4B6D8"]
    AppId:
      type: string
      description: An app ID, `app_` and a ULID.
      pattern: "^app_[0-9A-HJKMNP-TV-Z]{26}$"
      examples: ["app_01JB8Z0A1C3E5G7J9K1M3P5R7T"]
    ApiKeyId:
      type: string
      description: An API key's ID, `key_` and a ULID. Not the secret key itself.
      pattern: "^key_[0-9A-HJKMNP-TV-Z]{26}$"
      examples: ["key_01JB8Z1B2D4F6H8K0M2P4R6T8W"]
    SenderId:
      type: string
      description: A sender ID, `snd_` and a ULID.
      pattern: "^snd_[0-9A-HJKMNP-TV-Z]{26}$"
      examples: ["snd_01JB8Z4Q3V6W0R2N7C5H1M9K4T"]
    ContactId:
      type: string
      description: A contact ID, `ct_` and a ULID.
      pattern: "^ct_[0-9A-HJKMNP-TV-Z]{26}$"
      examples: ["ct_01JB8ZB2J4K6N8Q0S2V4W6Y8A0"]
    ConversationId:
      type: string
      description: A conversation ID, `conv_` and a ULID.
      pattern: "^conv_[0-9A-HJKMNP-TV-Z]{26}$"
      examples: ["conv_01JB8ZC3K5M7P9R1T3V5X7Z9B1"]
    MessageId:
      type: string
      description: A message ID, `msg_` and a ULID.
      pattern: "^msg_[0-9A-HJKMNP-TV-Z]{26}$"
      examples: ["msg_01JB8ZD4M6P8R0T2V4X6Z8B0C2"]
    EventId:
      type: string
      description: An event ID, `evt_` and a ULID.
      pattern: "^evt_[0-9A-HJKMNP-TV-Z]{26}$"
      examples: ["evt_01JB8ZE5N7Q9S1V3X5Z7B9D1E3"]
    WebhookEndpointId:
      type: string
      description: A webhook endpoint ID, `we_` and a ULID.
      pattern: "^we_[0-9A-HJKMNP-TV-Z]{26}$"
      examples: ["we_01JB8ZF6P8R0T2W4Y6A8C0E2F4"]
    TemplateId:
      type: string
      description: A template ID, `tpl_` and a ULID.
      pattern: "^tpl_[0-9A-HJKMNP-TV-Z]{26}$"
      examples: ["tpl_01JB8Z9X2D4F6H8K0M2P4R6T8V"]
    FileId:
      type: string
      description: A file ID, `file_` and a ULID.
      pattern: "^file_[0-9A-HJKMNP-TV-Z]{26}$"
      examples: ["file_01JB8ZG7Q9S1V3X5Z7B9D1F3G5"]

    # ---------------------------------------------------------------- shared
    Channel:
      type: string
      description: A messaging channel. `sms` is US SMS and MMS (in pilot), from dedicated numbers only.
      enum: [telegram, whatsapp, imessage, sms]
    Mode:
      type: string
      description: "`test` (sandbox senders and test data only) or `live`."
      enum: [test, live]
    Metadata:
      type: object
      description: Up to 20 string pairs of your own, kept with the object and returned unchanged. Keys up to 40 characters, values up to 500.
      maxProperties: 20
      additionalProperties:
        type: string
        maxLength: 500
    Deleted:
      type: object
      description: Confirms that an object was deleted.
      required: [id, deleted]
      properties:
        id:
          type: string
          description: The deleted object's ID.
        deleted:
          type: boolean
          description: Always `true`.

    # ---------------------------------------------------------------- errors
    ErrorType:
      type: string
      description: |
        What went wrong. A closed list: new types arrive only with a new API version.
        Each type has a page at `https://api.flow.engineer/docs/errors/<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
    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.
    Error:
      type: object
      description: The body of every error answer.
      required: [error]
      properties:
        error:
          $ref: "#/components/schemas/ErrorBody"

    # ---------------------------------------------------------------- account, app, key
    Account:
      type: object
      description: A customer company. Holds the plan, billing and members. Every app belongs to one account.
      required: [id, name, plan, created_at]
      properties:
        id:
          $ref: "#/components/schemas/AccountId"
        name:
          type: string
          description: The company's name.
        plan:
          type: string
          description: |
            The account's plan, set by the Flow team (there is no self-serve
            checkout). Each plan caps how many people your apps may talk with per
            channel; `people` shows the caps and what is used.

            - `free`: a small number of people per channel in total (25 today).
            - `shared`: Flow's shared senders, with caps per channel a day and a
              month (200 a day and 300 a month today).
            - `dedicated`: your own iMessage line, with a daily cap on it and no
              monthly cap (1,000 a day today); on Flow's shared senders it has
              `shared`'s caps, counted apart (`people` shows each `pool`).

            The numbers are settings and may change; read the caps that apply
            now from `people`, not from this list.
            - `pro`, `business`, `enterprise`: older plans, kept for accounts that
              have them; they have no people caps.

            New values may be added; treat one you do not know like `free`.
          enum: [free, shared, dedicated, pro, business, enterprise]
          x-enum-varnames: [Free, PlanShared, PlanDedicated, Pro, Business, Enterprise]
        people:
          $ref: "#/components/schemas/PeopleLimits"
        hipaa:
          type: boolean
          readOnly: true
          description: |
            `true` when the account handles protected health information under a
            business associate agreement with Flow. Its messages then go only
            through channels Flow holds such an agreement for (SMS): sends, typing,
            read receipts and sender connections on Telegram, WhatsApp and iMessage
            answer `403 permission` with `channel_code` `hipaa_channel_not_covered`,
            and messages already queued there fail the same way. The sandbox
            senders answer the account's contacts with a fixed refusal and store
            and deliver nothing they send; turning the flag on ends the account's
            sandbox joins. Only SMS can be simulated (`POST /v0/test/conversations`):
            creating a simulated Telegram, WhatsApp or iMessage conversation, or posting a
            turn to one made before the flag was set, answers the same refusal.
            Set by the Flow team.
        created_at:
          type: string
          format: date-time
          description: When the account was created.
    PeopleLimits:
      type: object
      readOnly: true
      description: |
        The account's plan caps on people, and what is used now. A person counts
        once in a window, with their first inbound message in it on that channel's
        sender, whether one-to-one or in a group chat; group members who never
        write, calls, and your own sends do not count. A person past a cap is
        **held**: their messages are kept but not delivered to your app, and are
        delivered once a place frees or the window rolls over. Absent when the
        account's plan has no people caps. These numbers are the source of truth
        for the caps; they are settings and can change.
      required: [per, channels]
      properties:
        per:
          type: string
          description: "`channel`: each channel has its own caps (every plan today). `account`: one set of caps over all channels, with no `channel` on its entry."
          enum: [channel, account]
        channels:
          type: array
          description: One entry per channel the caps apply to (one entry without `channel` when `per` is `account`).
          items:
            $ref: "#/components/schemas/ChannelPeople"
    ChannelPeople:
      type: object
      description: |
        The caps on people for one channel and one pool of senders, and how many
        people are held. A channel can appear twice, once per pool, since each
        pool is counted apart (a `dedicated` account has `shared`'s caps on Flow's
        shared senders and its own cap on its own line).
      required: [pool, limits, held]
      properties:
        channel:
          $ref: "#/components/schemas/Channel"
        pool:
          type: string
          description: "`shared`: Flow's shared senders on this channel. `own`: the account's own senders on it (a dedicated line, its own bot or number). Counted apart."
          enum: [shared, own]
          x-enum-varnames: [PeoplePoolShared, PeoplePoolOwn]
        limits:
          type: array
          description: "Each window the plan caps (`free`: `total`; `shared`: `day` and `month`; `dedicated`: `day`)."
          items:
            $ref: "#/components/schemas/PeopleWindow"
        held:
          type: integer
          minimum: 0
          description: People held now because a cap is reached; their messages wait and are delivered when a place frees or the window rolls over.
    PeopleWindow:
      type: object
      description: One capped window and how many people it has used.
      required: [window, limit, used]
      properties:
        window:
          type: string
          description: "`total`: since the account began (never resets). `day`: the UTC day. `month`: the UTC calendar month."
          enum: [total, day, month]
          x-enum-varnames: [PeopleWindowTotal, PeopleWindowDay, PeopleWindowMonth]
        limit:
          type: integer
          minimum: 0
          description: How many people may write in the window.
        used:
          type: integer
          minimum: 0
          description: How many distinct people wrote in the window so far.
        resets_at:
          type: string
          format: date-time
          description: When the window rolls over and `used` starts again. Absent for `total`.
    App:
      type: object
      description: One agent integration. Owns API keys (per mode), webhook endpoints and settings.
      required: [id, account, name, api_version, settings, created_at]
      properties:
        id:
          $ref: "#/components/schemas/AppId"
        account:
          $ref: "#/components/schemas/AccountId"
        name:
          type: string
          description: The app's name.
        api_version:
          type: string
          format: date
          description: The API version pinned to the app when it was created, used when a request sends no `Flow-Version`.
        settings:
          $ref: "#/components/schemas/AppSettings"
        sandbox_join_code:
          type: string
          description: |
            The app's sandbox join code on its own: two words and eight digits, for example `brave-otter-40718263`. A contact joins this app by sending a shared sandbox sender the join message, which ends with this code in brackets after `ref` (`Hi Sandbox app (ref brave-otter-40718263)`), or on Telegram by opening the bot's link with the code (`https://t.me/<bot>?start=brave-otter-40718263`, which sends `/start brave-otter-40718263`). Each shared sender's `join_code` gives the whole message: show that to testers, not this code.
          examples: ["brave-otter-40718263"]
        created_at:
          type: string
          format: date-time
          description: When the app was created.
    AppSettings:
      type: object
      description: Per-app behaviour. The Flow team sets it for now; ask them to change it.
      required: [transcription, auto_read]
      properties:
        transcription:
          type: boolean
          description: Not used yet. Flow makes no transcripts of its own on any channel; on iMessage, a received voice note carries the transcript iMessage made of it, whatever this setting says.
        auto_read:
          type: boolean
          description: When on, Flow marks inbound messages as read as soon as your endpoint accepts them.
    ApiKey:
      type: object
      description: An API key's record. The key itself is shown once, at creation, and stored only as a hash.
      required: [id, mode, last4, created_at]
      properties:
        id:
          $ref: "#/components/schemas/ApiKeyId"
        mode:
          $ref: "#/components/schemas/Mode"
        last4:
          type: string
          description: The key's last four characters, to tell keys apart.
          minLength: 4
          maxLength: 4
        created_at:
          type: string
          format: date-time
          description: When the key was created.
        expires_at:
          type: string
          format: date-time
          description: |
            When the key stops working. Set only on keys of an unclaimed app made with
            `POST /v0/sandbox/keys` (7 days after it was made); signing in through the
            device flow claims the app and removes it. An expired key can still start
            that sign-in for 30 days after this time, and claiming the app within those
            30 days makes the key work again.
    AppContext:
      type: object
      description: Who is calling, as worked out from the API key.
      required: [account, app, api_key, livemode]
      properties:
        account:
          $ref: "#/components/schemas/Account"
        app:
          $ref: "#/components/schemas/App"
        api_key:
          $ref: "#/components/schemas/ApiKey"
        livemode:
          type: boolean
          description: "`true` for a live key, `false` for a test key."
        allowance:
          $ref: "#/components/schemas/SandboxAllowance"

    # ---------------------------------------------------------------- onboarding
    SandboxAllowance:
      type: object
      description: |
        What the app may still send on the shared sandbox senders for free. Present
        only on apps that have one: apps made with `POST /v0/sandbox/keys`
        (`anonymous`, one allowance per app), and apps of people who signed in
        (`signed_in`, one allowance per person: every app the person owns or claimed
        draws on the same contacts and messages, so the counts here are the
        person's, over all those apps). Only messages your agent sends count, on the
        channels in `channels`; inbound messages are free. A contact counts once it
        joins an app on a sandbox sender, and keeps counting after it leaves. Sends
        past the allowance answer `403 permission` with `channel_code`
        `sandbox_allowance_used`; a join past `contacts.limit` is refused in the
        chat. Absent on an account with the free trial (your own account, from
        your first sign-in), which replaces it, and on paid plans.
      required: [tier, scope, channels, contacts, messages_per_contact, messages, upgrade]
      properties:
        tier:
          type: string
          description: "`anonymous`: an app made without an account, whose keys expire. `signed_in`: an app of a person who signed in (with a Flow account)."
          enum: [anonymous, signed_in]
        scope:
          type: string
          description: |
            Who the counts belong to. `app`: this app alone (`anonymous`). `person`:
            the signed-in person, shared by every app they own or claimed, so sends
            from their other apps use the same contacts and messages.
          enum: [app, person]
        channels:
          type: array
          description: The sandbox channels the allowance covers whose shared sandbox is open now (Telegram today; WhatsApp when its sandbox opens). Shared senders of other channels (iMessage) are not part of it and refuse its sends.
          items:
            $ref: "#/components/schemas/Channel"
        contacts:
          $ref: "#/components/schemas/AllowanceCount"
        messages_per_contact:
          type: integer
          minimum: 0
          description: How many messages may be sent to each contact (50 anonymous, so 50 in total; 100 signed in).
        messages:
          $ref: "#/components/schemas/AllowanceMessages"
        expires_at:
          type: string
          format: date-time
          description: "`anonymous` only: when the app's keys expire. Signing in removes it."
        upgrade:
          type: string
          description: One sentence saying how to raise the allowance, for example by signing in with `npx @flow-engineer/messaging login`.
    AllowanceCount:
      type: object
      description: Contacts the allowance has room for, and how many have joined (with `scope` `person`, over all the person's apps).
      required: [limit, used]
      properties:
        limit:
          type: integer
          minimum: 0
          description: How many contacts may join on the sandbox (1 for an anonymous app; 3 for a signed-in person, over all their apps).
        used:
          type: integer
          minimum: 0
          description: How many have joined so far.
    AllowanceMessages:
      type: object
      description: Messages sent against the allowance, over all its contacts (with `scope` `person`, from all the person's apps).
      required: [limit, used, remaining]
      properties:
        limit:
          type: integer
          minimum: 0
          description: "`contacts.limit` times `messages_per_contact`."
        used:
          type: integer
          minimum: 0
          description: Messages sent so far on the allowance's sandbox senders.
        remaining:
          type: integer
          minimum: 0
          description: Messages still allowed, counting each contact's own cap and the contacts that may still join.
    SandboxKeyRequest:
      type: object
      description: Optional details for a new sandbox app.
      properties:
        name:
          type: string
          maxLength: 80
          description: The app's name, shown in the dashboard once it is claimed. Defaults to "Sandbox app".
    SandboxKey:
      type: object
      description: A new app made without an account, with its test key. `key` and `claim_token` are shown only here.
      required: [key, api_key, account, app, allowance, claim_token, claim_url, senders]
      properties:
        key:
          type: string
          description: The API key itself (`fk_test_...`). Shown once; store it as `FLOW_MESSAGING_KEY`.
          examples: ["fk_test_..."]
        api_key:
          $ref: "#/components/schemas/ApiKey"
        account:
          $ref: "#/components/schemas/Account"
        app:
          $ref: "#/components/schemas/App"
        allowance:
          $ref: "#/components/schemas/SandboxAllowance"
        claim_token:
          type: string
          description: |
            Proves you hold this app when a person signs in to claim it: pass it to
            `POST /v0/device/authorizations`. Shown once; keep it with the key. It
            stops working once the app is claimed.
          examples: ["fct_..."]
        claim_url:
          type: string
          format: uri
          description: A page where a person signs in to Flow and claims the app in the browser, without the CLI. It holds the claim token, so treat it like one. The claim hands out no key and revokes the app's keys unless the person ticks "Keep my agent's current key working"; afterwards they make keys on the dashboard's Keys page.
          examples: ["https://api.flow.engineer/admin/claim#token=fct_..."]
        senders:
          type: array
          description: The shared sandbox senders the key can use, each with the link a person opens to join the app (`address.link`) and the join message (`join_code`).
          items:
            $ref: "#/components/schemas/Sender"
    DeviceAuthorizationRequest:
      type: object
      description: Optional details for a device sign-in.
      properties:
        claim_token:
          type: string
          description: The `claim_token` from `POST /v0/sandbox/keys`, to claim that app when the person approves.
        client_name:
          type: string
          maxLength: 80
          description: What is asking, shown to the person on the approval page, for example "flow CLI" or "Claude Code".
    DeviceAuthorization:
      type: object
      description: A device sign-in waiting for a person to approve it in a browser.
      required: [device_code, user_code, verification_uri, verification_uri_complete, expires_in, expires_at, interval]
      properties:
        device_code:
          type: string
          description: The secret you poll `POST /v0/device/token` with. Never show it to the person.
          examples: ["fdc_..."]
        user_code:
          type: string
          description: The code the person types on the approval page, eight letters in two groups. Always show it to the person, also when you show `verification_uri_complete`.
          examples: ["WDJB-MJHT"]
        verification_uri:
          type: string
          format: uri
          description: The page where the person signs in and enters `user_code`.
          examples: ["https://api.flow.engineer/admin/device"]
        verification_uri_complete:
          type: string
          format: uri
          description: The same page for this sign-in. Show this link (or a QR code of it) to the person together with `user_code`; the page asks them to type the code they see from you and checks it against the link, so a link on its own cannot approve a sign-in.
          examples: ["https://api.flow.engineer/admin/device?code=WDJB-MJHT"]
        expires_in:
          type: integer
          minimum: 0
          description: Seconds until the codes expire (15 minutes).
        expires_at:
          type: string
          format: date-time
          description: When the codes expire.
        interval:
          type: integer
          minimum: 1
          description: Seconds to wait between polls of `POST /v0/device/token`.
    DeviceTokenRequest:
      type: object
      description: The device sign-in to poll.
      required: [device_code]
      properties:
        device_code:
          type: string
          description: The `device_code` from `POST /v0/device/authorizations`.
    DeviceToken:
      type: object
      description: |
        A device sign-in's state. With `approved`, it carries a new test key (shown
        once), the app it belongs to and whether a sandbox app was claimed.
      required: [status, interval]
      properties:
        status:
          type: string
          description: |
            - `pending`: the person has not finished; poll again after `interval` seconds.
            - `approved`: signed in; `key` is set. The device code is now used up.
            - `denied`: the person refused. Stop polling.
            - `expired`: the codes ran out or were already used. Start again.
          enum: [pending, approved, denied, expired]
        interval:
          type: integer
          minimum: 1
          description: Seconds to wait before the next poll.
        key:
          type: string
          description: "`approved` only. A new API key (`fk_test_...`), shown once; store it as `FLOW_MESSAGING_KEY` in place of the sandbox key. When the sign-in claimed a sandbox app, that app's sandbox keys stop working as this key is handed out."
        api_key:
          $ref: "#/components/schemas/ApiKey"
        account:
          $ref: "#/components/schemas/Account"
        app:
          $ref: "#/components/schemas/App"
        claimed:
          type: boolean
          description: "`approved` only. `true` when the sign-in claimed the app of the `claim_token` or key it was started with."
        allowance:
          $ref: "#/components/schemas/SandboxAllowance"
        user:
          $ref: "#/components/schemas/SignedInUser"
    SignedInUser:
      type: object
      description: The person who approved a device sign-in.
      required: [name, provider]
      properties:
        name:
          type: string
          description: Their name or login at the provider.
        email:
          type: string
          format: email
          description: Their verified email address, where the provider gave one.
        provider:
          type: string
          description: |
            Where they signed in: `flow` (a Flow account, flow.engineer: every sign-in
            today). `github` and `google` are sign-ins made on the Messaging API itself
            before Flow accounts. More may be added later, so treat a value you do not
            know as a sign-in elsewhere rather than an error.
          enum: [flow, github, google]

    # ---------------------------------------------------------------- senders
    Sender:
      type: object
      description: |
        What your agent talks from: a Telegram bot, a WhatsApp number, an iMessage
        line or a US SMS number. `shared` senders are Flow's sandbox, used by many apps in test mode;
        `dedicated` senders are yours alone; `simulated` senders exist only in test
        mode, for simulated conversations. Each sender has its own limits and warm-up
        state.
      required: [id, channel, kind, livemode, status, address, limits, created_at]
      properties:
        id:
          $ref: "#/components/schemas/SenderId"
        channel:
          $ref: "#/components/schemas/Channel"
        kind:
          $ref: "#/components/schemas/SenderKind"
        livemode:
          type: boolean
          description: "`false` for sandbox and simulated senders, `true` for dedicated senders used in live mode."
        display_name:
          type: string
          description: The name contacts see, where the channel shows one.
        status:
          $ref: "#/components/schemas/SenderStatus"
        throttled_until:
          type: string
          format: date-time
          description: With status `throttled`, when starts are allowed again. Recovery is automatic.
        address:
          $ref: "#/components/schemas/SenderAddress"
        limits:
          $ref: "#/components/schemas/SenderLimits"
        quality_rating:
          type: string
          description: WhatsApp only. Meta's quality rating for the number.
          enum: [green, yellow, red, unknown]
        sms:
          $ref: "#/components/schemas/SmsSender"
        imessage:
          $ref: "#/components/schemas/ImessageSender"
        setup:
          $ref: "#/components/schemas/SenderSetup"
        join_code:
          type: string
          description: |
            Shared senders only. The whole message a contact sends to this sender to join your app: `Hi `, the app's name (`there` when it has none), then the app's `sandbox_join_code` (from `GET /v0/app`) in brackets after `ref`, for example `Hi Sandbox app (ref brave-otter-40718263)`. Show it to testers as is; a link that fills it in (wa.me, sms:) carries it URL-encoded. Any message of at most 160 characters that ends with `(ref <code>)` joins, so a tester may change the words before the brackets.
          examples: ["Hi Sandbox app (ref brave-otter-40718263)"]
        created_at:
          type: string
          format: date-time
          description: When the sender was created.
    SenderSetup:
      type: object
      readOnly: true
      description: |
        Only in the answer to `POST /v0/senders` that connected a WhatsApp number:
        what you still set in your own Meta app, which Flow cannot set for you.
        Flow already points the number's webhooks at `callback_url` (Meta's
        phone-number-level override); entering the same URL in your app makes its
        field subscriptions apply. The verify token is shown only in this answer
        (connecting the number again shows it again); keep it like a password.
      required: [callback_url, verify_token, webhook_fields, steps]
      properties:
        callback_url:
          type: string
          format: uri
          description: The callback URL to enter in your Meta app (App Dashboard > WhatsApp > Configuration > Webhook).
          examples: ["https://api.flow.engineer/ingress/whatsapp/snd_01J9ZK3N5B6VQ1M2X4C7R8T9WA"]
        verify_token:
          type: string
          description: The verify token to enter with the callback URL.
        webhook_fields:
          type: array
          description: The webhook fields to subscribe in the same place.
          items:
            type: string
          examples: [["messages", "message_template_status_update", "phone_number_quality_update", "account_update", "business_capability_update", "template_category_update", "account_alerts"]]
        steps:
          type: array
          description: The steps in your Meta app, in order, as plain sentences to show a person.
          items:
            type: string
    SmsSender:
      type: object
      readOnly: true
      description: |
        SMS senders only: how the number is registered with US carriers and what it
        can do. An SMS number stays `pending` until its registration is approved
        (carriers block traffic from unregistered numbers).
      required: [number_type, registration_status, two_way, mms]
      properties:
        number_type:
          type: string
          description: "`10dlc` (a local 10-digit number, registered as a brand and campaign) or `toll_free` (a verified toll-free number)."
          enum: [10dlc, toll_free]
        registration_status:
          type: string
          description: |
            The number's carrier registration (the 10DLC campaign, or toll-free
            verification): `pending` (submitted, not approved yet; the sender is
            `pending` and sends nothing), `approved`, or `rejected` (the sender is
            `flagged`; the Flow team resubmits).
          enum: [pending, approved, rejected]
        two_way:
          type: boolean
          description: Whether the number receives texts. Always `true` for senders Flow connects today.
        mms:
          type: boolean
          description: Whether the number sends and receives MMS (images, audio and video). Without it, a basic message with `media` answers `422 unsupported_content` with `param` `media` and `channel_code` `no_mms`.
        quiet_hours:
          $ref: "#/components/schemas/QuietHours"
    ImessageSender:
      type: object
      readOnly: true
      description: |
        iMessage senders only: what the line can do beyond replying in iMessage.
        Set by the Flow team (ask them to change it); absent on other channels.
      required: [fallback, can_initiate]
      properties:
        fallback:
          type: boolean
          description: |
            Whether messages to a contact who cannot get iMessage go by SMS or RCS
            instead. The network is chosen for each message when it is sent, never
            by your app; the message's `network` says which one carried it.
            Included on Flow's shared lines; on a dedicated line, an add-on
            arranged with the Flow team. Off, a message to a contact who is not on
            iMessage fails (`message.failed`). A message that goes by SMS or RCS
            shows no `imessage.styles`, `imessage.subject` or effect, and edits
            and unsends of it answer `422 unsupported_content` with
            `channel_code` `not_on_sms` (see `Message.network`).
        call_forwarding:
          $ref: "#/components/schemas/CallForwarding"
        can_initiate:
          type: boolean
          description: |
            Whether the line may write first: start a conversation with
            `POST /v0/messages` with a contact who has not messaged it. Off (the
            default), the line only replies and a start answers `403 permission`.
            Writing first is available on request from the Flow team, on dedicated
            lines only (always `false` on shared lines); a line that starts
            conversations spends its new-contact budget (`limits`), which grows
            during warm-up.
    CallForwarding:
      type: object
      description: |
        Dedicated lines only: where phone calls to the line's number are
        forwarded, set up with the Flow team. Forwarded calls send no event.
        FaceTime calls are not forwarded; they arrive as `call.received`. Absent
        when calls are not forwarded (always on shared lines).
      required: [phone]
      properties:
        phone:
          type: string
          description: The E.164 number calls are forwarded to.
          pattern: "^\\+[1-9][0-9]{6,14}$"
          examples: ["+15555550123"]
    QuietHours:
      type: object
      description: |
        The hours, in the contact's local time zone (worked out from their area
        code; when it is not known, every US time zone must be outside the
        hours), in which the sender does not message contacts who have not texted
        it in the last hour. Absent when the sender has no quiet hours. A send
        inside them answers `409 outside_window` with `channel_code` `quiet_hours`
        and `retry_after`; a message that waited in the queue into them is held
        until they end. Replies to a contact who texted within the hour always
        go.
      required: [start, end]
      properties:
        start:
          type: string
          description: When the quiet hours begin, `HH:MM` on a 24-hour clock.
          pattern: "^([01][0-9]|2[0-3]):[0-5][0-9]$"
          examples: ["21:00"]
        end:
          type: string
          description: When they end, `HH:MM` on a 24-hour clock (earlier than `start` when they span midnight).
          pattern: "^([01][0-9]|2[0-3]):[0-5][0-9]$"
          examples: ["08:00"]
    SenderKind:
      type: string
      description: |
        `shared` (the sandbox), `dedicated` (yours) or `simulated` (test mode only:
        your app's stand-in for a channel, used by simulated conversations from
        `POST /v0/test/conversations`; it never touches a network).
      enum: [shared, dedicated, simulated]
    SenderStatus:
      type: string
      description: |
        - `pending`: requested, being provisioned.
        - `active`: sending normally.
        - `warming_up`: active, with a new-contact budget that grows day by day.
        - `throttled`: the gate slowed it after an abuse signal; it recovers by itself.
        - `flagged`: the channel or Flow flagged it; starts are paused. A Telegram
          bot is also `flagged` when Telegram rejects its token (revoked in
          @BotFather): it then sends nothing, new sends answer `403 permission`, and
          queued messages wait until you connect the bot again with its new token
          (`POST /v0/senders`). They wait at most 72 hours after Flow accepted
          them; older ones fail with `outside_window` (`channel_code`
          `queued_too_long`) in `message.failed` instead of going out late.
        - `banned`: it cannot send or receive: the channel banned it, you
          disconnected it (`DELETE /v0/senders/{sender_id}`), or its bot or line was
          connected to another app.
      enum: [pending, active, warming_up, throttled, flagged, banned]
    SenderAddress:
      type: object
      description: How contacts reach the sender. Which fields are set depends on the channel.
      properties:
        phone:
          type: string
          description: WhatsApp, iMessage and SMS. E.164 phone number.
        username:
          type: string
          description: Telegram. The bot's username, without `@`.
        handle:
          type: string
          description: iMessage. The line's handle (phone number or email address).
        link:
          type: string
          format: uri
          description: |
            A link that opens a chat with the sender: `https://t.me/...` for a Telegram
            bot, `https://wa.me/...` for a WhatsApp number. For an iMessage line it is
            the line's opt-in link, which opens Messages with the line and a prefilled
            text the person sends to start the conversation; it is set only when the
            line has one configured.
    SenderLimits:
      type: object
      description: The sender's current budget for starting conversations, and how fast it sends.
      required: [new_contacts_per_day, new_contacts_per_hour]
      properties:
        new_contacts_per_day:
          type: integer
          description: New conversations the sender may start per day today (grows during warm-up).
        new_contacts_per_hour:
          type: integer
          description: New conversations the sender may start per hour.
        messages_per_second:
          type: number
          description: |
            The sender's pacing: how many messages a second it takes on average,
            replies included, with short bursts allowed above it. A send past it
            answers `429 rate_limited` with `retry_after`. A shared sandbox
            sender's pacing is shared by every app that uses it. Absent for
            simulated senders, which are not paced.
          examples: [1]
        whatsapp_tier:
          type: string
          description: WhatsApp only. Meta's messaging tier for the number.
    SenderRequest:
      type: object
      description: A request for a dedicated sender.
      required: [channel]
      properties:
        channel:
          $ref: "#/components/schemas/Channel"
        display_name:
          type: string
          maxLength: 64
          description: The name contacts should see.
        country:
          type: string
          description: WhatsApp and iMessage. ISO 3166-1 alpha-2 country for the number, for example `IN`.
          pattern: "^[A-Z]{2}$"
        telegram_bot_token:
          type: string
          writeOnly: true
          maxLength: 128
          pattern: "^[0-9]{1,20}:[A-Za-z0-9_-]{20,100}$"
          description: Telegram only, and required there. The bot token from BotFather. Stored encrypted; never returned.
        whatsapp_phone_number_id:
          type: string
          maxLength: 32
          pattern: "^[0-9]{1,32}$"
          description: WhatsApp only, and required there. The phone number ID of your number (WhatsApp Manager, or App Dashboard > WhatsApp > API Setup), not the phone number itself.
          examples: ["106540352242922"]
        whatsapp_business_account_id:
          type: string
          maxLength: 32
          pattern: "^[0-9]{1,32}$"
          description: WhatsApp only, and required there. The ID of the WhatsApp Business Account the number is in.
          examples: ["102290129340398"]
        whatsapp_access_token:
          type: string
          writeOnly: true
          maxLength: 2048
          description: |
            WhatsApp only, and required there. A system user access token of your
            business (it starts with `EAA`) that never expires, with
            `whatsapp_business_messaging` and `whatsapp_business_management`, and
            your WhatsApp Business Account and Meta app assigned to the system
            user. Stored encrypted; never returned.
        whatsapp_app_secret:
          type: string
          writeOnly: true
          maxLength: 64
          description: WhatsApp only, and required there. The App secret of your Meta app (App settings > Basic, 32 hexadecimal characters); Meta signs the number's webhooks with it. Stored encrypted; never returned.
        whatsapp_registration_pin:
          type: string
          writeOnly: true
          pattern: "^[0-9]{6}$"
          description: |
            WhatsApp only, optional. Give it only when the number is not registered
            for the Cloud API yet: Flow registers the number with this six-digit
            two-step verification PIN (the number's current PIN, if it has one).
            Meta allows 10 registrations per number in 72 hours. Stored encrypted;
            never returned.
    SenderList:
      type: object
      description: A page of senders.
      required: [data, has_more]
      properties:
        data:
          type: array
          items:
            $ref: "#/components/schemas/Sender"
        has_more:
          type: boolean
          description: Whether more items lie beyond this page in the direction you paged.

    # ---------------------------------------------------------------- contacts
    Contact:
      type: object
      description: A person on one channel. There is no cross-channel identity, and a contact may have no phone number at all (a Telegram user, a WhatsApp username, an iMessage email handle).
      required: [id, channel, livemode, address, created_at]
      properties:
        id:
          $ref: "#/components/schemas/ContactId"
        channel:
          $ref: "#/components/schemas/Channel"
        livemode:
          type: boolean
          description: Whether the contact belongs to live mode.
        address:
          $ref: "#/components/schemas/ContactAddress"
        name:
          type: string
          description: The contact's display name, as the channel reports it. May change or be missing.
        opted_out_at:
          type: string
          format: date-time
          readOnly: true
          description: |
            SMS. When the contact opted out of your app's SMS messages. Absent when
            they have not, or texted START (or UNSTOP, YES) since to a number they
            opted out through (START to another of your numbers does not lift it,
            since carriers and providers block per number). While it is set, sends
            to them on every SMS sender of your app answer `403 permission` with
            `channel_code` `contact_opted_out`. Every opt-out, opt-in and recorded
            consent is kept in the contact's history.
        opted_out_via:
          type: string
          readOnly: true
          description: |
            SMS. How the contact opted out: `keyword` (they texted STOP or one of
            its variants) or `carrier` (the carrier or provider reported the number
            unsubscribed when Flow sent to it). Set with `opted_out_at`.
          enum: [keyword, carrier]
        consent:
          $ref: "#/components/schemas/ContactConsent"
        simulated:
          type: boolean
          readOnly: true
          description: "`true` for a simulated contact, who exists only inside Flow (test mode only, from `POST /v0/test/conversations`); absent otherwise."
        created_at:
          type: string
          format: date-time
          description: When Flow first saw the contact.
    ContactAddress:
      type: object
      description: How the contact is reached on their channel. Which fields are set depends on the channel; do not assume `phone`.
      properties:
        phone:
          type: string
          description: E.164 phone number (WhatsApp and SMS; iMessage when the handle is a number).
        username:
          type: string
          description: A username without `@` (Telegram, WhatsApp usernames).
        telegram_user_id:
          type: string
          description: Telegram's numeric user ID, as a string.
        handle:
          type: string
          description: iMessage handle (a phone number or an email address).
    Consent:
      type: object
      description: |
        The contact's consent to receive messages from you, as you recorded it
        before starting the conversation. Required to start an SMS conversation
        with someone who has never texted your numbers (US law, the TCPA, needs
        their prior express consent). Flow keeps it on the contact. It does not
        undo an opt-out: only the contact texting START does.
      required: [obtained_at, method]
      properties:
        obtained_at:
          type: string
          format: date-time
          description: When the person gave consent. Not in the future.
        method:
          type: string
          description: How they gave it.
          enum: [web_form, paper_form, verbal, text_message, app, other]
        proof:
          type: string
          maxLength: 500
          description: Your own reference to the record of consent (a form submission ID or URL). Do not put health information here.
    ContactConsent:
      readOnly: true
      description: SMS. The consent recorded with the latest start to this contact that carried one. Absent when none was recorded.
      allOf:
        - $ref: "#/components/schemas/Consent"
        - type: object
          required: [recorded_at]
          properties:
            recorded_at:
              type: string
              format: date-time
              description: When Flow recorded it.
    ContactList:
      type: object
      description: A page of contacts.
      required: [data, has_more]
      properties:
        data:
          type: array
          items:
            $ref: "#/components/schemas/Contact"
        has_more:
          type: boolean
          description: Whether more items lie beyond this page in the direction you paged.
    Recipient:
      type: object
      description: |
        Who to start a conversation with: an existing `contact`, or an address on the
        sender's channel. Give exactly one field.
      minProperties: 1
      maxProperties: 1
      properties:
        contact:
          $ref: "#/components/schemas/ContactId"
        phone:
          type: string
          description: WhatsApp, iMessage or SMS. E.164 phone number; on SMS a US or Canadian number, `+1` and ten digits.
          pattern: "^\\+[1-9][0-9]{6,14}$"
        telegram_user_id:
          type: string
          description: Telegram. The user's numeric ID; they must have started the bot.
        handle:
          type: string
          description: iMessage. A phone number or an email address.

    # ---------------------------------------------------------------- conversations
    ConversationType:
      type: string
      description: |
        Flow always sends it; read a missing one as `direct` (answers from
        before groups existed). `direct`: one sender and one contact (every
        channel). `group`: a group chat
        one of your dedicated iMessage lines was added to; it has `group` and no
        `contact`. Flow's shared iMessage line ignores group chats, so they never
        reach your app from it.
      enum: [direct, group]
      default: direct
      x-enum-varnames: [ConversationDirect, ConversationGroup]
    Conversation:
      type: object
      description: |
        One sender talking with one contact (`type` `direct`), or with an iMessage
        group chat (`type` `group`). Holds the window state and whether the contact
        opted in.
      required: [id, app, channel, sender, livemode, opted_in, created_at]
      properties:
        id:
          $ref: "#/components/schemas/ConversationId"
        app:
          $ref: "#/components/schemas/AppId"
        type:
          $ref: "#/components/schemas/ConversationType"
        channel:
          $ref: "#/components/schemas/Channel"
        sender:
          $ref: "#/components/schemas/SenderId"
        contact:
          $ref: "#/components/schemas/ContactId"
          description: The contact the conversation is with. Always set when `type` is `direct`; absent on group conversations (each message's `from` says who wrote).
        group:
          $ref: "#/components/schemas/Group"
        livemode:
          type: boolean
          description: Whether the conversation belongs to live mode.
        last_inbound_at:
          type: string
          format: date-time
          description: When the contact last wrote. Absent if they never have.
        window_open_until:
          type: string
          format: date-time
          description: WhatsApp only. Until when free-form messages may be sent; after it, only templates. Absent when the window is closed or the channel has none.
        opted_in:
          type: boolean
          description: Whether the contact has agreed to receive messages (wrote first, joined the sandbox, or you recorded consent). Always `true` on group conversations, which begin with the group writing.
        simulated:
          type: boolean
          readOnly: true
          description: "`true` for a simulated conversation, whose contact exists only inside Flow (test mode only, from `POST /v0/test/conversations`); absent otherwise."
        metadata:
          $ref: "#/components/schemas/Metadata"
        created_at:
          type: string
          format: date-time
          description: When the conversation began.
    ConversationRef:
      type: object
      description: The conversation an event happened in, inlined so most handlers need no extra call. A group's participants are not inlined; read the conversation for them.
      required: [id, channel, sender]
      properties:
        id:
          $ref: "#/components/schemas/ConversationId"
        type:
          $ref: "#/components/schemas/ConversationType"
        channel:
          $ref: "#/components/schemas/Channel"
        sender:
          $ref: "#/components/schemas/SenderId"
        contact:
          $ref: "#/components/schemas/ContactId"
          description: The contact the conversation is with. Always set when `type` is `direct`; absent on group conversations (the message's `from` says who wrote).
        group_name:
          type: string
          description: Group conversations only. The group chat's name, when it has one.
        window_open_until:
          type: string
          format: date-time
          description: WhatsApp only. Until when free-form messages may be sent. Absent when closed or not applicable.
        simulated:
          type: boolean
          readOnly: true
          description: "`true` for a simulated conversation, whose contact exists only inside Flow (test mode only, from `POST /v0/test/conversations`); absent otherwise."
    Group:
      type: object
      readOnly: true
      description: |
        Group conversations only (iMessage): the group chat as Flow saw it last.
        People who join or leave change `participants` without an event; Flow
        updates the list when the group next writes. The channel's own group ID is
        never exposed: use the conversation's ID.
      required: [participants]
      properties:
        name:
          type: string
          description: The group chat's name, when it has one.
        participants:
          type: array
          description: The people in the group other than your line, each a contact on the channel (made the first time Flow sees them, like a one-to-one contact).
          items:
            $ref: "#/components/schemas/GroupParticipant"
    GroupParticipant:
      type: object
      description: One person in a group chat.
      required: [contact, address]
      properties:
        contact:
          $ref: "#/components/schemas/ContactId"
        address:
          $ref: "#/components/schemas/ContactAddress"
        name:
          type: string
          description: The person's display name, as the channel reports it. May be missing.
    ConversationList:
      type: object
      description: A page of conversations.
      required: [data, has_more]
      properties:
        data:
          type: array
          items:
            $ref: "#/components/schemas/Conversation"
        has_more:
          type: boolean
          description: Whether more items lie beyond this page in the direction you paged.
    ConversationAction:
      type: object
      description: The result of a typing or read call.
      required: [conversation, action]
      properties:
        conversation:
          $ref: "#/components/schemas/ConversationId"
        action:
          type: string
          description: Which action the channel took. `typing` (the indicator was shown) or `read` (the read receipt was sent).
          enum: [typing, read]
          x-enum-varnames: [ConversationActionTyping, ConversationActionRead]

    # ---------------------------------------------------------------- messages
    Message:
      type: object
      description: |
        One message in or out of a conversation, with its kind and content and its
        delivery status. What you send is what the contact gets: Flow never
        converts a message into another kind.
      required: [id, conversation, direction, status, content, livemode, created_at]
      properties:
        id:
          $ref: "#/components/schemas/MessageId"
        conversation:
          $ref: "#/components/schemas/ConversationId"
        direction:
          $ref: "#/components/schemas/MessageDirection"
        from:
          $ref: "#/components/schemas/ContactId"
          readOnly: true
          description: Inbound messages only, on every channel. The contact who wrote it; in a group conversation, which participant. Absent on your own messages.
        status:
          $ref: "#/components/schemas/MessageStatus"
        content:
          $ref: "#/components/schemas/MessageContent"
        network:
          $ref: "#/components/schemas/MessageNetwork"
        error:
          $ref: "#/components/schemas/ErrorBody"
        channel_message_id:
          type: string
          description: The channel's own ID for the message, once the channel accepted it.
        sms:
          $ref: "#/components/schemas/SmsSegments"
        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 or WhatsApp 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.
    SmsSegments:
      type: object
      readOnly: true
      description: |
        SMS text messages only: how the text travels as SMS. Carriers bill and
        deliver SMS in segments: 160 GSM-7 characters fit one segment (153 each
        when split), but a single character outside GSM-7 (an emoji, curly quotes,
        most non-Latin letters) makes the whole text UCS-2, 70 characters in one
        segment (67 each when split). An SMS send may use at most 10 segments:
        1530 GSM-7 characters (fewer when it uses GSM-7's two-unit characters such
        as `{` or `€`), or 670 when it holds an emoji or other character outside
        GSM-7; a longer text is refused with `400 invalid_request`. Absent on MMS
        and on other channels.
      required: [segments, encoding]
      properties:
        segments:
          type: integer
          minimum: 1
          description: How many SMS segments the text takes.
        encoding:
          type: string
          description: "`gsm7` or `ucs2`."
          enum: [gsm7, ucs2]
    MessageNetwork:
      type: string
      readOnly: true
      description: |
        iMessage channel only: the network that carried the message. `imessage`,
        or `sms` or `rcs` when the line has the SMS/RCS add-on
        (`Sender.imessage.fallback`) and the contact could not get iMessage. The
        network is chosen for each message when it is sent, never by your app.
        Set on inbound messages, and on your messages once the channel reports it
        (in `message.sent`, `message.delivered` or `message.failed`, as
        `data.message.network`); absent before that and on other channels. A
        message that went by `sms` or `rcs` showed no `imessage.styles`, no
        `imessage.subject` and no effect, and edits and unsends of it answer `422
        unsupported_content` with `channel_code` `not_on_sms`.
      enum: [imessage, sms, rcs]
      x-enum-varnames: [NetworkImessage, NetworkSms, NetworkRcs]
    MessageDirection:
      type: string
      description: "`in` from the contact, `out` from your app."
      enum: [in, out]
    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]
    MessageList:
      type: object
      description: A page of messages.
      required: [data, has_more]
      properties:
        data:
          type: array
          items:
            $ref: "#/components/schemas/Message"
        has_more:
          type: boolean
          description: Whether more items lie beyond this page in the direction you paged.
    StartConversationRequest:
      description: |
        The first message from one of your senders to a contact: a `basic` or a
        `template` message, with `kind` saying which (required here), plus
        `sender`, `to` and, on SMS, `consent`. A field not listed for the kind is
        refused with `400 invalid_request` naming it in `param`, never ignored; a
        `kind` other than `basic` or `template` answers `422 unsupported_content`
        with `param` `kind`. On iMessage the sender must be able to write first
        (`Sender.imessage.can_initiate`, available on request); otherwise the
        start answers `403 permission`. A start always makes a `direct`
        conversation: there is no way to start a group. There are no scheduled
        sends: to start a conversation later, wait in your own code and send then.
      oneOf:
        - $ref: "#/components/schemas/StartBasicMessage"
        - $ref: "#/components/schemas/StartTemplateMessage"
      discriminator:
        propertyName: kind
        mapping:
          basic: "#/components/schemas/StartBasicMessage"
          template: "#/components/schemas/StartTemplateMessage"
    StartConversationTarget:
      type: object
      description: Where a start goes. Part of every `StartConversationRequest`.
      required: [sender, to]
      properties:
        sender:
          $ref: "#/components/schemas/SenderId"
        to:
          $ref: "#/components/schemas/Recipient"
        consent:
          $ref: "#/components/schemas/Consent"
          description: SMS only. The contact's consent, required when they have never texted any of your SMS numbers; recorded on the contact. On another channel it answers `422 unsupported_content` with `param` `consent`.
    StartBasicMessage:
      description: A start whose first message is a basic message (`kind` `basic`).
      allOf:
        - $ref: "#/components/schemas/StartConversationTarget"
        - $ref: "#/components/schemas/BasicMessage"
        - required: [kind]
    StartTemplateMessage:
      description: A start whose first message is a WhatsApp template (`kind` `template`).
      allOf:
        - $ref: "#/components/schemas/StartConversationTarget"
        - $ref: "#/components/schemas/TemplateMessage"
        - required: [kind]
    EditMessageRequest:
      type: object
      description: |
        The new text of a sent message (`PATCH /v0/messages/{message_id}`), sent
        exactly as written. A field not listed here is refused with `400
        invalid_request` naming it in `param`; a channel block for another channel
        answers `422 unsupported_content` with `param` the block's name.
      required: [text]
      properties:
        text:
          type: string
          minLength: 1
          maxLength: 9999
          description: |
            The new text: at most 4096 UTF-16 code units on Telegram (1024 when it
            replaces a media message's caption) and 9999 characters on iMessage.
            Longer text is refused with `400 invalid_request`.
        telegram:
          $ref: "#/components/schemas/EditTelegramOptions"
        imessage:
          $ref: "#/components/schemas/EditImessageOptions"
    EditTelegramOptions:
      type: object
      description: Telegram only. How Telegram reads the new text. An unknown key is refused with `400 invalid_request`.
      additionalProperties: false
      properties:
        parse_mode:
          $ref: "#/components/schemas/TelegramParseMode"
    EditImessageOptions:
      type: object
      description: iMessage only. Styles for the new text. An unknown key is refused with `400 invalid_request`.
      additionalProperties: false
      properties:
        styles:
          type: array
          description: Styled ranges of the new `text` (see `ImessageStyle`).
          items:
            $ref: "#/components/schemas/ImessageStyle"

    # ---------------------------------------------------------------- message kinds
    MessageKind:
      type: string
      description: |
        The kinds of message. A closed list: a new kind arrives only with a new API
        version. Each kind has its own schema (`BasicMessage`, `VoiceMessage`, ...)
        and, where it can be sent over HTTP, its own endpoint of the same name
        (`POST /v0/conversations/{conversation_id}/<kind>`, with `contact-card` for
        `contact_card`). `edit` and `unsend` are sent over HTTP with `PATCH` and
        `DELETE /v0/messages/{message_id}`, and are kinds in webhook replies, on
        the stream and in a message's `content`. `button_reply` and `file_blocked`
        are inbound only.

        Received from a contact: `basic`, `voice`, `button_reply`, `reaction`,
        `location`, `contact_card` and `file_blocked`. Sent: every kind but
        `button_reply` and `file_blocked`.
      enum: [basic, voice, buttons, list, carousel, template, contact_card, location, effect, reaction, typing, read, edit, unsend, button_reply, file_blocked]
    MessageContent:
      type: object
      description: |
        A message's content: one kind object, in either direction, with `kind`
        (always present here) saying which. It is exactly what was sent or
        received, except `metadata`, which is kept once, at the message's own
        `metadata`, and not repeated here.
      required: [kind]
      properties:
        kind:
          $ref: "#/components/schemas/MessageKind"
      oneOf:
        - $ref: "#/components/schemas/BasicKind"
        - $ref: "#/components/schemas/VoiceKind"
        - $ref: "#/components/schemas/ButtonsKind"
        - $ref: "#/components/schemas/ListKind"
        - $ref: "#/components/schemas/CarouselKind"
        - $ref: "#/components/schemas/TemplateKind"
        - $ref: "#/components/schemas/ContactCardKind"
        - $ref: "#/components/schemas/LocationKind"
        - $ref: "#/components/schemas/EffectKind"
        - $ref: "#/components/schemas/ReactionKind"
        - $ref: "#/components/schemas/TypingKind"
        - $ref: "#/components/schemas/ReadKind"
        - $ref: "#/components/schemas/EditMessage"
        - $ref: "#/components/schemas/UnsendMessage"
        - $ref: "#/components/schemas/ButtonReplyMessage"
        - $ref: "#/components/schemas/FileBlockedMessage"
      discriminator:
        propertyName: kind
        mapping:
          basic: "#/components/schemas/BasicKind"
          voice: "#/components/schemas/VoiceKind"
          buttons: "#/components/schemas/ButtonsKind"
          list: "#/components/schemas/ListKind"
          carousel: "#/components/schemas/CarouselKind"
          template: "#/components/schemas/TemplateKind"
          contact_card: "#/components/schemas/ContactCardKind"
          location: "#/components/schemas/LocationKind"
          effect: "#/components/schemas/EffectKind"
          reaction: "#/components/schemas/ReactionKind"
          typing: "#/components/schemas/TypingKind"
          read: "#/components/schemas/ReadKind"
          edit: "#/components/schemas/EditMessage"
          unsend: "#/components/schemas/UnsendMessage"
          button_reply: "#/components/schemas/ButtonReplyMessage"
          file_blocked: "#/components/schemas/FileBlockedMessage"
    OutboundKind:
      type: object
      description: |
        One message to send where no URL names the kind: a piece of a webhook
        reply (`WebhookReply`) or the `message` of a stream `send` frame. `kind`
        is required and selects the shape; the fields and channel rules are the
        same as on the kind's endpoint (`edit` and `unsend` as on `PATCH` and
        `DELETE /v0/messages/{message_id}`, with the target in `message_id`).
      required: [kind]
      oneOf:
        - $ref: "#/components/schemas/BasicKind"
        - $ref: "#/components/schemas/VoiceKind"
        - $ref: "#/components/schemas/ButtonsKind"
        - $ref: "#/components/schemas/ListKind"
        - $ref: "#/components/schemas/CarouselKind"
        - $ref: "#/components/schemas/TemplateKind"
        - $ref: "#/components/schemas/ContactCardKind"
        - $ref: "#/components/schemas/LocationKind"
        - $ref: "#/components/schemas/EffectKind"
        - $ref: "#/components/schemas/ReactionKind"
        - $ref: "#/components/schemas/TypingKind"
        - $ref: "#/components/schemas/ReadKind"
        - $ref: "#/components/schemas/EditMessage"
        - $ref: "#/components/schemas/UnsendMessage"
      discriminator:
        propertyName: kind
        mapping:
          basic: "#/components/schemas/BasicKind"
          voice: "#/components/schemas/VoiceKind"
          buttons: "#/components/schemas/ButtonsKind"
          list: "#/components/schemas/ListKind"
          carousel: "#/components/schemas/CarouselKind"
          template: "#/components/schemas/TemplateKind"
          contact_card: "#/components/schemas/ContactCardKind"
          location: "#/components/schemas/LocationKind"
          effect: "#/components/schemas/EffectKind"
          reaction: "#/components/schemas/ReactionKind"
          typing: "#/components/schemas/TypingKind"
          read: "#/components/schemas/ReadKind"
          edit: "#/components/schemas/EditMessage"
          unsend: "#/components/schemas/UnsendMessage"
      examples:
        - {kind: basic, text: "Here are the sizes we have."}
        - kind: buttons
          text: "Which size?"
          buttons:
            - {id: size_s, label: Small}
            - {id: size_m, label: Medium}
    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]
    ButtonsKind:
      description: A `buttons` message with `kind` given, as in a message's `content`, a webhook reply or a stream frame (`ButtonsMessage` with `kind` required).
      allOf:
        - $ref: "#/components/schemas/ButtonsMessage"
        - required: [kind]
    ListKind:
      description: A `list` message with `kind` given, as in a message's `content`, a webhook reply or a stream frame (`ListMessage` with `kind` required).
      allOf:
        - $ref: "#/components/schemas/ListMessage"
        - required: [kind]
    CarouselKind:
      description: A `carousel` message with `kind` given, as in a message's `content`, a webhook reply or a stream frame (`CarouselMessage` with `kind` required).
      allOf:
        - $ref: "#/components/schemas/CarouselMessage"
        - required: [kind]
    TemplateKind:
      description: A `template` message with `kind` given, as in a message's `content`, a webhook reply or a stream frame (`TemplateMessage` with `kind` required).
      allOf:
        - $ref: "#/components/schemas/TemplateMessage"
        - 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]
    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]
    EffectKind:
      description: An `effect` message with `kind` given, as in a message's `content`, a webhook reply or a stream frame (`EffectMessage` with `kind` required).
      allOf:
        - $ref: "#/components/schemas/EffectMessage"
        - required: [kind]
    ReactionKind:
      description: A `reaction` message with `kind` given, as in a message's `content`, a webhook reply or a stream frame (`ReactionMessage` with `kind` required).
      allOf:
        - $ref: "#/components/schemas/ReactionMessage"
        - required: [kind]
    TypingKind:
      description: A `typing` message with `kind` given, as in a message's `content`, a webhook reply or a stream frame (`TypingMessage` with `kind` required).
      allOf:
        - $ref: "#/components/schemas/TypingMessage"
        - required: [kind]
    ReadKind:
      description: A `read` message with `kind` given, as in a message's `content`, a webhook reply or a stream frame (`ReadMessage` with `kind` required).
      allOf:
        - $ref: "#/components/schemas/ReadMessage"
        - required: [kind]
    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.
    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"
    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.
    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]
    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.
    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]
    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.
    ButtonsMessage:
      type: object
      description: |
        Text with buttons, sent only (`POST
        /v0/conversations/{conversation_id}/buttons`), optionally under a
        `header`. A tap on a reply button arrives as a `button_reply` message
        carrying the button's `id`. URL buttons open a link and send nothing back.
      required: [text, buttons]
      properties:
        kind:
          type: string
          const: buttons
          default: buttons
          description: Always `buttons`. Optional on its endpoint; required where no URL names the kind.
        header:
          $ref: "#/components/schemas/ContentHeader"
        text:
          type: string
          minLength: 1
          maxLength: 4096
          description: |
            The text above the buttons, sent exactly as written: at most 4096 UTF-16
            code units on Telegram, or 1024 when the message has a media `header`
            (Telegram's caption limit), and 1024 characters on WhatsApp (its limit
            for messages with buttons). Longer text is refused with `400
            invalid_request`.
        buttons:
          type: array
          minItems: 1
          maxItems: 10
          description: |
            The buttons, in order: 1 to 10 on Telegram; on WhatsApp 1 to 3 reply
            buttons or exactly one URL button (anything else answers `422
            unsupported_content` with `param` `buttons`; send a `list` for more
            choices).
          items:
            $ref: "#/components/schemas/Button"
        reply_to:
          $ref: "#/components/schemas/ReplyTo"
        telegram:
          $ref: "#/components/schemas/ButtonsTelegramOptions"
        metadata:
          $ref: "#/components/schemas/Metadata"
    ButtonsTelegramOptions:
      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.
    ContentHeader:
      type: object
      description: |
        What is shown above a buttons message's text, or a carousel card's image or
        video: an image, a video, a document or a short title. A media header
        takes exactly one of `url` (an HTTPS URL Flow fetches) or `file_id` (from
        `POST /v0/files`), and is fetched and checked for the channel like a basic
        message's `media` (so WebP and other formats work); a `text` header takes
        `text`. Anything else, or a value over a limit, is refused with `400
        invalid_request` naming the field in `param`.

        WhatsApp shows the header at the top of the same message bubble. Telegram
        sends one message: the media with the text as its caption and the buttons
        under it; a `text` header is the message's first line, in bold.
      required: [type]
      properties:
        type:
          type: string
          enum: [image, video, document, text]
          description: What the header holds.
        url:
          type: string
          format: uri
          description: Media headers only. An HTTPS URL of the media, which Flow fetches.
        file_id:
          $ref: "#/components/schemas/FileId"
        text:
          type: string
          minLength: 1
          maxLength: 60
          description: "`text` headers only. The title, 1 to 60 characters (WhatsApp's header limit)."
        filename:
          type: string
          maxLength: 255
          description: "`document` headers only. The file's name, shown with the document."
      examples:
        - {type: image, url: "https://example.com/menu.webp"}
        - {type: text, text: "Your order"}
    Button:
      description: A reply button (`id` and `label`) or a link button (`url` and `label`).
      oneOf:
        - $ref: "#/components/schemas/ReplyButton"
        - $ref: "#/components/schemas/UrlButton"
    ReplyButton:
      type: object
      description: A button that sends its `id` back as a `button_reply` when tapped.
      required: [id, label]
      properties:
        id:
          type: string
          minLength: 1
          maxLength: 64
          description: |
            Your ID for the button, returned in `button_reply` as `button_id`: 1 to 64
            bytes (Telegram's limit for a button's data), so at most 64 ASCII
            characters and fewer for other characters. Labels may repeat; the ID is
            what tells the taps apart, so buttons that share a label (a "Details" on
            every card) get their own IDs, such as `details:listing-17`.
        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.
    ListMessage:
      type: object
      description: |
        WhatsApp's list message, sent only (`POST
        /v0/conversations/{conversation_id}/list`): the text, a button that opens
        the list, and up to 10 rows in sections. A tap on a row arrives as a
        `button_reply` message whose `button_id` is the row's `id` and whose
        `label` is its title. Row `id`s are unique across the list; a repeated one
        is refused with `400 invalid_request`.
      required: [text, button, sections]
      properties:
        kind:
          type: string
          const: list
          default: list
          description: Always `list`. Optional on its endpoint; required where no URL names the kind.
        header:
          type: string
          minLength: 1
          maxLength: 60
          description: A title shown above the text, 1 to 60 characters.
        text:
          type: string
          minLength: 1
          maxLength: 4096
          description: The message's text, sent exactly as written, 1 to 4096 characters.
        button:
          type: string
          minLength: 1
          maxLength: 20
          description: The label of the button that opens the list, 1 to 20 characters.
        sections:
          type: array
          minItems: 1
          maxItems: 10
          description: 1 to 10 sections, in order, with at most 10 rows in all.
          items:
            $ref: "#/components/schemas/ListSection"
        reply_to:
          $ref: "#/components/schemas/ReplyTo"
        metadata:
          $ref: "#/components/schemas/Metadata"
    ListSection:
      type: object
      description: One section of a list.
      required: [rows]
      properties:
        title:
          type: string
          minLength: 1
          maxLength: 24
          description: The section's title, 1 to 24 characters. Required when the list has more than one section.
        rows:
          type: array
          minItems: 1
          maxItems: 10
          description: The section's rows, in order.
          items:
            $ref: "#/components/schemas/ListRow"
    ListRow:
      type: object
      description: One option of a list.
      required: [id, title]
      properties:
        id:
          type: string
          minLength: 1
          maxLength: 200
          description: Your ID for the row, returned in `button_reply` as `button_id`. 1 to 200 characters, unique across the list.
        title:
          type: string
          minLength: 1
          maxLength: 24
          description: The row's title, 1 to 24 characters, returned in `button_reply` as `label`.
        description:
          type: string
          minLength: 1
          maxLength: 72
          description: A line under the title, at most 72 characters.
    CarouselMessage:
      type: object
      description: |
        A row of 2 to 10 cards the contact scrolls sideways, under one text, sent
        only (`POST /v0/conversations/{conversation_id}/carousel`; WhatsApp). Each
        card has an image or video, optional text, and its own buttons. A tap on a
        reply button arrives as a `button_reply` message carrying the button's
        `id`; URL buttons open a link and send nothing back. Every card has the
        same number and kind of buttons; a card has either exactly one URL button
        or 1 to 2 reply buttons; reply button `id`s are unique across the
        carousel. Each rule is refused with `400 invalid_request` when broken.
      required: [text, cards]
      properties:
        kind:
          type: string
          const: carousel
          default: carousel
          description: Always `carousel`. Optional on its endpoint; required where no URL names the kind.
        text:
          type: string
          minLength: 1
          maxLength: 1024
          description: The message text shown above the cards, 1 to 1024 characters. Longer text is refused with `400 invalid_request`.
        cards:
          type: array
          minItems: 2
          maxItems: 10
          description: 2 to 10 cards, in the order shown (left to right).
          items:
            $ref: "#/components/schemas/CarouselCard"
        metadata:
          $ref: "#/components/schemas/Metadata"
      examples:
        - text: "Three picks for you"
          cards:
            - header: {type: image, url: "https://example.com/a.jpg"}
              text: "Linen shirt, 1,499"
              buttons: [{id: "buy_a", label: "Buy"}]
            - header: {type: image, url: "https://example.com/b.jpg"}
              text: "Denim jacket, 2,999"
              buttons: [{id: "buy_b", label: "Buy"}]
    CarouselCard:
      type: object
      description: One card of a carousel.
      required: [header, buttons]
      properties:
        header:
          $ref: "#/components/schemas/ContentHeader"
          description: The card's image or video, a `ContentHeader` of type `image` or `video` only (by `url` or `file_id`); any other type is refused with `400 invalid_request` at `cards[i].header.type`.
        text:
          type: string
          minLength: 1
          maxLength: 160
          description: The card's text, at most 160 characters and 2 line breaks.
        buttons:
          type: array
          minItems: 1
          maxItems: 2
          description: Exactly one URL button, or 1 to 2 reply buttons; the same number and kind on every card. Labels are at most 20 characters.
          items:
            $ref: "#/components/schemas/Button"
    TemplateMessage:
      type: object
      description: |
        A WhatsApp message template, sent only (`POST
        /v0/conversations/{conversation_id}/template`, or `POST /v0/messages` to
        start a conversation). Required to start a WhatsApp conversation or to
        write after the 24-hour window closed. The template must be `approved`.

        A media card carousel template (marketing category) is sent the same way,
        with one entry per card in `params.cards`. Inside the 24-hour window a
        carousel needs no template: send a `carousel` message.
      required: [template_id, language]
      properties:
        kind:
          type: string
          const: template
          default: template
          description: Always `template`. Optional on its endpoint; required where no URL names the kind (and on `POST /v0/messages`).
        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"
        reply_to:
          $ref: "#/components/schemas/ReplyTo"
        metadata:
          $ref: "#/components/schemas/Metadata"
    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"
        cards:
          type: array
          maxItems: 10
          description: Carousel templates only. Values for each card's header, body placeholders and buttons.
          items:
            $ref: "#/components/schemas/TemplateCardParams"
    TemplateCardParams:
      type: object
      description: The values for one card of a carousel template.
      required: [card_index]
      properties:
        card_index:
          type: integer
          minimum: 0
          maximum: 9
          description: The card's position in the template, from 0.
        header:
          $ref: "#/components/schemas/TemplateHeaderParam"
          description: The card's image or video (`kind` `image` or `video`, by `url` or `file_id`).
        body:
          type: array
          description: Values for the card body's `{{1}}`, `{{2}}`, ... in order.
          items:
            type: string
        buttons:
          type: array
          description: Values for the card's buttons that take one (URL suffixes, quick-reply payloads).
          items:
            $ref: "#/components/schemas/TemplateButtonParam"
      examples:
        - card_index: 0
          header: {kind: image, url: "https://example.com/a.jpg"}
          body: ["Linen shirt"]
          buttons: [{index: 0, value: "buy_a"}]
    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.
    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"
    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"
    EffectMessage:
      type: object
      description: Text sent with an iMessage effect, sent only (`POST /v0/conversations/{conversation_id}/effect`; iMessage). Which effects a contact's device shows may vary.
      required: [text, effect]
      properties:
        kind:
          type: string
          const: effect
          default: effect
          description: Always `effect`. Optional on its endpoint; required where no URL names the kind.
        text:
          type: string
          minLength: 1
          maxLength: 9999
          description: The text, sent exactly as written, at most 9999 characters. Longer text is refused with `400 invalid_request`.
        effect:
          type: string
          description: The effect.
          enum: [slam, loud, gentle, invisible_ink, echo, spotlight, balloons, confetti, love, lasers, fireworks, celebration]
        reply_to:
          $ref: "#/components/schemas/ReplyTo"
        metadata:
          $ref: "#/components/schemas/Metadata"
    ReactionMessage:
      type: object
      description: |
        A reaction to a message, in either direction (`POST
        /v0/conversations/{conversation_id}/reaction` sends one). Set `emoji` to
        `null` to remove your reaction. Inbound reactions also arrive as
        `reaction.added` and `reaction.removed` events.
      required: [message_id, emoji]
      properties:
        kind:
          type: string
          const: reaction
          default: reaction
          description: Always `reaction`. Optional on its endpoint; required where no URL names the kind.
        message_id:
          $ref: "#/components/schemas/MessageId"
        emoji:
          type: [string, "null"]
          maxLength: 16
          description: One emoji, or `null` to remove the reaction. On Telegram only its reaction emoji (listed on the endpoint); any other answers `422 unsupported_content` with `param` `emoji`.
        telegram:
          $ref: "#/components/schemas/ReactionTelegramOptions"
    ReactionTelegramOptions:
      type: object
      description: |
        Telegram only. On another channel the block answers `422
        unsupported_content` with `param` `telegram`; an unknown key `400
        invalid_request`.
      additionalProperties: false
      properties:
        is_big:
          type: boolean
          description: Show the reaction with a large animation.
    TypingMessage:
      type: object
      description: |
        The typing indicator, sent only (`POST
        /v0/conversations/{conversation_id}/typing`; a `typing` piece in a webhook
        reply or on the stream does the same). It shows the indicator, which
        clears itself when your next message goes or after a few seconds.
      properties:
        kind:
          type: string
          const: typing
          default: typing
          description: Always `typing`. Optional on its endpoint; required where no URL names the kind.
        imessage:
          $ref: "#/components/schemas/TypingImessageOptions"
    TypingImessageOptions:
      type: object
      description: |
        iMessage only. On another channel the block answers `422
        unsupported_content` with `param` `imessage`; an unknown key `400
        invalid_request`.
      additionalProperties: false
      properties:
        duration_seconds:
          type: integer
          minimum: 1
          maximum: 60
          description: How long to show the indicator, 1 to 60 seconds, unless a message goes first.
    ReadMessage:
      type: object
      description: |
        A read receipt, sent only (`POST /v0/conversations/{conversation_id}/read`;
        a `read` piece in a webhook reply or on the stream does the same).
      properties:
        kind:
          type: string
          const: read
          default: read
          description: Always `read`. Optional on its endpoint; required where no URL names the kind.
        up_to:
          $ref: "#/components/schemas/MessageId"
          description: WhatsApp only. The latest inbound message to mark read (default the latest inbound message). iMessage marks the whole conversation read, so `up_to` there answers `422 unsupported_content` with `param` `up_to`.
    EditMessage:
      description: |
        A change to the text of one of your sent messages, as a kind: a piece of a
        webhook reply or on the stream, and the `content` of the message that
        carries an edit (`PATCH /v0/messages/{message_id}` over HTTP, whose rules
        apply). `message_id` names the message to change.
      allOf:
        - $ref: "#/components/schemas/EditMessageRequest"
        - type: object
          required: [kind, message_id]
          properties:
            kind:
              type: string
              const: edit
              description: Always `edit`.
            message_id:
              $ref: "#/components/schemas/MessageId"
    UnsendMessage:
      type: object
      description: |
        Removes one of your sent messages from the contact's chat, as a kind: a
        piece of a webhook reply or on the stream, and the `content` of the message
        that carries an unsend (`DELETE /v0/messages/{message_id}` over HTTP,
        whose rules apply).
      required: [kind, message_id]
      properties:
        kind:
          type: string
          const: unsend
          description: Always `unsend`.
        message_id:
          $ref: "#/components/schemas/MessageId"
    ButtonReplyMessage:
      type: object
      description: Inbound only. The contact tapped a reply button of a buttons or carousel message, or a row of a list.
      required: [kind, button_id, label]
      properties:
        kind:
          type: string
          const: button_reply
          description: Always `button_reply`.
        button_id:
          type: string
          description: The `id` of the tapped button or list row.
        label:
          type: string
          description: The tapped button's label, or the list row's title.
        message_id:
          $ref: "#/components/schemas/MessageId"
    FileBlockedMessage:
      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, and one Flow could not scan arrives with `reason`
        `scan_failed`.
      required: [kind, reason]
      properties:
        kind:
          type: string
          const: 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`."
        media_type:
          type: string
          enum: [image, video, document, audio]
          description: What kind of file the channel said it was.
        filename:
          type: string
          description: The file's name, if it had one.
        mime_type:
          type: string
          description: The real type, read from the file's first bytes, when Flow read them.
        size_bytes:
          type: integer
          description: The file's size, when known.
        caption:
          type: string
          description: Text the contact sent with the file.

    # ---------------------------------------------------------------- events
    EventType:
      type: string
      description: |
        - `message.received`: the contact sent a message (`data.message.content`, with `kind`: `basic`, `voice`, `button_reply` for a tap on a button or list row, `reaction`, `location`, `contact_card` or `file_blocked`).
        - `message.sent`, `message.delivered`, `message.read`, `message.failed`: the status of your outbound messages. `message.failed` carries the error in `data.message.error`.
        - `reaction.added`, `reaction.removed`: the contact reacted to a message.
        - `typing.started`, `typing.stopped`: the contact is typing, where the channel reports it.
        - `conversation.started`: the first inbound message from a new contact, a sandbox join (`via` `sandbox_join`: Flow itself confirms the join, then the join message reaches you as the contact's first `message.received`), or your first send to a new contact (`via` `outbound`).
        - `conversation.window_closing`: WhatsApp only, opt-in. The 24-hour window closes in 1 hour.
        - `sender.status_changed`: a sender was throttled, flagged, banned or restored, or its WhatsApp quality rating changed.
        - `template.status_changed`: Meta approved, rejected or paused a template.
        - `contact.deleted`: a contact was deleted with `DELETE /v0/contacts/{contact_id}`. Carries only the contact's ID; delete what you keep about them too.
        - `contact.left`: a person on a shared sender (the sandbox) joined another app, or the other mode of your app, by sending its join message. Nothing more reaches you from them, and sends to them fail with `sandbox_contact_left`, until they send your join message again.
        - `call.received`: iMessage only. A contact tried to FaceTime the line. The line cannot answer it, and the call does not count as the contact writing: a reply-only line can message them only after they write.
      enum:
        - message.received
        - message.sent
        - message.delivered
        - message.read
        - message.failed
        - reaction.added
        - reaction.removed
        - typing.started
        - typing.stopped
        - conversation.started
        - conversation.window_closing
        - sender.status_changed
        - template.status_changed
        - contact.deleted
        - contact.left
        - call.received
    Timing:
      type: object
      description: |
        When the event moved through Flow, to measure Flow's overhead
        (`delivered_at` minus `received_at`).
      required: [received_at, stored_at]
      properties:
        received_at:
          type: string
          format: date-time
          description: When Flow received it from the channel, or when it happened inside Flow.
        stored_at:
          type: string
          format: date-time
          description: When it was committed to the log.
        delivered_at:
          type: string
          format: date-time
          description: |
            When the event went out to a webhook endpoint. Its value depends on
            where you read it. In a webhook delivery's body, it is when that delivery
            attempt started (each attempt to each endpoint gets its own), unless an
            endpoint had already accepted the event, in which case it is that
            acceptance time. In the event log (`GET /v0/events`,
            `GET /v0/events/{event_id}`, the stream), it is when the first endpoint
            accepted the event, and it is absent until one has (always, for an app
            with no webhook endpoints).
    EventBase:
      type: object
      description: The fields every event has.
      required: [id, created_at, app, livemode, timing]
      properties:
        id:
          $ref: "#/components/schemas/EventId"
        created_at:
          type: string
          format: date-time
          description: When the event was created. Events are ordered in the log by commit, not by this time.
        app:
          $ref: "#/components/schemas/AppId"
        livemode:
          type: boolean
          description: Whether the event belongs to live mode.
        conversation:
          $ref: "#/components/schemas/ConversationRef"
        timing:
          $ref: "#/components/schemas/Timing"
    Event:
      description: |
        One entry in your app's log. `type` says what happened and selects the shape
        of `data`. `conversation` is set for every event that happened in a
        conversation (all but `sender.status_changed`, `template.status_changed` and
        `contact.deleted`).
      oneOf:
        - $ref: "#/components/schemas/MessageReceivedEvent"
        - $ref: "#/components/schemas/MessageSentEvent"
        - $ref: "#/components/schemas/MessageDeliveredEvent"
        - $ref: "#/components/schemas/MessageReadEvent"
        - $ref: "#/components/schemas/MessageFailedEvent"
        - $ref: "#/components/schemas/ReactionAddedEvent"
        - $ref: "#/components/schemas/ReactionRemovedEvent"
        - $ref: "#/components/schemas/TypingStartedEvent"
        - $ref: "#/components/schemas/TypingStoppedEvent"
        - $ref: "#/components/schemas/ConversationStartedEvent"
        - $ref: "#/components/schemas/ConversationWindowClosingEvent"
        - $ref: "#/components/schemas/SenderStatusChangedEvent"
        - $ref: "#/components/schemas/TemplateStatusChangedEvent"
        - $ref: "#/components/schemas/ContactDeletedEvent"
        - $ref: "#/components/schemas/ContactLeftEvent"
        - $ref: "#/components/schemas/CallReceivedEvent"
      discriminator:
        propertyName: type
        mapping:
          message.received: "#/components/schemas/MessageReceivedEvent"
          message.sent: "#/components/schemas/MessageSentEvent"
          message.delivered: "#/components/schemas/MessageDeliveredEvent"
          message.read: "#/components/schemas/MessageReadEvent"
          message.failed: "#/components/schemas/MessageFailedEvent"
          reaction.added: "#/components/schemas/ReactionAddedEvent"
          reaction.removed: "#/components/schemas/ReactionRemovedEvent"
          typing.started: "#/components/schemas/TypingStartedEvent"
          typing.stopped: "#/components/schemas/TypingStoppedEvent"
          conversation.started: "#/components/schemas/ConversationStartedEvent"
          conversation.window_closing: "#/components/schemas/ConversationWindowClosingEvent"
          sender.status_changed: "#/components/schemas/SenderStatusChangedEvent"
          template.status_changed: "#/components/schemas/TemplateStatusChangedEvent"
          contact.deleted: "#/components/schemas/ContactDeletedEvent"
          contact.left: "#/components/schemas/ContactLeftEvent"
          call.received: "#/components/schemas/CallReceivedEvent"
    MessageEventData:
      type: object
      description: The message the event is about.
      required: [message]
      properties:
        message:
          $ref: "#/components/schemas/Message"
    MessageReceivedEvent:
      description: The contact sent a message. `data.message.content` is what they sent.
      allOf:
        - $ref: "#/components/schemas/EventBase"
        - type: object
          required: [type, data]
          properties:
            type:
              type: string
              enum: [message.received]
              description: Always `message.received`.
            data:
              $ref: "#/components/schemas/MessageEventData"
    MessageSentEvent:
      description: The channel accepted one of your messages.
      allOf:
        - $ref: "#/components/schemas/EventBase"
        - type: object
          required: [type, data]
          properties:
            type:
              type: string
              enum: [message.sent]
              description: Always `message.sent`.
            data:
              $ref: "#/components/schemas/MessageEventData"
    MessageDeliveredEvent:
      description: One of your messages reached the contact's device.
      allOf:
        - $ref: "#/components/schemas/EventBase"
        - type: object
          required: [type, data]
          properties:
            type:
              type: string
              enum: [message.delivered]
              description: Always `message.delivered`.
            data:
              $ref: "#/components/schemas/MessageEventData"
    MessageReadEvent:
      description: The contact read one of your messages.
      allOf:
        - $ref: "#/components/schemas/EventBase"
        - type: object
          required: [type, data]
          properties:
            type:
              type: string
              enum: [message.read]
              description: Always `message.read`.
            data:
              $ref: "#/components/schemas/MessageEventData"
    MessageFailedEvent:
      description: One of your messages could not be sent. `data.message.error` says why, mapped to an `ErrorType`.
      allOf:
        - $ref: "#/components/schemas/EventBase"
        - type: object
          required: [type, data]
          properties:
            type:
              type: string
              enum: [message.failed]
              description: Always `message.failed`.
            data:
              $ref: "#/components/schemas/MessageEventData"
    ReactionEventData:
      type: object
      description: A reaction the contact added or removed.
      required: [message_id, emoji]
      properties:
        from:
          $ref: "#/components/schemas/ContactId"
          description: The contact who reacted, where the channel reports it (in a group conversation, which participant).
        message_id:
          $ref: "#/components/schemas/MessageId"
        emoji:
          type: string
          description: The emoji (iMessage tapbacks are mapped to their closest emoji).
    ReactionAddedEvent:
      description: The contact reacted to a message.
      allOf:
        - $ref: "#/components/schemas/EventBase"
        - type: object
          required: [type, data]
          properties:
            type:
              type: string
              enum: [reaction.added]
              description: Always `reaction.added`.
            data:
              $ref: "#/components/schemas/ReactionEventData"
    ReactionRemovedEvent:
      description: The contact removed a reaction.
      allOf:
        - $ref: "#/components/schemas/EventBase"
        - type: object
          required: [type, data]
          properties:
            type:
              type: string
              enum: [reaction.removed]
              description: Always `reaction.removed`.
            data:
              $ref: "#/components/schemas/ReactionEventData"
    TypingEventData:
      type: object
      description: Who is typing.
      required: [contact]
      properties:
        contact:
          $ref: "#/components/schemas/ContactId"
    TypingStartedEvent:
      description: The contact started typing, where the channel reports it.
      allOf:
        - $ref: "#/components/schemas/EventBase"
        - type: object
          required: [type, data]
          properties:
            type:
              type: string
              enum: [typing.started]
              description: Always `typing.started`.
            data:
              $ref: "#/components/schemas/TypingEventData"
    TypingStoppedEvent:
      description: The contact stopped typing, where the channel reports it.
      allOf:
        - $ref: "#/components/schemas/EventBase"
        - type: object
          required: [type, data]
          properties:
            type:
              type: string
              enum: [typing.stopped]
              description: Always `typing.stopped`.
            data:
              $ref: "#/components/schemas/TypingEventData"
    ConversationStartedData:
      type: object
      description: The new conversation and how it began.
      required: [conversation, via]
      properties:
        conversation:
          $ref: "#/components/schemas/Conversation"
        via:
          type: string
          description: "`inbound` (the contact wrote first), `sandbox_join` (they sent your app's join message or opened its Telegram link) or `outbound` (you started it)."
          enum: [inbound, sandbox_join, outbound]
    ConversationStartedEvent:
      description: |
        A new conversation began. Sent before the first `message.received` of a new
        contact, or, when you started the conversation (`data.via` `outbound`), with
        your first send to the contact. A sandbox join (`data.via` `sandbox_join`) also starts one: the
        sandbox bot's confirmation ("You're connected to <your app>") is sent by Flow
        itself, not by your app, and counts against no allowance. Then the join
        message reaches your app as the contact's first `message.received`, after
        this event, with the code taken out: the words before `(ref <code>)`,
        trimmed (`Hi Sandbox app`), or `Hi` when nothing is left; a Telegram link's
        start arrives as `/start`. Answer it like any first message. Joining again
        in a conversation that already exists sends no new `conversation.started`;
        the join message is still delivered as a `message.received`.
      allOf:
        - $ref: "#/components/schemas/EventBase"
        - type: object
          required: [type, data]
          properties:
            type:
              type: string
              enum: [conversation.started]
              description: Always `conversation.started`.
            data:
              $ref: "#/components/schemas/ConversationStartedData"
    WindowClosingData:
      type: object
      description: When the window closes.
      required: [window_open_until]
      properties:
        window_open_until:
          type: string
          format: date-time
          description: After this time only templates can be sent.
    ConversationWindowClosingEvent:
      description: WhatsApp only, opt-in per endpoint. The conversation's 24-hour window closes in 1 hour.
      allOf:
        - $ref: "#/components/schemas/EventBase"
        - type: object
          required: [type, data]
          properties:
            type:
              type: string
              enum: [conversation.window_closing]
              description: Always `conversation.window_closing`.
            data:
              $ref: "#/components/schemas/WindowClosingData"
    SenderStatusChangedData:
      type: object
      description: The sender as it is now, and what changed.
      required: [sender, previous_status, reason]
      properties:
        sender:
          $ref: "#/components/schemas/Sender"
        previous_status:
          $ref: "#/components/schemas/SenderStatus"
        reason:
          type: string
          description: Why, in one sentence (for example "Many new conversations got no reply; starts are slowed for 24 hours").
    SenderStatusChangedEvent:
      description: A sender's status or WhatsApp quality rating changed.
      allOf:
        - $ref: "#/components/schemas/EventBase"
        - type: object
          required: [type, data]
          properties:
            type:
              type: string
              enum: [sender.status_changed]
              description: Always `sender.status_changed`.
            data:
              $ref: "#/components/schemas/SenderStatusChangedData"
    TemplateStatusChangedData:
      type: object
      description: The template as it is now, and what changed.
      required: [template, previous_status]
      properties:
        template:
          $ref: "#/components/schemas/Template"
        previous_status:
          $ref: "#/components/schemas/TemplateStatus"
        reason:
          type: string
          description: Meta's reason, when it gives one.
    TemplateStatusChangedEvent:
      description: Meta approved, rejected or paused a template.
      allOf:
        - $ref: "#/components/schemas/EventBase"
        - type: object
          required: [type, data]
          properties:
            type:
              type: string
              enum: [template.status_changed]
              description: Always `template.status_changed`.
            data:
              $ref: "#/components/schemas/TemplateStatusChangedData"
    ContactDeletedData:
      type: object
      description: The contact that was deleted. Only the ID is kept.
      required: [contact]
      properties:
        contact:
          $ref: "#/components/schemas/ContactId"
    ContactDeletedEvent:
      description: |
        A contact was deleted with `DELETE /v0/contacts/{contact_id}`: their address,
        messages, files and events are erased at Flow. Delete what you keep about
        them too. Sent once, for the first delete; it has no `conversation`.
      allOf:
        - $ref: "#/components/schemas/EventBase"
        - type: object
          required: [type, data]
          properties:
            type:
              type: string
              enum: [contact.deleted]
              description: Always `contact.deleted`.
            data:
              $ref: "#/components/schemas/ContactDeletedData"
    ContactLeftData:
      type: object
      description: The contact who left, the shared sender they left, and why.
      required: [contact, sender, reason]
      properties:
        contact:
          $ref: "#/components/schemas/ContactId"
        sender:
          $ref: "#/components/schemas/SenderId"
        reason:
          type: string
          enum: [joined_other_app]
          description: |
            Why the contact left. `joined_other_app`: they sent the join message of
            another app on the shared sender, or of your app's other mode (test or live).
    ContactLeftEvent:
      description: |
        A person on a shared sender (the sandbox) joined another app, or the other
        mode of your app, by sending its join message. Nothing more reaches you from
        them, and sends to them fail with `permission` (`channel_code`
        `sandbox_contact_left`), until they send your join message again, which starts
        a new `conversation.started`. The contact and their history are kept;
        `conversation` is the conversation they left.
      allOf:
        - $ref: "#/components/schemas/EventBase"
        - type: object
          required: [type, data, conversation]
          properties:
            type:
              type: string
              enum: [contact.left]
              description: Always `contact.left`.
            data:
              $ref: "#/components/schemas/ContactLeftData"
    CallData:
      type: object
      description: The call the contact tried to make.
      required: [call]
      properties:
        call:
          $ref: "#/components/schemas/Call"
    Call:
      type: object
      description: One call attempt to an iMessage line.
      required: [contact, network]
      properties:
        contact:
          $ref: "#/components/schemas/ContactId"
          description: The contact who called.
        network:
          type: string
          description: How they called. `facetime` (a FaceTime call) today.
          enum: [facetime]
          x-enum-varnames: [CallNetworkFacetime]
    CallReceivedEvent:
      description: |
        iMessage only. A contact tried to FaceTime one of your lines. The line
        cannot take calls, so nothing is answered. `conversation` is the direct conversation with the contact, made if it is
        new (with `conversation.started`, `via` `inbound`). A call is not a
        message: it does not count toward plan caps on people, and it does not
        open the conversation for a reply-only line (a send there before the
        contact writes is a start, refused with `403 permission` unless
        `Sender.imessage.can_initiate` is on). Phone calls forwarded by
        `Sender.imessage.call_forwarding` send no event. Delivered only to webhook
        endpoints that list `call.received` in `events`.
      allOf:
        - $ref: "#/components/schemas/EventBase"
        - type: object
          required: [type, data, conversation]
          properties:
            type:
              type: string
              enum: [call.received]
              description: Always `call.received`.
            data:
              $ref: "#/components/schemas/CallData"
    EventList:
      type: object
      description: A page of events, oldest first.
      required: [data, has_more]
      properties:
        data:
          type: array
          items:
            $ref: "#/components/schemas/Event"
        has_more:
          type: boolean
          description: Whether more committed events follow this page.

    # ---------------------------------------------------------------- stream
    StreamFrame:
      description: |
        One JSON frame on the `/v0/stream` WebSocket. The server sends `event`, `ack`,
        `error` and `reconnect`; the client sends `send` and `start`.
      oneOf:
        - $ref: "#/components/schemas/StreamEventFrame"
        - $ref: "#/components/schemas/StreamAckFrame"
        - $ref: "#/components/schemas/StreamErrorFrame"
        - $ref: "#/components/schemas/StreamReconnectFrame"
        - $ref: "#/components/schemas/StreamSendFrame"
        - $ref: "#/components/schemas/StreamStartFrame"
      discriminator:
        propertyName: type
        mapping:
          event: "#/components/schemas/StreamEventFrame"
          ack: "#/components/schemas/StreamAckFrame"
          error: "#/components/schemas/StreamErrorFrame"
          reconnect: "#/components/schemas/StreamReconnectFrame"
          send: "#/components/schemas/StreamSendFrame"
          start: "#/components/schemas/StreamStartFrame"
    StreamEventFrame:
      type: object
      description: Server to client. One event from the log.
      required: [type, event]
      properties:
        type:
          type: string
          enum: [event]
          description: Always `event`.
        event:
          $ref: "#/components/schemas/Event"
    StreamAckFrame:
      type: object
      description: Server to client. A `send` or `start` frame was accepted.
      required: [type, ref, message]
      properties:
        type:
          type: string
          enum: [ack]
          description: Always `ack`.
        ref:
          type: string
          description: The `ref` of the frame being answered.
        message:
          $ref: "#/components/schemas/Message"
    StreamErrorFrame:
      type: object
      description: Server to client. A `send` or `start` frame was refused (with its `ref`), or the stream itself was refused or failed (without `ref`; the server then closes the socket with a 4000-range close code, `4401` for `authentication`, see `GET /v0/stream`).
      required: [type, error]
      properties:
        type:
          type: string
          enum: [error]
          description: Always `error`.
        ref:
          type: string
          description: The `ref` of the frame being answered, if any.
        error:
          $ref: "#/components/schemas/ErrorBody"
    StreamReconnectFrame:
      type: object
      description: Server to client. The server is restarting; reconnect with `after` set to the last event you received. The socket closes after this frame.
      required: [type]
      properties:
        type:
          type: string
          enum: [reconnect]
          description: Always `reconnect`.
        after:
          $ref: "#/components/schemas/EventId"
    StreamSendFrame:
      type: object
      description: |
        Client to server. Sends one message into a conversation, the same as the
        endpoint of its `kind` (`POST /v0/conversations/{conversation_id}/<kind>`,
        or `PATCH` and `DELETE /v0/messages/{message_id}` for `edit` and
        `unsend`); `ref` is also the idempotency key. Frames into one conversation
        go out in the order accepted, in line with the conversation's other sends.
        The frame's `type` is the frame's own type; the message's kind is
        `message.kind`.
      required: [type, ref, conversation, message]
      properties:
        type:
          type: string
          enum: [send]
          description: Always `send`.
        ref:
          type: string
          minLength: 1
          maxLength: 255
          description: Your ID for this frame, echoed in the answer and used as the idempotency key.
        conversation:
          $ref: "#/components/schemas/ConversationId"
        message:
          $ref: "#/components/schemas/OutboundKind"
    StreamStartFrame:
      type: object
      description: Client to server. Same as `POST /v0/messages` (a `basic` or `template` message with `kind`, `sender` and `to`); `ref` is also the idempotency key.
      required: [type, ref, message]
      properties:
        type:
          type: string
          enum: [start]
          description: Always `start`.
        ref:
          type: string
          minLength: 1
          maxLength: 255
          description: Your ID for this frame, echoed in the answer and used as the idempotency key.
        message:
          $ref: "#/components/schemas/StartConversationRequest"

    # ---------------------------------------------------------------- files
    FileUpload:
      type: object
      description: A file to upload, as multipart form data.
      required: [file]
      properties:
        file:
          type: string
          format: binary
          description: The file's bytes. Its real type is read from its first bytes.
        filename:
          type: string
          maxLength: 255
          description: The name to show for the file; defaults to the uploaded part's file name.
        channel:
          $ref: "#/components/schemas/Channel"
    File:
      type: object
      description: A stored file. Send it by `file_id` as a basic message's `media`, a voice note or a header.
      required: [id, mime_type, size_bytes, url, livemode, created_at, expires_at]
      properties:
        id:
          $ref: "#/components/schemas/FileId"
        mime_type:
          type: string
          description: The real type, checked from the first bytes.
        size_bytes:
          type: integer
          description: The file's size.
        filename:
          type: string
          description: The file's name, if it has one.
        url:
          type: string
          format: uri
          description: Its Flow URL (`https://api.flow.engineer/v0/files/{file_id}`), which redirects to the bytes for an authenticated request.
        livemode:
          type: boolean
          description: Whether the file belongs to live mode.
        created_at:
          type: string
          format: date-time
          description: When the file was stored.
        expires_at:
          type: string
          format: date-time
          description: When the file will be deleted under your plan's retention.

    # ---------------------------------------------------------------- templates
    TemplateStatus:
      type: string
      description: A template's approval status at Meta.
      enum: [pending, approved, rejected, paused, disabled]
    TemplateCategory:
      type: string
      description: Meta's category for the template, which sets its price.
      enum: [marketing, utility, authentication]
    Template:
      type: object
      description: A WhatsApp message template, mirrored from Meta with its approval status.
      required: [id, sender, name, language, category, status, components, created_at]
      properties:
        id:
          $ref: "#/components/schemas/TemplateId"
        sender:
          $ref: "#/components/schemas/SenderId"
        name:
          type: string
          description: The template's name at Meta.
        language:
          type: string
          description: The template's language, as Meta names it.
        category:
          $ref: "#/components/schemas/TemplateCategory"
        status:
          $ref: "#/components/schemas/TemplateStatus"
        components:
          type: array
          description: The template's parts, as submitted.
          items:
            $ref: "#/components/schemas/TemplateComponent"
        rejection_reason:
          type: string
          description: Meta's reason, when the template was rejected.
        created_at:
          type: string
          format: date-time
          description: When the template was submitted.
    TemplateComponent:
      type: object
      description: One part of a template. Placeholders are written `{{1}}`, `{{2}}`, ...
      required: [type]
      properties:
        type:
          type: string
          enum: [header, body, footer, buttons]
          description: Which part this is.
        format:
          type: string
          enum: [text, image, video, document]
          description: For headers, what the header holds.
        text:
          type: string
          description: The part's text, with placeholders.
        examples:
          type: array
          maxItems: 100
          description: |
            For a body or a text header with placeholders, a realistic sample value
            for each placeholder, in order (`{{1}}` first). WhatsApp's reviewers read
            the template with these values, and a template whose samples do not look
            real is often rejected or moved to another category, so give them for
            every new template. Exactly one per placeholder; each 1 to 60 characters
            in a header and 1 to 1024 in a body, with no line breaks, tabs or more
            than four spaces in a row. Left out, Flow sends WhatsApp placeholder
            samples (`x1`, `x2`, ...) instead. Not taken on footers, buttons parts,
            media headers or text without placeholders.
          examples:
            - ["Maria", "A-1042"]
          items:
            type: string
            minLength: 1
            maxLength: 1024
        buttons:
          type: array
          description: For `buttons`, the buttons in order.
          items:
            $ref: "#/components/schemas/TemplateButton"
    TemplateButton:
      type: object
      description: One button of a template.
      required: [type, text]
      properties:
        type:
          type: string
          enum: [quick_reply, url, phone_number]
          description: What the button does.
        text:
          type: string
          description: The button's label.
        url:
          type: string
          description: For `url` buttons, the link, optionally ending in a `{{1}}` placeholder.
        example:
          type: string
          minLength: 1
          maxLength: 2000
          description: |
            For a `url` button whose link ends in `{{1}}`, a realistic sample value
            for the end of the link (what you will pass in `params.buttons` when
            sending), for WhatsApp's review: Flow shows the reviewers the link with
            the sample in place. That link is at most 2000 characters, and the sample
            has no spaces or line breaks. Left out, Flow sends a placeholder sample
            instead. Not taken on other buttons.
          examples: ["A-1042"]
        phone:
          type: string
          description: For `phone_number` buttons, the E.164 number.
    TemplateCreateRequest:
      type: object
      description: A template to submit to Meta.
      required: [sender, name, language, category, components]
      properties:
        sender:
          $ref: "#/components/schemas/SenderId"
        name:
          type: string
          pattern: "^[a-z0-9_]{1,512}$"
          description: Lowercase letters, digits and underscores.
        language:
          type: string
          description: The template's language, as Meta names it (`en`, `en_US`, `hi`).
        category:
          $ref: "#/components/schemas/TemplateCategory"
        components:
          type: array
          minItems: 1
          description: The template's parts. A body is required.
          items:
            $ref: "#/components/schemas/TemplateComponent"
    TemplateList:
      type: object
      description: A page of templates.
      required: [data, has_more]
      properties:
        data:
          type: array
          items:
            $ref: "#/components/schemas/Template"
        has_more:
          type: boolean
          description: Whether more items lie beyond this page in the direction you paged.

    # ---------------------------------------------------------------- testing
    TestConversationRequest:
      type: object
      description: A simulated conversation to create (test keys only).
      required: [channel]
      properties:
        channel:
          $ref: "#/components/schemas/Channel"
          description: The channel to simulate. `telegram`, `whatsapp`, `imessage` or `sms`.
        contact:
          $ref: "#/components/schemas/TestContactAddress"
        name:
          type: string
          minLength: 1
          maxLength: 64
          description: The simulated contact's display name, as a channel would report it. Not with `group`.
        group:
          $ref: "#/components/schemas/TestGroupRequest"
    TestGroupRequest:
      type: object
      description: |
        `imessage` only: make a simulated group conversation (`type` `group`)
        instead of a one-to-one one, with these participants. Not with `contact`
        or `name`. Each turn names the participant who writes (`from` on
        `POST /v0/test/conversations/{conversation_id}/inbound`). Another channel
        answers `400 invalid_request` with `param` `group`.
      required: [participants]
      properties:
        name:
          type: string
          minLength: 1
          maxLength: 64
          description: The group chat's name. Leave it out for an unnamed group.
        participants:
          type: array
          minItems: 2
          maxItems: 32
          description: The people in the group other than your line. Each becomes a simulated contact; a participant without `address` gets a made-up one.
          items:
            $ref: "#/components/schemas/TestGroupParticipant"
    TestGroupParticipant:
      type: object
      description: One simulated person in a simulated group.
      properties:
        address:
          $ref: "#/components/schemas/TestContactAddress"
        name:
          type: string
          minLength: 1
          maxLength: 64
          description: The participant's display name.
    TestContactAddress:
      type: object
      description: |
        The simulated contact's address on the channel. Give one field that fits the
        channel: `phone` on SMS, iMessage and WhatsApp, `handle` on iMessage,
        `telegram_user_id` on Telegram. Omit `contact` to get a new contact with a made-up address.
      minProperties: 1
      maxProperties: 1
      properties:
        phone:
          type: string
          description: SMS, iMessage or WhatsApp. E.164 phone number; on SMS a US number (`+1` and ten digits).
          pattern: "^\\+[1-9][0-9]{6,14}$"
        handle:
          type: string
          minLength: 3
          maxLength: 254
          description: iMessage. A phone number or an email address.
        telegram_user_id:
          type: string
          description: Telegram. A numeric user ID.
          pattern: "^[1-9][0-9]{0,15}$"
    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`.
    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"
    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"}
    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]
    TestTranscript:
      type: object
      description: A page of a simulated conversation, both directions, oldest first.
      required: [conversation, data, has_more]
      properties:
        conversation:
          $ref: "#/components/schemas/ConversationId"
        data:
          type: array
          items:
            $ref: "#/components/schemas/TranscriptEntry"
        has_more:
          type: boolean
          description: Whether more messages follow; page on with `after` set to the last message's ID.
    TranscriptEntry:
      description: |
        One message of a simulated conversation, as a `Message`. A sent message's
        `content` is exactly what the simulated contact sees: Flow converts
        nothing.
      allOf:
        - $ref: "#/components/schemas/Message"
    # ---------------------------------------------------------------- webhook endpoints
    WebhookEndpoint:
      type: object
      description: A URL that receives your app's events of the types it subscribes to.
      required: [id, url, events, enabled, livemode, created_at]
      properties:
        id:
          $ref: "#/components/schemas/WebhookEndpointId"
        url:
          type: string
          format: uri
          description: The HTTPS URL events are posted to.
        events:
          type: array
          description: The event types delivered to this endpoint.
          items:
            $ref: "#/components/schemas/EventType"
        description:
          type: string
          description: Your note about the endpoint.
        enabled:
          type: boolean
          description: Whether events are delivered. Disabled endpoints keep their place; nothing is lost from the log.
        livemode:
          type: boolean
          description: Whether the endpoint receives live-mode or test-mode events.
        secret:
          type: string
          description: The signing secret (`whsec_...`). Only in the answers to create and to rotate the secret.
        previous_secret_expires_at:
          type: string
          format: date-time
          description: While a secret rotation overlaps, when the previous secret stops signing. Absent when only one secret is active.
        created_at:
          type: string
          format: date-time
          description: When the endpoint was created.
    WebhookEndpointCreateRequest:
      type: object
      description: A new webhook endpoint.
      required: [url, events]
      properties:
        url:
          type: string
          format: uri
          maxLength: 2048
          description: An HTTPS URL on a public address.
        events:
          type: array
          minItems: 1
          description: The event types to deliver.
          items:
            $ref: "#/components/schemas/EventType"
        description:
          type: string
          maxLength: 500
          description: Your note about the endpoint.
    WebhookEndpointUpdateRequest:
      type: object
      description: Changes to a webhook endpoint. Fields left out are kept.
      properties:
        url:
          type: string
          format: uri
          maxLength: 2048
          description: An HTTPS URL on a public address.
        events:
          type: array
          minItems: 1
          description: The event types to deliver.
          items:
            $ref: "#/components/schemas/EventType"
        description:
          type: string
          maxLength: 500
          description: Your note about the endpoint.
        enabled:
          type: boolean
          description: Whether to deliver events.
    WebhookEndpointList:
      type: object
      description: A page of webhook endpoints.
      required: [data, has_more]
      properties:
        data:
          type: array
          items:
            $ref: "#/components/schemas/WebhookEndpoint"
        has_more:
          type: boolean
          description: Whether more items lie beyond this page in the direction you paged.
    WebhookSecretRotateRequest:
      type: object
      description: Options for rotating a webhook endpoint's signing secret.
      properties:
        overlap_seconds:
          type: integer
          minimum: 0
          maximum: 604800
          default: 86400
          description: How long the previous secret keeps signing alongside the new one, in seconds (at most 7 days). `0` retires it at once.
    WebhookReply:
      type: object
      description: |
        The optional body of your answer to a `message.received` delivery. `reply`
        is one message or a list of up to 11, sent in order into the event's
        conversation through the send gate: each piece is a kind object
        (`OutboundKind`, with `kind`), so a text plus 10 carousel cards sent one by
        one fits. A non-empty string, as `reply` or as an item of the list, is a
        basic message: `{"reply": "Hi"}` is short for `{"reply": {"kind":
        "basic", "text": "Hi"}}`. Leave `reply` out, set it to `null`, or answer
        `{}`, an empty body or any body that is not a JSON object (such as `OK`)
        to send nothing now; that is not an error.

        A JSON object whose `reply` does not match this shape (a piece that is not
        a kind object or a string, an unknown `kind`, an empty list, more than 11
        pieces), or a body that starts with `{` but is not valid JSON, sends
        nothing at all (no piece of a list goes out) and is recorded on the
        delivery as an `invalid_request` error with the reason; the delivery
        counts as delivered and is not retried. A piece the send gate refuses (for
        example a `carousel` on Telegram, `422 unsupported_content`) is reported
        as a `message.failed` event.
      properties:
        reply:
          description: One message, or a list of 1 to 11 sent in order. A non-empty string is a basic message's text. `null` sends nothing.
          oneOf:
            - $ref: "#/components/schemas/OutboundKind"
            - type: string
              minLength: 1
              description: "A basic message's text, short for `{\"kind\": \"basic\", \"text\": \"...\"}`."
            - type: array
              minItems: 1
              maxItems: 11
              items:
                oneOf:
                  - $ref: "#/components/schemas/OutboundKind"
                  - type: string
                    minLength: 1
                    description: "A basic message's text, short for `{\"kind\": \"basic\", \"text\": \"...\"}`."
            - type: "null"
      examples:
        - reply:
            - {kind: basic, text: "Here are the sizes we have."}
            - kind: buttons
              text: "Which size?"
              buttons:
                - {id: size_s, label: Small}
                - {id: size_m, label: Medium}
