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

# POST /v1/messages

> Single endpoint for every outbound message, on every connected channel. The message body is discriminated by its type field.

One endpoint sends everything. The `channel` field decides where the message goes, and the `type` inside `message` decides what it looks like — text, media, a location pin, an approved template, an interactive block, or a WhatsApp Flow.

## Endpoint

```
POST https://api.keebai.com/v1/messages
```

## Required scope

`messages:send`

## Headers

| Header          | Required | Value                    |
| --------------- | -------- | ------------------------ |
| `Authorization` | Yes      | `Bearer kbai_pk_<token>` |
| `Content-Type`  | Yes      | `application/json`       |

## Envelope

| Field                | Type     | Required | Description                                                                                                                                                                                                            |
| -------------------- | -------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `channel`            | `string` | Yes      | `whatsapp`, `whatsapp_qr`, `instagram` or `messenger`.                                                                                                                                                                 |
| `channel_id`         | `string` | No       | Keebai channel id. Only needed when the project has more than one active channel of that type — otherwise the single one is resolved automatically. List them with [`GET /v1/channels`](/dev/endpoints/channels-list). |
| `phone_number_id`    | `string` | No       | WhatsApp-only alias for `channel_id`: the numeric `phone_number_id` Meta issues for the sending number. List yours with [`GET /v1/whatsapp/numbers`](/dev/endpoints/whatsapp-numbers).                                 |
| `to`                 | `string` | Yes      | Recipient. E.164 phone number on `whatsapp` and `whatsapp_qr`; the Meta-scoped id (IGSID on Instagram, PSID on Messenger) on the other channels — the same value the inbound webhook delivered as `from`.              |
| `context.message_id` | `string` | No       | Provider message id (`wamid.*` on WhatsApp) this message quotes as a reply.                                                                                                                                            |
| `meta_data`          | `object` | No       | Free-form metadata persisted with the message and returned on webhooks for correlation.                                                                                                                                |
| `message`            | `object` | Yes      | The message itself, discriminated by `type`. See below.                                                                                                                                                                |

<Note>
  Passing `channel_id` and `phone_number_id` together is fine — `channel_id` wins. Passing neither is fine too, as long as the project has exactly one active channel of that type. With more than one, the request fails with `400 CHANNEL_AMBIGUOUS` and the response lists the candidate ids.
</Note>

## Message types

Every value of `message.type`, and which channels can deliver it:

| `type`     | WhatsApp | WhatsApp QR | Instagram | Messenger | Payload fields                                                                                                                    |
| ---------- | :------: | :---------: | :-------: | :-------: | --------------------------------------------------------------------------------------------------------------------------------- |
| `text`     |     ✅    |      ✅      |     ✅     |     ✅     | `body` (max 4096), `preview_url?`                                                                                                 |
| `image`    |     ✅    |      ✅      |     ✅     |     ✅     | `link?` \| `media_id?`, `caption?`                                                                                                |
| `video`    |     ✅    |      ✅      |     ✅     |     ✅     | `link?` \| `media_id?`, `caption?`                                                                                                |
| `audio`    |     ✅    |      ✅      |     ✅     |     ✅     | `link?` \| `media_id?`, `voice?`                                                                                                  |
| `document` |     ✅    |      ✅      |     ✅     |     ✅     | `link?` \| `media_id?`, `filename?`, `caption?`                                                                                   |
| `sticker`  |     ✅    |      ✅      |     ✅     |     ✅     | `link?` \| `media_id?`                                                                                                            |
| `location` |     ✅    |      —      |     —     |     —     | `latitude`, `longitude`, `name?`, `address?`                                                                                      |
| `contacts` |     ✅    |      —      |     —     |     —     | `contacts[]` (1–20 vCards)                                                                                                        |
| `reaction` |     ✅    |      —      |     ✅     |     ✅     | `message_id`, `emoji`                                                                                                             |
| `template` |     ✅    |      —      |     —     |     —     | `name`, `language`, `variables?`, `flow?`                                                                                         |
| `buttons`  |     ✅    |      ✅      |     ✅     |     ✅     | `body_text`, `buttons[]` (1–3), `header?`, `footer_text?`                                                                         |
| `list`     |     ✅    |      ✅      |     ✅     |     ✅     | `body_text`, `button_text`, `sections[]`, `header?`, `footer_text?`                                                               |
| `cta_url`  |     ✅    |      —      |     ✅     |     —     | `body_text`, `display_text`, `url`, `header?`, `footer_text?`                                                                     |
| `catalog`  |     ✅    |      —      |     —     |     —     | `body_text`, `thumbnail_product_retailer_id?`, `footer_text?`                                                                     |
| `carousel` |     ✅    |      —      |     ✅     |     —     | `body_text`, `cards[]` (1–10), `header?`, `footer_text?`                                                                          |
| `flow`     |     ✅    |      —      |     —     |     —     | `flow_id`, `flow_token`, `flow_cta`, `body_text`, `flow_action?`, `flow_action_payload?`, `mode?`, `header_text?`, `footer_text?` |

A combination outside this table fails with `422 MESSAGE_TYPE_NOT_SUPPORTED_FOR_CHANNEL`, and the response lists the types that channel does accept. WhatsApp QR flattens `buttons` and `list` into a plain text message — the options are rendered as text, not as tappable controls.

For media, `link` and `media_id` are mutually exclusive: exactly one is required. Upload a file with `POST /v1/media/upload` to get a `media_id`.

## Example requests

<CodeGroup>
  ```bash Text theme={null}
  curl -X POST https://api.keebai.com/v1/messages \
    -H "Authorization: Bearer kbai_pk_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" \
    -H "Content-Type: application/json" \
    -d '{
      "channel": "whatsapp",
      "to": "+5491155555555",
      "message": {
        "type": "text",
        "body": "Hola Juan, ¿pudiste revisar la propuesta?"
      }
    }'
  ```

  ```bash Image theme={null}
  curl -X POST https://api.keebai.com/v1/messages \
    -H "Authorization: Bearer kbai_pk_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" \
    -H "Content-Type: application/json" \
    -d '{
      "channel": "whatsapp",
      "phone_number_id": "109876543210987",
      "to": "+5491155555555",
      "message": {
        "type": "image",
        "link": "https://cdn.example.com/promo.png",
        "caption": "Promo de marzo"
      }
    }'
  ```

  ```bash Template theme={null}
  curl -X POST https://api.keebai.com/v1/messages \
    -H "Authorization: Bearer kbai_pk_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" \
    -H "Content-Type: application/json" \
    -d '{
      "channel": "whatsapp",
      "to": "+5491155555555",
      "message": {
        "type": "template",
        "name": "welcome_v2",
        "language": "es",
        "variables": { "nombre": "Juan", "monto": "1500" }
      }
    }'
  ```

  ```bash Buttons theme={null}
  curl -X POST https://api.keebai.com/v1/messages \
    -H "Authorization: Bearer kbai_pk_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" \
    -H "Content-Type: application/json" \
    -d '{
      "channel": "whatsapp",
      "to": "+5491155555555",
      "message": {
        "type": "buttons",
        "body_text": "¿Confirmas tu reserva para el viernes?",
        "buttons": [
          { "id": "confirm", "title": "Confirmar" },
          { "id": "reschedule", "title": "Reprogramar" }
        ],
        "footer_text": "Restaurante La Mela"
      }
    }'
  ```

  ```bash Instagram theme={null}
  curl -X POST https://api.keebai.com/v1/messages \
    -H "Authorization: Bearer kbai_pk_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" \
    -H "Content-Type: application/json" \
    -d '{
      "channel": "instagram",
      "to": "17841400000000000",
      "message": {
        "type": "text",
        "body": "¡Gracias por escribirnos!"
      }
    }'
  ```
</CodeGroup>

```js JavaScript theme={null}
async function send(message, extra = {}) {
  const res = await fetch("https://api.keebai.com/v1/messages", {
    method: "POST",
    headers: {
      Authorization: `Bearer ${process.env.KEEBAI_API_TOKEN}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      channel: "whatsapp",
      to: "+5491155555555",
      message,
      ...extra,
    }),
  });
  if (!res.ok) throw new Error(await res.text());
  return res.json();
}

await send({ type: "text", body: "Hola Juan" });
await send({ type: "location", latitude: -33.4489, longitude: -70.6693, name: "Sucursal Centro" });
```

```python Python theme={null}
import os, requests

def send(message, **extra):
    resp = requests.post(
        "https://api.keebai.com/v1/messages",
        headers={"Authorization": f"Bearer {os.environ['KEEBAI_API_TOKEN']}"},
        json={"channel": "whatsapp", "to": "+5491155555555", "message": message, **extra},
        timeout=10,
    )
    resp.raise_for_status()
    return resp.json()

send({"type": "text", "body": "Hola Juan"})
send({"type": "document", "media_id": "1234567890", "filename": "factura.pdf"})
```

## One body per type

Copy any of these into the `message` field. The envelope (`channel`, `to`, …) is the same for all of them, and every one is also available as a named example in the [playground](https://api.keebai.com/v1/docs).

<AccordionGroup>
  <Accordion title="text">
    ```json theme={null}
    { "type": "text", "body": "Hola Juan, ¿pudiste revisar la propuesta?", "preview_url": true }
    ```
  </Accordion>

  <Accordion title="image">
    ```json theme={null}
    { "type": "image", "link": "https://cdn.example.com/promo.png", "caption": "Promo de marzo" }
    ```

    Swap `link` for `media_id` to use a file uploaded through `POST /v1/media/upload`. Exactly one of the two.
  </Accordion>

  <Accordion title="video">
    ```json theme={null}
    { "type": "video", "link": "https://cdn.example.com/demo.mp4", "caption": "Cómo activar tu cuenta" }
    ```
  </Accordion>

  <Accordion title="audio">
    ```json theme={null}
    { "type": "audio", "link": "https://cdn.example.com/nota.ogg", "voice": true }
    ```

    `voice: true` sends a voice note (PTT). Omit it for a plain audio file.
  </Accordion>

  <Accordion title="document">
    ```json theme={null}
    {
      "type": "document",
      "media_id": "1234567890123456",
      "filename": "factura-A-1042.pdf",
      "caption": "Tu factura de marzo"
    }
    ```
  </Accordion>

  <Accordion title="sticker">
    ```json theme={null}
    { "type": "sticker", "link": "https://cdn.example.com/gracias.webp" }
    ```

    Must be WebP.
  </Accordion>

  <Accordion title="location — WhatsApp only">
    ```json theme={null}
    {
      "type": "location",
      "latitude": -33.4489,
      "longitude": -70.6693,
      "name": "Sucursal Centro",
      "address": "Av. Libertador 1234, Santiago"
    }
    ```
  </Accordion>

  <Accordion title="contacts — WhatsApp only">
    ```json theme={null}
    {
      "type": "contacts",
      "contacts": [
        {
          "name": { "formatted_name": "Camila Rojas", "first_name": "Camila", "last_name": "Rojas" },
          "phones": [{ "phone": "+56912345678", "type": "WORK" }],
          "emails": [{ "email": "camila@example.com", "type": "WORK" }],
          "org": { "company": "Keebai", "title": "Account Manager" }
        }
      ]
    }
    ```

    Shares a contact card in the chat. It does **not** create a contact in Keebai — that is [`POST /v1/contacts`](/dev/endpoints/contacts-create).
  </Accordion>

  <Accordion title="reaction">
    ```json theme={null}
    { "type": "reaction", "message_id": "wamid.HBgLNTQ5MTE1NTU1NTU1FQIAERgSN0EyRjc4", "emoji": "👍" }
    ```

    An empty `emoji` clears a previous reaction.
  </Accordion>

  <Accordion title="template — WhatsApp only">
    ```json theme={null}
    {
      "type": "template",
      "name": "welcome_v2",
      "language": "es",
      "variables": { "nombre": "Juan", "monto": "1500" }
    }
    ```

    The only type that reaches a closed 24-hour window. Add a `flow` block to bind one of its FLOW buttons:

    ```json theme={null}
    {
      "type": "template",
      "name": "encuesta_post_visita",
      "language": "es",
      "flow": { "flow_token": "csat-8f21c4", "button_index": 0, "flow_action_data": { "ticket_id": "A-1042" } }
    }
    ```
  </Accordion>

  <Accordion title="buttons">
    ```json theme={null}
    {
      "type": "buttons",
      "body_text": "¿Confirmas tu reserva para el viernes a las 21:00?",
      "buttons": [
        { "id": "confirm", "title": "Confirmar" },
        { "id": "reschedule", "title": "Reprogramar" },
        { "id": "cancel", "title": "Cancelar" }
      ],
      "header": { "type": "text", "text": "Restaurante La Mela" },
      "footer_text": "Responde antes de las 18:00"
    }
    ```

    Max 3 buttons, titles up to 20 characters.
  </Accordion>

  <Accordion title="list">
    ```json theme={null}
    {
      "type": "list",
      "body_text": "Elige el horario que te acomode.",
      "button_text": "Ver horarios",
      "sections": [
        {
          "title": "Viernes 2",
          "rows": [
            { "id": "fri-20", "title": "20:00", "description": "Mesa junto a la ventana" },
            { "id": "fri-21", "title": "21:00", "description": "Salón principal" }
          ]
        },
        { "title": "Sábado 3", "rows": [{ "id": "sat-20", "title": "20:00" }] }
      ]
    }
    ```

    Max 10 sections, 10 rows each.
  </Accordion>

  <Accordion title="cta_url">
    ```json theme={null}
    {
      "type": "cta_url",
      "body_text": "Dejaste productos en tu carrito. Retoma la compra cuando quieras.",
      "display_text": "Ir al carrito",
      "url": "https://tienda.example.com/checkout/abc123",
      "footer_text": "El carrito expira en 24 horas"
    }
    ```
  </Accordion>

  <Accordion title="catalog — WhatsApp only">
    ```json theme={null}
    {
      "type": "catalog",
      "body_text": "Mira nuestro catálogo de otoño.",
      "thumbnail_product_retailer_id": "SKU-1042",
      "footer_text": "Envíos a todo el país"
    }
    ```

    Requires a product catalog connected to the WhatsApp Business Account.
  </Accordion>

  <Accordion title="carousel">
    ```json theme={null}
    {
      "type": "carousel",
      "body_text": "Novedades de la semana",
      "cards": [
        {
          "header": { "type": "image", "link": "https://cdn.example.com/producto-1.png" },
          "body_text": "Zapatillas running — 20% off",
          "action": { "display_text": "Ver", "url": "https://tienda.example.com/p/1" }
        },
        {
          "header": { "type": "image", "link": "https://cdn.example.com/producto-2.png" },
          "body_text": "Mochila urbana — 15% off",
          "action": { "display_text": "Ver", "url": "https://tienda.example.com/p/2" }
        }
      ]
    }
    ```

    Max 10 cards. Meta rejects `.webp` images on carousel headers — those cards are dropped.
  </Accordion>

  <Accordion title="flow — WhatsApp only">
    ```json theme={null}
    {
      "type": "flow",
      "flow_id": "1234567890123456",
      "flow_token": "reserva-8f21c4",
      "flow_cta": "Reservar",
      "body_text": "Reserva tu mesa en menos de un minuto.",
      "header_text": "Restaurante La Mela",
      "footer_text": "Cupos limitados",
      "flow_action": "navigate",
      "flow_action_payload": { "screen": "WELCOME", "data": { "customer_name": "Juan" } }
    }
    ```

    [Create and publish the Flow](/dev/messaging/flows) first. Add `"mode": "draft"` to test an unpublished one against a registered WABA tester.
  </Accordion>
</AccordionGroup>

## Quoting a message

Reply to a specific message by passing its provider id in `context`:

```json theme={null}
{
  "channel": "whatsapp",
  "to": "+5491155555555",
  "context": { "message_id": "wamid.HBgLNTQ5MTE1NTU1NTU1FQIAERgSN0EyRjc4..." },
  "message": { "type": "text", "body": "Sí, ese mismo pedido." }
}
```

If the quoted message is too old for the provider to reference, Keebai retries once without the quote rather than failing the send.

## Response

### 202 Accepted

```json theme={null}
{
  "message_id": "wamid.HBgLNTQ5MTE1NTU1NTU1FQIAERgSN0EyRjc4...",
  "status": "sent",
  "sent_at": "2026-07-27T14:31:07.221Z"
}
```

### 400 Bad Request

| `code`                           | Meaning                                                                                               |
| -------------------------------- | ----------------------------------------------------------------------------------------------------- |
| `UNKNOWN_MESSAGE_TYPE`           | `message.type` is not one of the 16 supported values. The response lists them in `supported_types`.   |
| `INVALID_MESSAGE_PAYLOAD`        | The payload does not match the schema for that `type`. The response lists the offending field paths.  |
| `MEDIA_REFERENCE_REQUIRED`       | A media message carried neither `link` nor `media_id`.                                                |
| `MEDIA_REFERENCE_AMBIGUOUS`      | A media message carried both `link` and `media_id`.                                                   |
| `CHANNEL_AMBIGUOUS`              | More than one active channel of that type. Pass `channel_id`; candidates are in `channel_ids`.        |
| `CHANNEL_TYPE_MISMATCH`          | The `channel_id` belongs to a different channel type than `channel`.                                  |
| `CHANNEL_MISSING_ACCESS_TOKEN`   | The channel is connected but its provider token is missing or expired. Reconnect it in the dashboard. |
| `PHONE_NUMBER_ID_NOT_APPLICABLE` | `phone_number_id` was used with a non-WhatsApp channel.                                               |

### 401 Unauthorized

Missing, invalid, revoked, or expired token.

### 403 Forbidden

The token does not have the `messages:send` scope.

### 404 Not Found

`CHANNEL_NOT_FOUND` — the project has no active channel matching the selector.

### 422 Unprocessable Entity

`MESSAGE_TYPE_NOT_SUPPORTED_FOR_CHANNEL` — the channel cannot carry that message type. `supported_types` lists what it accepts.

## Operational notes

* **24-hour window**: free-form messages (everything except `template`) only reach a recipient whose last inbound message is under 24 hours old. Keebai attempts the send regardless; a closed window surfaces as the provider's own error passed through.
* **The message lands in the Keebai inbox**: every send is persisted against the recipient's contact and pushed to the portal in realtime, on all four channels. It shows up with the `agent` role, since a PAT send is an integrator acting on the tenant's behalf rather than the AI assistant.
* **`meta_data` round-trips**: whatever you put there is stored with the message and echoed back on delivery and read webhooks, which is the intended way to correlate a send with your own records.
* **Markdown**: WhatsApp interprets `*text*` as bold, `_text_` as italic and `~text~` as strikethrough. A backslash escapes them.


## OpenAPI

````yaml POST /v1/messages
openapi: 3.0.0
info:
  title: Keebai Public API
  description: Superficie pública REST autenticada con Personal Access Tokens (PAT).
  version: 1.0.0
  contact: {}
servers:
  - url: https://api.keebai.com
    description: Production
security: []
tags: []
paths:
  /v1/messages:
    post:
      tags:
        - messages
      summary: Send a message on any connected channel
      description: >-
        The `message` object is discriminated by `type`: text, image, video,
        audio, document, sticker, location, contacts, reaction, template,
        buttons, list, cta_url, catalog, carousel or flow. Not every channel
        carries every type — an unsupported combination returns 422
        MESSAGE_TYPE_NOT_SUPPORTED_FOR_CHANNEL listing what the channel does
        accept. Pick a ready-to-send body from the examples below.
      operationId: PublicMessagesController_send
      parameters: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/SendMessageDto'
            examples:
              text:
                summary: text — free-form message
                description: >-
                  Only reaches a recipient whose last inbound message is under
                  24 hours old.
                value:
                  channel: whatsapp
                  to: '+5491155555555'
                  message:
                    type: text
                    body: Hola Juan, ¿pudiste revisar la propuesta?
                    preview_url: true
              image:
                summary: image — by public URL
                description: Use `media_id` instead of `link` for an uploaded file.
                value:
                  channel: whatsapp
                  to: '+5491155555555'
                  message:
                    type: image
                    link: https://cdn.example.com/promo.png
                    caption: Promo de marzo
              video:
                summary: video — by public URL
                value:
                  channel: whatsapp
                  to: '+5491155555555'
                  message:
                    type: video
                    link: https://cdn.example.com/demo.mp4
                    caption: Cómo activar tu cuenta
              audio:
                summary: audio — voice note
                description: Omit `voice` or set it to false to send a plain audio file.
                value:
                  channel: whatsapp
                  to: '+5491155555555'
                  message:
                    type: audio
                    link: https://cdn.example.com/nota.ogg
                    voice: true
              document:
                summary: document — by uploaded media_id
                value:
                  channel: whatsapp
                  to: '+5491155555555'
                  message:
                    type: document
                    media_id: '1234567890123456'
                    filename: factura-A-1042.pdf
                    caption: Tu factura de marzo
              sticker:
                summary: sticker — WebP
                value:
                  channel: whatsapp
                  to: '+5491155555555'
                  message:
                    type: sticker
                    link: https://cdn.example.com/gracias.webp
              location:
                summary: location — map pin (WhatsApp only)
                value:
                  channel: whatsapp
                  to: '+5491155555555'
                  message:
                    type: location
                    latitude: -33.4489
                    longitude: -70.6693
                    name: Sucursal Centro
                    address: Av. Libertador 1234, Santiago
              contacts:
                summary: contacts — share a vCard (WhatsApp only)
                value:
                  channel: whatsapp
                  to: '+5491155555555'
                  message:
                    type: contacts
                    contacts:
                      - name:
                          formatted_name: Camila Rojas
                          first_name: Camila
                          last_name: Rojas
                        phones:
                          - phone: '+56912345678'
                            type: WORK
                        emails:
                          - email: camila@example.com
                            type: WORK
                        org:
                          company: Keebai
                          title: Account Manager
              reaction:
                summary: reaction — emoji on a previous message
                description: Send an empty `emoji` to clear a reaction.
                value:
                  channel: whatsapp
                  to: '+5491155555555'
                  message:
                    type: reaction
                    message_id: wamid.HBgLNTQ5MTE1NTU1NTU1FQIAERgSN0EyRjc4
                    emoji: 👍
              template:
                summary: template — approved template (WhatsApp only)
                description: The only type that reaches a closed 24-hour window.
                value:
                  channel: whatsapp
                  to: '+5491155555555'
                  message:
                    type: template
                    name: welcome_v2
                    language: es
                    variables:
                      nombre: Juan
                      monto: '1500'
              template_with_flow:
                summary: template — with a Flow button
                description: >-
                  Binds a Flow to one of the template FLOW buttons. `flow_token`
                  is echoed back on the whatsapp.flow.response webhook.
                value:
                  channel: whatsapp
                  to: '+5491155555555'
                  message:
                    type: template
                    name: encuesta_post_visita
                    language: es
                    variables:
                      nombre: Juan
                    flow:
                      flow_token: csat-8f21c4
                      button_index: 0
                      flow_action_data:
                        ticket_id: A-1042
              buttons:
                summary: buttons — up to 3 quick replies
                value:
                  channel: whatsapp
                  to: '+5491155555555'
                  message:
                    type: buttons
                    body_text: ¿Confirmas tu reserva para el viernes a las 21:00?
                    buttons:
                      - id: confirm
                        title: Confirmar
                      - id: reschedule
                        title: Reprogramar
                      - id: cancel
                        title: Cancelar
                    header:
                      type: text
                      text: Restaurante La Mela
                    footer_text: Responde antes de las 18:00
              list:
                summary: list — tappable menu
                value:
                  channel: whatsapp
                  to: '+5491155555555'
                  message:
                    type: list
                    body_text: Elige el horario que te acomode.
                    button_text: Ver horarios
                    sections:
                      - title: Viernes 2
                        rows:
                          - id: fri-20
                            title: '20:00'
                            description: Mesa junto a la ventana
                          - id: fri-21
                            title: '21:00'
                            description: Salón principal
                      - title: Sábado 3
                        rows:
                          - id: sat-20
                            title: '20:00'
                    footer_text: Restaurante La Mela
              cta_url:
                summary: cta_url — button that opens a link
                value:
                  channel: whatsapp
                  to: '+5491155555555'
                  message:
                    type: cta_url
                    body_text: >-
                      Dejaste productos en tu carrito. Retoma la compra cuando
                      quieras.
                    display_text: Ir al carrito
                    url: https://tienda.example.com/checkout/abc123
                    footer_text: El carrito expira en 24 horas
              catalog:
                summary: catalog — open the Meta catalog (WhatsApp only)
                value:
                  channel: whatsapp
                  to: '+5491155555555'
                  message:
                    type: catalog
                    body_text: Mira nuestro catálogo de otoño.
                    thumbnail_product_retailer_id: SKU-1042
                    footer_text: Envíos a todo el país
              carousel:
                summary: carousel — media cards with their own buttons
                description: Meta rejects .webp images on carousel headers.
                value:
                  channel: whatsapp
                  to: '+5491155555555'
                  message:
                    type: carousel
                    body_text: Novedades de la semana
                    cards:
                      - header:
                          type: image
                          link: https://cdn.example.com/producto-1.png
                        body_text: Zapatillas running — 20% off
                        action:
                          display_text: Ver
                          url: https://tienda.example.com/p/1
                      - header:
                          type: image
                          link: https://cdn.example.com/producto-2.png
                        body_text: Mochila urbana — 15% off
                        action:
                          display_text: Ver
                          url: https://tienda.example.com/p/2
              flow:
                summary: flow — published WhatsApp Flow (WhatsApp only)
                description: >-
                  Create and publish the Flow first with POST /v1/flows. Needs
                  an open 24-hour window; outside it, attach the Flow to a
                  template instead.
                value:
                  channel: whatsapp
                  to: '+5491155555555'
                  message:
                    type: flow
                    flow_id: '1234567890123456'
                    flow_token: reserva-8f21c4
                    flow_cta: Reservar
                    body_text: Reserva tu mesa en menos de un minuto.
                    header_text: Restaurante La Mela
                    footer_text: Cupos limitados
                    flow_action: navigate
                    flow_action_payload:
                      screen: WELCOME
                      data:
                        customer_name: Juan
              reply_to_message:
                summary: text — quoting a previous message
                description: Any type accepts `context` to render as a quoted reply.
                value:
                  channel: whatsapp
                  to: '+5491155555555'
                  context:
                    message_id: wamid.HBgLNTQ5MTE1NTU1NTU1FQIAERgSN0EyRjc4
                  message:
                    type: text
                    body: Sí, ese mismo pedido.
              instagram:
                summary: text — on Instagram instead of WhatsApp
                description: >-
                  The recipient is the Instagram-scoped id of the sender, not a
                  phone number.
                value:
                  channel: instagram
                  to: '17841400000000000'
                  message:
                    type: text
                    body: ¡Gracias por escribirnos!
              pick_channel_explicitly:
                summary: text — picking one of several channels
                description: >-
                  Needed only when the project has more than one active channel
                  of that type.
                value:
                  channel: whatsapp
                  to: '+5491155555555'
                  channel_id: 665f1a2b3c4d5e6f70819234
                  meta_data:
                    order_id: A-1042
                  message:
                    type: text
                    body: Hola Juan
      responses:
        '202':
          description: ''
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SingleSendResponse'
      security:
        - PAT: []
components:
  schemas:
    SendMessageDto:
      type: object
      properties:
        channel:
          type: string
          enum:
            - whatsapp
            - whatsapp_qr
            - instagram
            - messenger
          example: whatsapp
          description: Channel the message is delivered through.
        channel_id:
          type: string
          example: 665f1a2b3c4d5e6f70819234
          description: >-
            Keebai channel id. Optional when the tenant has exactly one active
            channel of the given type; required to disambiguate otherwise.
        phone_number_id:
          type: string
          example: '100000000000001'
          description: >-
            WhatsApp-only alias for channel_id: the Meta phone_number_id of the
            sending number. Ignored on other channels.
        to:
          type: string
          example: '+5491155555555'
          description: >-
            Recipient. E.164 phone number on whatsapp and whatsapp_qr; the
            Meta-scoped id (IGSID / PSID) on instagram and messenger.
        context:
          $ref: '#/components/schemas/MessageContextDto'
        meta_data:
          type: object
          description: >-
            Free-form metadata persisted with the message. Returned on webhooks
            for correlation.
        message:
          oneOf:
            - $ref: '#/components/schemas/TextMessagePayloadDto'
            - $ref: '#/components/schemas/ImageMessagePayloadDto'
            - $ref: '#/components/schemas/VideoMessagePayloadDto'
            - $ref: '#/components/schemas/AudioMessagePayloadDto'
            - $ref: '#/components/schemas/DocumentMessagePayloadDto'
            - $ref: '#/components/schemas/StickerMessagePayloadDto'
            - $ref: '#/components/schemas/LocationMessagePayloadDto'
            - $ref: '#/components/schemas/ContactsMessagePayloadDto'
            - $ref: '#/components/schemas/ReactionMessagePayloadDto'
            - $ref: '#/components/schemas/TemplateMessagePayloadDto'
            - $ref: '#/components/schemas/ButtonsMessagePayloadDto'
            - $ref: '#/components/schemas/ListMessagePayloadDto'
            - $ref: '#/components/schemas/CtaUrlMessagePayloadDto'
            - $ref: '#/components/schemas/CatalogMessagePayloadDto'
            - $ref: '#/components/schemas/CarouselMessagePayloadDto'
            - $ref: '#/components/schemas/FlowMessagePayloadDto'
          discriminator:
            propertyName: type
            mapping:
              text:
                $ref: '#/components/schemas/TextMessagePayloadDto'
              image:
                $ref: '#/components/schemas/ImageMessagePayloadDto'
              video:
                $ref: '#/components/schemas/VideoMessagePayloadDto'
              audio:
                $ref: '#/components/schemas/AudioMessagePayloadDto'
              document:
                $ref: '#/components/schemas/DocumentMessagePayloadDto'
              sticker:
                $ref: '#/components/schemas/StickerMessagePayloadDto'
              location:
                $ref: '#/components/schemas/LocationMessagePayloadDto'
              contacts:
                $ref: '#/components/schemas/ContactsMessagePayloadDto'
              reaction:
                $ref: '#/components/schemas/ReactionMessagePayloadDto'
              template:
                $ref: '#/components/schemas/TemplateMessagePayloadDto'
              buttons:
                $ref: '#/components/schemas/ButtonsMessagePayloadDto'
              list:
                $ref: '#/components/schemas/ListMessagePayloadDto'
              cta_url:
                $ref: '#/components/schemas/CtaUrlMessagePayloadDto'
              catalog:
                $ref: '#/components/schemas/CatalogMessagePayloadDto'
              carousel:
                $ref: '#/components/schemas/CarouselMessagePayloadDto'
              flow:
                $ref: '#/components/schemas/FlowMessagePayloadDto'
      required:
        - channel
        - to
        - message
    SingleSendResponse:
      type: object
      properties:
        message_id:
          type: string
          description: Meta message_id (wamid.*) of the sent WhatsApp message.
        status:
          type: string
          example: sent
        sent_at:
          type: string
          description: ISO-8601 timestamp of the dispatch.
      required:
        - message_id
        - status
        - sent_at
    MessageContextDto:
      type: object
      properties:
        message_id:
          type: string
          description: >-
            Provider message id (wamid.* on WhatsApp) this message quotes as a
            reply.
      required:
        - message_id
    TextMessagePayloadDto:
      type: object
      properties:
        type:
          type: string
          enum:
            - text
        body:
          type: string
          example: Hola, tu pedido va en camino.
          maxLength: 4096
        preview_url:
          type: boolean
          description: Render a link preview for the first URL in the body.
      required:
        - type
        - body
    ImageMessagePayloadDto:
      type: object
      properties:
        link:
          type: string
          description: Public HTTPS URL to the file.
        media_id:
          type: string
          description: Previously-uploaded media id (POST /v1/media/upload).
        type:
          type: string
          enum:
            - image
        caption:
          type: string
          maxLength: 1024
      required:
        - type
    VideoMessagePayloadDto:
      type: object
      properties:
        link:
          type: string
          description: Public HTTPS URL to the file.
        media_id:
          type: string
          description: Previously-uploaded media id (POST /v1/media/upload).
        type:
          type: string
          enum:
            - video
        caption:
          type: string
          maxLength: 1024
      required:
        - type
    AudioMessagePayloadDto:
      type: object
      properties:
        link:
          type: string
          description: Public HTTPS URL to the file.
        media_id:
          type: string
          description: Previously-uploaded media id (POST /v1/media/upload).
        type:
          type: string
          enum:
            - audio
        voice:
          type: boolean
          description: Send as a voice note (PTT) instead of an audio file.
      required:
        - type
    DocumentMessagePayloadDto:
      type: object
      properties:
        link:
          type: string
          description: Public HTTPS URL to the file.
        media_id:
          type: string
          description: Previously-uploaded media id (POST /v1/media/upload).
        type:
          type: string
          enum:
            - document
        filename:
          type: string
          maxLength: 240
        caption:
          type: string
          maxLength: 1024
      required:
        - type
    StickerMessagePayloadDto:
      type: object
      properties:
        link:
          type: string
          description: Public HTTPS URL to the file.
        media_id:
          type: string
          description: Previously-uploaded media id (POST /v1/media/upload).
        type:
          type: string
          enum:
            - sticker
      required:
        - type
    LocationMessagePayloadDto:
      type: object
      properties:
        type:
          type: string
          enum:
            - location
        latitude:
          type: number
          example: -33.4489
        longitude:
          type: number
          example: -70.6693
        name:
          type: string
          maxLength: 1000
        address:
          type: string
          maxLength: 1000
      required:
        - type
        - latitude
        - longitude
    ContactsMessagePayloadDto:
      type: object
      properties:
        type:
          type: string
          enum:
            - contacts
        contacts:
          minItems: 1
          maxItems: 20
          type: array
          items:
            $ref: '#/components/schemas/ContactCardDto'
      required:
        - type
        - contacts
    ReactionMessagePayloadDto:
      type: object
      properties:
        type:
          type: string
          enum:
            - reaction
        message_id:
          type: string
          description: Provider message id (wamid.* on WhatsApp) to react to.
        emoji:
          type: string
          description: Emoji to send as reaction. Empty string clears a previous reaction.
          example: 👍
      required:
        - type
        - message_id
        - emoji
    TemplateMessagePayloadDto:
      type: object
      properties:
        type:
          type: string
          enum:
            - template
        name:
          type: string
          example: welcome_v2
        language:
          type: string
          example: es
        variables:
          type: object
          example:
            nombre: Juan
            monto: '1500'
          description: Named template variables (Meta parameter_name).
        flow:
          $ref: '#/components/schemas/TemplateFlowButtonDto'
      required:
        - type
        - name
        - language
    ButtonsMessagePayloadDto:
      type: object
      properties:
        body_text:
          type: string
          maxLength: 1024
        header:
          $ref: '#/components/schemas/InteractiveHeaderDto'
        footer_text:
          type: string
          maxLength: 60
        type:
          type: string
          enum:
            - buttons
        buttons:
          minItems: 1
          maxItems: 3
          type: array
          items:
            $ref: '#/components/schemas/InteractiveButtonDto'
      required:
        - body_text
        - type
        - buttons
    ListMessagePayloadDto:
      type: object
      properties:
        body_text:
          type: string
          maxLength: 1024
        header:
          $ref: '#/components/schemas/InteractiveHeaderDto'
        footer_text:
          type: string
          maxLength: 60
        type:
          type: string
          enum:
            - list
        button_text:
          type: string
          maxLength: 20
          description: Label of the button that opens the list.
        sections:
          minItems: 1
          maxItems: 10
          type: array
          items:
            $ref: '#/components/schemas/InteractiveListSectionDto'
      required:
        - body_text
        - type
        - button_text
        - sections
    CtaUrlMessagePayloadDto:
      type: object
      properties:
        body_text:
          type: string
          maxLength: 1024
        header:
          $ref: '#/components/schemas/InteractiveHeaderDto'
        footer_text:
          type: string
          maxLength: 60
        type:
          type: string
          enum:
            - cta_url
        display_text:
          type: string
          maxLength: 20
        url:
          type: string
      required:
        - body_text
        - type
        - display_text
        - url
    CatalogMessagePayloadDto:
      type: object
      properties:
        body_text:
          type: string
          maxLength: 1024
        header:
          $ref: '#/components/schemas/InteractiveHeaderDto'
        footer_text:
          type: string
          maxLength: 60
        type:
          type: string
          enum:
            - catalog
        thumbnail_product_retailer_id:
          type: string
          description: Retailer id of the product used as the catalog thumbnail.
      required:
        - body_text
        - type
    CarouselMessagePayloadDto:
      type: object
      properties:
        body_text:
          type: string
          maxLength: 1024
        header:
          $ref: '#/components/schemas/InteractiveHeaderDto'
        footer_text:
          type: string
          maxLength: 60
        type:
          type: string
          enum:
            - carousel
        cards:
          minItems: 1
          maxItems: 10
          type: array
          items:
            $ref: '#/components/schemas/CarouselCardDto'
      required:
        - body_text
        - type
        - cards
    FlowMessagePayloadDto:
      type: object
      properties:
        type:
          type: string
          enum:
            - flow
        flow_id:
          type: string
          description: Meta Flow id, as returned by POST /v1/flows.
          example: '1234567890123456'
        flow_token:
          type: string
          description: >-
            Opaque correlation id echoed back on the flow response webhook. Must
            be unique per send.
          example: srv-9f2c41
        flow_cta:
          type: string
          description: Label of the button that opens the Flow.
          maxLength: 20
          example: Reservar
        flow_action:
          type: string
          enum:
            - navigate
            - data_exchange
          default: navigate
          description: >-
            `navigate` opens the screen named in flow_action_payload;
            `data_exchange` hands control to the Flow endpoint.
        flow_action_payload:
          description: Required when flow_action is `navigate`.
          allOf:
            - $ref: '#/components/schemas/FlowActionPayloadDto'
        mode:
          type: string
          enum:
            - draft
            - published
          default: published
          description: >-
            Send a Flow that has not been published yet. Draft mode only reaches
            numbers listed as testers on the WABA.
        body_text:
          type: string
          maxLength: 1024
        header_text:
          type: string
          maxLength: 60
        footer_text:
          type: string
          maxLength: 60
      required:
        - type
        - flow_id
        - flow_token
        - flow_cta
        - body_text
    ContactCardDto:
      type: object
      properties:
        name:
          $ref: '#/components/schemas/ContactNameDto'
        phones:
          type: array
          items:
            $ref: '#/components/schemas/ContactPhoneDto'
        emails:
          type: array
          items:
            $ref: '#/components/schemas/ContactEmailDto'
        urls:
          type: array
          items:
            $ref: '#/components/schemas/ContactUrlDto'
        org:
          $ref: '#/components/schemas/ContactOrgDto'
        birthday:
          type: string
          example: '1990-03-24'
      required:
        - name
    TemplateFlowButtonDto:
      type: object
      properties:
        flow_token:
          type: string
          description: >-
            Opaque correlation id echoed back on the flow response webhook. Must
            be unique per send.
          example: srv-9f2c41
        button_index:
          type: number
          description: Zero-based index of the FLOW button in the template.
          default: 0
        flow_action_data:
          type: object
          description: >-
            Initial screen data handed to the Flow when it opens. Only used when
            the template button was created with flow_action=navigate.
          example:
            ticket_id: A-1
      required:
        - flow_token
    InteractiveHeaderDto:
      type: object
      properties:
        type:
          type: string
          enum:
            - text
        text:
          type: string
          maxLength: 60
      required:
        - type
        - text
    InteractiveButtonDto:
      type: object
      properties:
        id:
          type: string
          maxLength: 256
        title:
          type: string
          maxLength: 20
      required:
        - id
        - title
    InteractiveListSectionDto:
      type: object
      properties:
        title:
          type: string
          maxLength: 24
        rows:
          minItems: 1
          maxItems: 10
          type: array
          items:
            $ref: '#/components/schemas/InteractiveListRowDto'
      required:
        - rows
    CarouselCardDto:
      type: object
      properties:
        header:
          $ref: '#/components/schemas/CarouselCardHeaderDto'
        body_text:
          type: string
          maxLength: 1024
        action:
          $ref: '#/components/schemas/CarouselCardActionDto'
      required:
        - header
        - action
    FlowActionPayloadDto:
      type: object
      properties:
        screen:
          type: string
          description: Name of the first screen to open, as declared in the Flow JSON.
          example: WELCOME
        data:
          type: object
          description: Initial data injected into that screen.
          example:
            customer_name: Juan
      required:
        - screen
    ContactNameDto:
      type: object
      properties:
        formatted_name:
          type: string
        first_name:
          type: string
        last_name:
          type: string
      required:
        - formatted_name
    ContactPhoneDto:
      type: object
      properties:
        phone:
          type: string
        type:
          type: string
        wa_id:
          type: string
      required:
        - phone
    ContactEmailDto:
      type: object
      properties:
        email:
          type: string
        type:
          type: string
      required:
        - email
    ContactUrlDto:
      type: object
      properties:
        url:
          type: string
        type:
          type: string
      required:
        - url
    ContactOrgDto:
      type: object
      properties:
        company:
          type: string
        department:
          type: string
        title:
          type: string
    InteractiveListRowDto:
      type: object
      properties:
        id:
          type: string
        title:
          type: string
          maxLength: 24
        description:
          type: string
          maxLength: 72
      required:
        - id
        - title
    CarouselCardHeaderDto:
      type: object
      properties:
        type:
          type: string
          enum:
            - image
            - video
        link:
          type: string
          description: >-
            Public HTTPS URL of the card media. Meta rejects .webp images on
            carousel headers.
      required:
        - type
        - link
    CarouselCardActionDto:
      type: object
      properties:
        display_text:
          type: string
          maxLength: 20
        url:
          type: string
      required:
        - display_text
        - url
  securitySchemes:
    PAT:
      scheme: bearer
      bearerFormat: kbai_pk_<hex>
      type: http
      description: >-
        Personal Access Token con prefijo `kbai_pk_`. Generar desde el portal
        con permiso `developer.manage_tokens`.

````