openapi: 3.0.3
info:
  title: API pública de Cobrixia
  version: 1.0.0
  description: |
    API para integrar marcadores y CRMs externos (Wolkvox, Five9, etc.) con Cobrixia: consultar deudores y
    campañas, y registrar el resultado de cada llamada.

    **Autenticación.** Cada organización tiene llaves de API (las crea y revoca su administrador en
    *Configuración → Integraciones*). Se envían en el encabezado `X-API-Key`. Hasta 2 llaves activas a la vez,
    para rotarlas sin cortar la integración.

    **Habilitación y límite.** La API la habilita Cobrixia por organización; apagada responde `403`.
    El límite por defecto es de 300 solicitudes por minuto por llave; al superarlo responde `429` con `Retry-After`.

    **Modo prueba.** Una llave de prueba (`cbx_test_…`) solo ve deudores marcados como de prueba. Una llave real
    (`cbx_live_…`) solo ve deudores reales y, mientras la organización esté en modo prueba, tampoco: queda igual
    que una de prueba. `GET /v1/ping` indica en qué modo está la llave.

    **Datos mínimos (PRODHAB).** Solo se entrega lo necesario para marcar: nombre, cédula, teléfono, cliente acreedor,
    saldo y días de mora. Nunca correo, dirección, facturas ni historial.

    **Errores.** Siempre `{"error": {"codigo", "mensaje", "solicitud_id"}}`; el `solicitud_id` también viaja en el
    encabezado `X-Request-Id`.

    **Webhooks salientes.** Cobrixia puede avisar a su sistema (campaña creada, promesa registrada, deudor "no llamar").
    Se describen en la sección *webhooks* al final; cada envío va firmado con HMAC-SHA256.
  contact:
    name: Soporte de Cobrixia
    email: soporte@cobrixia.com
servers:
  - url: https://api.cobrixia.com
    description: Producción
security:
  - ApiKey: []
tags:
  - name: Sistema
  - name: Deudores
  - name: Campañas
  - name: Gestiones
paths:
  /v1/ping:
    get:
      tags: [Sistema]
      operationId: ping
      summary: Comprobar la llave
      description: Valida la llave y dice si está en modo prueba. Cuenta como una solicitud para el límite.
      responses:
        '200':
          description: La llave es válida.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Ping' }
              example: {
                  "estado": "ok",
                  "modo_prueba": true
                }
        '401': { $ref: '#/components/responses/NoAutenticado' }
        '403': { $ref: '#/components/responses/ApiDeshabilitada' }
        '429': { $ref: '#/components/responses/LimiteExcedido' }
  /v1/resultados:
    get:
      tags: [Sistema]
      operationId: listarResultados
      summary: Catálogo de resultados estándar
      description: Los resultados que acepta `POST /v1/gestiones` en el campo `resultado`.
      responses:
        '200':
          description: Catálogo completo.
          content:
            application/json:
              schema:
                type: object
                required: [datos]
                properties:
                  datos:
                    type: array
                    items: { $ref: '#/components/schemas/Resultado' }
        '401': { $ref: '#/components/responses/NoAutenticado' }
        '403': { $ref: '#/components/responses/ApiDeshabilitada' }
        '429': { $ref: '#/components/responses/LimiteExcedido' }
  /v1/deudores:
    get:
      tags: [Deudores]
      operationId: buscarDeudores
      summary: Buscar deudores por cédula o teléfono
      description: |
        Envíe **exactamente uno** de `cedula` o `telefono`. La cédula se compara sin guiones ni espacios; el teléfono
        por dígitos (con o sin código de país, mínimo 7 dígitos). Una misma persona puede aparecer una vez por cada
        cliente acreedor. Devuelve hasta 20 resultados.
      parameters:
        - name: cedula
          in: query
          required: false
          description: Cédula o identificación. Obligatorio si no se envía `telefono`.
          schema: { type: string, maxLength: 40 }
          example: 1-0987-0654
        - name: telefono
          in: query
          required: false
          description: Teléfono en cualquier formato. Obligatorio si no se envía `cedula`.
          schema: { type: string, maxLength: 40 }
          example: '+50688887777'
      responses:
        '200':
          description: Coincidencias (puede ser una lista vacía).
          content:
            application/json:
              schema:
                type: object
                required: [datos]
                properties:
                  datos:
                    type: array
                    items: { $ref: '#/components/schemas/Deudor' }
        '401': { $ref: '#/components/responses/NoAutenticado' }
        '403': { $ref: '#/components/responses/ApiDeshabilitada' }
        '422': { $ref: '#/components/responses/Invalido' }
        '429': { $ref: '#/components/responses/LimiteExcedido' }
  /v1/campanas:
    get:
      tags: [Campañas]
      operationId: listarCampanas
      summary: Listar campañas
      description: De la más reciente a la más antigua. Paginado con cursor.
      parameters:
        - name: estado
          in: query
          required: false
          description: Filtra por estado de la campaña.
          schema: { type: string, enum: [activa, pausada, cerrada] }
          example: activa
        - name: limite
          in: query
          required: false
          description: Tamaño de página (1 a 100).
          schema: { type: integer, minimum: 1, maximum: 100, default: 50 }
          example: 50
        - name: cursor
          in: query
          required: false
          description: "Valor de `siguiente_cursor` de la página anterior. Es opaco: no lo modifique."
          schema: { type: string }
      responses:
        '200':
          description: Una página de campañas.
          content:
            application/json:
              schema:
                type: object
                required: [datos, siguiente_cursor]
                properties:
                  datos:
                    type: array
                    items: { $ref: '#/components/schemas/Campana' }
                  siguiente_cursor: { $ref: '#/components/schemas/Cursor' }
        '401': { $ref: '#/components/responses/NoAutenticado' }
        '403': { $ref: '#/components/responses/ApiDeshabilitada' }
        '422': { $ref: '#/components/responses/Invalido' }
        '429': { $ref: '#/components/responses/LimiteExcedido' }
  /v1/campanas/{id}/deudores:
    get:
      tags: [Campañas]
      operationId: deudoresDeCampana
      summary: Deudores de una campaña
      description: |
        Solo de campañas **activas** (las pausadas o cerradas devuelven lista vacía). Ordenados por prioridad, mayor
        primero. No incluye a quienes pidieron que no los llamen ni a los casos que salieron de la campaña. Paginado con cursor.
      parameters:
        - name: id
          in: path
          required: true
          description: Id de la campaña (de `GET /v1/campanas`).
          schema: { type: string, format: uuid }
        - name: limite
          in: query
          required: false
          description: Tamaño de página (1 a 500).
          schema: { type: integer, minimum: 1, maximum: 500, default: 100 }
          example: 100
        - name: cursor
          in: query
          required: false
          description: Valor de `siguiente_cursor` de la página anterior.
          schema: { type: string }
      responses:
        '200':
          description: Una página de deudores de la campaña.
          content:
            application/json:
              schema:
                type: object
                required: [datos, siguiente_cursor]
                properties:
                  datos:
                    type: array
                    items: { $ref: '#/components/schemas/DeudorDeCampana' }
                  siguiente_cursor: { $ref: '#/components/schemas/Cursor' }
        '401': { $ref: '#/components/responses/NoAutenticado' }
        '403': { $ref: '#/components/responses/ApiDeshabilitada' }
        '404':
          description: La campaña no existe en su organización (`campana_no_encontrada`).
          content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } }
        '422': { $ref: '#/components/responses/Invalido' }
        '429': { $ref: '#/components/responses/LimiteExcedido' }
  /v1/gestiones:
    post:
      tags: [Gestiones]
      operationId: registrarGestion
      summary: Registrar el resultado de una llamada
      description: |
        Crea la gestión (y la promesa de pago si el resultado es `promesa`). **Es idempotente** por
        `llamada_id_externo`: reenviar la misma llamada responde `200` con `"duplicado": true` y los mismos ids,
        sin crear nada nuevo. Los resultados `pidio_no_llamar` y `numero_equivocado` marcan al deudor como
        "no llamar".

        Envíe `resultado` (estándar) **o** `tipificacion_original` (la de su marcador, traducida con el mapeo de
        *Configuración → Integraciones*). Si envía ambos, manda `resultado`.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/GestionNueva' }
            examples:
              promesa:
                summary: Llamada con promesa de pago
                value:
                  deudor_id: 5372c7f1-d286-406d-8d0b-c201323271ea
                  resultado: promesa
                  nota: Pagará por SINPE el viernes
                  llamada_id_externo: WV-2026-000123
                  agente: ana.rojas
                  fecha_hora: '2026-10-16T14:30:00-06:00'
                  promesa: { monto: 75000, fecha: '2026-10-21' }
              tipificacion:
                summary: Con la tipificación propia de la agencia
                value:
                  deudor_id: ac45c313-3b7a-4328-88f3-e1066ae1480f
                  tipificacion_original: NO CONTESTA
                  llamada_id_externo: WV-2026-000124
                  agente: luis.mora
      responses:
        '201':
          description: Gestión creada.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/GestionCreada' }
              example: {
                  "gestion_id": "0b5f3c1e-5f5e-4c52-9a31-1c0c6a0f9d11",
                  "promesa_id": "7d1e2b64-9b0a-4a3c-8f6e-2f9a4b1c0d22",
                  "duplicado": false
                }
        '200':
          description: Ya existía una gestión con ese `llamada_id_externo`; no se creó nada.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/GestionCreada' }
              example: {
                  "gestion_id": "0b5f3c1e-5f5e-4c52-9a31-1c0c6a0f9d11",
                  "promesa_id": "7d1e2b64-9b0a-4a3c-8f6e-2f9a4b1c0d22",
                  "duplicado": true
                }
        '400':
          description: El cuerpo no es JSON válido (`json_invalido`).
          content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } }
        '401': { $ref: '#/components/responses/NoAutenticado' }
        '403': { $ref: '#/components/responses/ApiDeshabilitada' }
        '404':
          description: No existe un deudor con ese `deudor_id` en su organización y modo (`deudor_no_encontrado`).
          content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } }
        '409':
          description: Ese `llamada_id_externo` ya se registró con otro deudor (`llamada_id_externo_repetido`).
          content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } }
        '413':
          description: El cuerpo supera los 10 KB (`cuerpo_muy_grande`).
          content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } }
        '415':
          description: Falta `Content-Type - application/json` (`tipo_no_soportado`).
          content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } }
        '422': { $ref: '#/components/responses/Invalido' }
        '429': { $ref: '#/components/responses/LimiteExcedido' }
components:
  securitySchemes:
    ApiKey:
      type: apiKey
      in: header
      name: X-API-Key
      description: Llave de la organización (`cbx_live_…` o `cbx_test_…`). Se crea en Configuración → Integraciones.
  responses:
    NoAutenticado:
      description: Falta la llave (`llave_requerida`) o no es válida / fue revocada (`llave_invalida`).
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          example: {
              "error": {
                "codigo": "llave_invalida",
                "mensaje": "La llave de API no es válida o fue revocada",
                "solicitud_id": "8f9b1c3a-1d0e-4a57-9a7b-0c2b7f6a1e11"
              }
            }
    ApiDeshabilitada:
      description: La API no está habilitada para la organización (`api_deshabilitada`).
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    Invalido:
      description: Un parámetro o campo no es válido; el `mensaje` dice cuál.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          example: {
              "error": {
                "codigo": "promesa_requerida",
                "mensaje": "El resultado \"promesa\" exige el objeto promesa con monto y fecha",
                "solicitud_id": "3c1e4b7a-9d2f-4e60-8b11-5a7c9e0d2f33"
              }
            }
    LimiteExcedido:
      description: Superó el límite de solicitudes por minuto (`limite_excedido`).
      headers:
        Retry-After:
          description: Segundos que debe esperar antes de reintentar.
          schema: { type: integer }
          example: 23
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
  schemas:
    Error:
      type: object
      required: [error]
      properties:
        error:
          type: object
          required: [codigo, mensaje, solicitud_id]
          properties:
            codigo: { type: string, description: "Código estable para programar la reacción.", example: llave_invalida }
            mensaje: { type: string, description: "Explicación en español.", example: La llave de API no es válida o fue revocada }
            solicitud_id: { type: string, format: uuid, description: "Identificador de la solicitud; envíelo a soporte si necesita ayuda.", example: 8f9b1c3a-1d0e-4a57-9a7b-0c2b7f6a1e11 }
    Ping:
      type: object
      required: [estado, modo_prueba]
      properties:
        estado: { type: string, enum: [ok], description: "Siempre `ok` si la llave es válida.", example: ok }
        modo_prueba: { type: boolean, description: "'`true` si la llave solo ve deudores de prueba.'", example: true }
    Cursor:
      type: string
      nullable: true
      description: Cursor de la página siguiente, o `null` si esta es la última. Es opaco; no lo modifique.
      example: eyJ0IjoiMjAyNi0xMC0xNlQxMjozMDoyNC44NTc5MTErMDA6MDAiLCJpIjoiNjhjMDlhOTYtOTdkYS00NDJmLWE1YTMtNDgwNGFlNTc3OTYxIn0
    Resultado:
      type: object
      required: [codigo, nombre, descripcion, efecto]
      properties:
        codigo: { type: string, description: "Valor para el campo `resultado`.", example: promesa }
        nombre: { type: string, description: "Nombre para mostrar.", example: Promesa de pago }
        descripcion: { type: string, description: "Cuándo usarlo.", example: El deudor se comprometió a pagar. }
        efecto: { type: string, description: "Qué hace Cobrixia con él.", example: Crea la promesa de pago. }
    Deudor:
      type: object
      required: [id, nombre, cedula, telefono, cliente_acreedor, saldo, dias_mora, no_llamar, campana_id]
      properties:
        id: { type: string, format: uuid, description: "Id del deudor; es el `deudor_id` de `POST /v1/gestiones`.", example: 5372c7f1-d286-406d-8d0b-c201323271ea }
        nombre: { type: string, example: María Fernanda Solano Vargas }
        cedula: { type: string, example: 1-0987-0654 }
        telefono: { type: string, nullable: true, description: "Teléfono principal tal como está en la cartera.", example: '+50688887777' }
        cliente_acreedor: { type: string, description: "Empresa que le tercerizó la cobranza.", example: Financiera Ejemplo S.A. }
        saldo: { type: number, description: "Saldo pendiente en la moneda de la organización.", example: 150000 }
        dias_mora: { type: integer, description: "Días de mora.", example: 45 }
        no_llamar: { type: boolean, description: "'`true` si pidió no ser llamado o el número es equivocado. No debe marcarse.'", example: false }
        campana_id: { type: string, format: uuid, nullable: true, description: "Campaña activa en la que está, o `null`.", example: 68c09a96-97da-442f-a5a3-4804ae577961 }
    Campana:
      type: object
      required: [id, nombre, tipo, estado, cliente_acreedor, total_deudores, created_at]
      properties:
        id: { type: string, format: uuid, example: 68c09a96-97da-442f-a5a3-4804ae577961 }
        nombre: { type: string, example: Cobro octubre — mora 30 a 90 días }
        tipo: { type: string, enum: [ia, gestores], description: "'`ia` la trabaja Sofía; `gestores` la trabajan personas.'", example: gestores }
        estado: { type: string, enum: [activa, pausada, cerrada], example: activa }
        cliente_acreedor: { type: string, example: Financiera Ejemplo S.A. }
        total_deudores: { type: integer, description: "Deudores activos y llamables en el modo de su llave.", example: 120 }
        created_at: { type: string, format: date-time, example: "'2026-10-16T12:30:24.857911+00:00'" }
    DeudorDeCampana:
      type: object
      required: [deudor_id, nombre, cedula, telefono, saldo, dias_mora, prioridad]
      properties:
        deudor_id: { type: string, format: uuid, example: 5372c7f1-d286-406d-8d0b-c201323271ea }
        nombre: { type: string, example: María Fernanda Solano Vargas }
        cedula: { type: string, example: 1-0987-0654 }
        telefono: { type: string, nullable: true, example: '+50688887777' }
        saldo: { type: number, example: 150000 }
        dias_mora: { type: integer, example: 45 }
        prioridad: { type: integer, description: "Mayor número = llamar antes.", example: 100 }
    Promesa:
      type: object
      required: [monto, fecha]
      properties:
        monto: { type: number, description: "Monto prometido, mayor que cero. Debe ser un número (no texto).", example: 75000 }
        fecha: { type: string, format: date, description: "'Fecha de pago AAAA-MM-DD, entre hoy y el máximo de días de promesa de la organización (30 por defecto).'", example: '2026-10-21' }
    GestionNueva:
      type: object
      required: [deudor_id, llamada_id_externo]
      description: Se exige `resultado` o `tipificacion_original` (al menos uno).
      properties:
        deudor_id: { type: string, format: uuid, description: "Id del deudor (campo `id` de `GET /v1/deudores`).", example: 5372c7f1-d286-406d-8d0b-c201323271ea }
        resultado:
          type: string
          description: Resultado estándar (ver `GET /v1/resultados`).
          enum: [promesa, rechazo, no_contesta, buzon_de_voz, numero_equivocado, pidio_no_llamar, volver_a_llamar, sin_acuerdo, disputa, no_es_la_persona, pago_realizado]
          example: promesa
        tipificacion_original: { type: string, maxLength: 100, description: "Tipificación propia de su marcador; se traduce con el mapeo de la organización. Sin mapeo responde 422.", example: NO CONTESTA }
        llamada_id_externo: { type: string, minLength: 1, maxLength: 100, description: "'Id de la llamada en su marcador. Obligatorio: evita gestiones duplicadas al reintentar.'", example: WV-2026-000123 }
        nota: { type: string, maxLength: 500, description: "Comentario del agente. No incluya datos sensibles.", example: Pagará por SINPE el viernes }
        agente: { type: string, maxLength: 100, description: "Usuario o nombre del agente que hizo la llamada.", example: ana.rojas }
        fecha_hora: { type: string, format: date-time, description: "'Cuándo ocurrió la llamada (ISO 8601). Por defecto, ahora. No puede ser futura ni de hace más de 30 días.'", example: "'2026-10-16T14:30:00-06:00'" }
        promesa: { $ref: '#/components/schemas/Promesa' }
    GestionCreada:
      type: object
      required: [gestion_id, promesa_id, duplicado]
      properties:
        gestion_id: { type: string, format: uuid, example: 0b5f3c1e-5f5e-4c52-9a31-1c0c6a0f9d11 }
        promesa_id: { type: string, format: uuid, nullable: true, description: "Id de la promesa creada, o `null` si el resultado no era `promesa`.", example: 7d1e2b64-9b0a-4a3c-8f6e-2f9a4b1c0d22 }
        duplicado: { type: boolean, description: "'`true` si la llamada ya estaba registrada.'", example: false }
    EventoWebhook:
      type: object
      required: [id, evento, creado, prueba, datos]
      description: Cuerpo que Cobrixia envía por POST a su URL de webhook.
      properties:
        id: { type: string, format: uuid, description: "Id del evento; úselo para ignorar repetidos.", example: 3a2f6b1e-0c9d-4c7a-8e21-7f5d9b3c1a40 }
        evento: { type: string, enum: [campana.creada, promesa.registrada, deudor.no_llamar], example: promesa.registrada }
        creado: { type: string, format: date-time, example: "'2026-10-16T14:31:02.114+00:00'" }
        prueba: { type: boolean, description: "'`true` si el evento es de un deudor de prueba.'", example: false }
        datos:
          oneOf:
            - $ref: '#/components/schemas/DatosCampanaCreada'
            - $ref: '#/components/schemas/DatosPromesaRegistrada'
            - $ref: '#/components/schemas/DatosDeudorNoLlamar'
    DatosCampanaCreada:
      type: object
      required: [campana_id, nombre, tipo]
      properties:
        campana_id: { type: string, format: uuid, example: 68c09a96-97da-442f-a5a3-4804ae577961 }
        nombre: { type: string, example: Cobro octubre — mora 30 a 90 días }
        tipo: { type: string, enum: [ia, gestores], example: gestores }
    DatosPromesaRegistrada:
      type: object
      required: [deudor_id, cedula, promesa_id, acuerdo_id, monto, fecha, origen]
      properties:
        deudor_id: { type: string, format: uuid, example: 5372c7f1-d286-406d-8d0b-c201323271ea }
        cedula: { type: string, example: 1-0987-0654 }
        promesa_id: { type: string, format: uuid, example: 7d1e2b64-9b0a-4a3c-8f6e-2f9a4b1c0d22 }
        acuerdo_id: { type: string, format: uuid, description: "Agrupa las cuotas de un mismo acuerdo.", example: 7d1e2b64-9b0a-4a3c-8f6e-2f9a4b1c0d22 }
        monto: { type: number, example: 75000 }
        fecha: { type: string, format: date, example: '2026-10-21' }
        origen: { type: string, enum: [gestor, sofia, api], description: "Quién la registró.", example: api }
    DatosDeudorNoLlamar:
      type: object
      required: [deudor_id, cedula, motivo]
      properties:
        deudor_id: { type: string, format: uuid, example: 5372c7f1-d286-406d-8d0b-c201323271ea }
        cedula: { type: string, example: 1-0987-0654 }
        motivo: { type: string, enum: [pidio_no_llamar, numero_equivocado, manual], example: pidio_no_llamar }
x-webhooks:
  eventoCobrixia:
    post:
      summary: Evento enviado a su URL de webhook
      description: |
        Se envía por POST con `Content-Type: application/json` y estos encabezados:

        - `X-Cobrixia-Event`: nombre del evento.
        - `X-Cobrixia-Delivery`: id del envío (igual al `id` del cuerpo).
        - `X-Cobrixia-Signature`: `t=<segundos Unix>,v1=<firma>`, donde la firma es el HMAC-SHA256, en hexadecimal, de
          `"<t>.<cuerpo exacto recibido>"` con su secreto `whsec_…`. Verifíquela siempre y rechace marcas de tiempo
          con más de 5 minutos de diferencia.

        Responda cualquier `2xx` en menos de 10 segundos. Si no, Cobrixia reintenta a 1 minuto, 5 minutos,
        30 minutos, 2 horas y 6 horas; después marca el envío como fallido (visible en Configuración → Integraciones).
        No se siguen redirecciones. La entrega es *al menos una vez*: use `id` para ignorar repetidos.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/EventoWebhook' }
      responses:
        '200': { description: Recibido. }
