openapi: 3.1.0
info:
  title: WABA API de Whapi
  version: 1.0.0
  description: |
    Envía mensajes de WhatsApp desde tu sistema por las líneas de **WhatsApp Business API (WABA)** conectadas a tu cuenta de Whapi: plantillas aprobadas por Meta, texto y archivos dentro de la ventana de 24 horas.

    Cada mensaje enviado queda registrado en Whapi (contacto, ticket y lead), igual que si lo hubiera enviado un agente.

    - **Guías, autenticación y errores:** [whapi.chat/desarrolladores](https://www.whapi.chat/desarrolladores)
    - **Autenticación:** `Authorization: Bearer <clave de API>`. Las claves se crean en *Configuración de la empresa → Claves de API*.
    - **Requisito:** al menos un número WABA activo. Sin él, todas las operaciones responden `403 WABA_NOT_CONNECTED`.

    > **"Test Request" ejecuta llamadas reales** con la clave que ingreses. `POST /messages/send` envía un WhatsApp de verdad y Meta lo cobra.
  contact:
    name: Soporte de Whapi
    url: https://www.whapi.chat/contacto
servers:
  - url: https://api.whapi.chat/waba-api
    description: Producción
security:
  - apiKey: []
tags:
  - name: Números
    description: Líneas WABA activas de tu cuenta. Su `id` es el `wabaPhoneNumberId` que usan las demás operaciones.
  - name: Plantillas
    description: |
      Plantillas de mensaje de Meta. Son obligatorias para escribir a alguien que no te escribió en las últimas 24 horas.

      Una plantilla nueva queda en `PENDING` hasta que Meta la revisa. Consúltala con `GET /templates/{name}` hasta que pase a `APPROVED` o `REJECTED`, o suscríbete al webhook `waba.template.status`.
  - name: Mensajes
    description: |
      Un request = un WhatsApp a una persona. Para enviar en lote, haz un `POST /messages/send` por contacto.

      - **Plantilla:** abre la conversación sin que el cliente haya escrito antes.
      - **Texto o archivo:** solo si el cliente te escribió en las últimas 24 horas (ventana de sesión de Meta).
  - name: Media
    description: Descarga de archivos que los clientes envían a tus líneas WABA.
paths:
  /phone-numbers:
    get:
      operationId: listWabaPhoneNumbers
      tags: [Números]
      summary: Listar números
      description: Devuelve los números WABA activos de la cuenta dueña de la clave, ordenados por `id`.
      responses:
        "200":
          description: Números activos.
          headers:
            X-Whapi-Api-Key-Expires-At:
              $ref: "#/components/headers/ApiKeyExpiresAt"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PhoneNumberList"
              example:
                phoneNumbers:
                  - id: 3
                    platformId: "106540352242922"
                    displayPhoneNumber: "+51 1 7000000"
                    verifiedName: Mi Empresa
                    qualityRating: GREEN
                    active: true
                    cloudApiRegistered: true
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/WabaNotConnected"
        "429":
          $ref: "#/components/responses/RateLimited"
  /templates:
    get:
      operationId: listWabaTemplates
      tags: [Plantillas]
      summary: Listar plantillas
      description: |
        Plantillas de la cuenta de WhatsApp Business de la línea. Por defecto solo las `APPROVED`.

        Usa `name`, `language` y `preview.body` para armar un selector, y `variables` para saber qué datos pedir antes de enviar.
      parameters:
        - $ref: "#/components/parameters/WabaPhoneNumberIdQuery"
        - name: status
          in: query
          description: Estados a incluir, separados por coma (`APPROVED,PENDING`). `all` devuelve todas. Por defecto `APPROVED`.
          schema:
            type: string
            default: APPROVED
            examples: [APPROVED, "APPROVED,PENDING", all]
      responses:
        "200":
          description: Plantillas.
          headers:
            X-Whapi-Api-Key-Expires-At:
              $ref: "#/components/headers/ApiKeyExpiresAt"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/TemplateList"
              examples:
                approved:
                  $ref: "#/components/examples/TemplateListExample"
        "400":
          $ref: "#/components/responses/WabaPhoneError"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/WabaNotConnected"
        "404":
          $ref: "#/components/responses/WabaPhoneNotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
    post:
      operationId: createWabaTemplate
      tags: [Plantillas]
      summary: Crear plantilla
      description: |
        Envía la plantilla a revisión de Meta. Responde `201` con `status: PENDING` (o el estado que devuelva Meta).

        **Dos formas de describirla:**
        - **Simple:** `body`, y opcionalmente `header`, `footer`, `buttons` y `examples`. Las variables van entre llaves dobles: por nombre (`{{correo}}`) o por posición (`{{1}}`), sin mezclar.
        - **Formato de Meta:** `components` tal como los define la Graph API. Obligatorio para plantillas `AUTHENTICATION`.

        **Encabezado con imagen, video o PDF:** envía el request como `multipart/form-data` con el archivo de ejemplo en `headerMedia`. Ese archivo también queda como **archivo predeterminado** del encabezado (ver `useDefaultHeaderMedia` en el envío).
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/CreateTemplateRequest"
            examples:
              named:
                summary: Variables por nombre
                value:
                  wabaPhoneNumberId: 3
                  name: acceso_postulante
                  language: es
                  category: UTILITY
                  body: Hola, tu correo es {{correo_postulante}} y tu contraseña temporal es {{contrasena_postulante}}.
                  examples:
                    correo_postulante: ana@correo.com
                    contrasena_postulante: Temp1234
              positional:
                summary: Variables por posición
                value:
                  name: recordatorio_cita
                  language: es
                  category: UTILITY
                  header:
                    type: TEXT
                    text: Recordatorio
                  body: Hola {{1}}, te esperamos el {{2}}.
                  footer: Responde STOP para no recibir más avisos.
                  examples: [Ana, lunes 10 a las 15:00]
          multipart/form-data:
            schema:
              $ref: "#/components/schemas/CreateTemplateMultipartRequest"
            encoding:
              headerMedia:
                contentType: image/jpeg, image/png, video/mp4, application/pdf
      responses:
        "201":
          description: Plantilla enviada a revisión.
          headers:
            X-Whapi-Api-Key-Expires-At:
              $ref: "#/components/headers/ApiKeyExpiresAt"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CreateTemplateResponse"
              example:
                id: "1234567890"
                name: acceso_postulante
                language: es
                status: PENDING
                category: UTILITY
                template: null
        "400":
          description: |
            Datos inválidos o rechazados por Meta. Códigos:

            | `code` | Causa |
            |---|---|
            | `waba_template_name_invalid` | El nombre no es minúsculas, números y `_`. |
            | `waba_template_language_required` | Falta `language`. |
            | `waba_template_category_invalid` | `category` no es `UTILITY`, `MARKETING` ni `AUTHENTICATION`. |
            | `waba_template_body_required` | Sin `components`, falta `body`. |
            | `waba_template_placeholder_mix` | El `body` mezcla `{{nombre}}` y `{{1}}`. |
            | `waba_template_examples_required` | Falta un ejemplo para alguna variable (`missing` lista cuáles). |
            | `waba_template_examples_invalid` | `examples` no tiene la forma esperada. |
            | `waba_template_header_unsupported` | `header.type` no es `TEXT`, `IMAGE`, `VIDEO` ni `DOCUMENT`. |
            | `waba_template_header_handle_required` | Encabezado multimedia sin archivo (`headerMedia`). |
            | `waba_template_header_file_vs_text` | Se adjuntó un archivo para un encabezado de texto. |
            | `waba_template_header_media_type` / `_size` / `_mismatch` | El archivo no es del tipo o tamaño admitido para el encabezado. |
            | `waba_template_auth_components_required` | Las plantillas `AUTHENTICATION` necesitan `components`. |
            | `waba_phone_id_required` | La cuenta tiene varias líneas y no se indicó `wabaPhoneNumberId`. |
            | `waba_phone_inactive` | La línea indicada no está activa. |
            | `waba_send_failed` | Meta rechazó la plantilla (`graphCode` trae el código de Meta). |
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
              example:
                error: "examples missing for named variables: contrasena_postulante"
                code: waba_template_examples_required
                missing: [contrasena_postulante]
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/WabaNotConnected"
        "404":
          $ref: "#/components/responses/WabaPhoneNotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
        "502":
          $ref: "#/components/responses/MetaUnavailable"
  /templates/{name}:
    parameters:
      - $ref: "#/components/parameters/TemplateName"
    get:
      operationId: getWabaTemplate
      tags: [Plantillas]
      summary: Ver plantilla
      description: |
        Devuelve la plantilla en todos sus estados (no filtra por `APPROVED`). Si existe en varios idiomas y no indicas `language`, vienen todas.

        Útil para consultar el estado después de crearla.
      parameters:
        - name: language
          in: query
          description: Código de idioma (`es`, `es_PE`). Sin él, devuelve todas las traducciones.
          schema:
            type: string
        - $ref: "#/components/parameters/WabaPhoneNumberIdQuery"
      responses:
        "200":
          description: Traducciones de la plantilla.
          headers:
            X-Whapi-Api-Key-Expires-At:
              $ref: "#/components/headers/ApiKeyExpiresAt"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/TemplateList"
              examples:
                approved:
                  $ref: "#/components/examples/TemplateListExample"
        "400":
          $ref: "#/components/responses/WabaPhoneError"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/WabaNotConnected"
        "404":
          description: "`waba_template_not_found`: no hay una plantilla con ese nombre (e idioma). `waba_phone_not_found`: el `wabaPhoneNumberId` no existe."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
              example:
                error: Template not found
                code: waba_template_not_found
        "429":
          $ref: "#/components/responses/RateLimited"
    delete:
      operationId: deleteWabaTemplate
      tags: [Plantillas]
      summary: Eliminar plantilla
      description: Elimina la plantilla en Meta, **en todos sus idiomas**, y su archivo predeterminado de encabezado. No se puede deshacer.
      parameters:
        - $ref: "#/components/parameters/WabaPhoneNumberIdQuery"
      responses:
        "200":
          description: Plantilla eliminada.
          headers:
            X-Whapi-Api-Key-Expires-At:
              $ref: "#/components/headers/ApiKeyExpiresAt"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DeleteTemplateResponse"
              example:
                success: true
                name: acceso_postulante
        "400":
          $ref: "#/components/responses/WabaGraphError"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/WabaNotConnected"
        "404":
          $ref: "#/components/responses/WabaPhoneNotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
  /templates/{name}/header-media:
    parameters:
      - $ref: "#/components/parameters/TemplateName"
    put:
      operationId: updateWabaTemplateHeaderMedia
      tags: [Plantillas]
      summary: Cambiar archivo predeterminado del encabezado
      description: |
        Reemplaza el archivo que se usa cuando envías la plantilla con `useDefaultHeaderMedia: true`. Solo para plantillas con encabezado de imagen, video o PDF.

        No cambia la plantilla en Meta: el ejemplo aprobado sigue siendo el mismo.
      parameters:
        - name: language
          in: query
          description: Idioma de la plantilla. Obligatorio (también se acepta en el body).
          schema:
            type: string
        - $ref: "#/components/parameters/WabaPhoneNumberIdQuery"
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required: [headerMedia]
              properties:
                headerMedia:
                  type: string
                  format: binary
                  description: Imagen, video o PDF, según el formato del encabezado.
                language:
                  type: string
                  description: Alternativa al parámetro `language` de la query.
                wabaPhoneNumberId:
                  type: integer
                  description: Alternativa al parámetro de la query.
      responses:
        "200":
          description: Archivo guardado.
          headers:
            X-Whapi-Api-Key-Expires-At:
              $ref: "#/components/headers/ApiKeyExpiresAt"
          content:
            application/json:
              schema:
                type: object
                additionalProperties: false
                required: [headerMedia]
                properties:
                  headerMedia:
                    $ref: "#/components/schemas/TemplateHeaderMedia"
              example:
                headerMedia:
                  fileReferenceId: 8f14e45f-ceea-4e7a-9c1b-2a3d4e5f6a7b
                  url: https://api.whapi.chat/public/admin/cotizacion.pdf
                  mimeType: application/pdf
                  originalName: cotizacion.pdf
                  mediaType: document
        "400":
          description: "`waba_template_header_media_required` (falta el archivo), `waba_template_language_required`, `waba_template_header_media_type` / `_size` / `_mismatch` (archivo no admitido)."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/WabaNotConnected"
        "404":
          description: "`waba_template_not_found`, o `waba_template_header_not_media` si el encabezado no es de imagen, video o PDF."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "429":
          $ref: "#/components/responses/RateLimited"
        "502":
          $ref: "#/components/responses/MetaUnavailable"
  /messages/send:
    post:
      operationId: sendWabaMessage
      tags: [Mensajes]
      summary: Enviar mensaje
      description: |
        Envía **un** WhatsApp a `number`: una plantilla, un texto o un archivo.

        - Con `template`: no necesita ventana de 24 horas. La plantilla tiene que estar `APPROVED`.
        - Sin `template`: `body` y/o un archivo en `medias` (multipart). Solo si el cliente escribió en las últimas 24 horas; si no, `403 waba_session_window_expired`.

        **Plantillas con encabezado de imagen, video o PDF:** adjunta el archivo en `medias` (multipart), mándalo en `template.components`, o envía `useDefaultHeaderMedia: true` para usar el archivo predeterminado. Si no hay ninguno, responde `400 waba_template_header_media_missing` y `hasDefault` indica si existe un predeterminado.

        Si el número no existe en Whapi, se crean el contacto, el ticket y el lead. Los cambios de estado del mensaje (`sent`, `delivered`, `read`, `failed`) llegan por el webhook `waba.message.status`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/SendMessageRequest"
            examples:
              namedTemplate:
                summary: Plantilla con variables por nombre
                value:
                  wabaPhoneNumberId: 3
                  number: "51987654321"
                  template:
                    name: acceso_postulante
                    language: es
                    components:
                      - type: body
                        parameters:
                          - type: text
                            parameter_name: correo_postulante
                            text: ana@correo.com
                          - type: text
                            parameter_name: contrasena_postulante
                            text: Temp1234
              positionalTemplate:
                summary: Plantilla con variables por posición
                value:
                  wabaPhoneNumberId: 3
                  number: "51987654321"
                  template:
                    name: hola_bienvenida
                    language: es
                    components:
                      - type: body
                        parameters:
                          - type: text
                            text: Ana
              defaultHeaderMedia:
                summary: Plantilla con el archivo predeterminado del encabezado
                value:
                  number: "51987654321"
                  useDefaultHeaderMedia: true
                  template:
                    name: envio_cotizacion
                    language: es
                    components:
                      - type: body
                        parameters:
                          - type: text
                            text: Ana
              text:
                summary: Texto (ventana de 24 horas abierta)
                value:
                  wabaPhoneNumberId: 3
                  number: "51987654321"
                  body: Gracias por escribirnos, ¿en qué te ayudamos?
          multipart/form-data:
            schema:
              $ref: "#/components/schemas/SendMessageMultipartRequest"
      responses:
        "200":
          description: Meta aceptó el mensaje. La entrega se confirma después por `waba.message.status`.
          headers:
            X-Whapi-Api-Key-Expires-At:
              $ref: "#/components/headers/ApiKeyExpiresAt"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/SendMessageResponse"
              example:
                wamid: wamid.HBgMNTE5ODc2NTQzMjEVAgASGBQzQUYxRjQ
                ticketId: 42
                contactId: 18
                leadId: 9
                wabaPhoneNumberId: 3
                mode: template
                headerMediaSource: null
        "400":
          description: |
            | `code` | Causa |
            |---|---|
            | `INVALID_REQUEST` | Falta `number`, no es un teléfono válido, o no hay `template`, `body` ni archivo. |
            | `waba_phone_id_required` | La cuenta tiene varias líneas y no se indicó `wabaPhoneNumberId`. |
            | `waba_phone_inactive` | La línea indicada no está activa. |
            | `waba_template_invalid` / `waba_template_name_required` / `waba_template_language_required` | `template` mal armado. |
            | `waba_template_header_media_missing` | La plantilla lleva archivo en el encabezado y no se envió (`hasDefault`, `headerFormat`). |
            | `waba_template_header_media_mismatch` / `_required` | El archivo no coincide con el encabezado, o no hay predeterminado. |
            | `waba_use_default_header_media_invalid` | `useDefaultHeaderMedia` no es `true` ni `false`. |
            | `waba_template_not_found` | Meta no encuentra la plantilla aprobada en ese idioma. |
            | `waba_template_params_invalid` | Los parámetros no coinciden con la plantilla aprobada. |
            | `waba_invalid_recipient` | El número no tiene WhatsApp o no se le puede entregar. |
            | `waba_send_failed` | Otro error de Meta (`graphCode`, `graphSubcode`). |
            | `ERR_WABA_TOKEN_NOT_FOUND` | La línea no tiene un token de Meta vigente. |
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
              example:
                error: This template requires a header file. Attach it in `medias`, or send useDefaultHeaderMedia=true to send the template's default file.
                code: waba_template_header_media_missing
                headerFormat: DOCUMENT
                hasDefault: true
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: "`waba_session_window_expired`: texto o archivo fuera de la ventana de 24 horas (usa una plantilla). `waba_template_paused`: la plantilla está pausada en Meta. `WABA_NOT_CONNECTED`: no hay líneas activas."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
              example:
                error: WABA session window expired. The recipient must message first within 24 hours, or use a message template.
                code: waba_session_window_expired
        "404":
          $ref: "#/components/responses/WabaPhoneNotFound"
        "409":
          description: "`waba_ambiguous_contact`: hay varios contactos con ese teléfono en Whapi."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "429":
          description: "`RATE_LIMITED`: superaste el límite de Whapi por minuto. `waba_rate_limited`: Meta limitó los envíos de la línea; reintenta más tarde."
          headers:
            Retry-After:
              $ref: "#/components/headers/RetryAfter"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "502":
          description: "`waba_send_no_wamid`: Meta aceptó el request pero no devolvió el id del mensaje."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
  /media/{mediaId}:
    get:
      operationId: downloadWabaMedia
      tags: [Media]
      summary: Descargar archivo recibido
      description: |
        Descarga un archivo que un cliente envió a una de tus líneas. El `mediaId` llega en `data.media.id` del webhook `waba.message.received`.

        Solo sirve archivos que llegaron a líneas de tu cuenta. Meta los conserva unos 30 días; después responde `404 waba_media_expired`. Tamaño máximo: 100 MB.
      parameters:
        - name: mediaId
          in: path
          required: true
          description: Id del archivo en Meta (solo dígitos).
          schema:
            type: string
            pattern: "^\\d{1,40}$"
          example: "1234567890123456"
        - $ref: "#/components/parameters/WabaPhoneNumberIdQuery"
      responses:
        "200":
          description: El archivo, con su tipo de contenido original.
          headers:
            Content-Disposition:
              description: "`attachment` con el nombre original del archivo."
              schema:
                type: string
            X-Whapi-Waba-Phone-Number-Id:
              description: Línea por la que llegó el archivo.
              schema:
                type: integer
            X-Whapi-Media-Sha256:
              description: Hash SHA-256 informado por Meta, si existe.
              schema:
                type: string
            X-Whapi-Api-Key-Expires-At:
              $ref: "#/components/headers/ApiKeyExpiresAt"
          content:
            application/octet-stream:
              schema:
                type: string
                format: binary
        "400":
          description: "`waba_media_invalid_id`: el `mediaId` no es numérico."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/WabaNotConnected"
        "404":
          description: "`waba_media_not_found`: el archivo no llegó a una línea de tu cuenta. `waba_media_expired`: Meta ya no lo conserva."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "409":
          description: "`waba_media_no_token`: la línea no tiene un token de Meta vigente."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "413":
          description: "`waba_media_too_large`: el archivo supera el tamaño máximo."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "429":
          $ref: "#/components/responses/RateLimited"
        "502":
          description: "`waba_media_graph_error`: no se pudo consultar el archivo en Meta."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
webhooks:
  waba.message.received:
    post:
      operationId: webhookWabaMessageReceived
      summary: Mensaje recibido
      description: |
        Un cliente escribió a una de tus líneas WABA: texto, respuesta a un botón o lista, archivo o ubicación.

        Para descargar un archivo, usa `data.media.id` con `GET /media/{mediaId}`. Cómo verificar la firma y qué responder: [guía de webhooks](https://www.whapi.chat/desarrolladores/webhooks).
      parameters:
        - $ref: "#/components/parameters/WebhookEventId"
        - $ref: "#/components/parameters/WebhookEventType"
        - $ref: "#/components/parameters/WebhookTimestamp"
        - $ref: "#/components/parameters/WebhookSignature"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/WabaMessageReceivedEvent"
            example:
              apiVersion: "2026-06-15"
              id: 3b241101-e2bb-4255-8caf-4136c566a962
              type: waba.message.received
              createdAt: "2026-10-14T15:04:05.000Z"
              data:
                wabaPhoneNumberId: 3
                phoneNumberId: "106540352242922"
                displayPhoneNumber: "+51 1 7000000"
                from: "51987654321"
                fromUserId: null
                contactName: Ana Pérez
                messageId: wamid.HBgLNTE5ODc2NTQzMjEVAgASGBQzQUYxRjQ
                timestamp: 1760454245
                type: text
                text: Hola, quiero una factura por 2 cajas
                reply: null
                location: null
                media: null
                context: null
                ticketId: 42
                contactId: 18
      responses:
        "200":
          $ref: "#/components/responses/WebhookAck"
  waba.message.status:
    post:
      operationId: webhookWabaMessageStatus
      summary: Estado de un mensaje enviado
      description: |
        Cambió el estado de un mensaje que enviaste con `POST /messages/send`: `sent`, `delivered`, `read` o `failed`. Relaciónalo por `data.messageId` (el `wamid` de la respuesta del envío).

        Si falló, `data.errors` trae el detalle de Meta.
      parameters:
        - $ref: "#/components/parameters/WebhookEventId"
        - $ref: "#/components/parameters/WebhookEventType"
        - $ref: "#/components/parameters/WebhookTimestamp"
        - $ref: "#/components/parameters/WebhookSignature"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/WabaMessageStatusEvent"
            example:
              apiVersion: "2026-06-15"
              id: 9c1e2f4a-7b3d-4c5e-8f6a-1b2c3d4e5f60
              type: waba.message.status
              createdAt: "2026-10-14T15:04:09.000Z"
              data:
                wabaPhoneNumberId: 3
                messageId: wamid.HBgMNTE5ODc2NTQzMjEVAgASGBQzQUYxRjQ
                status: delivered
                timestamp: 1760454249
                recipient: "51987654321"
                recipientUserId: null
                ticketId: 42
                errors: null
      responses:
        "200":
          $ref: "#/components/responses/WebhookAck"
  waba.template.status:
    post:
      operationId: webhookWabaTemplateStatus
      summary: Estado de una plantilla
      description: Meta aprobó, rechazó o pausó una plantilla. `data.reason` explica el rechazo cuando Meta lo informa.
      parameters:
        - $ref: "#/components/parameters/WebhookEventId"
        - $ref: "#/components/parameters/WebhookEventType"
        - $ref: "#/components/parameters/WebhookTimestamp"
        - $ref: "#/components/parameters/WebhookSignature"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/WabaTemplateStatusEvent"
            example:
              apiVersion: "2026-06-15"
              id: 5d7e8f9a-0b1c-4d2e-9f3a-4b5c6d7e8f90
              type: waba.template.status
              createdAt: "2026-10-14T16:00:00.000Z"
              data:
                event: APPROVED
                name: acceso_postulante
                language: es
                status: APPROVED
                reason: null
                templateId: "1234567890"
                category: UTILITY
                wabaId: 1
                wabaPlatformId: "1087808567539124"
      responses:
        "200":
          $ref: "#/components/responses/WebhookAck"
components:
  securitySchemes:
    apiKey:
      type: http
      scheme: bearer
      bearerFormat: whk_…
      description: Clave de API de la empresa. Se crea en *Configuración de la empresa → Claves de API* y se muestra una sola vez.
  headers:
    ApiKeyExpiresAt:
      description: Solo si la clave fue regenerada y está en su período de gracia. Fecha (ISO 8601) en que deja de funcionar.
      schema:
        type: string
        format: date-time
    RetryAfter:
      description: Segundos a esperar antes de reintentar.
      schema:
        type: integer
  parameters:
    WabaPhoneNumberIdQuery:
      name: wabaPhoneNumberId
      in: query
      description: Línea a usar (`id` de `GET /phone-numbers`). Obligatorio si la cuenta tiene más de una línea activa.
      schema:
        type: integer
        minimum: 1
    TemplateName:
      name: name
      in: path
      required: true
      description: Nombre de la plantilla (minúsculas, números y `_`).
      schema:
        type: string
      example: acceso_postulante
    WebhookEventId:
      name: X-Whapi-Event-Id
      in: header
      required: true
      description: Id único del evento (el mismo que `id` en el body). Úsalo para descartar reintentos repetidos.
      schema:
        type: string
        format: uuid
    WebhookEventType:
      name: X-Whapi-Event-Type
      in: header
      required: true
      description: Tipo de evento (el mismo que `type` en el body).
      schema:
        type: string
    WebhookTimestamp:
      name: X-Whapi-Timestamp
      in: header
      required: true
      description: Momento del envío, en segundos Unix. Forma parte de la firma.
      schema:
        type: string
    WebhookSignature:
      name: X-Whapi-Signature
      in: header
      required: true
      description: '`sha256=` + HMAC-SHA256 en hexadecimal de `"{X-Whapi-Timestamp}.{body crudo}"`, con el secreto de firma de tu endpoint.'
      schema:
        type: string
  responses:
    Unauthorized:
      description: |
        Clave ausente o no válida.

        | `code` | Causa |
        |---|---|
        | `AUTH_HEADER_MISSING` | Falta `Authorization: Bearer <clave>`. |
        | `API_KEY_INVALID` | La clave no existe. |
        | `API_KEY_REVOKED` | La clave fue revocada. |
        | `API_KEY_EXPIRED` | La clave fue regenerada y terminó su período de gracia (`expiredAt`). |
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
          example:
            error: Company not found for the provided API key
            code: API_KEY_INVALID
    WabaNotConnected:
      description: "`WABA_NOT_CONNECTED`: la cuenta no tiene ninguna línea WABA activa."
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
          example:
            error: Waba API requires at least one active WABA connection
            code: WABA_NOT_CONNECTED
    WabaPhoneError:
      description: "`waba_phone_id_required`: la cuenta tiene varias líneas y no se indicó `wabaPhoneNumberId`. `waba_phone_inactive`: la línea no está activa. `ERR_WABA_TOKEN_NOT_FOUND`: la línea no tiene un token de Meta vigente."
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
          example:
            error: wabaPhoneNumberId is required when the tenant has more than one WABA connection
            code: waba_phone_id_required
    WabaGraphError:
      description: "Error de línea (`waba_phone_id_required`, `waba_phone_inactive`) o rechazo de Meta (`waba_send_failed`, con `graphCode`)."
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
    WabaPhoneNotFound:
      description: "`waba_phone_not_found`: el `wabaPhoneNumberId` no existe en tu cuenta."
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
          example:
            error: WABA phone number not found
            code: waba_phone_not_found
    RateLimited:
      description: "`RATE_LIMITED`: superaste el límite de requests por minuto de tu cuenta."
      headers:
        Retry-After:
          $ref: "#/components/headers/RetryAfter"
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
          example:
            error: Too many requests
            code: RATE_LIMITED
            limitPerMinute: 300
    MetaUnavailable:
      description: Meta no respondió como se esperaba al subir o guardar el archivo (`waba_template_upload_session`, `waba_template_upload_handle`, `waba_template_header_default_persist`).
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
    WebhookAck:
      description: Responde cualquier 2xx en menos de 15 segundos. Un `408`, `429`, `5xx`, un timeout o un error de red se reintentan (hasta 5 intentos en total, con esperas crecientes desde 1 minuto). Cualquier otro `4xx` se da por fallido y no se reintenta.
  examples:
    TemplateListExample:
      summary: Plantilla aprobada
      value:
        templates:
          - id: "1234567890"
            name: acceso_postulante
            language: es
            status: APPROVED
            category: UTILITY
            qualityScore: null
            components:
              - type: BODY
                text: Hola, tu correo es {{correo_postulante}} y tu contraseña temporal es {{contrasena_postulante}}.
            preview:
              header: null
              body: Hola, tu correo es {{correo_postulante}} y tu contraseña temporal es {{contrasena_postulante}}.
              footer: null
              buttons: []
            variables:
              headerFormat: null
              header: []
              body: [correo_postulante, contrasena_postulante]
              buttons: []
            headerMedia: null
  schemas:
    Error:
      type: object
      required: [error, code]
      description: Forma de todos los errores. Algunos códigos agregan campos (`hasDefault`, `headerFormat`, `missing`, `graphCode`, `graphSubcode`, `expiredAt`, `limitPerMinute`).
      properties:
        error:
          type: string
          description: Mensaje legible. Puede cambiar; para decidir, usa `code`.
        code:
          type: string
          description: Código estable del error.
      additionalProperties: true
    PhoneNumber:
      type: object
      additionalProperties: false
      required: [id, platformId, displayPhoneNumber, verifiedName, qualityRating, active, cloudApiRegistered]
      properties:
        id:
          type: integer
          description: Id de la línea en Whapi. Es el `wabaPhoneNumberId` de las demás operaciones.
        platformId:
          type: string
          description: '`phone_number_id` de Meta.'
        displayPhoneNumber:
          type: [string, "null"]
        verifiedName:
          type: [string, "null"]
          description: Nombre visible aprobado por Meta.
        qualityRating:
          type: [string, "null"]
          description: Calificación de calidad de Meta (`GREEN`, `YELLOW`, `RED`).
        active:
          type: boolean
        cloudApiRegistered:
          type: boolean
    PhoneNumberList:
      type: object
      additionalProperties: false
      required: [phoneNumbers]
      properties:
        phoneNumbers:
          type: array
          items:
            $ref: "#/components/schemas/PhoneNumber"
    TemplatePreview:
      type: object
      additionalProperties: false
      required: [header, body, footer, buttons]
      description: Textos de la plantilla, con las variables sin reemplazar.
      properties:
        header:
          type: [string, "null"]
        body:
          type: [string, "null"]
        footer:
          type: [string, "null"]
        buttons:
          type: array
          items:
            type: string
    TemplateVariables:
      type: object
      additionalProperties: false
      required: [headerFormat, header, body, buttons]
      description: Lo que hay que completar al enviar la plantilla.
      properties:
        headerFormat:
          type: [string, "null"]
          description: '`TEXT`, `IMAGE`, `VIDEO`, `DOCUMENT`, `LOCATION`, o `null` si no tiene encabezado.'
        header:
          type: array
          description: Variables del encabezado de texto, en orden.
          items:
            type: string
        body:
          type: array
          description: Variables del cuerpo, en orden (`"1"`, `"2"` o el nombre).
          items:
            type: string
        buttons:
          type: array
          items:
            type: object
            additionalProperties: false
            required: [index, type, text, param]
            properties:
              index:
                type: integer
              type:
                type: string
              text:
                type: string
              param:
                type: [string, "null"]
                description: Variable del sufijo de un botón URL dinámico.
    TemplateHeaderMedia:
      type: object
      additionalProperties: false
      required: [fileReferenceId, url, mimeType, originalName, mediaType]
      description: Archivo predeterminado del encabezado.
      properties:
        fileReferenceId:
          type: string
        url:
          type: [string, "null"]
        mimeType:
          type: [string, "null"]
        originalName:
          type: [string, "null"]
        mediaType:
          type: [string, "null"]
          enum: [image, video, document, null]
    Template:
      type: object
      additionalProperties: false
      required: [id, name, language, status, category, qualityScore, components, preview, variables, headerMedia]
      properties:
        id:
          type: [string, "null"]
          description: Id de la plantilla en Meta.
        name:
          type: string
        language:
          type: string
        status:
          type: string
          description: Estado en Meta (`APPROVED`, `PENDING`, `REJECTED`, `PAUSED`, `DISABLED`…).
        category:
          type: [string, "null"]
        qualityScore:
          type: [string, "null"]
        components:
          type: array
          description: Componentes tal como los devuelve la Graph API de Meta.
          items:
            type: object
        preview:
          $ref: "#/components/schemas/TemplatePreview"
        variables:
          $ref: "#/components/schemas/TemplateVariables"
        headerMedia:
          oneOf:
            - $ref: "#/components/schemas/TemplateHeaderMedia"
            - type: "null"
    TemplateList:
      type: object
      additionalProperties: false
      required: [templates]
      properties:
        templates:
          type: array
          items:
            $ref: "#/components/schemas/Template"
    CreateTemplateRequest:
      type: object
      required: [name, language]
      properties:
        name:
          type: string
          description: Minúsculas, números y `_`. Los espacios se convierten en `_`.
          examples: [acceso_postulante]
        language:
          type: string
          description: Código de idioma de Meta.
          examples: [es, es_PE, en_US]
        category:
          type: string
          enum: [UTILITY, MARKETING, AUTHENTICATION]
          default: UTILITY
        allowCategoryChange:
          type: boolean
          default: true
          description: Permite que Meta cambie la categoría si no coincide con el contenido.
        wabaPhoneNumberId:
          type: integer
          description: Obligatorio si la cuenta tiene más de una línea activa.
        body:
          type: string
          description: Texto del cuerpo. Obligatorio si no se envía `components`.
        header:
          type: object
          description: Encabezado opcional. Para `IMAGE`, `VIDEO` o `DOCUMENT`, adjunta el archivo de ejemplo en `headerMedia` (multipart).
          properties:
            type:
              type: string
              enum: [TEXT, IMAGE, VIDEO, DOCUMENT]
              default: TEXT
            text:
              type: string
              description: Obligatorio para `TEXT`.
        footer:
          type: string
        buttons:
          type: array
          description: Botones en el formato de la Graph API (`QUICK_REPLY`, `URL`, `PHONE_NUMBER`…).
          items:
            type: object
            required: [type]
            properties:
              type:
                type: string
        examples:
          description: 'Ejemplos de las variables del cuerpo, obligatorios para que Meta revise la plantilla. Un objeto `{ nombre: ejemplo }` si son por nombre; una lista en orden si son por posición.'
          oneOf:
            - type: object
              additionalProperties:
                type: string
            - type: array
              items:
                type: string
        parameterFormat:
          type: string
          enum: [NAMED, POSITIONAL]
          description: Se deduce del `body`. Solo hace falta con `components`.
        components:
          type: array
          description: Alternativa a `body`/`header`/`footer`/`buttons`, en el formato de la Graph API.
          items:
            type: object
    CreateTemplateMultipartRequest:
      type: object
      required: [name, language]
      description: Los mismos campos que en JSON. `header`, `examples`, `buttons` y `components` van como texto JSON.
      properties:
        name:
          type: string
        language:
          type: string
        category:
          type: string
          enum: [UTILITY, MARKETING, AUTHENTICATION]
        wabaPhoneNumberId:
          type: integer
        body:
          type: string
        header:
          type: string
          description: 'JSON, por ejemplo `{"type":"DOCUMENT"}`. Si se omite, el tipo sale del archivo.'
        footer:
          type: string
        examples:
          type: string
          description: JSON del objeto o la lista de ejemplos.
        buttons:
          type: string
          description: JSON de la lista de botones.
        headerMedia:
          type: string
          format: binary
          description: Archivo de ejemplo del encabezado (imagen, video o PDF). Queda como archivo predeterminado.
    TemplateToSend:
      type: object
      required: [name, language]
      properties:
        name:
          type: string
        language:
          description: 'Código de idioma, como texto o como `{ "code": "es" }`.'
          oneOf:
            - type: string
            - type: object
              required: [code]
              properties:
                code:
                  type: string
        components:
          type: array
          description: Parámetros en el formato de la Graph API. Para variables por nombre, cada parámetro lleva `parameter_name`.
          items:
            type: object
            required: [type]
            properties:
              type:
                type: string
                description: '`header`, `body` o `button`.'
              parameters:
                type: array
                items:
                  type: object
    SendMessageRequest:
      type: object
      required: [number]
      properties:
        number:
          type: string
          description: Teléfono del destinatario con código de país, solo dígitos.
          examples: ["51987654321"]
        wabaPhoneNumberId:
          type: integer
          description: Línea que envía. Obligatorio si la cuenta tiene más de una línea activa.
        template:
          $ref: "#/components/schemas/TemplateToSend"
        body:
          type: string
          description: Texto libre (ventana de 24 horas). También se acepta como `message`.
        useDefaultHeaderMedia:
          type: boolean
          default: false
          description: Si la plantilla lleva archivo en el encabezado y no se envía ninguno, usa el archivo predeterminado.
    SendMessageMultipartRequest:
      type: object
      required: [number]
      description: Para adjuntar un archivo (al encabezado de una plantilla o como mensaje dentro de la ventana de 24 horas).
      properties:
        number:
          type: string
        wabaPhoneNumberId:
          type: integer
        template:
          type: string
          description: El objeto `template` como texto JSON.
        body:
          type: string
          description: Texto que acompaña al archivo.
        useDefaultHeaderMedia:
          type: string
          enum: ["true", "false"]
        medias:
          type: string
          format: binary
          description: El archivo. Se usa solo el primero.
    SendMessageResponse:
      type: object
      additionalProperties: false
      required: [wamid, ticketId, contactId, leadId, wabaPhoneNumberId, mode, headerMediaSource]
      properties:
        wamid:
          type: string
          description: Id del mensaje en Meta. Llega como `messageId` en `waba.message.status`.
        ticketId:
          type: integer
        contactId:
          type: integer
        leadId:
          type: [integer, "null"]
        wabaPhoneNumberId:
          type: integer
        mode:
          type: string
          enum: [template, session]
          description: '`template` si se envió una plantilla; `session` para texto o archivo.'
        headerMediaSource:
          type: [string, "null"]
          enum: [request, components, default, null]
          description: De dónde salió el archivo del encabezado de la plantilla, si lo tiene.
    CreateTemplateResponse:
      type: object
      additionalProperties: false
      required: [id, name, language, status, category, template]
      properties:
        id:
          type: [string, "null"]
        name:
          type: string
        language:
          type: string
        status:
          type: string
          description: Normalmente `PENDING`.
        category:
          type: [string, "null"]
        template:
          description: La plantilla tal como quedó en Meta, si ya se pudo leer.
          oneOf:
            - $ref: "#/components/schemas/Template"
            - type: "null"
    DeleteTemplateResponse:
      type: object
      additionalProperties: false
      required: [success, name]
      properties:
        success:
          type: boolean
        name:
          type: string
    WebhookEnvelope:
      type: object
      required: [apiVersion, id, type, createdAt, data]
      properties:
        apiVersion:
          type: string
          const: "2026-06-15"
          description: Versión del formato de los eventos.
        id:
          type: string
          format: uuid
          description: Id único del evento. Los reintentos repiten el mismo id.
        type:
          type: string
        createdAt:
          type: string
          format: date-time
        data:
          type: object
    WabaMessageReceivedData:
      type: object
      additionalProperties: false
      required: [wabaPhoneNumberId, phoneNumberId, displayPhoneNumber, from, fromUserId, contactName, messageId, timestamp, type, text, reply, location, media, context, ticketId, contactId]
      properties:
        wabaPhoneNumberId:
          type: integer
          description: Línea que recibió el mensaje.
        phoneNumberId:
          type: [string, "null"]
          description: '`phone_number_id` de Meta.'
        displayPhoneNumber:
          type: [string, "null"]
        from:
          type: [string, "null"]
          description: Teléfono del cliente, solo dígitos con código de país.
        fromUserId:
          type: [string, "null"]
          description: Id de usuario de WhatsApp del cliente, cuando Meta lo informa.
        contactName:
          type: [string, "null"]
          description: Nombre de perfil de WhatsApp del cliente.
        messageId:
          type: string
        timestamp:
          type: [integer, "null"]
          description: Segundos Unix.
        type:
          type: string
          description: '`text`, `image`, `video`, `audio`, `document`, `sticker`, `location`, `button`, `interactive`…'
        text:
          type: [string, "null"]
          description: Texto, pie de un archivo o título de la opción elegida.
        reply:
          description: Respuesta a un botón o lista.
          oneOf:
            - type: object
              additionalProperties: false
              required: [kind, id, title, description]
              properties:
                kind:
                  type: string
                id:
                  type: [string, "null"]
                title:
                  type: [string, "null"]
                description:
                  type: [string, "null"]
            - type: "null"
        location:
          oneOf:
            - type: object
              additionalProperties: false
              required: [latitude, longitude, name, address]
              properties:
                latitude:
                  type: [number, "null"]
                longitude:
                  type: [number, "null"]
                name:
                  type: [string, "null"]
                address:
                  type: [string, "null"]
            - type: "null"
        media:
          description: Archivo recibido. Descárgalo con `GET /media/{media.id}`.
          oneOf:
            - type: object
              additionalProperties: false
              required: [id, mimeType, filename, sha256, fileSize]
              properties:
                id:
                  type: string
                mimeType:
                  type: [string, "null"]
                filename:
                  type: [string, "null"]
                sha256:
                  type: [string, "null"]
                fileSize:
                  type: [integer, "null"]
            - type: "null"
        context:
          description: Si el cliente respondió citando un mensaje.
          oneOf:
            - type: object
              additionalProperties: false
              required: [replyToMessageId]
              properties:
                replyToMessageId:
                  type: string
            - type: "null"
        ticketId:
          type: [integer, "null"]
        contactId:
          type: [integer, "null"]
        test:
          type: boolean
          description: Solo en el evento de prueba enviado desde Whapi.
    WabaMessageStatusData:
      type: object
      additionalProperties: false
      required: [wabaPhoneNumberId, messageId, status, timestamp, recipient, recipientUserId, ticketId, errors]
      properties:
        wabaPhoneNumberId:
          type: [integer, "null"]
        messageId:
          type: string
          description: El `wamid` devuelto por `POST /messages/send`.
        status:
          type: string
          description: '`sent`, `delivered`, `read` o `failed`.'
        timestamp:
          type: [integer, "null"]
        recipient:
          type: [string, "null"]
        recipientUserId:
          type: [string, "null"]
        ticketId:
          type: [integer, "null"]
        errors:
          description: Detalle de Meta cuando `status` es `failed`.
          oneOf:
            - type: array
              items:
                type: object
                additionalProperties: false
                required: [code, title, message, details]
                properties:
                  code:
                    type: [integer, "null"]
                  title:
                    type: [string, "null"]
                  message:
                    type: [string, "null"]
                  details:
                    type: [string, "null"]
            - type: "null"
        test:
          type: boolean
          description: Solo en el evento de prueba enviado desde Whapi.
    WabaTemplateStatusData:
      type: object
      additionalProperties: false
      required: [event, name, language, status, reason, templateId, category, wabaId, wabaPlatformId]
      properties:
        event:
          type: string
          description: Evento de Meta (`APPROVED`, `REJECTED`, `PAUSED`, `DISABLED`…).
        name:
          type: string
        language:
          type: string
        status:
          type: string
          description: Igual a `event`.
        reason:
          type: [string, "null"]
        templateId:
          type: [string, "null"]
        category:
          type: [string, "null"]
        wabaId:
          type: integer
          description: Id de la cuenta de WhatsApp Business en Whapi.
        wabaPlatformId:
          type: string
          description: Id de la cuenta de WhatsApp Business en Meta.
        test:
          type: boolean
          description: Solo en el evento de prueba enviado desde Whapi.
    WabaMessageReceivedEvent:
      allOf:
        - $ref: "#/components/schemas/WebhookEnvelope"
        - type: object
          properties:
            type:
              const: waba.message.received
            data:
              $ref: "#/components/schemas/WabaMessageReceivedData"
    WabaMessageStatusEvent:
      allOf:
        - $ref: "#/components/schemas/WebhookEnvelope"
        - type: object
          properties:
            type:
              const: waba.message.status
            data:
              $ref: "#/components/schemas/WabaMessageStatusData"
    WabaTemplateStatusEvent:
      allOf:
        - $ref: "#/components/schemas/WebhookEnvelope"
        - type: object
          properties:
            type:
              const: waba.template.status
            data:
              $ref: "#/components/schemas/WabaTemplateStatusData"
