openapi: 3.1.0
info:
  title: Kharyo API
  version: "1.0"
  description: |
    API pública de Kharyo (v1). Recursos HTTP versionados para integrar
    conciliación de pagos, avisos de WhatsApp y mensajería de bandeja de
    entrada desde tu propio sistema.

    Autenticación: Bearer token (crea tu llave en `/settings/api` dentro de
    tu cuenta de Kharyo). Cada llave tiene un conjunto de permisos
    (abilities) y queda asociada a un único espacio de trabajo (tenant); no
    puede leer ni escribir datos de otro espacio de trabajo. Cada operación
    de este documento indica la ability que exige.

    Todas las respuestas son JSON. Los errores siguen dos formas posibles
    según la capa que los genera: ver los esquemas `AppError` y
    `FrameworkError` más abajo, y la sección "Manejo de errores" en la guía
    en `/developers`.

    Webhooks salientes: además de estos endpoints, con `ability:
    webhooks:manage` puedes registrar tu propio endpoint HTTPS para que
    Kharyo te avise cuando ocurra alguno de los 6 eventos suscribibles (ver
    el bloque `webhooks` al final de este documento). Cada envío trae las
    cabeceras `X-Kharyo-Event`, `X-Kharyo-Delivery`, `X-Kharyo-Timestamp` y
    `X-Kharyo-Signature`. Si tu endpoint no responde un `2xx` dentro de 10
    segundos, Kharyo reintenta hasta 5 intentos en total, con esperas de
    1, 5 y 30 minutos y 2 horas entre ellos, sin seguir redirecciones; al
    llegar a 20 fallos consecutivos la suscripción se desactiva sola y se
    avisa al dueño del espacio de trabajo. No hay forma de reactivarla ni
    de rotar el secreto: solo eliminarla y crear una nueva.
  contact:
    email: hola@kharyo.com
servers:
  - url: https://api.kharyo.com/v1
    description: Producción

security:
  - bearerAuth: []

tags:
  - name: Avisos
    description: Envío de notificaciones de WhatsApp a destinos verificados del espacio de trabajo.
  - name: Conciliación
    description: Registro y consulta de cobros, y cierre de caja diario.
  - name: Uso
    description: Consumo del propio espacio de trabajo (tokens, ejecuciones, requests de la API).
  - name: Bandeja de entrada
    description: Conversaciones, mensajes y plantillas de WhatsApp.
  - name: Plantillas de WhatsApp
    description: Gestión completa de plantillas de WhatsApp (recurso `/templates`) — listar, crear, sincronizar con Meta y borrar.
  - name: Webhooks
    description: Gestión de suscripciones a webhooks salientes (eventos que Kharyo envía a tu propio endpoint).

paths:
  /notifications/whatsapp:
    post:
      tags: [Avisos]
      summary: Enviar un aviso de WhatsApp
      description: |
        **Ability requerida:** `notify:send`

        Envía un mensaje a los destinos de WhatsApp que el espacio de
        trabajo haya habilitado para la API en `/dashboard/avisos`. Si no
        se envía `target`, el mensaje se manda a **todos** los destinos
        verificados y habilitados para la API.

        Límites (se aplican en este orden, el primero en agotarse corta la
        petición):
        1. 30 solicitudes/minuto y 2500/día: presupuesto propio de este
           endpoint, por espacio de trabajo.
        2. 60 solicitudes/minuto y 5000/día: presupuesto compartido con
           los avisos que dispara el propio negocio (que este endpoint
           consume también). En la práctica, el límite 1 siempre corta
           primero.
        3. 60 solicitudes/minuto: límite genérico por llave (ver
           "Límites de uso" en la guía).

        Para adjuntar un comprobante como imagen (caption = `message`), usar
        `multipart/form-data` con el campo `image` en vez de JSON.
      operationId: sendWhatsappNotification
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [message]
              properties:
                message:
                  type: string
                  maxLength: 4000
                  description: Texto del aviso.
                target:
                  type: string
                  maxLength: 64
                  nullable: true
                  description: |
                    Número o id de chat de un destino específico (con o sin
                    `@c.us`/`@g.us`). Si se omite, se envía a todos los
                    destinos habilitados.
              example:
                message: "Nuevo comprobante recibido por $45,00"
          multipart/form-data:
            schema:
              type: object
              required: [message]
              properties:
                message:
                  type: string
                  maxLength: 4000
                  description: Texto del aviso (se envía como caption de la imagen).
                target:
                  type: string
                  maxLength: 64
                  nullable: true
                  description: |
                    Número o id de chat de un destino específico (con o sin
                    `@c.us`/`@g.us`). Si se omite, se envía a todos los
                    destinos habilitados.
                image:
                  type: string
                  format: binary
                  nullable: true
                  description: |
                    Comprobante (jpeg/png/webp, máximo 5 MB) a adjuntar como
                    imagen de WhatsApp con `message` como caption.
      responses:
        '200':
          description: Aviso encolado.
          content:
            application/json:
              schema:
                type: object
                properties:
                  status:
                    type: string
                    example: queued
                  dispatched:
                    type: integer
                    description: Cantidad de destinos a los que se encoló el envío.
                    example: 2
        '401':
          $ref: '#/components/responses/Unauthenticated'
        '403':
          $ref: '#/components/responses/Forbidden'
        '422':
          description: |
            Sin destinos habilitados (`no_destinations_enabled`), el
            `target` indicado no existe o no está habilitado para la API
            (`target_not_found`), validación de parámetros, o la llave no
            tiene un espacio de trabajo asociado (`no_tenant`).
          content:
            application/json:
              schema:
                oneOf:
                  - $ref: '#/components/schemas/FrameworkError'
                  - $ref: '#/components/schemas/AppError'
        '429':
          $ref: '#/components/responses/AppRateLimited'

  /notification-targets:
    get:
      tags: [Avisos]
      summary: Listar destinos de avisos habilitados para la API
      description: |
        **Ability requerida:** `targets:read`

        Solo lectura. El alta de un destino nuevo se hace desde el
        dashboard (`/dashboard/avisos`) porque exige verificar el número
        por WhatsApp con un código; esta ruta solo lista lo ya verificado
        y habilitado. El `chat_id` se devuelve enmascarado.
      operationId: listNotificationTargets
      security:
        - bearerAuth: []
      responses:
        '200':
          description: Lista de destinos.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/NotificationTarget'
        '401':
          $ref: '#/components/responses/Unauthenticated'
        '403':
          $ref: '#/components/responses/Forbidden'
        '422':
          $ref: '#/components/responses/NoTenant'
        '429':
          $ref: '#/components/responses/GlobalRateLimited'

  /payment-claims:
    post:
      tags: [Conciliación]
      summary: Registrar un cobro para conciliar
      description: |
        **Ability requerida:** `claims:write`

        Registra un cobro esperado para que el conciliador lo cruce contra
        las notificaciones bancarias del espacio de trabajo. Si el pago ya
        llegó, la respuesta viene con `status: matched` de inmediato.

        Idempotencia: envía el header `Idempotency-Key` para poder
        reintentar de forma segura ante un timeout de red. Un reintento con
        la misma llave y el mismo cuerpo devuelve `200` con el cobro ya
        creado, sin crear un duplicado y sin consumir cupo del límite de
        30/minuto. La misma llave con un cuerpo distinto devuelve `422`.

        Límites: 30 solicitudes/minuto por espacio de trabajo (el gate se
        aplica antes de cada intento real; un reintento de
        `Idempotency-Key` que solo devuelve el cobro existente nunca
        consume cupo).
      operationId: createPaymentClaim
      security:
        - bearerAuth: []
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [amount, currency, reference]
              properties:
                amount:
                  type: number
                  format: float
                  minimum: 0.01
                  maximum: 9999999999.99
                currency:
                  type: string
                  description: Código ISO-4217 de 3 letras (se normaliza a mayúsculas).
                  example: VES
                reference:
                  type: string
                  maxLength: 50
                  description: Referencia bancaria del pago esperado.
                expires_at:
                  type: string
                  format: date-time
                  nullable: true
                  description: |
                    Vencimiento personalizado del cobro. Debe ser una fecha
                    futura y no puede exceder el tope configurado del lado
                    de Kharyo (por defecto 168 horas desde ahora). Si se
                    omite, se usa el vencimiento por defecto (24 horas).
                external_ref:
                  type: string
                  maxLength: 100
                  nullable: true
                  description: Identificador propio del integrador (se devuelve tal cual en la respuesta).
              example:
                amount: 250.50
                currency: VES
                reference: "987654321"
                external_ref: "ERP-001"
      responses:
        '201':
          description: Cobro creado (`waiting`) o emparejado de inmediato (`matched`).
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/PaymentClaim'
        '200':
          description: Reintento de `Idempotency-Key` con el mismo cuerpo, devuelve el cobro ya existente.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/PaymentClaim'
        '401':
          $ref: '#/components/responses/Unauthenticated'
        '403':
          $ref: '#/components/responses/Forbidden'
        '409':
          description: |
            `payment_already_claimed` (el pago bancario que coincide con
            esta referencia ya está reclamado por otro cobro) o
            `idempotency_in_progress` (otra solicitud con la misma
            `Idempotency-Key` está en curso, reintenta en unos segundos).
            Cuando la referencia ya corresponde a un cobro existente del
            propio espacio de trabajo (sin `Idempotency-Key`), el cuerpo sí
            incluye `data` con ese cobro.
          content:
            application/json:
              schema:
                oneOf:
                  - $ref: '#/components/schemas/AppError'
                  - type: object
                    properties:
                      data:
                        $ref: '#/components/schemas/PaymentClaim'
        '422':
          description: |
            Validación de campos, `currency_mismatch` (la referencia
            coincide con un pago bancario en una moneda distinta a la
            enviada), `idempotency_conflict` (la misma `Idempotency-Key`
            se usó antes con datos distintos), o la llave no tiene un
            espacio de trabajo asociado (`no_tenant`).
          content:
            application/json:
              schema:
                oneOf:
                  - $ref: '#/components/schemas/FrameworkError'
                  - $ref: '#/components/schemas/AppError'
        '429':
          $ref: '#/components/responses/AppRateLimited'
    get:
      tags: [Conciliación]
      summary: Listar cobros registrados
      description: |
        **Ability requerida:** `claims:read`
      operationId: listPaymentClaims
      security:
        - bearerAuth: []
      parameters:
        - name: status
          in: query
          schema:
            type: string
            enum: [waiting, matched, notified, expired, cancelled, disputed, rejected, registered, manual_review]
        - name: reference
          in: query
          schema:
            type: string
            maxLength: 50
        - name: from
          in: query
          description: Filtra por fecha de creación (`created_at >= from`).
          schema:
            type: string
            format: date-time
        - name: to
          in: query
          description: Filtra por fecha de creación (`created_at <= to`). Debe ser posterior o igual a `from`.
          schema:
            type: string
            format: date-time
        - $ref: '#/components/parameters/PerPage'
      responses:
        '200':
          description: Página de cobros, más recientes primero.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/PaginatedCollection'
                  - type: object
                    properties:
                      data:
                        type: array
                        items:
                          $ref: '#/components/schemas/PaymentClaim'
        '401':
          $ref: '#/components/responses/Unauthenticated'
        '403':
          $ref: '#/components/responses/Forbidden'
        '422':
          description: |
            Validación de parámetros, o la llave no tiene un espacio de
            trabajo asociado (`no_tenant`).
          content:
            application/json:
              schema:
                oneOf:
                  - $ref: '#/components/schemas/FrameworkError'
                  - $ref: '#/components/schemas/AppError'
        '429':
          $ref: '#/components/responses/GlobalRateLimited'

  /payment-claims/{id}:
    get:
      tags: [Conciliación]
      summary: Consultar un cobro por id
      description: |
        **Ability requerida:** `claims:read`
      operationId: getPaymentClaim
      security:
        - bearerAuth: []
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: integer
      responses:
        '200':
          description: El cobro.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/PaymentClaim'
        '401':
          $ref: '#/components/responses/Unauthenticated'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/AppNotFound'
        '422':
          $ref: '#/components/responses/NoTenant'
        '429':
          $ref: '#/components/responses/GlobalRateLimited'

  /reconciliation/daily-close:
    get:
      tags: [Conciliación]
      summary: Cierre de caja de un día
      description: |
        **Ability requerida:** `reconciliation:read`

        Mismos números que la pantalla "Hoy" del conciliador dentro de
        Kharyo. Sin `date`, devuelve el día de hoy. El rango permitido va
        desde 90 días atrás hasta hoy (nunca una fecha futura). La
        respuesta se cachea 60 segundos del lado del servidor.
      operationId: getDailyClose
      security:
        - bearerAuth: []
      parameters:
        - name: date
          in: query
          schema:
            type: string
            format: date
            example: "2026-08-07"
      responses:
        '200':
          description: Cierre del día.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/DailyClose'
        '401':
          $ref: '#/components/responses/Unauthenticated'
        '403':
          $ref: '#/components/responses/Forbidden'
        '422':
          description: |
            Validación de parámetros, o la llave no tiene un espacio de
            trabajo asociado (`no_tenant`).
          content:
            application/json:
              schema:
                oneOf:
                  - $ref: '#/components/schemas/FrameworkError'
                  - $ref: '#/components/schemas/AppError'
        '429':
          $ref: '#/components/responses/GlobalRateLimited'

  /usage:
    get:
      tags: [Uso]
      summary: Consumo del espacio de trabajo en el mes actual
      description: |
        **Ability requerida:** `usage:read`

        Solo lectura, nunca escribe en ningún contador de billing. Un
        espacio de trabajo sin plan activo responde con contadores en cero
        y límites en `null` (nunca un error).
      operationId: getUsage
      security:
        - bearerAuth: []
      responses:
        '200':
          description: Consumo del mes.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/Usage'
        '401':
          $ref: '#/components/responses/Unauthenticated'
        '403':
          $ref: '#/components/responses/Forbidden'
        '422':
          $ref: '#/components/responses/NoTenant'
        '429':
          $ref: '#/components/responses/GlobalRateLimited'

  /inbox/conversations:
    get:
      tags: [Bandeja de entrada]
      summary: Listar conversaciones
      description: |
        **Ability requerida:** `inbox:read`
      operationId: listConversations
      security:
        - bearerAuth: []
      parameters:
        - name: status
          in: query
          schema:
            type: string
            enum: [open, closed, snoozed]
        - name: channel
          in: query
          description: Coincide con el canal de la conversación (por ejemplo `whatsapp`, `instagram`, `messenger`).
          schema:
            type: string
            maxLength: 32
        - name: updated_since
          in: query
          description: Filtra por `updated_at >= updated_since`. Útil para hacer polling de cambios.
          schema:
            type: string
            format: date-time
        - name: search
          in: query
          description: Busca por nombre de contacto o identificador externo.
          schema:
            type: string
            maxLength: 100
        - $ref: '#/components/parameters/PerPage'
      responses:
        '200':
          description: Página de conversaciones, más recientes primero por último mensaje.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/PaginatedCollection'
                  - type: object
                    properties:
                      data:
                        type: array
                        items:
                          $ref: '#/components/schemas/Conversation'
        '401':
          $ref: '#/components/responses/Unauthenticated'
        '403':
          $ref: '#/components/responses/Forbidden'
        '422':
          description: |
            Validación de parámetros, o la llave no tiene un espacio de
            trabajo asociado (`no_tenant`).
          content:
            application/json:
              schema:
                oneOf:
                  - $ref: '#/components/schemas/FrameworkError'
                  - $ref: '#/components/schemas/AppError'
        '429':
          $ref: '#/components/responses/GlobalRateLimited'

  /inbox/conversations/{id}:
    get:
      tags: [Bandeja de entrada]
      summary: Consultar una conversación
      description: |
        **Ability requerida:** `inbox:read`
      operationId: getConversation
      security:
        - bearerAuth: []
      parameters:
        - $ref: '#/components/parameters/ConversationId'
      responses:
        '200':
          description: La conversación.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/Conversation'
        '401':
          $ref: '#/components/responses/Unauthenticated'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/AppNotFound'
        '422':
          $ref: '#/components/responses/NoTenant'
        '429':
          $ref: '#/components/responses/GlobalRateLimited'

  /inbox/conversations/{id}/messages:
    get:
      tags: [Bandeja de entrada]
      summary: Listar mensajes de una conversación
      description: |
        **Ability requerida:** `inbox:read`

        Nota: esta ruta usa un formato de paginación reducido (sin
        `links`), distinto del que usan `GET /payment-claims` y
        `GET /inbox/conversations`. Ver "Formatos de paginación" en la
        guía.
      operationId: listConversationMessages
      security:
        - bearerAuth: []
      parameters:
        - $ref: '#/components/parameters/ConversationId'
        - $ref: '#/components/parameters/PerPage'
      responses:
        '200':
          description: Página de mensajes.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/Message'
                  meta:
                    $ref: '#/components/schemas/SimplePaginationMeta'
        '401':
          $ref: '#/components/responses/Unauthenticated'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/AppNotFound'
        '422':
          description: |
            Validación de parámetros, o la llave no tiene un espacio de
            trabajo asociado (`no_tenant`).
          content:
            application/json:
              schema:
                oneOf:
                  - $ref: '#/components/schemas/FrameworkError'
                  - $ref: '#/components/schemas/AppError'
        '429':
          $ref: '#/components/responses/GlobalRateLimited'
    post:
      tags: [Bandeja de entrada]
      summary: Enviar un mensaje manual
      description: |
        **Ability requerida:** `inbox:write`

        Envía un mensaje de texto en nombre del negocio dentro de una
        conversación existente.

        Idempotencia: envía `client_id` (UUID) en el cuerpo para poder
        reintentar de forma segura. Un reintento con el mismo `client_id`
        devuelve `200` con el mensaje ya creado, nunca duplica el envío, y
        no consume cupo del límite de 30/minuto (comparte el mismo
        presupuesto con `POST .../template`, por espacio de trabajo).

        Requiere que el plan del espacio de trabajo esté activo.
      operationId: sendConversationMessage
      security:
        - bearerAuth: []
      parameters:
        - $ref: '#/components/parameters/ConversationId'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [content]
              properties:
                content:
                  type: string
                  maxLength: 4096
                client_id:
                  type: string
                  format: uuid
                  nullable: true
                  description: Identificador propio del integrador para reintentos seguros.
              example:
                content: "Hola, tenemos tu pedido listo."
                client_id: "8f14e45f-ceea-467e-add3-8e0a1c0f5f3a"
      responses:
        '201':
          description: Mensaje enviado.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/Message'
        '200':
          description: Reintento del mismo `client_id`, devuelve el mensaje ya existente.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/Message'
        '401':
          $ref: '#/components/responses/Unauthenticated'
        '402':
          description: El plan del espacio de trabajo no está activo (`subscription_required`).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AppError'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/AppNotFound'
        '409':
          description: Otra solicitud con el mismo `client_id` está en curso (`idempotency_in_progress`), reintenta en unos segundos.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AppError'
        '422':
          description: |
            Validación de parámetros, o la llave no tiene un espacio de
            trabajo asociado (`no_tenant`).
          content:
            application/json:
              schema:
                oneOf:
                  - $ref: '#/components/schemas/FrameworkError'
                  - $ref: '#/components/schemas/AppError'
        '429':
          $ref: '#/components/responses/AppRateLimited'

  /inbox/conversations/{id}/template:
    post:
      tags: [Bandeja de entrada]
      summary: Enviar una plantilla de WhatsApp
      description: |
        **Ability requerida:** `inbox:write`

        Envía una plantilla ya aprobada del espacio de trabajo. Solo
        disponible en conversaciones de WhatsApp con un canal de WhatsApp
        Cloud asociado a un WABA de Meta. Reabre la ventana de 24 horas de
        WhatsApp, igual que cualquier mensaje iniciado por el negocio.

        `template_id` es el ULID de una plantilla del espacio de trabajo
        (ver `GET /v1/templates` o `GET /inbox/templates`), scopeada
        además al WABA del canal de la conversación: la plantilla de otro
        WABA del mismo espacio de trabajo no se encuentra.

        `variables` es una lista **posicional** de valores texto o número
        que cubre todo el mensaje en el orden en que aparecen las
        variables `{{N}}` de la plantilla: si el HEADER es de texto y
        tiene una variable, ocupa la posición 1; las del BODY ocupan las
        posiciones siguientes. Acepta tanto una lista (`["Juan", "#123"]`)
        como un mapa por posición (`{"1": "Juan", "2": "#123"}`). La
        cantidad debe calzar exacto con lo que la plantilla requiere, sin
        huecos: distinto número de variables devuelve `422`
        (`template_variable_mismatch`). Botones URL dinámicos no están
        soportados: la variable de un botón nunca se sustituye.

        `media.link` es obligatorio cuando la plantilla tiene un HEADER de
        imagen, video o documento; si falta, devuelve `422`
        (`template_media_required`).
      operationId: sendConversationTemplate
      security:
        - bearerAuth: []
      parameters:
        - $ref: '#/components/parameters/ConversationId'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [template_id]
              properties:
                template_id:
                  type: string
                  format: ulid
                  description: Ulid de una plantilla del espacio de trabajo, del mismo WABA que el canal de la conversación (ver `GET /v1/templates`).
                variables:
                  type: array
                  items:
                    oneOf:
                      - type: string
                      - type: number
                  description: Valores posicionales para los `{{N}}` del HEADER (si es de texto) y del BODY. Omitir en una plantilla sin variables.
                media:
                  type: object
                  nullable: true
                  properties:
                    link:
                      type: string
                      format: uri
                      maxLength: 2048
                  description: Requerido solo si el HEADER de la plantilla es de tipo imagen, video o documento.
              example:
                template_id: "01J9Z8Q3K7N4M2P5R6S8T0V1W3"
                variables: ["Juan", "#123"]
      responses:
        '201':
          description: Plantilla enviada.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/Message'
        '401':
          $ref: '#/components/responses/Unauthenticated'
        '402':
          description: El plan del espacio de trabajo no está activo (`subscription_required`).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AppError'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          description: |
            La conversación no existe, o `template_id` no corresponde a
            una plantilla del espacio de trabajo en el WABA del canal de
            la conversación (`not_found` en ambos casos).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AppError'
        '422':
          description: |
            Validación de parámetros (incluye una `variables.*` que no es
            texto ni número), la conversación no es de WhatsApp
            (`channel_not_supported`), no tiene un canal de WhatsApp
            asociado (`channel_missing`), el canal no está conectado a un
            WABA de Meta (`channel_missing_waba`), la plantilla no está
            aprobada o su calidad es demasiado baja para enviarse
            (`template_not_usable`), la cantidad de `variables` no calza
            con la plantilla (`template_variable_mismatch`), falta
            `media.link` en una plantilla con HEADER de media
            (`template_media_required`), o la llave no tiene un espacio
            de trabajo asociado (`no_tenant`).
          content:
            application/json:
              schema:
                oneOf:
                  - $ref: '#/components/schemas/FrameworkError'
                  - $ref: '#/components/schemas/AppError'
        '429':
          $ref: '#/components/responses/AppRateLimited'
        '500':
          description: El proveedor de WhatsApp rechazó el envío (`send_failed`).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AppError'

  /inbox/conversations/{id}/mode:
    post:
      tags: [Bandeja de entrada]
      summary: Pausar o reactivar la IA de una conversación
      description: |
        **Ability requerida:** `inbox:write`

        `active` reactiva las respuestas automáticas; `paused` las detiene
        para que un agente humano tome el control. El modo interno
        `suggest` (usado solo dentro de la app web de Kharyo) no está
        disponible para integradores externos y devuelve un error de
        validación.
      operationId: setConversationMode
      security:
        - bearerAuth: []
      parameters:
        - $ref: '#/components/parameters/ConversationId'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [mode]
              properties:
                mode:
                  type: string
                  enum: [active, paused]
      responses:
        '200':
          description: Modo actualizado.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      status:
                        type: string
                        example: updated
                      mode:
                        type: string
                        enum: [active, paused]
        '401':
          $ref: '#/components/responses/Unauthenticated'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/AppNotFound'
        '422':
          description: |
            Validación de parámetros (incluye `mode=suggest`, no
            disponible para integradores externos), o la llave no tiene
            un espacio de trabajo asociado (`no_tenant`).
          content:
            application/json:
              schema:
                oneOf:
                  - $ref: '#/components/schemas/FrameworkError'
                  - $ref: '#/components/schemas/AppError'
        '429':
          $ref: '#/components/responses/GlobalRateLimited'

  /inbox/templates:
    get:
      tags: [Bandeja de entrada]
      summary: Listar plantillas de WhatsApp aprobadas
      description: |
        **Ability requerida:** `inbox:read`

        Proyección mínima de solo lectura, pensada para armar el selector
        de plantillas de un envío. Solo trae las plantillas **aprobadas**.
        Para gestión completa (filtros por WABA/estado/idioma/categoría,
        crear, sincronizar con Meta, borrar) usa el recurso completo
        `GET/POST /v1/templates`.
      operationId: listTemplates
      security:
        - bearerAuth: []
      responses:
        '200':
          description: Plantillas aprobadas del espacio de trabajo, ordenadas por nombre.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/Template'
        '401':
          $ref: '#/components/responses/Unauthenticated'
        '403':
          $ref: '#/components/responses/Forbidden'
        '422':
          $ref: '#/components/responses/NoTenant'
        '429':
          $ref: '#/components/responses/GlobalRateLimited'

  /templates/wabas:
    get:
      tags: [Plantillas de WhatsApp]
      summary: Listar los WABA conectados del espacio de trabajo
      description: |
        **Ability requerida:** `templates:read`

        Lista, sin paginar, los WABA (WhatsApp Business Account) de Meta
        que tienen al menos un canal de WhatsApp conectado en el espacio
        de trabajo — el insumo para elegir `waba_id` al listar, crear o
        sincronizar plantillas.
      operationId: listTemplateWabas
      security:
        - bearerAuth: []
      responses:
        '200':
          description: WABAs conectados, sin paginar.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/Waba'
        '401':
          $ref: '#/components/responses/Unauthenticated'
        '403':
          $ref: '#/components/responses/Forbidden'
        '422':
          $ref: '#/components/responses/NoTenant'
        '429':
          $ref: '#/components/responses/GlobalRateLimited'

  /templates/sync:
    post:
      tags: [Plantillas de WhatsApp]
      summary: Sincronizar plantillas desde Meta
      description: |
        **Ability requerida:** `templates:write`

        Encola una sincronización asíncrona: trae desde Meta el estado
        actual (aprobación, calidad, rechazo) de todas las plantillas del
        WABA indicado y actualiza la copia local. No devuelve las
        plantillas sincronizadas — consulta `GET /v1/templates` después,
        o suscríbete al webhook `template.status_changed` para enterarte
        en cuanto cambien.
      operationId: syncTemplates
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [waba_id]
              properties:
                waba_id:
                  type: string
                  maxLength: 64
              example:
                waba_id: "123456789012345"
      responses:
        '202':
          description: Sincronización encolada.
          content:
            application/json:
              schema:
                type: object
                properties:
                  status:
                    type: string
                    example: queued
                  waba_id:
                    type: string
        '401':
          $ref: '#/components/responses/Unauthenticated'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          description: El `waba_id` indicado no tiene un canal de WhatsApp conectado en el espacio de trabajo (`waba_not_connected`).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AppError'
        '422':
          description: |
            Validación de parámetros, o la llave no tiene un espacio de
            trabajo asociado (`no_tenant`).
          content:
            application/json:
              schema:
                oneOf:
                  - $ref: '#/components/schemas/FrameworkError'
                  - $ref: '#/components/schemas/AppError'
        '429':
          $ref: '#/components/responses/GlobalRateLimited'

  /templates:
    get:
      tags: [Plantillas de WhatsApp]
      summary: Listar plantillas de WhatsApp (recurso completo)
      description: |
        **Ability requerida:** `templates:read`

        Lista paginada (25 por página) de las plantillas del espacio de
        trabajo, en cualquier estado, más recientes primero. Usa el
        formato de paginación estándar (ver "Formatos de paginación" en
        la guía) — sin `per_page` configurable.
      operationId: listWhatsappTemplates
      security:
        - bearerAuth: []
      parameters:
        - name: waba_id
          in: query
          schema:
            type: string
            maxLength: 64
        - name: status
          in: query
          schema:
            type: string
            enum: [PENDING, APPROVED, REJECTED, PAUSED, DISABLED]
        - name: language
          in: query
          schema:
            type: string
            maxLength: 16
        - name: category
          in: query
          schema:
            type: string
            enum: [AUTHENTICATION, MARKETING, UTILITY]
      responses:
        '200':
          description: Página de plantillas.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/PaginatedCollection'
                  - type: object
                    properties:
                      data:
                        type: array
                        items:
                          $ref: '#/components/schemas/WhatsappTemplate'
        '401':
          $ref: '#/components/responses/Unauthenticated'
        '403':
          $ref: '#/components/responses/Forbidden'
        '422':
          description: |
            Validación de parámetros (filtros fuera de los valores
            permitidos), o la llave no tiene un espacio de trabajo
            asociado (`no_tenant`).
          content:
            application/json:
              schema:
                oneOf:
                  - $ref: '#/components/schemas/FrameworkError'
                  - $ref: '#/components/schemas/AppError'
        '429':
          $ref: '#/components/responses/GlobalRateLimited'
    post:
      tags: [Plantillas de WhatsApp]
      summary: Crear una plantilla y enviarla a aprobación de Meta
      description: |
        **Ability requerida:** `templates:write`

        Crea la plantilla localmente (estado `PENDING`) y la envía a Meta
        para aprobación. Si Meta rechaza la creación, la fila local se
        elimina sin dejar residuo y la respuesta es `502`
        (`meta_submission_failed`). Suscríbete a `template.status_changed`
        para enterarte cuando Meta apruebe, rechace o pause la plantilla.

        `components` sigue el formato de componentes de plantilla de Meta:
        cada elemento es `{type, text?, format?, example?, buttons?}` con
        `type` en `HEADER`, `BODY`, `FOOTER` o `BUTTONS`. Los headers con
        multimedia (`format` distinto de `TEXT`) todavía no se soportan en
        la creación por esta API. Hasta 3 `buttons`, tipo `QUICK_REPLY`,
        `URL` (requiere `url`) o `PHONE_NUMBER` (requiere `phone_number`).

        No existen dos plantillas con el mismo `(waba_id, name, language)`
        a la vez: reintentar con la misma combinación mientras la anterior
        sigue viva devuelve `409` (`template_already_exists`); una carrera
        entre dos creaciones simultáneas con la misma combinación la
        resuelve el índice único de la base y devuelve `409`
        (`duplicate_template`) a la que pierde. Sí puedes recrear una
        plantilla que ya borraste (`DELETE /v1/templates/{id}`) con el
        mismo nombre e idioma.
      operationId: createWhatsappTemplate
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [waba_id, name, language, category, components]
              properties:
                waba_id:
                  type: string
                  maxLength: 64
                name:
                  type: string
                  maxLength: 512
                  pattern: '^[a-z0-9_]+$'
                  description: Minúsculas, números y guiones bajos únicamente.
                language:
                  type: string
                  maxLength: 16
                  example: es
                category:
                  type: string
                  enum: [AUTHENTICATION, MARKETING, UTILITY]
                components:
                  type: array
                  minItems: 1
                  items:
                    type: object
                    additionalProperties: true
                  description: Componentes en formato de definición de Meta (ver descripción del endpoint).
              example:
                waba_id: "123456789012345"
                name: order_confirmation
                language: es
                category: UTILITY
                components:
                  - type: BODY
                    text: "Hola {{1}}, tu pedido {{2}} está confirmado."
      responses:
        '201':
          description: Plantilla creada y enviada a Meta.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/WhatsappTemplate'
        '401':
          $ref: '#/components/responses/Unauthenticated'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          description: El `waba_id` indicado no tiene un canal de WhatsApp conectado en el espacio de trabajo (`waba_not_connected`).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AppError'
        '409':
          description: Ya existe una plantilla activa con ese `(waba_id, name, language)` (`template_already_exists`, o `duplicate_template` si perdió una carrera de creación simultánea).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AppError'
        '422':
          description: |
            Validación de parámetros (incluye un header con `format`
            distinto de `TEXT`, o más de 3 `buttons`), o la llave no
            tiene un espacio de trabajo asociado (`no_tenant`).
          content:
            application/json:
              schema:
                oneOf:
                  - $ref: '#/components/schemas/FrameworkError'
                  - $ref: '#/components/schemas/AppError'
        '429':
          $ref: '#/components/responses/GlobalRateLimited'
        '502':
          description: Meta rechazó la creación de la plantilla (`meta_submission_failed`). No queda residuo local.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AppError'

  /templates/{id}:
    get:
      tags: [Plantillas de WhatsApp]
      summary: Consultar una plantilla
      description: |
        **Ability requerida:** `templates:read`
      operationId: showWhatsappTemplate
      security:
        - bearerAuth: []
      parameters:
        - $ref: '#/components/parameters/TemplateId'
      responses:
        '200':
          description: Plantilla encontrada.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/WhatsappTemplate'
        '401':
          $ref: '#/components/responses/Unauthenticated'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/AppNotFound'
        '422':
          $ref: '#/components/responses/NoTenant'
        '429':
          $ref: '#/components/responses/GlobalRateLimited'
    delete:
      tags: [Plantillas de WhatsApp]
      summary: Borrar una plantilla
      description: |
        **Ability requerida:** `templates:write`

        Borra la plantilla en Meta y, solo si eso tiene éxito, localmente
        (soft delete). Si Meta la rechaza, no se borra nada local y la
        respuesta es `502` (`meta_delete_failed`). El `(waba_id, name,
        language)` queda libre para recrearse una vez el borrado
        completa.
      operationId: deleteWhatsappTemplate
      security:
        - bearerAuth: []
      parameters:
        - $ref: '#/components/parameters/TemplateId'
      responses:
        '200':
          description: Plantilla borrada.
          content:
            application/json:
              schema:
                type: object
                properties:
                  status:
                    type: string
                    example: deleted
        '401':
          $ref: '#/components/responses/Unauthenticated'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          description: |
            La plantilla no existe, o el WABA al que pertenece ya no
            tiene un canal de WhatsApp conectado (`not_found` en ambos
            casos, o `waba_not_connected` para el segundo).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AppError'
        '422':
          $ref: '#/components/responses/NoTenant'
        '429':
          $ref: '#/components/responses/GlobalRateLimited'
        '502':
          description: Meta rechazó el borrado de la plantilla (`meta_delete_failed`). La plantilla sigue viva.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AppError'

  /webhooks:
    get:
      tags: [Webhooks]
      summary: Listar suscripciones de webhook
      description: |
        **Ability requerida:** `webhooks:manage`

        Lista las suscripciones de webhook del espacio de trabajo, más
        recientes primero. El secreto nunca viaja en esta respuesta: solo
        aparece en claro una vez, en la respuesta de creación.
      operationId: listWebhookSubscriptions
      security:
        - bearerAuth: []
      responses:
        '200':
          description: Suscripciones del espacio de trabajo (sin paginar).
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/ApiWebhookSubscription'
        '401':
          $ref: '#/components/responses/Unauthenticated'
        '403':
          $ref: '#/components/responses/Forbidden'
        '422':
          $ref: '#/components/responses/NoTenant'
        '429':
          $ref: '#/components/responses/GlobalRateLimited'
    post:
      tags: [Webhooks]
      summary: Crear una suscripción de webhook
      description: |
        **Ability requerida:** `webhooks:manage`

        `url` debe empezar con `https://` y debe apuntar a un endpoint
        público: si resuelve a una IP privada, de loopback o link-local, el
        envío no sale y ese intento cuenta como fallo para el umbral de
        auto-desactivación. `events` es una lista no vacía tomada del
        catálogo cerrado de eventos suscribibles (ver la sección
        "Webhooks" al final de este documento); `ping` no está en ese
        catálogo, es solo el evento que dispara
        `POST /webhooks/{id}/test`.

        Tope de 5 suscripciones **activas** por espacio de trabajo: crear
        una sexta devuelve `422` (`subscription_limit_reached`).

        El secreto (prefijo `whsec_`) se devuelve en texto plano **solo en
        esta respuesta**. Guárdalo: Kharyo no vuelve a mostrarlo, y no hay
        forma de rotarlo (solo eliminar la suscripción y crear una nueva).
      operationId: createWebhookSubscription
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [url, events]
              properties:
                url:
                  type: string
                  format: uri
                  maxLength: 2048
                  description: Debe empezar con `https://`.
                events:
                  type: array
                  minItems: 1
                  items:
                    type: string
                    enum: [payment_claim.matched, payment_claim.registered, lead.captured, inbox.message.received, inbox.sla.breached, template.status_changed]
              example:
                url: "https://example.com/webhooks/kharyo"
                events: ["payment_claim.matched", "lead.captured"]
      responses:
        '201':
          description: Suscripción creada. `secret` solo viene en esta respuesta.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    allOf:
                      - $ref: '#/components/schemas/ApiWebhookSubscription'
                      - type: object
                        required: [secret]
                        properties:
                          secret:
                            type: string
                            example: "whsec_9f8a7b6c5d4e3f2a1b0c9d8e7f6a5b4c3d2e1f0a"
                            description: Secreto en texto plano, solo en esta respuesta. Úsalo para verificar la firma de cada entrega.
        '401':
          $ref: '#/components/responses/Unauthenticated'
        '403':
          $ref: '#/components/responses/Forbidden'
        '422':
          description: |
            Validación de campos (incluida una `url` que no empieza con
            `https://` o un evento fuera del catálogo cerrado),
            `subscription_limit_reached` (ya hay 5 suscripciones activas),
            o la llave no tiene un espacio de trabajo asociado
            (`no_tenant`).
          content:
            application/json:
              schema:
                oneOf:
                  - $ref: '#/components/schemas/FrameworkError'
                  - $ref: '#/components/schemas/AppError'
        '429':
          $ref: '#/components/responses/GlobalRateLimited'

  /webhooks/{id}:
    delete:
      tags: [Webhooks]
      summary: Eliminar una suscripción de webhook
      description: |
        **Ability requerida:** `webhooks:manage`

        Elimina la suscripción y, en cascada, su historial de entregas
        guardado. Es la única forma de "rotar" el secreto: eliminar y
        crear una suscripción nueva.
      operationId: deleteWebhookSubscription
      security:
        - bearerAuth: []
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: integer
      responses:
        '200':
          description: Suscripción eliminada.
          content:
            application/json:
              schema:
                type: object
                properties:
                  status:
                    type: string
                    example: deleted
        '401':
          $ref: '#/components/responses/Unauthenticated'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/AppNotFound'
        '422':
          $ref: '#/components/responses/NoTenant'
        '429':
          $ref: '#/components/responses/GlobalRateLimited'

  /webhooks/{id}/test:
    post:
      tags: [Webhooks]
      summary: Enviar un evento de prueba
      description: |
        **Ability requerida:** `webhooks:manage`

        Encola un evento `ping` hacia la suscripción indicada, firmado
        igual que cualquier entrega real (mismas cabeceras, misma fórmula
        de firma). `ping` no forma parte del catálogo de eventos
        suscribibles: solo se produce por esta ruta.
      operationId: testWebhookSubscription
      security:
        - bearerAuth: []
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: integer
      responses:
        '200':
          description: Entrega de prueba encolada.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      delivery_id:
                        type: string
                        format: uuid
        '401':
          $ref: '#/components/responses/Unauthenticated'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/AppNotFound'
        '422':
          $ref: '#/components/responses/NoTenant'
        '429':
          $ref: '#/components/responses/GlobalRateLimited'

  /webhooks/{id}/deliveries:
    get:
      tags: [Webhooks]
      summary: Listar los últimos intentos de entrega
      description: |
        **Ability requerida:** `webhooks:manage`

        Los últimos 50 intentos de entrega de la suscripción, más
        recientes primero, sin el cuerpo (`data`) que se envió en cada uno
        (para eso, revisa tu propio endpoint receptor). El historial se
        conserva 30 días y no está paginado: la respuesta siempre trae, como
        máximo, esas 50 filas.
      operationId: listWebhookDeliveries
      security:
        - bearerAuth: []
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: integer
      responses:
        '200':
          description: Últimas entregas de la suscripción (sin paginar).
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/ApiWebhookDelivery'
        '401':
          $ref: '#/components/responses/Unauthenticated'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/AppNotFound'
        '422':
          $ref: '#/components/responses/NoTenant'
        '429':
          $ref: '#/components/responses/GlobalRateLimited'

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      description: |
        Token creado en `/settings/api`. Cada llave tiene `abilities`
        propias: la petición se rechaza con `403` si la llave no tiene la
        ability que exige el endpoint (indicada al inicio de la
        descripción de cada operación).

  parameters:
    PerPage:
      name: per_page
      in: query
      description: Tamaño de página. Máximo 100.
      schema:
        type: integer
        minimum: 1
        maximum: 100
        default: 20
    IdempotencyKey:
      name: Idempotency-Key
      in: header
      required: false
      description: Clave elegida por el integrador para reintentos seguros. Ver descripción del endpoint.
      schema:
        type: string
    ConversationId:
      name: id
      in: path
      required: true
      schema:
        type: string
        format: uuid
    TemplateId:
      name: id
      in: path
      required: true
      description: Ulid de la plantilla.
      schema:
        type: string
        format: ulid
    KharyoEventHeader:
      name: X-Kharyo-Event
      in: header
      required: true
      description: Nombre público del evento (igual al campo `event` del cuerpo).
      schema:
        type: string
    KharyoDeliveryHeader:
      name: X-Kharyo-Delivery
      in: header
      required: true
      description: |
        Id de este intento de entrega (igual al campo `id` del cuerpo). Dos
        intentos del mismo evento comparten el mismo id: úsalo para
        deduplicar si procesas una entrega más de una vez.
      schema:
        type: string
        format: uuid
    KharyoTimestampHeader:
      name: X-Kharyo-Timestamp
      in: header
      required: true
      description: Unix timestamp (segundos, como texto) del momento del envío. Forma parte de la firma.
      schema:
        type: string
    KharyoSignatureHeader:
      name: X-Kharyo-Signature
      in: header
      required: true
      description: |
        Firma del cuerpo, formato `sha256=<hex>`. Se calcula como
        `HMAC-SHA256(secret, timestamp + "." + body)` sobre los bytes
        EXACTOS del cuerpo recibido (no reserialices el JSON antes de
        verificar: usa el string crudo). Compara con una función de tiempo
        constante, nunca con `==`.
      schema:
        type: string

  responses:
    Unauthenticated:
      description: Falta el token o es inválido.
      content:
        application/json:
          schema:
            oneOf:
              - $ref: '#/components/schemas/FrameworkError'
              - $ref: '#/components/schemas/AppError'
    NoTenant:
      description: La llave no tiene un espacio de trabajo asociado (`no_tenant`).
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/AppError'
    Forbidden:
      description: |
        La llave no tiene la ability requerida (`error: "Forbidden"`,
        forma `FrameworkError`), o el usuario ya no pertenece al espacio de
        trabajo del token (`error: "forbidden_tenant"`, forma `AppError`).
      content:
        application/json:
          schema:
            oneOf:
              - $ref: '#/components/schemas/FrameworkError'
              - $ref: '#/components/schemas/AppError'
    AppNotFound:
      description: El recurso no existe o no pertenece a tu espacio de trabajo.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/AppError'
    ValidationFailed:
      description: Parámetros inválidos.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/FrameworkError'
    GlobalRateLimited:
      description: Límite genérico de 60 solicitudes/minuto por llave excedido.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/FrameworkError'
    AppRateLimited:
      description: Límite propio del endpoint excedido (ver descripción del endpoint para el número exacto).
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/AppError'

  schemas:
    FrameworkError:
      type: object
      description: |
        Forma de error del framework: se produce ANTES de llegar a la
        lógica del endpoint (token inválido, ability faltante, ruta
        inexistente, validación de parámetros, límite genérico excedido).
        `error` es una frase legible, no un código.
      properties:
        error:
          type: string
          enum: [Unauthenticated, Forbidden, "Not found", "Validation failed", "Too many requests", "Internal server error"]
        message:
          type: string
        errors:
          type: object
          nullable: true
          description: Detalle por campo, presente solo cuando `error` es `"Validation failed"`.
          additionalProperties:
            type: array
            items:
              type: string
      required: [error, message]
    AppError:
      type: object
      description: |
        Forma de error específica del endpoint: la petición llegó a la
        lógica de negocio y esta la rechazó. `error` es un código estable
        en snake_case, pensado para hacer `switch` sobre él.
      properties:
        error:
          type: string
          example: rate_limited
        message:
          type: string
        retry_after:
          type: integer
          nullable: true
          description: Segundos hasta que el límite se libera. Presente solo en respuestas `429`.
      required: [error, message]

    NotificationTarget:
      type: object
      properties:
        id:
          type: integer
        type:
          type: string
          enum: [number, group]
        name:
          type: string
          description: Etiqueta del destino, o un nombre genérico si no tiene una asignada.
        chat_id:
          type: string
          description: |
            Identificador del destino con el centro enmascarado (por
            ejemplo `58****34@c.us`). No es utilizable como parámetro
            `target` de `POST /notifications/whatsapp`, solo es
            informativo.

    PaymentClaim:
      type: object
      properties:
        id:
          type: integer
        status:
          type: string
          enum: [waiting, matched, notified, expired, cancelled, disputed, rejected, registered, manual_review]
        reference:
          type: string
        amount:
          type: number
          format: float
          nullable: true
        currency:
          type: string
        bank_code:
          type: string
          nullable: true
        bank_name:
          type: string
          nullable: true
        external_ref:
          type: string
          nullable: true
        matched_at:
          type: string
          format: date-time
          nullable: true
        expires_at:
          type: string
          format: date-time
          nullable: true
        created_at:
          type: string
          format: date-time
          nullable: true

    DailyClose:
      type: object
      description: |
        Cada bloque `_by_bank` es una lista de filas `{currency, bank_code,
        bank_name, count, total, unreadable_amount_count}`; el bloque sin
        sufijo es el mismo desglose colapsado por moneda
        (`{currency, count, total, unreadable_amount_count}`).
      properties:
        date:
          type: string
          format: date
        received:
          type: array
          items:
            type: object
        received_by_bank:
          type: array
          items:
            type: object
        verified:
          type: array
          items:
            type: object
        verified_by_bank:
          type: array
          items:
            type: object
        unverified:
          type: array
          items:
            type: object
        unverified_by_bank:
          type: array
          items:
            type: object
        unverified_total_count:
          type: integer
          description: Cantidad total de pagos bancarios sin cruzar en el rango (el detalle fila a fila no se expone por esta API).
        dismissed:
          type: array
          items:
            type: object
        dismissed_by_bank:
          type: array
          items:
            type: object
        claims_waiting:
          type: array
          description: Cobros `waiting`/`registered` creados ese día, agrupados por moneda.
          items:
            type: object
            properties:
              currency:
                type: string
              count:
                type: integer
              total:
                type: number
        claims_expired:
          type: array
          description: Cobros que expiraron ese día, agrupados por moneda.
          items:
            type: object
            properties:
              currency:
                type: string
              count:
                type: integer
              total:
                type: number
        expiring_soon:
          type: array
          description: Hasta 10 cobros `waiting`/`registered` que vencen en las próximas 2 horas (no depende de `date`).
          items:
            type: object
            properties:
              id:
                type: integer
              reference:
                type: string
              expected_amount:
                type: number
              currency:
                type: string
              bank_name:
                type: string
                nullable: true
              expires_at:
                type: string
                format: date-time
              created_at:
                type: string
                format: date-time

    Usage:
      type: object
      properties:
        period:
          type: string
          example: "2026-08"
        tokens:
          type: object
          properties:
            used:
              type: integer
            limit:
              type: integer
              nullable: true
              description: "`null` cuando el espacio de trabajo no tiene plan activo o el plan es ilimitado."
        executions:
          type: object
          properties:
            used:
              type: integer
            limit:
              type: integer
              nullable: true
        api_requests:
          type: object
          properties:
            total:
              type: integer
              description: Requests a esta API en lo que va del mes.
            by_endpoint:
              type: object
              description: Conteo por endpoint (clave `"MÉTODO ruta"`, valor cantidad).
              additionalProperties:
                type: integer

    Conversation:
      type: object
      properties:
        id:
          type: string
          format: uuid
        contact:
          type: object
          properties:
            name:
              type: string
              nullable: true
            identifier:
              type: string
              description: Teléfono en formato E.164 en WhatsApp; handle o id del contacto en otros canales.
        channel:
          type: string
          example: whatsapp
        status:
          type: string
          enum: [open, closed, snoozed]
        ai_mode:
          type: string
          example: active
        last_message_at:
          type: string
          format: date-time
          nullable: true
        unread_count:
          type: integer
        tags:
          type: array
          items:
            type: string

    Message:
      type: object
      properties:
        id:
          type: integer
        direction:
          type: string
          enum: [inbound, outbound]
        type:
          type: string
          example: text
        content:
          type: string
          nullable: true
        sender:
          type: string
          enum: [customer, bot, business]
          description: "`customer` en mensajes entrantes; `bot` cuando el saliente lo generó el agente de IA; `business` para cualquier otro saliente (envío manual, plantilla, o eco de la app nativa de Meta)."
        created_at:
          type: string
          format: date-time
          nullable: true

    Template:
      type: object
      description: Proyección mínima de solo lectura (`GET /inbox/templates`). Para el recurso completo, ver `WhatsappTemplate`.
      properties:
        id:
          type: string
          format: ulid
        name:
          type: string
        category:
          type: string
        language:
          type: string
        body_template:
          type: string
          description: Texto del componente BODY de la plantilla.

    WhatsappTemplate:
      type: object
      description: Shape completo de una plantilla (`GET/POST /v1/templates`, `GET /v1/templates/{id}`).
      properties:
        id:
          type: string
          format: ulid
        waba_id:
          type: string
        external_template_id:
          type: string
          nullable: true
          description: Id de la plantilla en Meta. `null` mientras la creación sigue en curso.
        name:
          type: string
        language:
          type: string
        category:
          type: string
          enum: [AUTHENTICATION, MARKETING, UTILITY]
        status:
          type: string
          enum: [PENDING, APPROVED, REJECTED, PAUSED, DISABLED]
        quality_rating:
          type: string
          nullable: true
          description: Calidad reportada por Meta (por ejemplo `GREEN`, `YELLOW`, `RED`). `null` hasta que Meta la reporta.
        rejection_reason:
          type: string
          nullable: true
        components:
          type: array
          description: Componentes en formato de DEFINICIÓN de Meta (el mismo que se usó para crearla) — úsalos para saber cuántas variables `{{N}}` tiene el BODY antes de enviarla con `POST /inbox/conversations/{id}/template`.
          items:
            type: object
            additionalProperties: true
        last_synced_at:
          type: string
          format: date-time
          nullable: true
        created_at:
          type: string
          format: date-time
          nullable: true
        updated_at:
          type: string
          format: date-time
          nullable: true

    Waba:
      type: object
      properties:
        waba_id:
          type: string
        display_name:
          type: string
          nullable: true
        phone_numbers:
          type: array
          items:
            type: string

    SimplePaginationMeta:
      type: object
      properties:
        current_page:
          type: integer
        last_page:
          type: integer
        per_page:
          type: integer
        total:
          type: integer

    PaginatedCollection:
      type: object
      description: Formato estándar de paginación (usado por `GET /payment-claims` y `GET /inbox/conversations`).
      properties:
        links:
          type: object
          properties:
            first:
              type: string
              nullable: true
            last:
              type: string
              nullable: true
            prev:
              type: string
              nullable: true
            next:
              type: string
              nullable: true
        meta:
          type: object
          properties:
            current_page:
              type: integer
            from:
              type: integer
              nullable: true
            last_page:
              type: integer
            path:
              type: string
            per_page:
              type: integer
            to:
              type: integer
              nullable: true
            total:
              type: integer

    ApiWebhookSubscription:
      type: object
      properties:
        id:
          type: integer
        url:
          type: string
        events:
          type: array
          items:
            type: string
        active:
          type: boolean
        disabled_at:
          type: string
          format: date-time
          nullable: true
        disable_reason:
          type: string
          nullable: true
          description: "`null` si nunca se auto-desactivó. Hoy el único valor posible es `auto_failures` (20 fallos consecutivos): no existe una forma de pausarla a mano."
        created_at:
          type: string
          format: date-time
          nullable: true

    ApiWebhookDelivery:
      type: object
      description: |
        Un intento de entrega. Deliberadamente sin `data`/`payload`: el
        detalle de auditoría de una entrega puntual no es parte de esta
        referencia, revisa tu propio endpoint receptor.
      properties:
        id:
          type: string
          format: uuid
        event:
          type: string
        status:
          type: string
          enum: [pending, delivered, failed, dead]
        attempts:
          type: integer
        response_status:
          type: integer
          nullable: true
        last_attempt_at:
          type: string
          format: date-time
          nullable: true
        delivered_at:
          type: string
          format: date-time
          nullable: true
        created_at:
          type: string
          format: date-time
          nullable: true

    WebhookEnvelope:
      type: object
      description: Sobre común de todo envío saliente (los 6 eventos suscribibles y `ping`).
      properties:
        id:
          type: string
          format: uuid
          description: Id de este intento de entrega (igual al header `X-Kharyo-Delivery`).
        event:
          type: string
        created_at:
          type: string
          format: date-time
        data:
          type: object
          description: Shape específico de cada evento, ver cada operación del bloque `webhooks`.
      required: [id, event, created_at, data]

    WebhookLeadCapturedData:
      type: object
      properties:
        name:
          type: string
          nullable: true
        identifier:
          type: string
          description: Teléfono o correo del lead (lo primero disponible).
        source:
          type: string
          nullable: true
          example: whatsapp
        captured_at:
          type: string
          format: date-time
          nullable: true

    WebhookInboxMessageReceivedData:
      type: object
      properties:
        message:
          $ref: '#/components/schemas/Message'
        conversation:
          $ref: '#/components/schemas/Conversation'

    WebhookInboxSlaBreachedData:
      type: object
      properties:
        conversation:
          $ref: '#/components/schemas/Conversation'
        minutes_overdue:
          type: integer
          nullable: true

    WebhookTemplateStatusChangedData:
      type: object
      properties:
        template:
          type: object
          properties:
            id:
              type: string
              format: ulid
            name:
              type: string
            language:
              type: string
            category:
              type: string
              enum: [AUTHENTICATION, MARKETING, UTILITY]
            status:
              type: string
              enum: [PENDING, APPROVED, REJECTED, PAUSED, DISABLED]
            rejection_reason:
              type: string
              nullable: true
            quality_rating:
              type: string
              nullable: true
        previous_status:
          type: string
          enum: [PENDING, APPROVED, REJECTED, PAUSED, DISABLED]

    WebhookPingData:
      type: object
      properties:
        message:
          type: string
          example: "Este es un evento de prueba enviado desde Kharyo."

webhooks:
  payment_claim.matched:
    post:
      summary: payment_claim.matched
      description: |
        Se dispara cuando el conciliador cruza un cobro registrado (`POST
        /payment-claims`) con una notificación bancaria del espacio de
        trabajo. `data` es el mismo shape que `PaymentClaim` (ver `GET
        /payment-claims/{id}`), con `status: matched`.
      parameters:
        - $ref: '#/components/parameters/KharyoEventHeader'
        - $ref: '#/components/parameters/KharyoDeliveryHeader'
        - $ref: '#/components/parameters/KharyoTimestampHeader'
        - $ref: '#/components/parameters/KharyoSignatureHeader'
      requestBody:
        content:
          application/json:
            schema:
              allOf:
                - $ref: '#/components/schemas/WebhookEnvelope'
                - type: object
                  properties:
                    event:
                      type: string
                      enum: [payment_claim.matched]
                    data:
                      $ref: '#/components/schemas/PaymentClaim'
      responses:
        '200':
          description: Cualquier `2xx` confirma la entrega. Cualquier otro código, o agotar el tiempo de espera, cuenta como fallo y reintenta.

  payment_claim.registered:
    post:
      summary: payment_claim.registered
      description: |
        Se dispara cuando se registra un cobro esperado (`POST
        /payment-claims`) que todavía no cruzó contra el banco: es una
        señal de presencia, nunca evidencia de pago. `data` es el mismo
        shape que `PaymentClaim`, con `status: registered`.
      parameters:
        - $ref: '#/components/parameters/KharyoEventHeader'
        - $ref: '#/components/parameters/KharyoDeliveryHeader'
        - $ref: '#/components/parameters/KharyoTimestampHeader'
        - $ref: '#/components/parameters/KharyoSignatureHeader'
      requestBody:
        content:
          application/json:
            schema:
              allOf:
                - $ref: '#/components/schemas/WebhookEnvelope'
                - type: object
                  properties:
                    event:
                      type: string
                      enum: [payment_claim.registered]
                    data:
                      $ref: '#/components/schemas/PaymentClaim'
      responses:
        '200':
          description: Cualquier `2xx` confirma la entrega. Cualquier otro código, o agotar el tiempo de espera, cuenta como fallo y reintenta.

  lead.captured:
    post:
      summary: lead.captured
      description: |
        Se dispara cuando un chatbot captura un lead accionable (con
        teléfono o correo).
      parameters:
        - $ref: '#/components/parameters/KharyoEventHeader'
        - $ref: '#/components/parameters/KharyoDeliveryHeader'
        - $ref: '#/components/parameters/KharyoTimestampHeader'
        - $ref: '#/components/parameters/KharyoSignatureHeader'
      requestBody:
        content:
          application/json:
            schema:
              allOf:
                - $ref: '#/components/schemas/WebhookEnvelope'
                - type: object
                  properties:
                    event:
                      type: string
                      enum: [lead.captured]
                    data:
                      $ref: '#/components/schemas/WebhookLeadCapturedData'
      responses:
        '200':
          description: Cualquier `2xx` confirma la entrega. Cualquier otro código, o agotar el tiempo de espera, cuenta como fallo y reintenta.

  inbox.message.received:
    post:
      summary: inbox.message.received
      description: |
        Se dispara con cada mensaje entrante de un contacto en la bandeja
        de entrada.
      parameters:
        - $ref: '#/components/parameters/KharyoEventHeader'
        - $ref: '#/components/parameters/KharyoDeliveryHeader'
        - $ref: '#/components/parameters/KharyoTimestampHeader'
        - $ref: '#/components/parameters/KharyoSignatureHeader'
      requestBody:
        content:
          application/json:
            schema:
              allOf:
                - $ref: '#/components/schemas/WebhookEnvelope'
                - type: object
                  properties:
                    event:
                      type: string
                      enum: [inbox.message.received]
                    data:
                      $ref: '#/components/schemas/WebhookInboxMessageReceivedData'
      responses:
        '200':
          description: Cualquier `2xx` confirma la entrega. Cualquier otro código, o agotar el tiempo de espera, cuenta como fallo y reintenta.

  inbox.sla.breached:
    post:
      summary: inbox.sla.breached
      description: |
        Se dispara cuando una conversación supera el SLA de primera
        respuesta configurado del espacio de trabajo.
      parameters:
        - $ref: '#/components/parameters/KharyoEventHeader'
        - $ref: '#/components/parameters/KharyoDeliveryHeader'
        - $ref: '#/components/parameters/KharyoTimestampHeader'
        - $ref: '#/components/parameters/KharyoSignatureHeader'
      requestBody:
        content:
          application/json:
            schema:
              allOf:
                - $ref: '#/components/schemas/WebhookEnvelope'
                - type: object
                  properties:
                    event:
                      type: string
                      enum: [inbox.sla.breached]
                    data:
                      $ref: '#/components/schemas/WebhookInboxSlaBreachedData'
      responses:
        '200':
          description: Cualquier `2xx` confirma la entrega. Cualquier otro código, o agotar el tiempo de espera, cuenta como fallo y reintenta.

  template.status_changed:
    post:
      summary: template.status_changed
      description: |
        Se dispara cuando Meta cambia el `status` de una plantilla del
        espacio de trabajo (aprobada, rechazada, pausada o
        deshabilitada). No se dispara por otros cambios que Meta también
        reporta (por ejemplo, un `quality_rating` que cambia sin que el
        `status` cambie).
      parameters:
        - $ref: '#/components/parameters/KharyoEventHeader'
        - $ref: '#/components/parameters/KharyoDeliveryHeader'
        - $ref: '#/components/parameters/KharyoTimestampHeader'
        - $ref: '#/components/parameters/KharyoSignatureHeader'
      requestBody:
        content:
          application/json:
            schema:
              allOf:
                - $ref: '#/components/schemas/WebhookEnvelope'
                - type: object
                  properties:
                    event:
                      type: string
                      enum: [template.status_changed]
                    data:
                      $ref: '#/components/schemas/WebhookTemplateStatusChangedData'
      responses:
        '200':
          description: Cualquier `2xx` confirma la entrega. Cualquier otro código, o agotar el tiempo de espera, cuenta como fallo y reintenta.

  ping:
    post:
      summary: ping
      description: |
        Evento de prueba: solo se produce al llamar
        `POST /webhooks/{id}/test`. No forma parte del catálogo de eventos
        suscribibles, así que no puede aparecer en `events` al crear una
        suscripción.
      parameters:
        - $ref: '#/components/parameters/KharyoEventHeader'
        - $ref: '#/components/parameters/KharyoDeliveryHeader'
        - $ref: '#/components/parameters/KharyoTimestampHeader'
        - $ref: '#/components/parameters/KharyoSignatureHeader'
      requestBody:
        content:
          application/json:
            schema:
              allOf:
                - $ref: '#/components/schemas/WebhookEnvelope'
                - type: object
                  properties:
                    event:
                      type: string
                      enum: [ping]
                    data:
                      $ref: '#/components/schemas/WebhookPingData'
      responses:
        '200':
          description: Cualquier `2xx` confirma la entrega. Cualquier otro código, o agotar el tiempo de espera, cuenta como fallo y reintenta.
