openapi: 3.1.0
info:
  title: Lead Inbox API
  version: 1.0.0
  summary: WhatsApp oficial (con Coexistencia) dentro de tu software.
  description: |
    Lead Inbox conecta el WhatsApp de tus clientes a tu plataforma. Tus usuarios pueden seguir usando
    la app de WhatsApp Business en su celular (Coexistencia de Meta): todo lo que mandan y reciben,
    desde la app o desde tu software, te llega por webhook.

    **Messenger e Instagram:** la API ya los soporta (canal `facebook`), pero Meta todavía no aprueba
    el acceso para Páginas de clientes externos. Hasta entonces solo funcionan con Páginas de prueba.

    **Tu propio asistente:** el asistente con IA de Lead Inbox viene apagado en cada inbox nuevo y se
    prende con `PUT .../agent` (`enabled: true`). Si prefieres tu propio modelo, déjalo apagado: recibe
    cada mensaje por webhook, respóndelo con tu modelo y mándalo con `POST .../messages`.

    **Modelo:** tu cuenta de partner → tus clientes (negocios) → sus inboxes (un inbox = un negocio con
    sus canales: su número de WhatsApp y su Página de Facebook, con el Instagram vinculado). Cada contacto,
    conversación y mensaje dice su `channel` (`whatsapp`, `messenger` o `instagram`).

    Toda llave de API pertenece a tu cuenta de partner y solo ve los inboxes que tú creaste
    o que un negocio conectó a tu cuenta. Un negocio puede estar conectado a varios partners (por
    ejemplo, su CRM y su ERP): cada uno recibe sus webhooks. Configurar el asistente, generar el
    connect_url o borrar el inbox es solo del partner que lo creó.

    **Oportunidades:** cada pedido de un contacto. Sincronízalas con tu CRM con el estado común
    (pending, sent, won, lost): escucha `lead.qualified` y `deal.stage_changed`, y mueve con
    `PATCH /v1/inboxes/{inbox_id}/deals/{deal_id}`.

    **Autenticación:** `Authorization: Bearer li_live_…`.

    **Sandbox:** para probar antes de conectar a un cliente, pide acceso a contacto@nube01.com. Te
    asignamos un inbox de pruebas con un número de WhatsApp de Meta y registramos tus celulares de
    prueba; usas tu misma llave. No sirve para probar el alta de números ni Coexistencia.

    **Paginación:** por cursor. Las listas regresan `{ data, has_more, next_cursor }`; pide la
    siguiente página con `?cursor=<next_cursor>`.

    **Campañas:** envíos masivos por WhatsApp con el motor del panel (`POST /v1/inboxes/{inbox_id}/campaigns`).
    Cuestan 4 créditos del negocio por destinatario; prueba con `dry_run`.

    **Idempotencia:** manda `Idempotency-Key` en `POST /v1/inboxes`, `POST /v1/inboxes/{inbox_id}/messages`
    y `POST /v1/inboxes/{inbox_id}/campaigns`; repetir la misma llave regresa lo ya creado sin duplicarlo
    (ni cobrarlo otra vez).
  contact:
    name: Lead Inbox (Nube Cero Uno)
    url: https://www.leadinbox.com.mx/developers
servers:
  - url: https://www.leadinbox.com.mx/api
    description: Producción
security:
  - apiKey: []
tags:
  - name: Inboxes
    description: |
      Un inbox es el negocio de uno de tus clientes con sus canales: su número de WhatsApp y su Página de
      Facebook (Messenger), con el Instagram profesional vinculado a esa Página. Cada canal se conecta con
      su propio `connect_url`.
  - name: Conversaciones
  - name: Mensajes
  - name: Contactos
  - name: Empresas
    description: Las empresas de los contactos (Clientes → Empresas en el panel).
  - name: Oportunidades
    description: >-
      Cada pedido de un contacto es una oportunidad en el embudo del negocio. `status` es un estado
      común para sincronizar con tu CRM (pending, sent, won, lost): cada negocio dice qué etapa de su
      embudo equivale a cada uno. Guarda en `external_id` el id de la oportunidad en tu sistema.
  - name: Plantillas
    description: Fuera de la ventana de 24 h, WhatsApp solo permite mandar plantillas aprobadas por Meta.
  - name: Configurar webhooks
    description: Registra los endpoints que reciben los eventos (ver la sección Webhooks para el catálogo).
  - name: Listas
    description: Listas de contactos del negocio (las mismas de Clientes en el panel). La audiencia natural de una campaña.
  - name: Campañas
    description: |
      Envíos masivos por WhatsApp con el mismo motor del panel: ritmo, cobro, bajas y seguimiento.
      Por ahora solo WhatsApp.
  - name: Asistente de IA
    description: Opcional. Un asistente que atiende y califica a los contactos nuevos y avisa cuando un humano debe tomar la conversación.
  - name: Llaves
    description: Tus llaves de API. Rótalas sin pedirnos nada.
  - name: Uso

paths:
  /v1/inboxes:
    get:
      tags: [Inboxes]
      summary: Listar inboxes
      operationId: listInboxes
      parameters:
        - $ref: '#/components/parameters/Cursor'
        - $ref: '#/components/parameters/Limit'
        - name: external_id
          in: query
          schema: { type: string }
          description: Filtra por el identificador de tu sistema.
      responses:
        '200':
          description: Lista paginada.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/Page'
                  - properties:
                      data: { type: array, items: { $ref: '#/components/schemas/Inbox' } }
        '401': { $ref: '#/components/responses/Error' }
    post:
      tags: [Inboxes]
      summary: Crear un inbox
      description: |
        Crea el inbox y regresa un `connect_url`. Muestra ese enlace a tu cliente (por ejemplo, detrás
        de un botón "Conecta tu WhatsApp"): abre el registro oficial de Meta, donde elige su número y,
        si ya usa la app de WhatsApp Business, lo conecta conservando la app y su historial.
        Cuando termina recibes el evento `inbox.connected`.

        El enlace sirve una sola vez y vence a los 7 días. Mientras el inbox siga en
        `pending_connection`, `GET /v1/inboxes/{inbox_id}` siempre regresa un `connect_url` vigente
        (si el anterior venció, emite uno nuevo con el mismo `redirect_url`).

        Con la misma `Idempotency-Key` regresa el inbox ya creado con `200`, sin crear otro.
        Máximo 20 inboxes nuevos por día por cuenta.
      operationId: createInbox
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name: { type: string, maxLength: 120, description: Nombre del negocio, como lo verás en tu panel., example: Inmobiliaria Las Lomas }
                external_id: { type: string, maxLength: 200, description: Tu identificador de este cliente., example: cust_8812 }
                channel:
                  type: string
                  enum: [whatsapp, facebook]
                  default: whatsapp
                  description: 'Qué conecta el `connect_url`: el número de WhatsApp, o la Página de Facebook (Messenger) con su Instagram vinculado.'
                redirect_url:
                  type: string
                  format: uri
                  description: |
                    A dónde regresa tu cliente al terminar de conectar (https, dominio público). Le agregamos
                    `inbox_id` y `status` (`connected` o `error`) como parámetros.
                  example: 'https://app.tucrm.com/integraciones/whatsapp'
      responses:
        '201':
          description: Inbox creado, pendiente de conexión.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Inbox' }
        '200':
          description: Ya existía un inbox creado con esa `Idempotency-Key`.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Inbox' }
        '400': { $ref: '#/components/responses/Error' }
        '429': { $ref: '#/components/responses/Error' }

  /v1/inboxes/{inbox_id}:
    parameters:
      - $ref: '#/components/parameters/InboxId'
    get:
      tags: [Inboxes]
      summary: Obtener un inbox
      operationId: getInbox
      responses:
        '200':
          description: El inbox.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Inbox' }
        '404': { $ref: '#/components/responses/Error' }
    delete:
      tags: [Inboxes]
      summary: Desconectar y borrar un inbox
      description: |
        Deja de recibir y enviar mensajes por ese número y borra sus conversaciones y mensajes en Lead
        Inbox (no se puede deshacer). El número sigue siendo de tu cliente en Meta.
      operationId: deleteInbox
      responses:
        '204': { description: Borrado. }
        '404': { $ref: '#/components/responses/Error' }
        '409': { description: 'El inbox no se creó con POST /v1/inboxes (`not_deletable`).', content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }

  /v1/inboxes/{inbox_id}/connect-links:
    parameters:
      - $ref: '#/components/parameters/InboxId'
    post:
      tags: [Inboxes]
      summary: Enlace para conectar otro canal
      description: |
        Un `connect_url` nuevo para conectar (o reconectar) un canal del inbox: `whatsapp` (registro
        oficial de Meta, con Coexistencia) o `facebook` (tu cliente entra con Facebook y elige la Página
        de su negocio; si tiene un Instagram profesional vinculado, también se conecta). Al terminar
        recibes `inbox.connected` (WhatsApp) o `channel.connected` (Messenger e Instagram), y tu cliente
        regresa a `redirect_url` con `inbox_id`, `channel` y `status`.

        El enlace sirve una sola vez y vence a los 7 días. Solo el partner que creó el inbox.
      operationId: createConnectLink
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [channel]
              properties:
                channel: { type: string, enum: [whatsapp, facebook] }
                redirect_url: { type: string, format: uri }
      responses:
        '201':
          description: Enlace creado.
          content:
            application/json:
              schema:
                type: object
                properties:
                  channel: { type: string, enum: [whatsapp, facebook] }
                  connect_url: { type: string, format: uri }
                  expires_at: { type: string, format: date-time }
        '409':
          description: '`channel_already_connected` o `not_api_inbox`.'
          content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } }

  /v1/inboxes/{inbox_id}/profile:
    parameters:
      - $ref: '#/components/parameters/InboxId'
    get:
      tags: [Inboxes]
      summary: Perfil de WhatsApp Business
      description: Lo que ven los contactos del número. Vive en Meta. Solo el partner que creó el inbox.
      operationId: getProfile
      responses:
        '200': { description: El perfil., content: { application/json: { schema: { $ref: '#/components/schemas/BusinessProfile' } } } }
        '409': { $ref: '#/components/responses/Error' }
    patch:
      tags: [Inboxes]
      summary: Editar el perfil
      description: 'Con Coexistencia, Meta solo deja editarlo desde la app de WhatsApp Business (409 `coexistence_profile_locked`).'
      operationId: updateProfile
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                about: { type: string, maxLength: 139 }
                address: { type: string, maxLength: 256 }
                description: { type: string, maxLength: 512 }
                email: { type: string, maxLength: 128 }
                vertical: { type: string, description: 'Giro según Meta: UNDEFINED, OTHER, AUTO, BEAUTY, APPAREL, EDU, ENTERTAIN, EVENT_PLAN, FINANCE, GROCERY, GOVT, HOTEL, HEALTH, NONPROFIT, PROF_SERVICES, RETAIL, TRAVEL, RESTAURANT, NOT_A_BIZ.' }
                websites: { type: array, maxItems: 2, items: { type: string, format: uri } }
      responses:
        '200': { description: Guardado., content: { application/json: { schema: { $ref: '#/components/schemas/BusinessProfile' } } } }
        '409': { $ref: '#/components/responses/Error' }

  /v1/inboxes/{inbox_id}/profile/photo:
    parameters:
      - $ref: '#/components/parameters/InboxId'
    post:
      tags: [Inboxes]
      summary: Cambiar la foto de perfil
      operationId: updateProfilePhoto
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [photo_url]
              properties:
                photo_url: { type: string, format: uri, description: 'https pública, JPG o PNG, hasta 5 MB.' }
      responses:
        '200': { description: Cambiada., content: { application/json: { schema: { $ref: '#/components/schemas/BusinessProfile' } } } }
        '409': { description: '`not_api_inbox`, `inbox_not_connected` o `coexistence_profile_locked`.', content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
        '422': { description: '`media_unavailable` o `unsupported_media_type`.', content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
        '502': { $ref: '#/components/responses/Error' }

  /v1/inboxes/{inbox_id}/history-sync:
    parameters:
      - $ref: '#/components/parameters/InboxId'
    post:
      tags: [Inboxes]
      summary: Importar historial (Coexistencia)
      description: |
        Pide a Meta el historial de la app de WhatsApp Business del número (hasta 6 meses, según
        Meta). Los mensajes importados llegan como eventos `message.received` / `message.sent` con
        `historical: true`, y pueden tardar desde minutos hasta días en llegar completos.

        Meta solo lo permite **una vez por número** y en las **24 h después de conectarlo**. Solo el
        partner que creó el inbox puede pedirlo.
      operationId: syncHistory
      responses:
        '202':
          description: Solicitud aceptada.
          content:
            application/json:
              schema:
                type: object
                properties:
                  status: { type: string, const: requested }
        '409':
          description: |
            `inbox_not_connected` (el número aún no está conectado), `already_synced` (ya se pidió antes),
            `sync_window_expired` (pasaron más de 24 h desde la conexión) o `not_api_inbox`.
          content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } }
        '502': { description: 'Meta rechazó la solicitud (`meta_error`). Ya no se puede volver a pedir.', content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }

  /v1/inboxes/{inbox_id}/conversations:
    parameters:
      - $ref: '#/components/parameters/InboxId'
    get:
      tags: [Conversaciones]
      summary: Listar conversaciones
      operationId: listConversations
      parameters:
        - $ref: '#/components/parameters/Cursor'
        - $ref: '#/components/parameters/Limit'
        - name: updated_after
          in: query
          schema: { type: string, format: date-time }
          description: Solo las que tuvieron actividad después de esta fecha.
        - $ref: '#/components/parameters/Channel'
        - name: external_id
          in: query
          schema: { type: string }
      responses:
        '200':
          description: Lista paginada, la más reciente primero.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/Page'
                  - properties:
                      data: { type: array, items: { $ref: '#/components/schemas/Conversation' } }

  /v1/inboxes/{inbox_id}/conversations/{conversation_id}:
    parameters:
      - $ref: '#/components/parameters/InboxId'
      - $ref: '#/components/parameters/ConversationId'
    get:
      tags: [Conversaciones]
      summary: Obtener una conversación
      operationId: getConversation
      responses:
        '200':
          description: La conversación.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Conversation' }
    patch:
      tags: [Conversaciones]
      summary: Actualizar una conversación
      operationId: updateConversation
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                external_id: { type: string, description: 'Tu identificador (por ejemplo, la oportunidad o el ticket).' }
                status: { type: string, enum: [open, closed] }
                bot: { type: string, enum: [on, off], description: Solo si el inbox tiene el asistente de IA activado. }
                tags: { type: array, items: { type: string } }
                assignees: { type: array, items: { type: string }, description: 'Reemplaza a las personas asignadas (ids de `GET /team`); `[]` = nadie.' }
                followup: { type: string, enum: [on, off], description: Seguimiento automático del asistente en esta conversación. }
      responses:
        '200':
          description: Actualizada.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Conversation' }

  /v1/inboxes/{inbox_id}/conversations/{conversation_id}/reopen:
    parameters:
      - $ref: '#/components/parameters/InboxId'
      - $ref: '#/components/parameters/ConversationId'
    post:
      tags: [Conversaciones]
      summary: Reabrir con el asistente (ciclo nuevo)
      description: |
        Como "Reactivar el bot" del panel: cierra la oportunidad automática actual, deja la conversación
        con el asistente y conserva solo los datos de la persona (nombre, correo…). El siguiente pedido
        del contacto nace como oportunidad nueva.
      operationId: reopenConversation
      responses:
        '200': { description: Reabierta., content: { application/json: { schema: { $ref: '#/components/schemas/Conversation' } } } }
        '409': { description: 'El inbox no tiene el asistente activado (`bot_disabled`).', content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }

  /v1/inboxes/{inbox_id}/conversations/{conversation_id}/notes:
    parameters:
      - $ref: '#/components/parameters/InboxId'
      - $ref: '#/components/parameters/ConversationId'
    get:
      tags: [Conversaciones]
      summary: Notas internas
      description: Las notas del equipo (el contacto nunca las ve), las más nuevas primero. Hasta 200.
      operationId: listNotes
      responses:
        '200':
          description: Notas.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { type: array, items: { $ref: '#/components/schemas/Note' } }
    post:
      tags: [Conversaciones]
      summary: Agregar una nota
      operationId: createNote
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [text]
              properties:
                text: { type: string, maxLength: 5000 }
                sent_by: { type: string, description: El usuario de tu sistema que la escribe. }
      responses:
        '201': { description: Creada., content: { application/json: { schema: { $ref: '#/components/schemas/Note' } } } }

  /v1/inboxes/{inbox_id}/conversations/{conversation_id}/notes/{note_id}:
    parameters:
      - $ref: '#/components/parameters/InboxId'
      - $ref: '#/components/parameters/ConversationId'
      - name: note_id
        in: path
        required: true
        schema: { type: string }
    patch:
      tags: [Conversaciones]
      summary: Editar una nota
      operationId: updateNote
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [text]
              properties:
                text: { type: string, maxLength: 5000 }
      responses:
        '200': { description: Editada., content: { application/json: { schema: { $ref: '#/components/schemas/Note' } } } }
    delete:
      tags: [Conversaciones]
      summary: Borrar una nota
      operationId: deleteNote
      responses:
        '204': { description: Borrada. }

  /v1/inboxes/{inbox_id}/conversations/{conversation_id}/pending-messages:
    parameters:
      - $ref: '#/components/parameters/InboxId'
      - $ref: '#/components/parameters/ConversationId'
    get:
      tags: [Mensajes]
      summary: Mensajes por salir
      description: 'Lo que todavía no sale en esta conversación (en cola, mandándose o programado), sin campañas, por hora de salida.'
      operationId: listPendingMessages
      responses:
        '200':
          description: Pendientes, por hora de salida.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        id: { type: string }
                        conversation_id: { type: string }
                        source: { type: string, enum: [api, app, bot, panel] }
                        sent_by: { type: [string, 'null'] }
                        type: { type: string }
                        text: { type: [string, 'null'] }
                        template: { type: [object, 'null'] }
                        media: { type: [object, 'null'], properties: { filename: { type: [string, 'null'] } } }
                        status: { type: string, enum: [queued, sending, scheduled] }
                        scheduled_for: { type: [string, 'null'], format: date-time }
                        created_at: { type: string, format: date-time }

  /v1/inboxes/{inbox_id}/conversations/{conversation_id}/messages:
    parameters:
      - $ref: '#/components/parameters/InboxId'
      - $ref: '#/components/parameters/ConversationId'
    get:
      tags: [Mensajes]
      summary: Listar mensajes de una conversación
      operationId: listMessages
      parameters:
        - $ref: '#/components/parameters/Cursor'
        - $ref: '#/components/parameters/Limit'
      responses:
        '200':
          description: Lista paginada, el más reciente primero.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/Page'
                  - properties:
                      data: { type: array, items: { $ref: '#/components/schemas/Message' } }

  /v1/inboxes/{inbox_id}/messages/{message_id}:
    parameters:
      - $ref: '#/components/parameters/InboxId'
      - name: message_id
        in: path
        required: true
        schema: { type: string }
    delete:
      tags: [Mensajes]
      summary: Cancelar un mensaje programado
      description: 'Solo mensajes programados que todavía no salen (los inmediatos ya se cobraron y salen en segundos). Uno que ya se está mandando o ya salió responde 409 `not_cancellable`; los de campañas se cancelan con la campaña (`campaign_message`).'
      operationId: cancelMessage
      responses:
        '204': { description: Cancelado. }
        '409': { $ref: '#/components/responses/Error' }

  /v1/inboxes/{inbox_id}/messages/{message_id}/reaction:
    parameters:
      - $ref: '#/components/parameters/InboxId'
      - name: message_id
        in: path
        required: true
        schema: { type: string }
    post:
      tags: [Mensajes]
      summary: Reaccionar a un mensaje
      description: 'Un emoji; vacío o null quita la reacción. Solo WhatsApp y mensajes de hasta 30 días.'
      operationId: reactToMessage
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                emoji: { type: [string, 'null'], example: 👍 }
      responses:
        '200':
          description: Enviada.
          content:
            application/json:
              schema:
                type: object
                properties:
                  message_id: { type: string }
                  emoji: { type: [string, 'null'] }
        '422': { $ref: '#/components/responses/Error' }

  /v1/inboxes/{inbox_id}/team:
    parameters:
      - $ref: '#/components/parameters/InboxId'
    get:
      tags: [Conversaciones]
      summary: Equipo del negocio
      description: Las personas que atienden en el panel o la app; sus ids sirven para `assignees`.
      operationId: listTeam
      responses:
        '200':
          description: Equipo.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        id: { type: string }
                        email: { type: [string, 'null'] }
                        role: { type: string, enum: [owner, admin, vendedor] }

  /v1/inboxes/{inbox_id}/messages:
    parameters:
      - $ref: '#/components/parameters/InboxId'
    post:
      tags: [Mensajes]
      summary: Mandar un mensaje
      description: |
        Manda texto, un archivo o una plantilla. Identifica al destinatario con `to` (número de WhatsApp
        en formato internacional), con `contact_id` o con `conversation_id`. El canal es el del contacto.

        **Ventana de 24 h:** si el contacto no te ha escrito en las últimas 24 horas, WhatsApp solo
        acepta plantillas, y Messenger e Instagram no aceptan nada. Un texto o archivo fuera de la ventana
        falla con `outside_customer_service_window`; revisa `service_window_expires_at` en la conversación
        antes de mandar. Messenger e Instagram no usan plantillas (`template_not_supported`).

        El mensaje se acepta como `queued` y su avance (`sent`, `delivered`, `read`, `failed`) llega por
        el evento `message.status`.
      operationId: sendMessage
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/SendMessageRequest' }
            examples:
              texto:
                summary: Texto dentro de la ventana de 24 h
                value: { to: '+5215512345678', type: text, text: 'Hola Ana, te comparto la propuesta.', sent_by: 'user_42' }
              plantilla:
                summary: Plantilla para retomar la conversación
                value: { to: '+5215512345678', type: template, template: { name: seguimiento_cotizacion, language: es_MX, variables: [Ana, 'COT-1093'] }, sent_by: 'user_42' }
      responses:
        '202':
          description: Aceptado y en cola.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Message' }
        '422': { $ref: '#/components/responses/Error' }
        '429': { $ref: '#/components/responses/Error' }

  /v1/inboxes/{inbox_id}/contacts:
    parameters:
      - $ref: '#/components/parameters/InboxId'
    get:
      tags: [Contactos]
      summary: Listar contactos
      operationId: listContacts
      parameters:
        - $ref: '#/components/parameters/Cursor'
        - $ref: '#/components/parameters/Limit'
        - name: phone
          in: query
          schema: { type: string }
          description: Busca por número en formato internacional (en México encuentra el contacto con o sin el 1 después del 52).
        - name: external_id
          in: query
          schema: { type: string }
          description: Busca por el id de tu sistema.
        - name: company_id
          in: query
          schema: { type: string }
          description: Contactos de esa empresa.
        - $ref: '#/components/parameters/Channel'
      responses:
        '200':
          description: Lista paginada.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/Page'
                  - properties:
                      data: { type: array, items: { $ref: '#/components/schemas/Contact' } }

    post:
      tags: [Contactos]
      summary: Crear un contacto de WhatsApp
      description: |
        Crea el contacto con su conversación (como "Nuevo contacto" del panel). Si ese número ya
        existía regresa `200` y solo actualiza lo que mandes. Messenger e Instagram no se crean: el
        contacto aparece cuando escribe por primera vez.
      operationId: createContact
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [phone]
              properties:
                phone: { type: string, example: '+5215512345678' }
                name: { type: string, maxLength: 120 }
                email: { type: string, format: email }
                city: { type: string, maxLength: 120 }
                external_id: { type: string, maxLength: 200 }
      responses:
        '201':
          description: Creado.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Contact' }
        '200':
          description: Ya existía.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Contact' }
        '400': { $ref: '#/components/responses/Error' }

  /v1/inboxes/{inbox_id}/contacts/{contact_id}:
    parameters:
      - $ref: '#/components/parameters/InboxId'
      - name: contact_id
        in: path
        required: true
        schema: { type: string }
    get:
      tags: [Contactos]
      summary: Consultar un contacto
      operationId: getContact
      responses:
        '200':
          description: El contacto.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Contact' }
        '404': { $ref: '#/components/responses/Error' }
    patch:
      tags: [Contactos]
      summary: Actualizar un contacto
      description: |
        Liga el contacto con tu sistema (`external_id`), corrige sus datos o lo da de baja.
        `opted_out: true` lo saca de campañas y seguimientos automáticos (el asistente le sigue
        contestando si escribe); `false` quita la baja. Los campos en `null` se borran.
        Una baja que puso WhatsApp (la persona escribió "stop") no se puede quitar: responde 409 `meta_opt_out`.
      operationId: updateContact
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                external_id: { type: [string, 'null'], description: 'El id del cliente en tu sistema. A partir de aquí viaja en todos los eventos de este contacto.', example: lead_5521 }
                name: { type: string, maxLength: 120 }
                email: { type: [string, 'null'], format: email }
                city: { type: [string, 'null'], maxLength: 120 }
                company_id: { type: [string, 'null'], description: 'Ligarlo a una empresa (`GET /companies`); null la quita.' }
                opted_out: { type: boolean }
      responses:
        '200':
          description: Actualizado.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Contact' }
        '409': { $ref: '#/components/responses/Error' }

  /v1/inboxes/{inbox_id}/companies:
    parameters:
      - $ref: '#/components/parameters/InboxId'
    get:
      tags: [Empresas]
      summary: Listar empresas
      operationId: listCompanies
      parameters:
        - $ref: '#/components/parameters/Cursor'
        - $ref: '#/components/parameters/Limit'
        - name: name
          in: query
          schema: { type: string }
          description: Las que contienen ese texto en el nombre (sin importar mayúsculas).
      responses:
        '200':
          description: Lista paginada, las más nuevas primero.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/Page'
                  - properties:
                      data: { type: array, items: { $ref: '#/components/schemas/Company' } }
    post:
      tags: [Empresas]
      summary: Crear una empresa
      description: 'El nombre es único por negocio (sin importar mayúsculas): si ya existe responde 409 `company_exists` con su id en `existing_id`.'
      operationId: createCompany
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/CompanyInput' }
      responses:
        '201': { description: Creada., content: { application/json: { schema: { $ref: '#/components/schemas/Company' } } } }
        '409': { $ref: '#/components/responses/Error' }

  /v1/inboxes/{inbox_id}/companies/{company_id}:
    parameters:
      - $ref: '#/components/parameters/InboxId'
      - name: company_id
        in: path
        required: true
        schema: { type: string }
    get:
      tags: [Empresas]
      summary: Ver una empresa
      description: 'Sus contactos: `GET /contacts?company_id=`.'
      operationId: getCompany
      responses:
        '200': { description: La empresa., content: { application/json: { schema: { $ref: '#/components/schemas/Company' } } } }
    patch:
      tags: [Empresas]
      summary: Editar una empresa
      operationId: updateCompany
      requestBody:
        content:
          application/json:
            schema: { $ref: '#/components/schemas/CompanyInput' }
      responses:
        '200': { description: Editada., content: { application/json: { schema: { $ref: '#/components/schemas/Company' } } } }
        '409': { $ref: '#/components/responses/Error' }
    delete:
      tags: [Empresas]
      summary: Borrar una empresa
      description: Sus contactos se quedan, sin empresa.
      operationId: deleteCompany
      responses:
        '204': { description: Borrada. }

  /v1/inboxes/{inbox_id}/companies/{company_id}/merge:
    parameters:
      - $ref: '#/components/parameters/InboxId'
      - name: company_id
        in: path
        required: true
        schema: { type: string }
    post:
      tags: [Empresas]
      summary: Fusionar otra empresa en esta
      description: |
        Como "Fusionar" en el panel: los contactos de `company_id` (el del cuerpo) pasan a esta, los datos
        vacíos de esta se llenan con los de la otra, y la otra se borra. No se puede deshacer.
      operationId: mergeCompanies
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [company_id]
              properties:
                company_id: { type: string, description: La empresa que desaparece. }
      responses:
        '200': { description: Fusionadas; regresa la que queda., content: { application/json: { schema: { $ref: '#/components/schemas/Company' } } } }

  /v1/inboxes/{inbox_id}/deals:
    parameters:
      - $ref: '#/components/parameters/InboxId'
    get:
      tags: [Oportunidades]
      summary: Listar oportunidades
      operationId: listDeals
      parameters:
        - $ref: '#/components/parameters/Cursor'
        - $ref: '#/components/parameters/Limit'
        - name: status
          in: query
          schema: { $ref: '#/components/schemas/DealStatus' }
        - name: external_id
          in: query
          schema: { type: string }
        - name: conversation_id
          in: query
          schema: { type: string }
      responses:
        '200':
          description: Lista paginada, la más nueva primero.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/Page'
                  - properties:
                      data: { type: array, items: { $ref: '#/components/schemas/Deal' } }
        '401': { $ref: '#/components/responses/Error' }
    post:
      tags: [Oportunidades]
      summary: Crear una oportunidad
      description: |
        Oportunidad manual (`manual: true`), como "Nueva oportunidad" del panel; el asistente nunca la
        toca. El contacto necesita conversación (todo contacto creado con `POST /contacts` o que haya
        escrito ya la tiene). Etapa por `stage_id` o por `status`; sin `funnel_id`, el embudo principal.
      operationId: createDeal
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [contact_id]
              properties:
                contact_id: { type: string }
                funnel_id: { type: string }
                stage_id: { type: string }
                status: { $ref: '#/components/schemas/DealStatus' }
                title: { type: string, maxLength: 200 }
                tags: { type: array, items: { type: string } }
                notes: { type: string }
                fields: { type: object, additionalProperties: { type: string } }
                external_id: { type: string }
      responses:
        '201':
          description: Creada.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Deal' }
        '404': { $ref: '#/components/responses/Error' }
        '409':
          description: '`contact_without_conversation`, `stage_in_other_funnel` o `status_not_mapped`.'
          content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } }

  /v1/inboxes/{inbox_id}/deals/{deal_id}:
    parameters:
      - $ref: '#/components/parameters/InboxId'
      - $ref: '#/components/parameters/DealId'
    get:
      tags: [Oportunidades]
      summary: Obtener una oportunidad
      operationId: getDeal
      responses:
        '200':
          description: La oportunidad.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Deal' }
        '404': { $ref: '#/components/responses/Error' }
    patch:
      tags: [Oportunidades]
      summary: Mover de etapa, editar o ligar a tu sistema
      description: >-
        `status` mueve la oportunidad a la etapa de su embudo con ese estado (si ya está ahí, no pasa
        nada); `stage_id`, a una etapa concreta de su embudo (ver `GET /funnels`). Manda uno de los
        dos. Si el embudo no tiene etapa para ese estado responde 409 `status_not_mapped` y no se
        guarda nada. `fields` se mezcla con lo que ya tiene; una llave en `null` la borra.
      operationId: updateDeal
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                status: { $ref: '#/components/schemas/DealStatus' }
                stage_id: { type: string }
                title: { type: string, maxLength: 200 }
                tags: { type: array, items: { type: string }, description: Reemplaza las etiquetas. }
                notes: { type: [string, 'null'] }
                fields: { type: object, additionalProperties: { type: [string, 'null'] } }
                external_id: { type: [string, 'null'], description: 'El id de la oportunidad en tu sistema (por ejemplo, tu cotización). null lo quita.' }
      responses:
        '200':
          description: Actualizada.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Deal' }
        '409': { $ref: '#/components/responses/Error' }

  /v1/inboxes/{inbox_id}/funnels:
    parameters:
      - $ref: '#/components/parameters/InboxId'
    get:
      tags: [Oportunidades]
      summary: Embudos y etapas
      description: Los embudos del negocio con sus etapas en orden, y el estado común (`status`) de cada etapa.
      operationId: listFunnels
      responses:
        '200':
          description: Embudos.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { type: array, items: { $ref: '#/components/schemas/Funnel' } }
    post:
      tags: [Oportunidades]
      summary: Crear un embudo
      description: |
        Con sus etapas en orden. El primero queda como principal (y adopta las oportunidades sin embudo).
        En el plan Base solo cabe uno (409 `plan_limit`). Solo el partner que creó el inbox.
      operationId: createFunnel
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name: { type: string, maxLength: 80 }
                stages: { type: array, maxItems: 20, items: { $ref: '#/components/schemas/StageInput' } }
      responses:
        '201': { description: Creado., content: { application/json: { schema: { $ref: '#/components/schemas/Funnel' } } } }
        '409': { $ref: '#/components/responses/Error' }

  /v1/inboxes/{inbox_id}/funnels/{funnel_id}:
    parameters:
      - $ref: '#/components/parameters/InboxId'
      - $ref: '#/components/parameters/FunnelId'
    get:
      tags: [Oportunidades]
      summary: Ver un embudo
      operationId: getFunnel
      responses:
        '200': { description: El embudo con sus etapas., content: { application/json: { schema: { $ref: '#/components/schemas/Funnel' } } } }
    patch:
      tags: [Oportunidades]
      summary: Editar un embudo
      description: '`default: true` lo vuelve el principal (donde nacen las oportunidades del asistente).'
      operationId: updateFunnel
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                name: { type: string, maxLength: 80 }
                position: { type: integer, minimum: 1 }
                default: { type: boolean, enum: [true] }
      responses:
        '200': { description: Editado., content: { application/json: { schema: { $ref: '#/components/schemas/Funnel' } } } }
    delete:
      tags: [Oportunidades]
      summary: Borrar un embudo
      description: 'No se borra el principal (`default_funnel`) ni uno con oportunidades (`funnel_has_deals`).'
      operationId: deleteFunnel
      responses:
        '204': { description: Borrado. }
        '409': { $ref: '#/components/responses/Error' }

  /v1/inboxes/{inbox_id}/funnels/{funnel_id}/stages:
    parameters:
      - $ref: '#/components/parameters/InboxId'
      - $ref: '#/components/parameters/FunnelId'
    post:
      tags: [Oportunidades]
      summary: Agregar una etapa
      description: Al final, o en `position`. Hasta 20 por embudo.
      operationId: createStage
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/StageInput' }
      responses:
        '201': { description: Creada., content: { application/json: { schema: { $ref: '#/components/schemas/Stage' } } } }

  /v1/inboxes/{inbox_id}/funnels/{funnel_id}/stages/{stage_id}:
    parameters:
      - $ref: '#/components/parameters/InboxId'
      - $ref: '#/components/parameters/FunnelId'
      - name: stage_id
        in: path
        required: true
        schema: { type: string }
    patch:
      tags: [Oportunidades]
      summary: Editar una etapa
      description: 'Incluye su estado común (`status`), que es lo que usa la sincronización con tu CRM; null lo quita.'
      operationId: updateStage
      requestBody:
        content:
          application/json:
            schema: { $ref: '#/components/schemas/StageInput' }
      responses:
        '200': { description: Editada., content: { application/json: { schema: { $ref: '#/components/schemas/Stage' } } } }
    delete:
      tags: [Oportunidades]
      summary: Borrar una etapa
      description: 'No se borra una etapa con oportunidades (`stage_has_deals`): muévelas antes.'
      operationId: deleteStage
      responses:
        '204': { description: Borrada. }
        '409': { $ref: '#/components/responses/Error' }

  /v1/inboxes/{inbox_id}/templates:
    parameters:
      - $ref: '#/components/parameters/InboxId'
    get:
      tags: [Plantillas]
      summary: Listar plantillas
      operationId: listTemplates
      responses:
        '200':
          description: Plantillas del número, con su estado en Meta.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { type: array, items: { $ref: '#/components/schemas/Template' } }
    post:
      tags: [Plantillas]
      summary: Enviar una plantilla a revisión de Meta
      operationId: createTemplate
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name, language, category, body]
              properties:
                name: { type: string, example: seguimiento_cotizacion }
                language: { type: string, example: es_MX }
                category: { type: string, enum: [MARKETING, UTILITY] }
                body: { type: string, example: 'Hola {{1}}, ¿pudiste revisar la cotización {{2}}?' }
                examples: { type: array, items: { type: string }, example: [Ana, 'COT-1093'] }
      responses:
        '201':
          description: Enviada; el resultado llega con el evento `template.status`.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Template' }
        '400': { $ref: '#/components/responses/Error' }
        '422': { description: 'Meta la rechazó al recibirla (`meta_rejected`): nombre repetido, categoría que no corresponde…', content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }

  /v1/inboxes/{inbox_id}/lists:
    parameters:
      - $ref: '#/components/parameters/InboxId'
    get:
      tags: [Listas]
      summary: Listar listas de contactos
      operationId: listLists
      responses:
        '200':
          description: Las listas del negocio.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { type: array, items: { $ref: '#/components/schemas/ContactList' } }
    post:
      tags: [Listas]
      summary: Crear una lista
      operationId: createList
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name: { type: string, maxLength: 120 }
      responses:
        '201':
          description: Creada.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ContactList' }
        '409': { description: 'Ya existe una lista con ese nombre (`list_name_taken`).', content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }

  /v1/inboxes/{inbox_id}/lists/{list_id}:
    parameters:
      - $ref: '#/components/parameters/InboxId'
      - name: list_id
        in: path
        required: true
        schema: { type: string }
    delete:
      tags: [Listas]
      summary: Borrar una lista
      description: Los contactos se quedan.
      operationId: deleteList
      responses:
        '204': { description: Borrada. }

  /v1/inboxes/{inbox_id}/lists/{list_id}/contacts:
    parameters:
      - $ref: '#/components/parameters/InboxId'
      - name: list_id
        in: path
        required: true
        schema: { type: string }
    get:
      tags: [Listas]
      summary: Contactos de una lista
      operationId: listListContacts
      parameters:
        - $ref: '#/components/parameters/Cursor'
        - $ref: '#/components/parameters/Limit'
      responses:
        '200':
          description: Lista paginada, los agregados más recientemente primero.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/Page'
                  - properties:
                      data: { type: array, items: { $ref: '#/components/schemas/Contact' } }
    post:
      tags: [Listas]
      summary: Agregar contactos
      description: |
        `contact_ids` agrega contactos que ya existen. `contacts` agrega números de WhatsApp (10 dígitos
        se toman como México); los que no existen se crean sin conversación, como al importar un CSV en
        el panel, y un contacto que ya existía conserva su nombre. Hasta 5,000 por llamada. Necesitas el
        permiso de esas personas para escribirles.
      operationId: addListContacts
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                contact_ids: { type: array, items: { type: string } }
                contacts:
                  type: array
                  items:
                    type: object
                    required: [phone]
                    properties:
                      phone: { type: string }
                      name: { type: string }
      responses:
        '200':
          description: Agregados.
          content:
            application/json:
              schema:
                type: object
                properties:
                  added: { type: integer }
                  phones:
                    type: [object, 'null']
                    description: Resumen de `contacts`.
                    properties:
                      created: { type: integer }
                      existing: { type: integer }
                      invalid: { type: integer }
                      duplicates: { type: integer }
                      total: { type: integer }
    delete:
      tags: [Listas]
      summary: Sacar contactos de la lista
      operationId: removeListContacts
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [contact_ids]
              properties:
                contact_ids: { type: array, items: { type: string } }
      responses:
        '204': { description: Sacados (no se borran). }

  /v1/inboxes/{inbox_id}/campaigns:
    parameters:
      - $ref: '#/components/parameters/InboxId'
    get:
      tags: [Campañas]
      summary: Listar campañas
      description: Las del panel y las de la API (sin borradores), las más nuevas primero.
      operationId: listCampaigns
      parameters:
        - $ref: '#/components/parameters/Cursor'
        - $ref: '#/components/parameters/Limit'
      responses:
        '200':
          description: Lista paginada.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/Page'
                  - properties:
                      data: { type: array, items: { $ref: '#/components/schemas/Campaign' } }
    post:
      tags: [Campañas]
      summary: Crear y lanzar una campaña
      description: |
        Campaña de WhatsApp con una **plantilla aprobada** (`template`) o con **texto libre** (`text`,
        solo llega a quien te escribió en las últimas 24 h; a los demás WhatsApp la rechaza y quedan
        como fallidos). Messenger e Instagram no reciben campañas.

        **Audiencia:** contactos (`contact_ids`), listas (`list_ids`) y/o condiciones (las mismas del
        armador del panel). Nunca entran los dados de baja; por default tampoco el equipo
        (`exclude_staff`) ni quien está hablando con una persona (`exclude_human_active`).

        **Variables:** cada variable de la plantilla (o el texto) puede usar `{{nombre}}` o `{{un dato}}`
        de la oportunidad del contacto (lo que juntó el asistente). A quien le falte un dato no se le
        manda: queda como fallido con motivo `missing_data` y no se cobra.

        **Quién:** solo el partner dueño del inbox (el que lo creó); un partner solo conectado recibe 403 `owner_only`. Igual para pausar, reanudar, cancelar y clasificar.

        **Cobro:** en los inboxes que creaste por la API, las campañas van incluidas en tu plan por número:
        no se cobran créditos (`credits` solo informa). En un negocio conectado que paga Lead Inbox con
        créditos, son 4 por destinatario que sí sale, todo antes de encolar nada; si no alcanzan, responde
        402 `insufficient_credits` y no se crea nada, y cancelar reembolsa lo que no salió.
        Usa `dry_run: true` para saber destinatarios sin crear nada.

        Manda `Idempotency-Key`: repetir la misma llave regresa la campaña ya creada sin cobrar otra vez.
      operationId: createCampaign
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/CreateCampaignRequest' }
            examples:
              plantilla_a_lista:
                summary: Plantilla a una lista
                value:
                  name: Promo octubre
                  template: { name: promo_octubre, language: es_MX, variables: ['{{nombre}}'] }
                  audience: { list_ids: ['3f1c…'] }
                  pace_per_minute: 30
              prueba:
                summary: Calcular sin crear
                value:
                  name: Reactivación
                  template: { name: seguimiento, language: es_MX, variables: ['{{nombre}}', '{{Zona de interés}}'] }
                  audience: { conditions: [{ field: activity, op: older, values: [90] }] }
                  dry_run: true
      responses:
        '201':
          description: Creada y en cola.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/Campaign'
                  - properties:
                      credits_charged: { type: number }
        '200':
          description: 'Con `dry_run`: el cálculo (`dry_run`, `recipients`, `queued`, `missing_data`, `credits`). Con una `Idempotency-Key` repetida: la campaña ya creada.'
        '403': { description: 'Solo el partner dueño del inbox (`owner_only`).', content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
        '402': { $ref: '#/components/responses/Error' }
        '409': { $ref: '#/components/responses/Error' }
        '422': { $ref: '#/components/responses/Error' }

  /v1/inboxes/{inbox_id}/campaigns/{campaign_id}:
    parameters:
      - $ref: '#/components/parameters/InboxId'
      - $ref: '#/components/parameters/CampaignId'
    get:
      tags: [Campañas]
      summary: Consultar una campaña
      operationId: getCampaign
      responses:
        '200':
          description: La campaña con sus conteos.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Campaign' }
        '404': { $ref: '#/components/responses/Error' }

  /v1/inboxes/{inbox_id}/campaigns/{campaign_id}/pause:
    parameters:
      - $ref: '#/components/parameters/InboxId'
      - $ref: '#/components/parameters/CampaignId'
    post:
      tags: [Campañas]
      summary: Pausar
      description: El mensaje que ya se está mandando termina; el resto espera.
      operationId: pauseCampaign
      responses:
        '200': { description: Pausada., content: { application/json: { schema: { $ref: '#/components/schemas/Campaign' } } } }
        '403': { description: 'Solo el partner dueño del inbox (`owner_only`).', content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
        '409': { $ref: '#/components/responses/Error' }

  /v1/inboxes/{inbox_id}/campaigns/{campaign_id}/resume:
    parameters:
      - $ref: '#/components/parameters/InboxId'
      - $ref: '#/components/parameters/CampaignId'
    post:
      tags: [Campañas]
      summary: Reanudar
      description: La agenda se recorre lo que duró la pausa; se conserva el ritmo.
      operationId: resumeCampaign
      responses:
        '200': { description: Reanudada., content: { application/json: { schema: { $ref: '#/components/schemas/Campaign' } } } }
        '403': { description: 'Solo el partner dueño del inbox (`owner_only`).', content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
        '409': { $ref: '#/components/responses/Error' }

  /v1/inboxes/{inbox_id}/campaigns/{campaign_id}/cancel:
    parameters:
      - $ref: '#/components/parameters/InboxId'
      - $ref: '#/components/parameters/CampaignId'
    post:
      tags: [Campañas]
      summary: Cancelar
      description: Cancela lo que falta y reembolsa sus créditos al negocio.
      operationId: cancelCampaign
      responses:
        '200': { description: Cancelada., content: { application/json: { schema: { $ref: '#/components/schemas/Campaign' } } } }
        '403': { description: 'Solo el partner dueño del inbox (`owner_only`).', content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
        '409': { $ref: '#/components/responses/Error' }

  /v1/inboxes/{inbox_id}/campaigns/{campaign_id}/classify-replies:
    parameters:
      - $ref: '#/components/parameters/InboxId'
      - $ref: '#/components/parameters/CampaignId'
    post:
      tags: [Campañas]
      summary: Clasificar el sentimiento de las respuestas
      description: 'Clasifica (positive, neutral, negative) las respuestas que aún no lo tienen, como hace el panel al abrir la campaña. Después `recipients` trae `reply.sentiment`.'
      operationId: classifyCampaignReplies
      responses:
        '200':
          description: Cuántas se clasificaron.
          content:
            application/json:
              schema:
                type: object
                properties:
                  classified: { type: integer }
        '403': { description: 'Solo el partner dueño del inbox (`owner_only`).', content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }

  /v1/inboxes/{inbox_id}/campaigns/{campaign_id}/recipients:
    parameters:
      - $ref: '#/components/parameters/InboxId'
      - $ref: '#/components/parameters/CampaignId'
    get:
      tags: [Campañas]
      summary: Destinatarios y respuestas
      description: 'Para reintentar a los fallidos (`failure.reason`), crea otra campaña con sus `contact_ids`.'
      operationId: listCampaignRecipients
      parameters:
        - $ref: '#/components/parameters/Cursor'
        - name: limit
          in: query
          schema: { type: integer, minimum: 1, maximum: 200, default: 50 }
        - name: status
          in: query
          schema: { type: string, enum: [queued, sent, failed, cancelled] }
      responses:
        '200':
          description: Lista paginada.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/Page'
                  - properties:
                      data: { type: array, items: { $ref: '#/components/schemas/CampaignRecipient' } }

  /v1/inboxes/{inbox_id}/agent:
    parameters:
      - $ref: '#/components/parameters/InboxId'
    get:
      tags: [Asistente de IA]
      summary: Ver la configuración del asistente
      operationId: getAgent
      responses:
        '200':
          description: Configuración y categorías.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/Agent'
                  - properties:
                      categories: { type: array, items: { $ref: '#/components/schemas/Category' } }
        '404': { $ref: '#/components/responses/Error' }
        '409': { description: 'El inbox no se creó por la API (`not_api_inbox`).', content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
    put:
      tags: [Asistente de IA]
      summary: Configurar el asistente de IA
      description: |
        Opcional. Cuando está activo, el asistente contesta a los contactos con la información del
        negocio y pide los datos que definas. Se limita al negocio configurado: no es un asistente de
        propósito general (regla de Meta). Los inboxes nacen con el asistente apagado.

        Solo cambia los campos que mandes; `null` borra un texto o el horario. Sirve para los inboxes
        creados con `POST /v1/inboxes` (los demás responden `409 not_api_inbox`).

        Al juntar los datos (o según el `handoff` de la categoría), la conversación pasa a manos del
        equipo (`bot: off`): recibes `conversation.handoff` (y `lead.qualified` si juntó los datos), y se
        avisa por WhatsApp a los `notify_phones` de la categoría.
      operationId: configureAgent
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/Agent' }
      responses:
        '200':
          description: Guardado; regresa la configuración completa.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Agent' }
        '400': { $ref: '#/components/responses/Error' }
        '404': { $ref: '#/components/responses/Error' }
        '409': { description: 'El inbox no se creó por la API (`not_api_inbox`).', content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }

  /v1/inboxes/{inbox_id}/agent/documents:
    parameters:
      - $ref: '#/components/parameters/InboxId'
    get:
      tags: [Asistente de IA]
      summary: Documentos de conocimiento
      description: Lo que el asistente lee en cada respuesta (catálogo, precios, preguntas frecuentes). Sin el texto completo.
      operationId: listDocuments
      responses:
        '200':
          description: Documentos.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { type: array, items: { $ref: '#/components/schemas/Document' } }
    post:
      tags: [Asistente de IA]
      summary: Agregar un documento
      description: |
        Con `content` (texto) o con `file_url` (https pública, hasta 5 MB; la URL debe terminar en la
        extensión: pdf, docx, xlsx, xls, html, jpg, png, o txt/md/csv solo como texto). Si mandas archivo sin `content`, extraemos el texto de pdf, docx, xlsx, html, txt o
        md (hasta 4 MB y 100,000 caracteres). Con `shareable: true` el asistente puede mandarle el
        archivo al cliente. Máximo 50 documentos; cuentan para el almacenamiento del negocio.
      operationId: createDocument
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name: { type: string, maxLength: 200 }
                content: { type: string, maxLength: 100000 }
                file_url: { type: string, format: uri }
                shareable: { type: boolean, default: false }
      responses:
        '201': { description: Creado., content: { application/json: { schema: { $ref: '#/components/schemas/Document' } } } }
        '413': { $ref: '#/components/responses/Error' }
        '422': { $ref: '#/components/responses/Error' }

  /v1/inboxes/{inbox_id}/agent/documents/{document_id}:
    parameters:
      - $ref: '#/components/parameters/InboxId'
      - name: document_id
        in: path
        required: true
        schema: { type: string }
    get:
      tags: [Asistente de IA]
      summary: Ver un documento
      operationId: getDocument
      responses:
        '200': { description: El documento con su texto., content: { application/json: { schema: { $ref: '#/components/schemas/Document' } } } }
    patch:
      tags: [Asistente de IA]
      summary: Editar un documento
      description: Nombre, texto o si se puede compartir. Para cambiar el archivo, borra y crea otro.
      operationId: updateDocument
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                name: { type: string }
                content: { type: [string, 'null'] }
                shareable: { type: boolean }
      responses:
        '200': { description: Editado., content: { application/json: { schema: { $ref: '#/components/schemas/Document' } } } }
    delete:
      tags: [Asistente de IA]
      summary: Borrar un documento
      operationId: deleteDocument
      responses:
        '204': { description: Borrado (con su archivo). }

  /v1/inboxes/{inbox_id}/agent/ignored-numbers:
    parameters:
      - $ref: '#/components/parameters/InboxId'
    get:
      tags: [Asistente de IA]
      summary: Números que el asistente ignora
      description: El equipo o números de prueba. Tampoco reciben campañas si `exclude_staff` está prendido.
      operationId: listIgnoredNumbers
      responses:
        '200':
          description: Números.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        id: { type: string }
                        phone: { type: string, description: Como se guardó; se compara por los últimos 10 dígitos. }
                        label: { type: [string, 'null'] }
                        created_at: { type: string, format: date-time }
    post:
      tags: [Asistente de IA]
      summary: Agregar un número
      operationId: addIgnoredNumber
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [phone]
              properties:
                phone: { type: string }
                label: { type: string, maxLength: 80 }
      responses:
        '201': { description: Agregado. }
        '409': { description: 'Ya estaba (`already_exists`).', content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }

  /v1/inboxes/{inbox_id}/agent/ignored-numbers/{number_id}:
    parameters:
      - $ref: '#/components/parameters/InboxId'
      - name: number_id
        in: path
        required: true
        schema: { type: string }
    delete:
      tags: [Asistente de IA]
      summary: Quitar un número
      operationId: deleteIgnoredNumber
      responses:
        '204': { description: Quitado. }

  /v1/inboxes/{inbox_id}/agent/categories:
    parameters:
      - $ref: '#/components/parameters/InboxId'
    get:
      tags: [Asistente de IA]
      summary: Listar categorías
      description: |
        Las categorías le dicen al asistente qué tipos de conversación hay (por ejemplo "Compra",
        "Renta", "Proveedor"), qué datos pedir en cada una, cuándo pasar la conversación al equipo y a
        quién avisar. El asistente elige la categoría por su `description`.
      operationId: listCategories
      responses:
        '200':
          description: Categorías del inbox.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { type: array, items: { $ref: '#/components/schemas/Category' } }
        '404': { $ref: '#/components/responses/Error' }
        '409': { description: 'El inbox no se creó por la API (`not_api_inbox`).', content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
    post:
      tags: [Asistente de IA]
      summary: Crear una categoría
      description: Máximo 20 por inbox. El nombre no se puede repetir (`409 duplicate_name`).
      operationId: createCategory
      requestBody:
        required: true
        content:
          application/json:
            schema:
              allOf:
                - $ref: '#/components/schemas/CategoryInput'
                - required: [name, description]
      responses:
        '201':
          description: Creada.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Category' }
        '400': { $ref: '#/components/responses/Error' }
        '404': { $ref: '#/components/responses/Error' }
        '409': { description: 'Nombre repetido (`duplicate_name`) o el inbox no se creó por la API (`not_api_inbox`).', content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }

  /v1/inboxes/{inbox_id}/agent/categories/{category_id}:
    parameters:
      - $ref: '#/components/parameters/InboxId'
      - name: category_id
        in: path
        required: true
        schema: { type: string }
    patch:
      tags: [Asistente de IA]
      summary: Editar una categoría
      description: Solo cambia los campos que mandes.
      operationId: updateCategory
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/CategoryInput' }
      responses:
        '200':
          description: Guardada.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Category' }
        '400': { $ref: '#/components/responses/Error' }
        '404': { $ref: '#/components/responses/Error' }
        '409': { description: 'Nombre repetido (`duplicate_name`) o el inbox no se creó por la API (`not_api_inbox`).', content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
    delete:
      tags: [Asistente de IA]
      summary: Borrar una categoría
      description: Las conversaciones que la tenían se quedan sin categoría.
      operationId: deleteCategory
      responses:
        '204': { description: Borrada. }
        '404': { $ref: '#/components/responses/Error' }

  /v1/webhooks:
    get:
      tags: [Configurar webhooks]
      summary: Listar endpoints de webhook
      operationId: listWebhooks
      responses:
        '200':
          description: Tus endpoints.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { type: array, items: { $ref: '#/components/schemas/WebhookEndpoint' } }
    post:
      tags: [Configurar webhooks]
      summary: Registrar un endpoint
      description: Regresa el `secret` para verificar firmas. Solo se muestra una vez.
      operationId: createWebhook
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [url]
              properties:
                url: { type: string, format: uri, example: 'https://api.tucrm.com/webhooks/leadinbox' }
                events: { type: array, items: { type: string }, description: 'Vacío = todos.', example: [message.received, message.sent, message.status] }
      responses:
        '201':
          description: Registrado.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/WebhookEndpoint'
                  - properties:
                      secret: { type: string, example: whsec_3f9a… }

  /v1/webhooks/{webhook_id}:
    parameters:
      - name: webhook_id
        in: path
        required: true
        schema: { type: string }
    get:
      tags: [Configurar webhooks]
      summary: Obtener un endpoint
      operationId: getWebhook
      responses:
        '200':
          description: El endpoint.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/WebhookEndpoint' }
        '404': { $ref: '#/components/responses/Error' }
    patch:
      tags: [Configurar webhooks]
      summary: Cambiar URL o eventos
      description: 'El secreto no cambia; para eso usa `rotate-secret`. `events` vacío = todos.'
      operationId: updateWebhook
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                url: { type: string, format: uri }
                events: { type: array, items: { type: string } }
      responses:
        '200':
          description: Actualizado.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/WebhookEndpoint' }
    delete:
      tags: [Configurar webhooks]
      summary: Borrar un endpoint
      operationId: deleteWebhook
      responses:
        '204': { description: Borrado. }

  /v1/webhooks/{webhook_id}/rotate-secret:
    parameters:
      - name: webhook_id
        in: path
        required: true
        schema: { type: string }
    post:
      tags: [Configurar webhooks]
      summary: Rotar el secreto
      description: El secreto anterior deja de servir de inmediato. El nuevo se muestra una sola vez.
      operationId: rotateWebhookSecret
      responses:
        '200':
          description: Secreto nuevo.
          content:
            application/json:
              schema:
                type: object
                properties:
                  id: { type: string }
                  secret: { type: string }

  /v1/webhooks/{webhook_id}/deliveries:
    parameters:
      - name: webhook_id
        in: path
        required: true
        schema: { type: string }
    get:
      tags: [Configurar webhooks]
      summary: Historial de entregas
      operationId: listWebhookDeliveries
      parameters:
        - $ref: '#/components/parameters/Cursor'
        - $ref: '#/components/parameters/Limit'
        - name: status
          in: query
          schema: { type: string, enum: [pending, delivered, failed] }
      responses:
        '200':
          description: Lista paginada, la más nueva primero.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/Page'
                  - properties:
                      data:
                        type: array
                        items:
                          type: object
                          properties:
                            id: { type: string }
                            event_id: { type: string }
                            event_type: { type: string }
                            inbox_id: { type: string }
                            status: { type: string, enum: [pending, delivered, failed] }
                            attempts: { type: integer }
                            last_status_code: { type: [integer, 'null'] }
                            last_error: { type: [string, 'null'] }
                            delivered_at: { type: [string, 'null'], format: date-time }
                            created_at: { type: string, format: date-time }

  /v1/webhooks/{webhook_id}/replay:
    parameters:
      - name: webhook_id
        in: path
        required: true
        schema: { type: string }
    post:
      tags: [Configurar webhooks]
      summary: Reenviar eventos
      description: |
        Reenvía a este endpoint un evento (`event_id`), o todos los que le fallaron desde una fecha
        (`failed_since`, máximo 30 días atrás y 500 por llamada; si `has_more` es true, vuelve a llamar).
        Manda uno de los dos. El evento sale igual que la primera vez (mismo `id`); los enlaces de
        archivos pueden haber vencido, pídelos de nuevo con `GET` de mensajes.
      operationId: replayWebhook
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                event_id: { type: string }
                failed_since: { type: string, format: date-time }
      responses:
        '202':
          description: Reenvío en curso.
          content:
            application/json:
              schema:
                type: object
                properties:
                  replayed: { type: integer, example: 37 }
                  has_more: { type: boolean }
        '400': { $ref: '#/components/responses/Error' }
        '404': { $ref: '#/components/responses/Error' }
        '409': { description: 'El evento aún no se envía por primera vez (`not_dispatched`).', content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }

  /v1/api-keys:
    get:
      tags: [Llaves]
      summary: Listar llaves
      operationId: listApiKeys
      responses:
        '200':
          description: Tus llaves activas (nunca la llave completa).
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { type: array, items: { $ref: '#/components/schemas/ApiKey' } }
    post:
      tags: [Llaves]
      summary: Crear una llave
      description: 'Para rotar: crea una nueva, cámbiala en tu sistema y revoca la anterior. La llave completa sale solo en esta respuesta.'
      operationId: createApiKey
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name: { type: string, maxLength: 80, example: produccion-2026 }
      responses:
        '201':
          description: Creada.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/ApiKey'
                  - properties:
                      key: { type: string, example: li_live_… }

  /v1/api-keys/{key_id}:
    parameters:
      - name: key_id
        in: path
        required: true
        schema: { type: string }
    delete:
      tags: [Llaves]
      summary: Revocar una llave
      description: Deja de servir de inmediato. No puedes revocar tu última llave activa (`last_api_key`).
      operationId: revokeApiKey
      responses:
        '204': { description: Revocada. }
        '409': { $ref: '#/components/responses/Error' }

  /v1/usage:
    get:
      tags: [Uso]
      summary: Consumo del periodo
      description: Mes calendario en curso (UTC), sumando todos los negocios conectados a tu cuenta. No cuenta los mensajes importados del historial.
      operationId: getUsage
      responses:
        '200':
          description: Consumo de tu cuenta en el periodo actual.
          content:
            application/json:
              schema:
                type: object
                properties:
                  period_start: { type: string, format: date-time }
                  connected_inboxes: { type: integer, example: 14 }
                  messages_sent: { type: integer, example: 5310 }
                  messages_received: { type: integer, example: 8022 }
                  agent_replies: { type: integer, description: Respuestas del asistente de IA., example: 1200 }
                  ai: { $ref: '#/components/schemas/AiUsage' }
    patch:
      tags: [Uso]
      summary: Cambiar el umbral de aviso de IA
      description: |
        Cuando las respuestas del asistente del mes llegan a `ai_alert_replies`, recibes una vez
        `usage.threshold_reached`. No bloquea nada: el asistente sigue contestando. `null` = sin aviso.
      operationId: updateUsageAlert
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [ai_alert_replies]
              properties:
                ai_alert_replies: { type: [integer, 'null'], minimum: 1, example: 5000 }
      responses:
        '200':
          description: El umbral nuevo y el uso del mes.
          content:
            application/json:
              schema:
                type: object
                properties:
                  ai: { $ref: '#/components/schemas/AiUsage' }
        '400': { $ref: '#/components/responses/Error' }

webhooks:
  message.received:
    post:
      summary: Llegó un mensaje de un contacto
      requestBody:
        content:
          application/json:
            schema:
              allOf:
                - $ref: '#/components/schemas/Event'
                - properties:
                    type: { const: message.received }
                    data: { $ref: '#/components/schemas/Message' }
            example:
              id: evt_01J9Z4K8
              type: message.received
              api_version: '2026-10'
              created_at: '2026-10-05T16:02:11Z'
              inbox_id: inb_7Hq2
              data:
                id: msg_01J9Z4K7
                conversation_id: conv_3Lm9
                contact: { id: ct_88Qa, phone: '+5215512345678', name: Ana Torres, external_id: lead_5521 }
                direction: inbound
                source: contact
                type: text
                text: Hola, ¿sigue disponible el depa de Polanco?
                historical: false
                created_at: '2026-10-05T16:02:10Z'
      responses:
        '200': { description: Responde 2xx en menos de 10 s para confirmar la entrega. }
  message.sent:
    post:
      summary: Salió un mensaje del negocio (desde tu software, desde la app del celular o del asistente)
      description: |
        `source` dice desde dónde: `api` (tu software), `app` (la app de WhatsApp Business del
        celular, gracias a Coexistencia), `bot` (asistente de IA) o `panel`. Con un número compartido,
        un mensaje con `source: app` no dice qué persona lo escribió; los que mandas por API traen tu `sent_by`.
      requestBody:
        content:
          application/json:
            schema:
              allOf:
                - $ref: '#/components/schemas/Event'
                - properties:
                    type: { const: message.sent }
                    data: { $ref: '#/components/schemas/Message' }
            example:
              id: evt_01J9Z4QX
              type: message.sent
              api_version: '2026-10'
              created_at: '2026-10-05T16:03:40Z'
              inbox_id: inb_7Hq2
              data:
                id: msg_01J9Z4QW
                conversation_id: conv_3Lm9
                contact: { id: ct_88Qa, phone: '+5215512345678', name: Ana Torres, external_id: lead_5521 }
                direction: outbound
                source: app
                sent_by: null
                type: text
                text: Sí, ¿te late verlo mañana a las 11?
                status: sent
                historical: false
                created_at: '2026-10-05T16:03:39Z'
      responses:
        '200': { description: Confirmado. }
  message.status:
    post:
      summary: Cambió el estado de un mensaje enviado
      requestBody:
        content:
          application/json:
            schema:
              allOf:
                - $ref: '#/components/schemas/Event'
                - properties:
                    type: { const: message.status }
                    data:
                      type: object
                      properties:
                        message_id: { type: string, description: 'El mismo id que regresó POST /messages (y el de message.sent).' }
                        conversation_id: { type: string }
                        status: { type: string, enum: [sent, delivered, read, failed] }
                        error: { $ref: '#/components/schemas/MetaError' }
                        timestamp: { type: string, format: date-time, description: Cuándo pasó según Meta. }
      responses:
        '200': { description: Confirmado. }
  message.reaction:
    post:
      summary: Alguien reaccionó a un mensaje (o quitó su reacción)
      requestBody:
        content:
          application/json:
            schema:
              allOf:
                - $ref: '#/components/schemas/Event'
                - properties:
                    type: { const: message.reaction }
                    data:
                      type: object
                      properties:
                        message_id: { type: string, description: El mensaje al que se reaccionó. }
                        conversation_id: { type: string }
                        emoji: { type: [string, 'null'], description: null si quitó la reacción. }
                        by: { type: string, enum: [contact, team], description: '`team` = el negocio (desde tu software, el panel o la app del celular).' }
                        timestamp: { type: string, format: date-time }
      responses:
        '200': { description: Confirmado. }
  message.edited:
    post:
      summary: Se editó un mensaje
      description: |
        El contacto o el negocio (desde la app del celular) editó un mensaje. `data` es el mensaje
        completo con el texto nuevo y `edited: true`; reemplaza el que tienes con el mismo `id`.
        WhatsApp no permite editar mensajes enviados por la API.
      requestBody:
        content:
          application/json:
            schema:
              allOf:
                - $ref: '#/components/schemas/Event'
                - properties:
                    type: { const: message.edited }
                    data: { $ref: '#/components/schemas/Message' }
      responses:
        '200': { description: Confirmado. }
  conversation.status_changed:
    post:
      summary: Una conversación cambió de estado
      description: |
        Se cerró, se reabrió, o cambió quién la atiende (`bot` on/off), desde el panel, la app, tu
        software o el asistente. Trae el estado nuevo y el anterior.
      requestBody:
        content:
          application/json:
            schema:
              allOf:
                - $ref: '#/components/schemas/Event'
                - properties:
                    type: { const: conversation.status_changed }
                    data:
                      allOf:
                        - $ref: '#/components/schemas/Conversation'
                        - properties:
                            previous_status: { type: string, enum: [open, closed] }
                            previous_bot: { type: string, enum: [on, off] }
      responses:
        '200': { description: Confirmado. }
  inbox.connected:
    post:
      summary: El número quedó conectado (al terminar el alta con el connect_url, o al reconectarse)
      requestBody:
        content:
          application/json:
            schema:
              allOf:
                - $ref: '#/components/schemas/Event'
                - properties:
                    data: { $ref: '#/components/schemas/InboxConnectionChange' }
      responses:
        '200': { description: Confirmado. }
  inbox.disconnected:
    post:
      summary: El número dejó de estar conectado (lo desconectó el negocio, Meta lo restringió o se venció el acceso)
      requestBody:
        content:
          application/json:
            schema:
              allOf:
                - $ref: '#/components/schemas/Event'
                - properties:
                    data: { $ref: '#/components/schemas/InboxConnectionChange' }
      responses:
        '200': { description: Confirmado. }
  inbox.quality_updated:
    post:
      summary: Meta cambió la calidad del número o su límite de mensajes
      description: |
        `event` viene de Meta tal cual (por ejemplo `FLAGGED`, `UNFLAGGED`, `UPGRADE`, `DOWNGRADE`).
        `messaging_limit` es el máximo de contactos distintos a los que el número puede escribir primero
        (con plantilla) en 24 h (por ejemplo `TIER_250`, `TIER_2K`, `TIER_10K`, `TIER_100K`, `TIER_UNLIMITED`).
      requestBody:
        content:
          application/json:
            schema:
              allOf:
                - $ref: '#/components/schemas/Event'
                - properties:
                    type: { const: inbox.quality_updated }
                    data:
                      type: object
                      properties:
                        phone_number: { type: [string, 'null'] }
                        event: { type: [string, 'null'] }
                        messaging_limit: { type: [string, 'null'] }
      responses:
        '200': { description: Confirmado. }
  template.status:
    post:
      summary: Meta aprobó, rechazó o pausó una plantilla
      requestBody:
        content:
          application/json:
            schema:
              allOf:
                - $ref: '#/components/schemas/Event'
                - properties:
                    type: { const: template.status }
                    data:
                      type: object
                      properties:
                        id: { type: [string, 'null'], description: Id de la plantilla en Meta. }
                        name: { type: [string, 'null'] }
                        language: { type: [string, 'null'] }
                        status: { type: [string, 'null'], description: 'Tal cual lo manda Meta: APPROVED, REJECTED, PENDING, PAUSED, DISABLED…' }
                        rejected_reason: { type: [string, 'null'] }
      responses:
        '200': { description: Confirmado. }
  channel.connected:
    post:
      summary: Quedó conectada la Página de Facebook (Messenger) o su Instagram
      description: Uno por canal. Para WhatsApp el aviso es `inbox.connected`.
      requestBody:
        content:
          application/json:
            schema:
              allOf:
                - $ref: '#/components/schemas/Event'
                - properties:
                    type: { const: channel.connected }
                    data: { $ref: '#/components/schemas/ChannelEvent' }
      responses:
        '200': { description: Confirmado. }
  channel.disconnected:
    post:
      summary: Se desconectó la Página de Facebook (Messenger) o su Instagram
      description: 'Facebook retiró el acceso, el negocio la desconectó o la cambió por otra. Para WhatsApp el aviso es `inbox.disconnected`.'
      requestBody:
        content:
          application/json:
            schema:
              allOf:
                - $ref: '#/components/schemas/Event'
                - properties:
                    type: { const: channel.disconnected }
                    data: { $ref: '#/components/schemas/ChannelEvent' }
      responses:
        '200': { description: Confirmado. }
  contact.created:
    post:
      summary: Hay un contacto nuevo
      description: 'Escribió por primera vez, lo creaste por API o en el panel, o llegó en la importación de contactos de la app.'
      requestBody:
        content:
          application/json:
            schema:
              allOf:
                - $ref: '#/components/schemas/Event'
                - properties:
                    type: { const: contact.created }
                    data: { $ref: '#/components/schemas/Contact' }
      responses:
        '200': { description: Confirmado. }
  contact.updated:
    post:
      summary: Cambió un contacto
      description: 'Cambió su nombre, email, ciudad, `external_id` o su baja. `changed` dice cuáles.'
      requestBody:
        content:
          application/json:
            schema:
              allOf:
                - $ref: '#/components/schemas/Event'
                - properties:
                    type: { const: contact.updated }
                    data:
                      allOf:
                        - $ref: '#/components/schemas/Contact'
                        - properties:
                            changed: { type: array, items: { type: string, enum: [name, email, city, external_id, opted_out] } }
      responses:
        '200': { description: Confirmado. }
  deal.created:
    post:
      summary: Hay una oportunidad nueva
      description: 'La creó el asistente (un pedido nuevo de un contacto), una persona en el panel o la app, o tu sistema por API.'
      requestBody:
        content:
          application/json:
            schema:
              allOf:
                - $ref: '#/components/schemas/Event'
                - properties:
                    type: { const: deal.created }
                    data: { $ref: '#/components/schemas/Deal' }
      responses:
        '200': { description: Confirmado. }
  campaign.status_changed:
    post:
      summary: Se pausó, reanudó o canceló una campaña
      requestBody:
        content:
          application/json:
            schema:
              allOf:
                - $ref: '#/components/schemas/Event'
                - properties:
                    type: { const: campaign.status_changed }
                    data:
                      allOf:
                        - $ref: '#/components/schemas/Campaign'
                        - properties:
                            previous_status: { type: string, enum: [sending, paused, cancelled] }
      responses:
        '200': { description: Confirmado. }
  campaign.completed:
    post:
      summary: Terminó una campaña
      description: Ya no queda nada en cola. Trae los conteos finales (las respuestas pueden seguir llegando).
      requestBody:
        content:
          application/json:
            schema:
              allOf:
                - $ref: '#/components/schemas/Event'
                - properties:
                    type: { const: campaign.completed }
                    data: { $ref: '#/components/schemas/Campaign' }
      responses:
        '200': { description: Confirmado. }
  usage.threshold_reached:
    post:
      summary: Llegaste a tu umbral de IA del mes
      description: Una vez por mes, cuando las respuestas del asistente en tus inboxes llegan a `ai_alert_replies` (`PATCH /v1/usage`). No bloquea nada. `inbox_id` es el inbox de la respuesta que cruzó el umbral.
      requestBody:
        content:
          application/json:
            schema:
              allOf:
                - $ref: '#/components/schemas/Event'
                - properties:
                    type: { const: usage.threshold_reached }
                    data:
                      type: object
                      properties:
                        period: { type: string, example: '2026-11' }
                        ai_replies: { type: integer, example: 5000 }
                        ai_alert_replies: { type: integer, example: 5000 }
      responses:
        '200': { description: Confirmado. }
  inbox.storage_threshold:
    post:
      summary: Un inbox va al 80% o al 100% de su almacenamiento
      description: Cada número incluye 10 GB para los archivos de sus conversaciones. Llega una vez al 80% y otra al 100%; al 100% los archivos nuevos que mandan los contactos dejan de guardarse (el mensaje sí llega). Pide más espacio a Lead Inbox.
      requestBody:
        content:
          application/json:
            schema:
              allOf:
                - $ref: '#/components/schemas/Event'
                - properties:
                    type: { const: inbox.storage_threshold }
                    data:
                      type: object
                      properties:
                        pct: { type: integer, example: 80 }
                        used_bytes: { type: integer, example: 8589934592 }
                        limit_bytes: { type: integer, example: 10737418240 }
      responses:
        '200': { description: Confirmado. }
  conversation.assigned:
    post:
      summary: Se asignó una persona a una conversación
      description: Desde el panel, la app, tu sistema o la asignación automática de una categoría.
      requestBody:
        content:
          application/json:
            schema:
              allOf:
                - $ref: '#/components/schemas/Event'
                - properties:
                    type: { const: conversation.assigned }
                    data:
                      allOf:
                        - $ref: '#/components/schemas/Conversation'
                        - properties:
                            user: { type: object, properties: { id: { type: string }, email: { type: [string, 'null'] } } }
      responses:
        '200': { description: Confirmado. }
  conversation.unassigned:
    post:
      summary: Se quitó a una persona de una conversación
      requestBody:
        content:
          application/json:
            schema:
              allOf:
                - $ref: '#/components/schemas/Event'
                - properties:
                    type: { const: conversation.unassigned }
                    data:
                      allOf:
                        - $ref: '#/components/schemas/Conversation'
                        - properties:
                            user: { type: object, properties: { id: { type: string }, email: { type: [string, 'null'] } } }
      responses:
        '200': { description: Confirmado. }
  lead.qualified:
    post:
      summary: El asistente de IA juntó los datos que pediste
      requestBody:
        content:
          application/json:
            schema:
              allOf:
                - $ref: '#/components/schemas/Event'
                - properties:
                    data:
                      type: object
                      properties:
                        conversation_id: { type: string }
                        deal_id: { type: [string, 'null'], description: 'La oportunidad de este pedido: guárdala para recibir deal.stage_changed y usar PATCH /deals.' }
                        sync: { type: boolean, description: 'false si el negocio marcó esta categoría para no mandarse a sistemas conectados: no crees nada con este aviso.' }
                        contact: { $ref: '#/components/schemas/Contact' }
                        category: { type: string, example: Compra }
                        fields:
                          type: object
                          additionalProperties: { type: string }
                          example: { Nombre: Ana Torres, Zona de interés: Polanco, Presupuesto: 6 a 8 millones }
                        summary: { type: string }
      responses:
        '200': { description: Confirmado. }
  conversation.handoff:
    post:
      summary: Un humano debe tomar la conversación (el contacto lo pidió o el asistente terminó su parte)
      requestBody:
        content:
          application/json:
            schema:
              allOf:
                - $ref: '#/components/schemas/Event'
                - properties:
                    data:
                      type: object
                      properties:
                        conversation_id: { type: string }
                        reason: { type: string, enum: [qualified, contact_requested_human, out_of_scope], description: '`out_of_scope` todavía no se emite.' }
                        summary: { type: string }
      responses:
        '200': { description: Confirmado. }
  deal.stage_changed:
    post:
      summary: Una oportunidad cambió de etapa
      description: >-
        Se manda por cualquier cambio de etapa: desde el panel, la app, otro sistema conectado o tu
        propio PATCH /deals. Para no rebotar un cambio que tú hiciste, compara `status` con el estado
        que ya tienes y no hagas nada si coincide.
      requestBody:
        content:
          application/json:
            schema:
              allOf:
                - $ref: '#/components/schemas/Event'
                - properties:
                    data:
                      type: object
                      properties:
                        deal_id: { type: string }
                        status:
                          oneOf: [{ $ref: '#/components/schemas/DealStatus' }, { type: 'null' }]
                          description: Estado común de la etapa nueva (null si la etapa no tiene uno).
                        previous_status:
                          oneOf: [{ $ref: '#/components/schemas/DealStatus' }, { type: 'null' }]
                        stage: { type: [object, 'null'], properties: { id: { type: string }, name: { type: string } } }
                        previous_stage: { type: [object, 'null'], properties: { id: { type: string }, name: { type: string } } }
                        deal: { $ref: '#/components/schemas/Deal', description: La oportunidad como está al mandar el aviso. }
      responses:
        '200': { description: Confirmado. }

components:
  securitySchemes:
    apiKey:
      type: http
      scheme: bearer
      description: '`li_live_…`. Las llaves de sandbox (`li_test_…`) todavía no están disponibles.'
  parameters:
    Channel:
      name: channel
      in: query
      schema: { type: string, enum: [whatsapp, messenger, instagram] }
      description: Solo los de este canal.
    FunnelId:
      name: funnel_id
      in: path
      required: true
      schema: { type: string }
    CampaignId:
      name: campaign_id
      in: path
      required: true
      schema: { type: string }
    InboxId:
      name: inbox_id
      in: path
      required: true
      schema: { type: string, example: inb_7Hq2 }
    ConversationId:
      name: conversation_id
      in: path
      required: true
      schema: { type: string, example: conv_3Lm9 }
    DealId:
      name: deal_id
      in: path
      required: true
      schema: { type: string, example: deal_8Kp1 }
    Cursor:
      name: cursor
      in: query
      schema: { type: string }
    Limit:
      name: limit
      in: query
      schema: { type: integer, minimum: 1, maximum: 100, default: 25 }
    IdempotencyKey:
      name: Idempotency-Key
      in: header
      schema: { type: string, example: 3c1f2a7e-9b0d-4e1a-8f4c-2d6b5a9e1c07 }
  responses:
    Error:
      description: Error.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
  schemas:
    Page:
      type: object
      properties:
        has_more: { type: boolean }
        next_cursor: { type: [string, 'null'] }
    Inbox:
      type: object
      properties:
        id: { type: string, example: inb_7Hq2 }
        name: { type: string, example: Inmobiliaria Las Lomas }
        external_id: { type: [string, 'null'], example: cust_8812 }
        status:
          type: string
          enum: [pending_connection, connected, disconnected]
          description: '`connected` si al menos un canal está conectado; `pending_connection` si nunca se conectó ninguno.'
        channels: { type: array, items: { $ref: '#/components/schemas/InboxChannel' } }
        phone_number: { type: [string, 'null'], example: '+525512345678', description: El número de WhatsApp, si tiene. }
        coexistence: { type: boolean, description: true si el número también se usa en la app de WhatsApp Business. }
        connect_url: { type: [string, 'null'], format: uri, description: 'Solo mientras está `pending_connection`. Para conectar otro canal usa `POST /v1/inboxes/{inbox_id}/connect-links`.' }
        created_at: { type: string, format: date-time }
    InboxChannel:
      type: object
      properties:
        type: { type: string, enum: [whatsapp, messenger, instagram] }
        status: { type: string, enum: [connected, disconnected] }
        phone_number: { type: [string, 'null'], description: Solo WhatsApp. }
        page_id: { type: [string, 'null'], description: Solo Messenger (Página de Facebook). }
        account_id: { type: [string, 'null'], description: Solo Instagram. }
        name: { type: [string, 'null'], description: Nombre de la Página (Messenger). }
        username: { type: [string, 'null'], description: Usuario de Instagram, sin @. }
        disconnect_reason: { type: [string, 'null'] }
    ChannelEvent:
      type: object
      properties:
        channel: { type: string, enum: [messenger, instagram] }
        status: { type: string, enum: [connected, disconnected] }
        page_id: { type: string }
        name: { type: [string, 'null'], description: Nombre de la Página. }
        account_id: { type: [string, 'null'], description: Solo Instagram. }
        username: { type: [string, 'null'], description: Solo Instagram. }
        reason: { type: [string, 'null'], description: Solo al desconectarse. }
    Agent:
      type: object
      properties:
        enabled: { type: boolean }
        instructions: { type: [string, 'null'], maxLength: 20000, description: Qué vende el negocio y cómo debe atender. }
        prohibitions: { type: [string, 'null'], maxLength: 5000, description: Lo que nunca debe decir o prometer. }
        required_fields: { type: array, maxItems: 20, items: { type: string, maxLength: 80 }, example: [Nombre, Zona de interés, Presupuesto] }
        business_hours:
          type: [object, 'null']
          description: Horario de atención; fuera de él el asistente lo menciona.
          properties:
            enabled: { type: boolean, default: true }
            timezone: { type: string, default: America/Mexico_City }
            schedule:
              type: object
              description: 'Por día (mon, tue, wed, thu, fri, sat, sun), hasta 4 rangos. Día sin rangos = cerrado.'
              additionalProperties:
                type: array
                items:
                  type: object
                  properties:
                    open: { type: string, example: '09:00' }
                    close: { type: string, example: '18:00' }
          example: { timezone: America/Mexico_City, schedule: { mon: [{ open: '09:00', close: '18:00' }], sat: [] } }
        role: { type: string, enum: [ventas, soporte, agenda], default: ventas, description: 'ventas: califica prospectos; soporte: atiende dudas y problemas; agenda: junta servicio, fecha y hora.' }
        tone: { type: string, enum: [profesional, amigable, vendedor, formal, custom], description: 'Con custom manda tone_text.' }
        tone_text: { type: [string, 'null'], maxLength: 2000, description: Cómo habla el asistente (solo con tone custom). }
        objective: { type: [string, 'null'], maxLength: 2000, description: 'Qué debe lograr en cada conversación (p. ej. agendar una visita).' }
        followup_enabled: { type: boolean, description: Si el contacto deja de contestar, el asistente le escribe una vez. }
        followup_hours: { type: integer, minimum: 1, maximum: 23, description: Horas sin respuesta antes del seguimiento (dentro de la ventana de 24 h). }
    Document:
      type: object
      properties:
        id: { type: string }
        name: { type: string }
        shareable: { type: boolean, description: El asistente puede mandarle el archivo al cliente. }
        has_file: { type: boolean }
        content_length: { type: integer, description: Caracteres del texto que lee el asistente. }
        content: { type: [string, 'null'], description: 'Solo en GET de uno, POST y PATCH.' }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
    CategoryInput:
      type: object
      properties:
        name: { type: string, maxLength: 60, example: Renta }
        description: { type: string, maxLength: 1000, description: Cuándo aplica; con esto la elige el asistente., example: Quiere rentar una casa o departamento. }
        instructions: { type: [string, 'null'], maxLength: 10000, description: Qué hacer en este caso (el asistente solo las ve cuando detecta esta categoría). }
        required_fields: { type: array, maxItems: 20, items: { type: string, maxLength: 80 }, description: Datos a pedir en este caso, además de los del asistente., example: [Zona, Presupuesto mensual] }
        handoff:
          type: string
          enum: [when_complete, immediately, never]
          default: when_complete
          description: Cuándo pasa al equipo. `when_complete` = al juntar los datos; `immediately` = en cuanto detecta la categoría; `never` = el asistente solo sigue las instrucciones, sin pedir datos ni avisar.
        notify_phones: { type: array, maxItems: 5, items: { type: string }, description: WhatsApp con lada de país a los que se avisa., example: ['+5215512345678'] }
        is_default: { type: boolean, description: Se usa cuando el asistente no detecta ninguna categoría. Solo una por inbox. }
    Category:
      allOf:
        - properties:
            id: { type: string }
        - $ref: '#/components/schemas/CategoryInput'
    InboxConnectionChange:
      type: object
      properties:
        id: { type: string, description: El inbox. }
        status: { type: string, enum: [connected, disconnected] }
        reason: { type: [string, 'null'], description: Motivo que dio Meta al desconectar. }
    Contact:
      type: object
      properties:
        id: { type: string, example: ct_88Qa }
        channel: { type: string, enum: [whatsapp, messenger, instagram] }
        phone: { type: [string, 'null'], example: '+5215512345678', description: Solo WhatsApp. }
        social_id: { type: [string, 'null'], description: 'Messenger e Instagram: el id que Meta le da a la persona en esa Página (PSID / IGSID).' }
        name: { type: [string, 'null'], description: El nombre de perfil, o el que tú le pongas. }
        email: { type: [string, 'null'] }
        city: { type: [string, 'null'] }
        company_id: { type: [string, 'null'] }
        external_id: { type: [string, 'null'] }
        opted_out: { type: boolean, description: 'Dado de baja de campañas y seguimientos automáticos.' }
        created_at: { type: [string, 'null'], format: date-time }
    Conversation:
      type: object
      properties:
        id: { type: string, example: conv_3Lm9 }
        inbox_id: { type: string }
        channel: { type: string, enum: [whatsapp, messenger, instagram] }
        assignees: { type: array, items: { type: string }, description: 'Personas del equipo asignadas (ids de `GET /team`).' }
        followup: { type: string, enum: [on, off], description: Seguimiento automático del asistente en esta conversación. }
        contact: { $ref: '#/components/schemas/Contact' }
        external_id: { type: [string, 'null'] }
        status: { type: string, enum: [open, closed] }
        bot: { type: string, enum: [on, off] }
        tags: { type: array, items: { type: string } }
        last_message_at: { type: string, format: date-time }
        service_window_expires_at:
          type: [string, 'null']
          format: date-time
          description: Hasta cuándo puedes mandar texto libre. Después, solo plantillas.
        free_entry_expires_at:
          type: [string, 'null']
          format: date-time
          description: Si el contacto llegó por un anuncio de Meta, hasta cuándo los mensajes son gratis.
    DealStatus:
      type: string
      enum: [pending, sent, won, lost]
      description: 'Estado común: pending (por calificar), sent (propuesta enviada), won (ganada), lost (perdida).'
    Deal:
      type: object
      properties:
        id: { type: string, example: deal_8Kp1 }
        conversation_id: { type: [string, 'null'] }
        contact: { $ref: '#/components/schemas/Contact' }
        title: { type: [string, 'null'], description: El título o la categoría que detectó el asistente. }
        category: { type: [string, 'null'], example: Edecanía }
        status:
          oneOf:
            - $ref: '#/components/schemas/DealStatus'
            - type: 'null'
          description: null si su etapa no tiene un estado común asignado.
        stage: { type: [object, 'null'], properties: { id: { type: string }, name: { type: string, example: Propuesta Enviada } } }
        funnel: { type: [object, 'null'], properties: { id: { type: string }, name: { type: string } } }
        external_id: { type: [string, 'null'], example: COT-2026-131 }
        manual: { type: boolean, description: true si la creó una persona y no el asistente. }
        fields:
          type: object
          additionalProperties: { type: [string, 'null'] }
          description: Los datos del pedido (lo que juntó el asistente o lo que se capturó).
        tags: { type: array, items: { type: string } }
        notes: { type: [string, 'null'] }
        created_at: { type: string, format: date-time }
        closed_at: { type: [string, 'null'], format: date-time }
    StageInput:
      type: object
      description: '`name` es obligatorio al crear (POST); en PATCH todo es opcional.'
      properties:
        name: { type: string, maxLength: 80 }
        description: { type: [string, 'null'], maxLength: 500 }
        status:
          oneOf:
            - $ref: '#/components/schemas/DealStatus'
            - type: 'null'
        won: { type: boolean }
        lost: { type: boolean }
        position: { type: integer, minimum: 0, maximum: 1000 }
    Stage:
      type: object
      properties:
        id: { type: string }
        name: { type: string }
        description: { type: [string, 'null'] }
        status:
          oneOf:
            - $ref: '#/components/schemas/DealStatus'
            - type: 'null'
        won: { type: boolean }
        lost: { type: boolean }
        position: { type: integer }
    Funnel:
      type: object
      properties:
        id: { type: string }
        name: { type: string }
        default: { type: boolean, description: El embudo donde nacen las oportunidades del asistente. }
        stages:
          type: array
          items:
            type: object
            properties:
              id: { type: string }
              name: { type: string }
              description: { type: [string, 'null'] }
              status:
                oneOf:
                  - $ref: '#/components/schemas/DealStatus'
                  - type: 'null'
              won: { type: boolean }
              lost: { type: boolean }
              position: { type: integer }
    Message:
      type: object
      properties:
        id: { type: string }
        conversation_id: { type: string }
        channel: { type: string, enum: [whatsapp, messenger, instagram] }
        contact: { $ref: '#/components/schemas/Contact' }
        direction: { type: string, enum: [inbound, outbound] }
        source:
          type: string
          enum: [contact, api, app, bot, panel]
          description: '`app` = enviado desde la app de WhatsApp Business del celular (Coexistencia).'
        sent_by: { type: [string, 'null'], description: 'El usuario de tu sistema que lo mandó (solo con source = api).' }
        type: { type: string, enum: [text, image, video, audio, document, sticker, template, location, contacts, unsupported] }
        text: { type: [string, 'null'], description: Texto, pie de foto o, en notas de voz, la transcripción. }
        media:
          type: [object, 'null']
          properties:
            url: { type: string, format: uri, description: Enlace temporal (expira en 1 h). }
            mime_type: { type: string }
            filename: { type: [string, 'null'] }
        template:
          type: [object, 'null']
          properties:
            name: { type: string }
            language: { type: string }
            variables: { type: array, items: { type: string } }
        status: { type: [string, 'null'], enum: [queued, scheduled, sent, delivered, read, failed, null] }
        edited: { type: boolean }
        reply_to: { type: [string, 'null'], description: 'Id del mensaje que este cita (respuesta citada, como en WhatsApp). null si no cita o si el citado no está guardado (anterior a la importación de historial).' }
        historical: { type: boolean, description: true si viene de la importación de historial. }
        created_at: { type: string, format: date-time }
    SendMessageRequest:
      type: object
      required: [type]
      properties:
        to: { type: string, description: 'Número de WhatsApp en formato internacional. Usa `to`, `contact_id` o `conversation_id`.' }
        contact_id: { type: string, description: 'Contacto de cualquier canal. En Messenger e Instagram el contacto tiene que haber escrito primero.' }
        conversation_id: { type: string }
        type: { type: string, enum: [text, template, image, video, audio, document] }
        text: { type: string, description: 'Texto, o pie del archivo (hasta 1,024 caracteres). Los audios no llevan texto.' }
        media_url:
          type: string
          format: uri
          description: |
            URL **https pública** del archivo, sin redirecciones (usa la URL final). La descargamos al
            momento y la guardamos; puede ser un enlace firmado de corta duración.
            Formatos y máximos (los de WhatsApp): imagen JPEG/PNG 5 MB; video MP4/3GP 16 MB;
            audio MP3/M4A/AAC/AMR/OGG 16 MB (se manda como nota de voz); documento de cualquier tipo, 100 MB.
            En Instagram un documento llega como enlace; en Messenger e Instagram el texto va como mensaje aparte.
        filename: { type: string, description: 'Nombre que verá el contacto (documentos).' }
        document_id: { type: string, description: 'En vez de media_url: un documento compartible del asistente (`GET /agent/documents`). Se manda el archivo ya guardado, con type image (jpg, png) o document.' }
        template:
          type: object
          properties:
            name: { type: string }
            language: { type: string }
            variables: { type: array, items: { type: string } }
        sent_by: { type: string, description: El usuario de tu sistema que lo manda; regresa en `message.sent`. }
        schedule_at:
          type: string
          format: date-time
          description: |
            Programarlo (hasta 7 días). Responde `status: scheduled`. No le quita la conversación al
            asistente, y se cancela solo si el contacto escribe antes. La ventana de 24 h se revisa al
            salir: si para entonces está cerrada, llega `message.status` con `failed`.
        reply_to:
          type: string
          description: |
            Id de un mensaje de esta misma conversación para responder citándolo (texto o archivo).
            Si el citado no salió por WhatsApp (no tiene id de Meta), el mensaje se manda sin cita. En
            Messenger e Instagram siempre sale sin cita.
            Las plantillas ignoran este campo.
    Template:
      type: object
      properties:
        id: { type: [string, 'null'], description: Id de la plantilla en Meta (el mismo de template.status). }
        name: { type: string }
        language: { type: string }
        category: { type: string, enum: [MARKETING, UTILITY, AUTHENTICATION] }
        status: { type: string, enum: [PENDING, APPROVED, REJECTED, PAUSED] }
        body: { type: string }
        rejected_reason: { type: [string, 'null'] }
    Note:
      type: object
      properties:
        id: { type: string }
        conversation_id: { type: string }
        text: { type: string }
        source: { type: string, enum: [api, panel] }
        author: { type: [string, 'null'], description: 'Con source api, tu sent_by; con panel, el correo de quien la escribió.' }
        created_at: { type: string, format: date-time }
    CompanyInput:
      type: object
      properties:
        name: { type: string, maxLength: 200 }
        city: { type: [string, 'null'] }
        industry: { type: [string, 'null'] }
        website: { type: [string, 'null'] }
        rfc: { type: [string, 'null'], maxLength: 13 }
        address: { type: [string, 'null'] }
        lifecycle: { type: string, enum: [prospecto, cliente, cliente_recurrente] }
    Company:
      type: object
      properties:
        id: { type: string }
        name: { type: string }
        city: { type: [string, 'null'] }
        industry: { type: [string, 'null'] }
        website: { type: [string, 'null'] }
        rfc: { type: [string, 'null'] }
        address: { type: [string, 'null'] }
        lifecycle: { type: string, enum: [prospecto, cliente, cliente_recurrente] }
        created_at: { type: string, format: date-time }
    BusinessProfile:
      type: object
      properties:
        about: { type: [string, 'null'] }
        address: { type: [string, 'null'] }
        description: { type: [string, 'null'] }
        email: { type: [string, 'null'] }
        vertical: { type: [string, 'null'] }
        websites: { type: array, items: { type: string } }
        photo_url: { type: [string, 'null'] }
    ContactList:
      type: object
      properties:
        id: { type: string }
        name: { type: string }
        contacts: { type: integer, description: Cuántos contactos tiene. }
        created_at: { type: string, format: date-time }
    CampaignAudience:
      type: object
      description: Se suman `contact_ids`; `list_ids` y `conditions` se combinan con `combine`.
      properties:
        contact_ids: { type: array, items: { type: string }, maxItems: 10000 }
        list_ids: { type: array, items: { type: string } }
        conditions:
          type: array
          maxItems: 20
          items:
            type: object
            required: [field, op, values]
            properties:
              field: { type: string, enum: [activity, wrote, lifecycle, tag, stage, category, city, company, list, campaign] }
              op:
                type: string
                description: |
                  activity: older | newer (values: [días]); wrote: is (values: [ever | never]);
                  lifecycle, stage, category, city, company: is | is_not; tag: has | has_not;
                  list: in | not_in; campaign: received | replied | not_replied (values: [campaign_id]).
              values: { type: array, items: { type: [string, number] } }
        combine: { type: string, enum: [all, any], default: all }
        exclude_staff: { type: boolean, default: true, description: Números del equipo. }
        exclude_human_active: { type: boolean, default: true, description: Conversaciones que atiende una persona. }
        exclude_recent_days: { type: [integer, 'null'], description: Quien recibió otra campaña en estos días. }
    CreateCampaignRequest:
      type: object
      required: [name, audience]
      properties:
        name: { type: string, maxLength: 200 }
        template:
          type: object
          properties:
            name: { type: string }
            language: { type: string }
            variables: { type: array, items: { type: string }, description: 'Una por variable de la plantilla; pueden usar {{nombre}} o {{un dato}}.' }
        text: { type: string, maxLength: 4096, description: 'Texto libre (en vez de template); puede usar {{nombre}} o {{un dato}}.' }
        audience: { $ref: '#/components/schemas/CampaignAudience' }
        pace_per_minute: { type: integer, minimum: 1, maximum: 60, default: 20 }
        schedule_at: { type: string, format: date-time, description: Cuándo sale el primero (si no, en unos segundos). }
        sent_by: { type: string, description: El usuario de tu sistema que la lanza. }
        dry_run: { type: boolean, default: false }
    AiUsage:
      type: object
      description: Respuestas del asistente que se facturan (mes calendario de CDMX, solo los inboxes que creaste).
      properties:
        month: { type: string, example: '2026-11' }
        ai_replies: { type: integer, example: 1830 }
        ai_alert_replies: { type: [integer, 'null'], description: Umbral de aviso; null = sin aviso., example: 10000 }
    Campaign:
      type: object
      properties:
        id: { type: string }
        inbox_id: { type: string }
        name: { type: string }
        status:
          type: string
          enum: [sending, paused, cancelled, completed]
          description: '`sending` incluye las programadas (ver `next_send_at`).'
        type: { type: string, enum: [template, text] }
        template: { type: [object, 'null'], properties: { name: { type: string }, language: { type: string } } }
        text: { type: [string, 'null'] }
        recipients: { type: integer }
        stats:
          type: object
          properties:
            queued: { type: integer }
            sent: { type: integer }
            delivered: { type: integer }
            read: { type: integer }
            replied: { type: integer }
            failed: { type: integer }
            cancelled: { type: integer }
        next_send_at: { type: [string, 'null'], format: date-time }
        source: { type: string, enum: [api, panel] }
        sent_by: { type: [string, 'null'] }
        created_at: { type: string, format: date-time }
        paused_at: { type: [string, 'null'], format: date-time }
        completed_at: { type: [string, 'null'], format: date-time }
    CampaignRecipient:
      type: object
      properties:
        id: { type: string }
        contact: { $ref: '#/components/schemas/Contact' }
        conversation_id: { type: string }
        status: { type: string, enum: [queued, sent, failed, cancelled] }
        delivered: { type: boolean }
        read: { type: boolean }
        failure:
          type: [object, 'null']
          properties:
            reason: { type: string, enum: [opted_out, marketing_limit, no_whatsapp, window_closed, missing_data, other] }
            message: { type: [string, 'null'] }
            meta_code: { type: [integer, 'null'] }
        scheduled_for: { type: [string, 'null'], format: date-time }
        sent_at: { type: [string, 'null'], format: date-time }
        reply:
          type: [object, 'null']
          description: Lo primero que contestó después de recibirla.
          properties:
            text: { type: [string, 'null'] }
            at: { type: string, format: date-time }
            sentiment: { type: [string, 'null'], enum: [positive, neutral, negative, null], description: 'Si ya se clasificó (panel o `classify-replies`).' }
    ApiKey:
      type: object
      properties:
        id: { type: string }
        name: { type: string }
        prefix: { type: string, example: li_live_3f9a }
        current: { type: boolean, description: true si es la llave con la que hiciste esta petición. }
        created_at: { type: string, format: date-time }
        last_used_at: { type: [string, 'null'], format: date-time }
    WebhookEndpoint:
      type: object
      properties:
        id: { type: string, example: wh_2Kd8 }
        url: { type: string, format: uri }
        events: { type: array, items: { type: string } }
        created_at: { type: string, format: date-time }
    Event:
      type: object
      description: |
        Sobre que envuelve todo evento. Cada entrega lleva el header
        `Lead-Inbox-Signature: t=<unix>,v1=<hex>`, donde `v1` es HMAC-SHA256 de `"<t>.<cuerpo>"` con
        el `secret` de tu endpoint. Rechaza firmas con más de 5 minutos de antigüedad. Si no respondes
        2xx, reintentamos a 1 min, 5 min, 30 min, 2 h, 6 h y 24 h; los eventos pueden llegar
        duplicados o desordenados, así que usa `id` para deduplicar y `created_at` para ordenar.
      properties:
        id: { type: string }
        type: { type: string }
        api_version: { type: string, example: '2026-10' }
        created_at: { type: string, format: date-time }
        inbox_id: { type: string }
        data: { type: object }
    MetaError:
      type: [object, 'null']
      properties:
        code: { type: [integer, 'null'], example: 131047, description: 'Código de Meta; null si falló antes de llegar a Meta (sin créditos, contacto inexistente).' }
        message: { type: string, example: Re-engagement message }
    Error:
      type: object
      properties:
        error:
          type: object
          properties:
            code:
              type: string
              enum:
                - invalid_api_key
                - not_found
                - validation_error
                - outside_customer_service_window
                - template_not_approved
                - reply_to_not_found
                - inbox_not_connected
                - idempotency_conflict
                - channel_not_connected
                - channel_already_connected
                - template_not_supported
                - contact_without_conversation
                - stage_in_other_funnel
                - status_not_mapped
                - last_api_key
                - no_recipients
                - too_many_recipients
                - invalid_campaign_state
                - list_name_taken
                - not_cancellable
                - campaign_message
                - not_supported
                - reaction_failed
                - bot_disabled
                - storage_quota_exceeded
                - extraction_failed
                - media_unavailable
                - unsupported_media_type
                - already_exists
                - company_exists
                - plan_limit
                - default_funnel
                - funnel_has_deals
                - stage_has_deals
                - coexistence_profile_locked
                - document_not_shareable
                - already_synced
                - sync_window_expired
                - not_api_inbox
                - not_dispatched
                - rate_limited
                - meta_error
                - insufficient_credits
                - internal_error
            message: { type: string }
            meta_error: { $ref: '#/components/schemas/MetaError' }
            existing_id: { type: string, description: 'Con `company_exists`: el id de la empresa que ya tiene ese nombre.' }
