openapi: 3.1.0

info:
  title: Botyconnect API
  version: 1.0.0
  summary: API publica de Botyconnect para empresas terceras.
  description: |
    API REST para que el backend de tu empresa conecte numeros de WhatsApp en modo
    coexistencial, envie campanas masivas, consulte creditos y administre webhooks.

    ## Autenticacion

    Toda la API (excepto `POST /access-requests`) usa una API key con formato
    `bc_live_{public_id}.{secret}` enviada como Bearer token:

    ```
    Authorization: Bearer bc_live_bck_xxxxxxxx.xxxxxxxxxxxxxxxx
    ```

    El secret se muestra una sola vez al crear o rotar la key. Cada key tiene
    scopes explicitos (documentados por operacion con `x-botyconnect-scope`)
    y puede restringirse por IPs permitidas.

    ## Idempotencia

    Las operaciones de escritura aceptan el header `Idempotency-Key`:

    - Misma key + mismo body: devuelve la respuesta original sin repetir la operacion.
    - Misma key + body distinto: responde `409 Conflict`.
    - Expiracion: 48 horas.

    ## Rate limits

    | Ambito | Limite |
    |---|---|
    | Por API key | configurable, 120 req/min por defecto |
    | Por empresa (tenant) | 1000 req/min |
    | Lecturas (GET) | 600 req/min por endpoint |
    | Escrituras | 120 req/min por endpoint |
    | Crear campana | 60 req/min |
    | Links de conexion | 20 req/min |
    | Webhook endpoints | 30 req/min |

    Al exceder un limite se responde `429 Too Many Requests`.

    ## IDs publicos

    Los recursos usan IDs opacos con prefijo: `wacc_` (numeros), `cmp_` (campanas),
    `rcp_` (destinatarios), `whend_` (webhook endpoints), `evt_` (eventos),
    `bcar_` (solicitudes de acceso), `oba_` (intentos de onboarding).
  contact:
    name: Botyconnect Developers
    url: https://developers.botyconnect.com
    email: soporte@botyconnect.com
  license:
    name: Proprietary
    url: https://botyconnect.com/terminos

servers:
  - url: https://api.botyconnect.com/api/v1/botyconnect
    description: Produccion

security:
  - BotyconnectApiKey: []

tags:
  - name: Access
    description: Solicitud publica de acceso (sin API key).
  - name: Health
    description: Verificacion de disponibilidad.
  - name: Accounts
    description: Numeros WhatsApp, conexion coexistencial y templates.
  - name: Campaigns
    description: Campanas masivas de mensajes template.
  - name: Messages
    description: Mensajes individuales (texto, multimedia e interactivos) dentro de la ventana de 24h.
  - name: Credits
    description: Saldo global de la empresa y movimientos.
  - name: Webhook Endpoints
    description: Endpoints donde Botyconnect entrega eventos firmados.

paths:
  /access-requests:
    post:
      operationId: createAccessRequest
      tags: [Access]
      summary: Solicitar acceso a la API
      description: |
        Crea una solicitud de acceso en estado `pending`. No entrega API key ni
        crea acceso directo: un admin de Botyconnect revisa y aprueba la empresa.
        Endpoint publico con rate limit estricto (5 req/min por IP+email).
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/AccessRequestInput'
      responses:
        '201':
          description: Solicitud creada en estado pending.
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                    const: true
                  message:
                    type: string
                    example: Botyconnect access request received.
                  data:
                    type: object
                    properties:
                      request_id:
                        type: string
                        example: bcar_x8k2m9p4q7w1n5r3t6y0u2i4
                      status:
                        type: string
                        const: pending
        '422':
          $ref: '#/components/responses/ValidationError'
        '429':
          $ref: '#/components/responses/RateLimited'

  /health:
    get:
      operationId: getHealth
      tags: [Health]
      summary: Health check autenticado
      responses:
        '200':
          description: Servicio disponible.
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                    const: true
                  data:
                    type: object
                    properties:
                      service:
                        type: string
                        const: botyconnect_api
                      version:
                        type: string
                        const: v1
                      status:
                        type: string
                        const: ok
        '401':
          $ref: '#/components/responses/Unauthorized'

  /accounts/coexistence-link:
    post:
      operationId: createCoexistenceLink
      tags: [Accounts]
      summary: Generar link de conexion coexistencial
      description: |
        Genera una URL de Embedded Signup de Meta para conectar un numero en modo
        coexistencial (la app de WhatsApp Business sigue funcionando). Redirige al
        usuario a `onboarding_url`; al completar, Botyconnect redirige a tu
        `redirect_url` con `?success=true&account_id=wacc_xxx&attempt_id=oba_xxx`
        y emite el webhook `account.connected`.
      x-botyconnect-scope: accounts:connect
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [redirect_url]
              properties:
                label:
                  type: string
                  maxLength: 120
                  description: Nombre interno del numero para tu operacion.
                  example: Ventas Bogota
                external_reference:
                  type: string
                  maxLength: 120
                  description: Identificador del numero en tu sistema.
                  example: sucursal-bogota
                redirect_url:
                  type: string
                  format: uri
                  maxLength: 1000
                  description: URL HTTPS de tu sistema a la que se redirige al terminar.
                  example: https://empresa.com/botyconnect/callback
      responses:
        '201':
          description: Link de onboarding generado.
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                    const: true
                  data:
                    type: object
                    properties:
                      onboarding_url:
                        type: string
                        format: uri
                        example: https://business.facebook.com/messaging/whatsapp/onboard/...
                      attempt_id:
                        type: string
                        example: oba_123
                      expires_at:
                        type: string
                        format: date-time
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '409':
          $ref: '#/components/responses/IdempotencyConflict'
        '422':
          $ref: '#/components/responses/ValidationError'
        '429':
          $ref: '#/components/responses/RateLimited'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'

  /accounts:
    get:
      operationId: listAccounts
      tags: [Accounts]
      summary: Listar numeros conectados
      x-botyconnect-scope: accounts:read
      parameters:
        - $ref: '#/components/parameters/Page'
        - $ref: '#/components/parameters/PerPage'
      responses:
        '200':
          description: Numeros del tenant, paginados.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/PaginatedCollection'
                  - type: object
                    properties:
                      data:
                        type: array
                        items:
                          $ref: '#/components/schemas/Account'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'

  /accounts/{account_id}:
    get:
      operationId: getAccount
      tags: [Accounts]
      summary: Detalle de un numero
      x-botyconnect-scope: accounts:read
      parameters:
        - $ref: '#/components/parameters/AccountId'
      responses:
        '200':
          description: Detalle del numero.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/Account'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'

  /accounts/{account_id}/templates:
    get:
      operationId: listAccountTemplates
      tags: [Accounts]
      summary: Templates aprobados de un numero
      description: Solo devuelve templates con estado `approved` y activos.
      x-botyconnect-scope: templates:read
      parameters:
        - $ref: '#/components/parameters/AccountId'
        - $ref: '#/components/parameters/Page'
        - $ref: '#/components/parameters/PerPage'
      responses:
        '200':
          description: Templates aprobados, paginados.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/PaginatedCollection'
                  - type: object
                    properties:
                      data:
                        type: array
                        items:
                          $ref: '#/components/schemas/Template'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'

  /campaigns:
    post:
      operationId: createCampaign
      tags: [Campaigns]
      summary: Crear campana masiva
      description: |
        Crea una campana de mensajes template. Valida que el numero pertenezca al
        tenant y este activo, que el template este aprobado, y que el saldo
        disponible alcance para todos los destinatarios (se **reservan** creditos
        al crear; cada mensaje aceptado por Meta **consume** uno; lo que nunca
        llega a Meta se **libera**).
      x-botyconnect-scope: campaigns:write
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CampaignInput'
      responses:
        '201':
          description: Campana creada y encolada.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/Campaign'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '409':
          $ref: '#/components/responses/IdempotencyConflict'
        '422':
          description: |
            Payload invalido o regla de negocio violada (template no aprobado,
            numero pausado, saldo insuficiente, variables que no coinciden).
          content:
            application/json:
              schema:
                oneOf:
                  - $ref: '#/components/schemas/ApiError'
                  - $ref: '#/components/schemas/LaravelValidationError'
        '429':
          $ref: '#/components/responses/RateLimited'
    get:
      operationId: listCampaigns
      tags: [Campaigns]
      summary: Listar campanas
      x-botyconnect-scope: campaigns:read
      parameters:
        - name: status
          in: query
          description: Filtrar por estado.
          schema:
            $ref: '#/components/schemas/CampaignStatus'
        - $ref: '#/components/parameters/Page'
        - $ref: '#/components/parameters/PerPage'
      responses:
        '200':
          description: Campanas creadas por API, paginadas (mas recientes primero).
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/PaginatedCollection'
                  - type: object
                    properties:
                      data:
                        type: array
                        items:
                          $ref: '#/components/schemas/Campaign'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'

  /campaigns/{campaign_id}:
    get:
      operationId: getCampaign
      tags: [Campaigns]
      summary: Detalle de una campana
      x-botyconnect-scope: campaigns:read
      parameters:
        - $ref: '#/components/parameters/CampaignId'
      responses:
        '200':
          description: Detalle con contadores y creditos.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/Campaign'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'

  /campaigns/{campaign_id}/recipients:
    get:
      operationId: listCampaignRecipients
      tags: [Campaigns]
      summary: Destinatarios de una campana
      x-botyconnect-scope: campaigns:read
      parameters:
        - $ref: '#/components/parameters/CampaignId'
        - $ref: '#/components/parameters/Page'
        - name: per_page
          in: query
          description: Resultados por pagina (max 200 en este endpoint).
          schema:
            type: integer
            minimum: 1
            maximum: 200
            default: 50
      responses:
        '200':
          description: Destinatarios con su estado individual, paginados.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/PaginatedCollection'
                  - type: object
                    properties:
                      data:
                        type: array
                        items:
                          $ref: '#/components/schemas/CampaignRecipient'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'

  /campaigns/{campaign_id}/cancel:
    post:
      operationId: cancelCampaign
      tags: [Campaigns]
      summary: Cancelar campana en cola
      description: Solo se cancela si sigue en `queued` y no tiene mensajes enviados.
      x-botyconnect-scope: campaigns:cancel
      parameters:
        - $ref: '#/components/parameters/CampaignId'
        - $ref: '#/components/parameters/IdempotencyKey'
      responses:
        '200':
          description: Campana cancelada; los creditos reservados se liberan.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/Campaign'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          description: La campana ya no se puede cancelar (o conflicto de idempotencia).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'

  /messages:
    post:
      operationId: sendMessage
      tags: [Messages]
      summary: Enviar un mensaje individual
      description: |
        Envia un mensaje suelto a un contacto: texto libre, multimedia (imagen, video,
        documento, audio) o interactivo (botones o lista). Solo se permite dentro de la
        **ventana de servicio al cliente de 24h** (el contacto debe haber escrito en las
        ultimas 24 horas); fuera de ventana responde `422 outside_24h_window`.

        **Asincrono:** la peticion valida, reserva 1 credito y encola el envio,
        devolviendo `202` con `status: queued` (sin `wam_id` todavia). El envio real a
        Meta ocurre en background; el resultado final llega por webhook `message.sent`
        (consume el credito) o `message.failed` (libera el credito). Para iniciar
        conversaciones fuera de ventana usa una campana con plantilla.
      x-botyconnect-scope: messages:write
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/MessageInput'
      responses:
        '202':
          description: Mensaje validado, credito reservado y envio encolado.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/Message'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/IdempotencyConflict'
        '422':
          description: |
            Payload invalido o regla de negocio violada. Codigos posibles:
            `outside_24h_window`, `insufficient_credits`, `account_not_ready`,
            `account_inactive`, `daily_limit_reached`, `invalid_recipient`.
          content:
            application/json:
              schema:
                oneOf:
                  - $ref: '#/components/schemas/ApiError'
                  - $ref: '#/components/schemas/LaravelValidationError'
        '429':
          $ref: '#/components/responses/RateLimited'
        '502':
          description: WhatsApp/Meta rechazo el mensaje (`whatsapp_send_failed`). El credito reservado se libera.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'

  /credits/balance:
    get:
      operationId: getCreditsBalance
      tags: [Credits]
      summary: Saldo de la empresa
      x-botyconnect-scope: credits:read
      responses:
        '200':
          description: Saldo global, reservado y disponible.
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                    const: true
                  data:
                    $ref: '#/components/schemas/Wallet'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'

  /credits/ledger:
    get:
      operationId: listCreditsLedger
      tags: [Credits]
      summary: Movimientos de creditos
      x-botyconnect-scope: credits:read
      parameters:
        - $ref: '#/components/parameters/Page'
        - $ref: '#/components/parameters/PerPage'
      responses:
        '200':
          description: Movimientos mas recientes primero.
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                    const: true
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/LedgerEntry'
                  meta:
                    $ref: '#/components/schemas/PaginationMeta'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'

  /webhooks/endpoints:
    post:
      operationId: createWebhookEndpoint
      tags: [Webhook Endpoints]
      summary: Registrar webhook endpoint
      description: |
        Registra una URL HTTPS de tu backend. El `signing_secret` se devuelve
        **una sola vez**; guardalo para validar la firma HMAC de cada evento.
      x-botyconnect-scope: webhooks:write
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [url, events]
              properties:
                url:
                  type: string
                  format: uri
                  pattern: '^https://'
                  maxLength: 1000
                  example: https://empresa.com/webhooks/botyconnect
                events:
                  type: array
                  minItems: 1
                  items:
                    $ref: '#/components/schemas/WebhookEventName'
      responses:
        '201':
          description: Endpoint creado. Unica entrega del signing secret.
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                    const: true
                  data:
                    type: object
                    properties:
                      id:
                        type: string
                        example: whend_x8k2m9p4q7w1n5r3t6y0u2i4
                      url:
                        type: string
                        format: uri
                      events:
                        type: array
                        items:
                          $ref: '#/components/schemas/WebhookEventName'
                      status:
                        type: string
                        const: active
                      signing_secret:
                        type: string
                        description: Solo se muestra en esta respuesta.
                        example: whsec_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
                      signing_secret_preview:
                        type: string
                        example: whsec_xxxx...
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '409':
          $ref: '#/components/responses/IdempotencyConflict'
        '422':
          $ref: '#/components/responses/ValidationError'
        '429':
          $ref: '#/components/responses/RateLimited'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
    get:
      operationId: listWebhookEndpoints
      tags: [Webhook Endpoints]
      summary: Listar webhook endpoints
      x-botyconnect-scope: webhooks:read
      parameters:
        - $ref: '#/components/parameters/Page'
        - $ref: '#/components/parameters/PerPage'
      responses:
        '200':
          description: Endpoints registrados, paginados.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/PaginatedCollection'
                  - type: object
                    properties:
                      data:
                        type: array
                        items:
                          $ref: '#/components/schemas/WebhookEndpoint'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'

  /webhooks/endpoints/{endpoint_id}:
    delete:
      operationId: deleteWebhookEndpoint
      tags: [Webhook Endpoints]
      summary: Desactivar webhook endpoint
      description: Marca el endpoint como `inactive`; deja de recibir eventos.
      x-botyconnect-scope: webhooks:write
      parameters:
        - name: endpoint_id
          in: path
          required: true
          schema:
            type: string
          example: whend_x8k2m9p4q7w1n5r3t6y0u2i4
        - $ref: '#/components/parameters/IdempotencyKey'
      responses:
        '200':
          description: Endpoint desactivado.
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                    const: true
                  data:
                    type: object
                    properties:
                      id:
                        type: string
                      status:
                        type: string
                        const: inactive
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'

webhooks:
  account.connected:
    post:
      operationId: onAccountConnected
      summary: Numero conectado por coexistencia
      description: Se emite cuando un numero completa Embedded Signup y queda activo.
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                event:
                  type: string
                  const: account.connected
                account:
                  type: object
                  properties:
                    id:
                      type: string
                      example: wacc_101
                    label:
                      type: string
                      example: Ventas Bogota
                    external_reference:
                      type: string
                      example: sucursal-bogota
                    phone_number:
                      type: string
                      example: '+57 300 111 2233'
                    connection_mode:
                      type: string
                      example: coexistence
                    status:
                      type: string
                      example: active
      responses:
        '200':
          description: Tu backend debe responder 2xx rapido; cualquier otro codigo dispara reintentos con backoff exponencial.

  campaign.created:
    post:
      operationId: onCampaignCreated
      summary: Campana creada
      requestBody:
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/WebhookCampaignEvent'
      responses:
        '200':
          description: Acuse de recibo.

  campaign.completed:
    post:
      operationId: onCampaignCompleted
      summary: Campana completada
      requestBody:
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/WebhookCampaignEvent'
      responses:
        '200':
          description: Acuse de recibo.

  message.sent:
    post:
      operationId: onMessageSent
      summary: Mensaje aceptado por Meta
      description: Meta acepto el mensaje y devolvio `wam_id`; consume 1 credito.
      requestBody:
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/WebhookMessageEvent'
      responses:
        '200':
          description: Acuse de recibo.

  message.delivered:
    post:
      operationId: onMessageDelivered
      summary: Mensaje entregado al dispositivo
      requestBody:
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/WebhookMessageEvent'
      responses:
        '200':
          description: Acuse de recibo.

  message.read:
    post:
      operationId: onMessageRead
      summary: Mensaje leido por el destinatario
      requestBody:
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/WebhookMessageEvent'
      responses:
        '200':
          description: Acuse de recibo.

  message.failed:
    post:
      operationId: onMessageFailed
      summary: Fallo de entrega reportado por Meta
      requestBody:
        content:
          application/json:
            schema:
              allOf:
                - $ref: '#/components/schemas/WebhookMessageEvent'
                - type: object
                  properties:
                    error:
                      type: object
                      properties:
                        code:
                          type: string
                          example: '131026'
                        message:
                          type: string
                          example: Message undeliverable
      responses:
        '200':
          description: Acuse de recibo.

  message.inbound.created:
    post:
      operationId: onMessageInboundCreated
      summary: Mensaje entrante de un cliente
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                event:
                  type: string
                  const: message.inbound.created
                account_id:
                  type: string
                  example: wacc_101
                from:
                  type: string
                  example: '573001112233'
                type:
                  type: string
                  example: text
                body:
                  type: string
                  example: Quiero mas informacion
                wam_id:
                  type: string
                  example: wamid.xxx
                timestamp:
                  type: string
                  format: date-time
      responses:
        '200':
          description: Acuse de recibo.

  message.echo.created:
    post:
      operationId: onMessageEchoCreated
      summary: Respuesta manual desde WhatsApp Business App (coexistencia)
      description: |
        Alguien de tu equipo respondio manualmente desde la app de WhatsApp
        Business. Permite que tu sistema registre la intervencion humana.
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                event:
                  type: string
                  const: message.echo.created
                account_id:
                  type: string
                  example: wacc_101
                direction:
                  type: string
                  const: outgoing_from_business_app
                to:
                  type: string
                  example: '573001112233'
                type:
                  type: string
                  example: text
                body:
                  type: string
                  example: Hola, ya te atiendo
                wam_id:
                  type: string
                  example: wamid.xxx
                timestamp:
                  type: string
                  format: date-time
      responses:
        '200':
          description: Acuse de recibo.

components:
  securitySchemes:
    BotyconnectApiKey:
      type: http
      scheme: bearer
      bearerFormat: bc_live_{public_id}.{secret}
      description: |
        API key de empresa. El secret completo se muestra una sola vez al crear
        o rotar la key. Validacion adicional opcional por IPs permitidas.

  parameters:
    IdempotencyKey:
      name: Idempotency-Key
      in: header
      required: false
      description: |
        Identificador unico de la operacion en tu sistema. Obligatorio en
        integraciones serias: ante un timeout, repite el mismo request con la
        misma key y recibiras la respuesta original. Misma key con body distinto
        responde 409. Expira a las 48 horas.
      schema:
        type: string
        maxLength: 255
      example: erp-campaign-9981
    Page:
      name: page
      in: query
      schema:
        type: integer
        minimum: 1
        default: 1
    PerPage:
      name: per_page
      in: query
      description: Resultados por pagina (max 100).
      schema:
        type: integer
        minimum: 1
        maximum: 100
        default: 50
    AccountId:
      name: account_id
      in: path
      required: true
      description: ID publico del numero.
      schema:
        type: string
      example: wacc_101
    CampaignId:
      name: campaign_id
      in: path
      required: true
      description: ID publico de la campana.
      schema:
        type: string
      example: cmp_123

  responses:
    Unauthorized:
      description: API key ausente, invalida, revocada o IP no permitida.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ApiError'
          example:
            success: false
            error:
              message: Invalid Botyconnect API key.
              code: invalid_api_key
    Forbidden:
      description: La API key no tiene el scope requerido.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ApiError'
          example:
            success: false
            error:
              message: API key is missing the required scope.
              code: missing_scope
    NotFound:
      description: El recurso no existe o pertenece a otra empresa.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ApiError'
          example:
            success: false
            error:
              message: Campaign not found.
              code: campaign_not_found
    IdempotencyConflict:
      description: Idempotency-Key reutilizada con un body diferente.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ApiError'
          example:
            success: false
            error:
              message: Idempotency key was already used with a different request body.
              code: idempotency_conflict
    ValidationError:
      description: Payload invalido.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/LaravelValidationError'
    RateLimited:
      description: Rate limit excedido. Reintentar con backoff exponencial.
      content:
        application/json:
          schema:
            type: object
            properties:
              message:
                type: string
                example: Too Many Attempts.
    ServiceUnavailable:
      description: Almacenamiento de autenticacion/idempotencia/webhooks no disponible.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ApiError'

  schemas:
    ApiError:
      type: object
      properties:
        success:
          type: boolean
          const: false
        error:
          type: object
          required: [message]
          properties:
            message:
              type: string
            code:
              type: string
              description: Codigo estable para manejo programatico.
            details:
              type: object
              additionalProperties: true

    LaravelValidationError:
      type: object
      description: Formato estandar de validacion de Laravel.
      properties:
        message:
          type: string
          example: The company name field is required.
        errors:
          type: object
          additionalProperties:
            type: array
            items:
              type: string

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

    PaginatedCollection:
      type: object
      description: Coleccion paginada estilo Laravel (links + meta).
      properties:
        links:
          type: object
          properties:
            first:
              type: [string, 'null']
            last:
              type: [string, 'null']
            prev:
              type: [string, 'null']
            next:
              type: [string, 'null']
        meta:
          type: object
          properties:
            current_page:
              type: integer
            from:
              type: [integer, 'null']
            last_page:
              type: integer
            path:
              type: string
            per_page:
              type: integer
            to:
              type: [integer, 'null']
            total:
              type: integer

    AccessRequestInput:
      type: object
      required: [company_name, contact_name, contact_email]
      properties:
        company_name:
          type: string
          maxLength: 255
          example: Empresa Demo
        tax_id:
          type: string
          maxLength: 80
          example: 900000000-1
        country:
          type: string
          maxLength: 120
          example: Colombia
        contact_name:
          type: string
          maxLength: 255
          example: Laura Gomez
        contact_email:
          type: string
          format: email
          maxLength: 255
          example: laura@empresa.com
        contact_phone:
          type: string
          maxLength: 60
          example: '+57 300 000 0000'
        estimated_monthly_messages:
          type: integer
          minimum: 1
          maximum: 100000000
          example: 50000
        estimated_numbers_count:
          type: integer
          minimum: 1
          maximum: 1000
          example: 3
        use_case:
          type: string
          maxLength: 5000
          example: Mensajeria masiva transaccional desde backend propio.
        webhook_url:
          type: string
          format: uri
          pattern: '^https://'
          maxLength: 1000
          example: https://empresa.com/webhooks/botyconnect

    Account:
      type: object
      properties:
        id:
          type: string
          example: wacc_101
        label:
          type: [string, 'null']
          example: Ventas Bogota
        external_reference:
          type: [string, 'null']
          example: sucursal-bogota
        phone_number:
          type: [string, 'null']
          example: '+57 300 111 2233'
        name:
          type: [string, 'null']
        status:
          type: string
          enum: [active, paused]
        connection_mode:
          type: string
          description: '`coexistence` o `internal`.'
          example: coexistence
        business_account_id:
          type: [string, 'null']
        phone_id:
          type: [string, 'null']
        subscribed_to_webhooks:
          type: boolean
        quality_rating:
          type: [string, 'null']
          example: GREEN
        daily_limit:
          type: [integer, 'null']
          example: 1000
        messages_sent_today:
          type: [integer, 'null']
          example: 120
        created_at:
          type: [string, 'null']
          format: date-time
        connected_at:
          type: [string, 'null']
          format: date-time

    Template:
      type: object
      properties:
        id:
          type: [string, 'null']
          description: ID del template en Meta.
        name:
          type: string
          description: Nombre tecnico a usar en `template_name`.
          example: promo_junio
        display_name:
          type: [string, 'null']
        language:
          type: string
          example: es_CO
        status:
          type: string
          example: approved
        category:
          type: [string, 'null']
          example: MARKETING
        quality_score:
          type: [string, 'null']
        body_text:
          type: [string, 'null']
          example: 'Hola {{1}}, tienes {{2}} de descuento.'
        header_type:
          type: [string, 'null']
          example: image
        variable_count:
          type: [integer, 'null']
          description: Cantidad de variables que exige el body.
          example: 2
        variables:
          type: array
          items: {}
        components:
          type: array
          items: {}
        updated_at:
          type: [string, 'null']
          format: date-time

    CampaignStatus:
      type: string
      enum: [queued, pending, processing, completed, failed, cancelled]

    CampaignInput:
      type: object
      required: [account_id, template_name, recipients]
      properties:
        account_id:
          type: string
          maxLength: 64
          description: ID publico del numero desde el que se envia.
          example: wacc_101
        external_reference:
          type: string
          maxLength: 160
          description: Identificador de la campana en tu sistema.
          example: erp-campaign-9981
        template_name:
          type: string
          maxLength: 255
          example: promo_junio
        language:
          type: string
          maxLength: 20
          description: Idioma del template. Alias aceptado `template_language`.
          example: es_CO
        header_type:
          type: string
          enum: [none, image, video, document]
          description: Tipo de header multimedia si el template lo tiene.
        header_url:
          type: string
          format: uri
          maxLength: 1000
          description: URL publica del archivo del header.
        body_placeholders:
          type: array
          description: Variables por defecto, posicionales, para destinatarios sin `vars`.
          items:
            type: [string, 'null']
            maxLength: 1000
          example: [Cliente, '20%']
        dedupe:
          type: boolean
          description: Elimina destinatarios duplicados por `wa_id`.
          default: false
        recipients:
          type: array
          minItems: 1
          maxItems: 5000
          items:
            type: object
            required: [wa_id]
            properties:
              wa_id:
                type: string
                maxLength: 32
                description: Numero destino; se normaliza a solo digitos.
                example: '573001112233'
              external_contact_id:
                type: string
                maxLength: 160
                description: ID del contacto en tu sistema; vuelve en los webhooks.
                example: crm-1001
              vars:
                type: array
                description: Variables posicionales que reemplazan `body_placeholders` para este destinatario.
                items:
                  type: [string, 'null']
                  maxLength: 1000
                example: [Laura, '25%']
              payload:
                type: object
                additionalProperties: true
                description: Metadata libre de tu sistema; se guarda con el destinatario.

    Campaign:
      type: object
      properties:
        id:
          type: string
          example: cmp_123
        external_reference:
          type: [string, 'null']
          example: erp-campaign-9981
        account_id:
          type: [string, 'null']
          example: wacc_101
        template_name:
          type: [string, 'null']
          example: promo_junio
        template_language:
          type: [string, 'null']
          example: es_CO
        status:
          $ref: '#/components/schemas/CampaignStatus'
        total_recipients:
          type: integer
        reserved_credits:
          type: integer
        consumed_credits:
          type: integer
        released_credits:
          type: integer
        queued:
          type: integer
        sent:
          type: integer
        delivered:
          type: integer
        read:
          type: integer
        failed:
          type: integer
        pending:
          type: integer
        created_at:
          type: [string, 'null']
          format: date-time
        started_at:
          type: [string, 'null']
          format: date-time
        finished_at:
          type: [string, 'null']
          format: date-time

    CampaignRecipient:
      type: object
      properties:
        id:
          type: string
          example: rcp_9001
        wa_id:
          type: string
          example: '573001112233'
        external_contact_id:
          type: [string, 'null']
          example: crm-1001
        status:
          type: string
          enum: [pending, queued, sent, delivered, read, failed, cancelled]
        attempts:
          type: integer
        last_error:
          type: [string, 'null']
        message_id:
          type: [string, 'null']
          example: msg_5001
        sent_at:
          type: [string, 'null']
          format: date-time
        created_at:
          type: [string, 'null']
          format: date-time

    MediaInput:
      type: object
      description: 'Multimedia: enviar `url` o `media_id` (uno de los dos es obligatorio).'
      properties:
        url:
          type: string
          format: uri
          maxLength: 2000
          description: URL publica del archivo (alternativa a `media_id`).
        media_id:
          type: string
          maxLength: 255
          description: Media ID previamente subido a Meta (alternativa a `url`).
        caption:
          type: string
          maxLength: 1024
          description: Texto opcional. No aplica a audio.

    InteractiveInput:
      type: object
      description: Requerido si `type=interactive`.
      required: [kind, body]
      properties:
        kind:
          type: string
          enum: [buttons, list]
        body:
          type: string
          maxLength: 1024
        header:
          type: string
          maxLength: 60
          description: Header de texto opcional.
        footer:
          type: string
          maxLength: 60
          description: Footer de texto opcional.
        buttons:
          type: array
          description: Requerido si `kind=buttons` (max 3 botones de respuesta rapida).
          maxItems: 3
          items:
            type: object
            required: [id, title]
            properties:
              id:
                type: string
                maxLength: 256
              title:
                type: string
                maxLength: 20
        button:
          type: string
          maxLength: 20
          description: Requerido si `kind=list`. Texto del boton que abre la lista.
        sections:
          type: array
          description: Requerido si `kind=list` (max 10 secciones).
          maxItems: 10
          items:
            type: object
            required: [rows]
            properties:
              title:
                type: string
                maxLength: 24
              rows:
                type: array
                minItems: 1
                maxItems: 10
                items:
                  type: object
                  required: [id, title]
                  properties:
                    id:
                      type: string
                      maxLength: 200
                    title:
                      type: string
                      maxLength: 24
                    description:
                      type: string
                      maxLength: 72

    MessageInput:
      type: object
      required: [account_id, to, type]
      properties:
        account_id:
          type: string
          maxLength: 64
          description: ID publico del numero desde el que se envia.
          example: wacc_101
        to:
          type: string
          maxLength: 32
          description: Numero destino; se normaliza a solo digitos.
          example: '573001112233'
        type:
          type: string
          enum: [text, image, document, audio, video, interactive]
        external_reference:
          type: string
          maxLength: 160
          description: Identificador del mensaje en tu sistema; se guarda y vuelve en la respuesta.
          example: ticket-555
        text:
          type: object
          description: Requerido si `type=text`.
          required: [body]
          properties:
            body:
              type: string
              maxLength: 4096
              example: Hola, ya te atiendo.
        image:
          allOf:
            - $ref: '#/components/schemas/MediaInput'
          description: Requerido si `type=image`.
        video:
          allOf:
            - $ref: '#/components/schemas/MediaInput'
          description: Requerido si `type=video`.
        audio:
          allOf:
            - $ref: '#/components/schemas/MediaInput'
          description: Requerido si `type=audio` (sin caption).
        document:
          description: Requerido si `type=document`.
          allOf:
            - $ref: '#/components/schemas/MediaInput'
            - type: object
              properties:
                filename:
                  type: string
                  maxLength: 255
        interactive:
          $ref: '#/components/schemas/InteractiveInput'

    Message:
      type: object
      properties:
        id:
          type: string
          example: msg_123
        wam_id:
          type: [string, 'null']
          description: ID del mensaje en Meta. Es `null` mientras `status` es `queued`; se llena al enviarse.
          example: wamid.xxx
        account_id:
          type: [string, 'null']
          example: wacc_101
        to:
          type: string
          example: '573001112233'
        type:
          type: string
          enum: [text, image, document, audio, video, interactive]
        status:
          type: string
          description: '`queued` al crear; pasa a `sent` o `failed` tras el envio en background.'
          enum: [queued, sent, failed]
          example: queued
        external_reference:
          type: [string, 'null']
          example: ticket-555
        credits_reserved:
          type: integer
          description: Creditos reservados (1). Se consume al enviarse o se libera si falla.
          example: 1
        created_at:
          type: [string, 'null']
          format: date-time

    Wallet:
      type: object
      properties:
        balance:
          type: integer
          description: Total de creditos comprados/asignados vigentes.
          example: 50000
        reserved_balance:
          type: integer
          description: Reservado por campanas en curso.
          example: 2000
        available_balance:
          type: integer
          description: balance - reserved_balance; lo que puedes usar en nuevas campanas.
          example: 48000
        status:
          type: string
          enum: [active, paused]
        low_balance_threshold:
          type: integer
          example: 1000

    LedgerEntry:
      type: object
      properties:
        id:
          type: integer
        type:
          type: string
          enum: [purchase, manual_grant, manual_adjust, reserve, consume, release, refund]
        amount:
          type: integer
          description: Positivo suma saldo; negativo lo resta.
        balance_after:
          type: integer
        reserved_after:
          type: integer
        reference:
          type: [string, 'null']
          example: wompi-transaction-123
        description:
          type: [string, 'null']
        campaign_id:
          type: [integer, 'null']
        whatsapp_account_id:
          type: [integer, 'null']
        created_at:
          type: [string, 'null']
          format: date-time

    WebhookEventName:
      type: string
      enum:
        - account.connected
        - campaign.created
        - campaign.completed
        - message.sent
        - message.delivered
        - message.read
        - message.failed
        - message.inbound.created
        - message.echo.created

    WebhookEndpoint:
      type: object
      properties:
        id:
          type: string
          example: whend_x8k2m9p4q7w1n5r3t6y0u2i4
        url:
          type: string
          format: uri
        events:
          type: array
          items:
            $ref: '#/components/schemas/WebhookEventName'
        status:
          type: string
          enum: [active, inactive]
        signing_secret_preview:
          type: [string, 'null']
          example: whsec_xxxx...
        last_success_at:
          type: [string, 'null']
          format: date-time
        last_failure_at:
          type: [string, 'null']
          format: date-time
        failure_count:
          type: integer
        created_at:
          type: [string, 'null']
          format: date-time

    WebhookCampaignEvent:
      type: object
      description: |
        Entregado con headers `X-Botyconnect-Event-Id`, `X-Botyconnect-Timestamp`
        y `X-Botyconnect-Signature` (HMAC SHA-256 de `timestamp + "." + raw_body`
        con el signing secret del endpoint).
      properties:
        event:
          type: string
          enum: [campaign.created, campaign.completed]
        account_id:
          type: string
          example: wacc_101
        campaign_id:
          type: string
          example: cmp_123
        external_reference:
          type: [string, 'null']
          example: erp-campaign-9981
        status:
          type: string
        total_recipients:
          type: integer
        timestamp:
          type: string
          format: date-time

    WebhookMessageEvent:
      type: object
      description: |
        Entregado con headers `X-Botyconnect-Event-Id`, `X-Botyconnect-Timestamp`
        y `X-Botyconnect-Signature` (HMAC SHA-256 de `timestamp + "." + raw_body`
        con el signing secret del endpoint). Valida la firma con comparacion de
        tiempo constante, rechaza timestamps antiguos y deduplica por event id.
      properties:
        event:
          type: string
          enum: [message.sent, message.delivered, message.read, message.failed]
        account_id:
          type: string
          example: wacc_101
        campaign_id:
          type: [string, 'null']
          example: cmp_123
        recipient:
          type: string
          example: '573001112233'
        external_contact_id:
          type: [string, 'null']
          example: crm-1001
        wam_id:
          type: [string, 'null']
          example: wamid.xxx
        status:
          type: string
          example: delivered
        timestamp:
          type: string
          format: date-time
