openapi: 3.1.0
info:
  title: Validocus API
  version: "1.1"
  x-logo:
    url: /logo-validocus.png
    altText: Validocus — volver al inicio
    href: /
  description: |
    API de Validocus para mandar documentos a firmar, pedir formularios y
    verificar la identidad de una persona. Puedes pedir todo junto en una sola
    llamada (el firmante llena el formulario, verifica su identidad y firma) o
    cada cosa por separado, sin documento de por medio.

    ## Cómo empezar

    1. Genera un token en el panel (**Perfil → Tokens de integración**). Va en
       cada llamada como `Authorization: Bearer <token>`.
    2. Haz la llamada que corresponda a lo que quieres pedir (tabla de abajo).
    3. Registra un webhook (**Perfil → Webhooks**) para enterarte cuando la
       persona termine, sin tener que consultar a cada rato.

    Hay dos ambientes con las mismas rutas: **producción**
    (`https://api.validocus.com`) y **pruebas**
    (`https://apivalidocus.concilbot.com`), con cuenta y saldo separados. En
    pruebas los firmantes **sí reciben** correos, SMS y WhatsApp reales: usa tus
    propios datos de contacto.

    ## Qué quieres pedir

    | Quieres… | Llamada |
    |---|---|
    | Que firmen un PDF | `POST /documents/createContract` |
    | Que firmen **y además** llenen un formulario | `createContract` con `form_template_id` |
    | Que firmen **y además** verifiquen su identidad | `createContract` con `signer_id_verify_liveness: "1"` en el firmante |
    | Firma + formulario + identidad + videofirma | `createContract` con todo lo anterior y `signer_video: 1` — una sola llamada |
    | **Solo** un formulario, sin documento | `POST /formrequests/create` (necesita el id del contacto: `POST /users/contact`) |
    | **Llenar tú** el formulario desde tu sistema (un bot de WhatsApp, un chat, tu app) | `formrequests/create` con `notify: ""` y después `/forms/sign/{token}/…` (sección *Llenar un formulario por API*) |
    | **Solo** verificar identidad, sin documento | `POST /identityverification/create` |
    | Consultar antecedentes de una persona o empresa | `POST /backgroundchecks/create` |

    Cuando pides varias cosas en `createContract`, el firmante las recorre con
    el mismo enlace y en este orden: **formulario → verificación de identidad →
    firma electrónica → videofirma**.

    ## Cuándo queda cerrado un documento

    Un documento no se cierra solo porque todos firmaron: **tú apruebas** lo que
    entregó cada firmante. Se cierra —y llega el webhook `document.completed`—
    cuando se cumple todo esto:

    - Todos los firmantes firmaron.
    - Aprobaste sus firmas y videofirmas en el panel, o tienes activa la
      **aprobación automática** de firmas en tu perfil.
    - Si pediste verificación de identidad, la aprobaste: en el expediente del
      documento o con `POST /identityverification/ownerDecision`. **La
      aprobación automática de firmas no aprueba la identidad.**
    - Si pediste formulario, el firmante lo completó. Si la plantilla está
      configurada para revisión, además lo aprobaste en el expediente.

    ## Errores

    Depende del grupo de endpoints:

    - **Documentos y contactos** responden **HTTP 200 también cuando algo
      falla**. Mira `success`: si es `false`, `error_code` (o `msj`) dice qué
      pasó. En `createContract` puedes pedir códigos HTTP reales (400, 401, 402,
      422, 429, 500) enviando el header `X-Validocus-Api: 2026-11-03`. La
      excepción es `signStatus`, que responde 401 cuando el token no sirve.
    - **Formularios, verificación de identidad y antecedentes** usan códigos HTTP
      reales y responden `{"success": false, "msj": "<código>"}`.

    Si escribes a soporte, incluye el `request_id` cuando la respuesta lo traiga.

    ## Límites y seguridad

    - El token da acceso total a tu cuenta y gasta saldo: guárdalo solo en tu
      servidor, nunca en una página o app pública. Para revocarlo, bórralo en el
      panel y genera otro.
    - Llamadas de servidor a servidor: no hace falta enviar el header `Origin`.
    - `createContract` admite hasta **60 llamadas por minuto** por cuenta; al
      pasarte responde `rate_limit_exceeded` con el header `Retry-After`.
    - Todo lo que se cobra sale de tu saldo con las tarifas de tu plan. Si no
      alcanza, la llamada responde `insufficient_balance` y **no se crea nada**:
      no hay cobros parciales.
servers:
  - url: https://api.validocus.com
    description: Producción — crea documentos reales y gasta saldo
  - url: https://apivalidocus.concilbot.com
    description: Pruebas — cuenta y saldo separados. Ojo, las personas reciben correos, SMS y WhatsApp reales
security:
  - bearerAuth: []
x-tagGroups:
  - name: Firma
    tags: [Documentos]
  - name: Por separado, sin documento
    tags: [Contactos, Formularios, Llenar un formulario por API, Verificación de identidad, Antecedentes, Riesgo on-chain (KYT)]
  - name: Avisos
    tags: [Webhooks]
  - name: Servicio
    tags: [General]
tags:
  - name: General
    description: Estado del servicio y tarifas vigentes.
  - name: Documentos
    description: >
      Crear un documento y mandarlo a firmar —con formulario, verificación de
      identidad y videofirma si los necesitas—, consultar su estado, cancelarlo
      y obtener su evidencia.
  - name: Contactos
    description: >
      Las personas a las que les pides algo. `createContract` crea o reutiliza el
      contacto por su cuenta; para pedir un formulario o una verificación **sin
      documento** necesitas su id (`idContacto`).
  - name: Formularios
    description: >
      Pedirle a un contacto que llene uno de tus formularios, sin documento que
      firmar. Las plantillas se diseñan en el panel; aquí consultas sus ids, envías
      la invitación y lees las respuestas.


      Si en cambio quieres que el formulario se llene **antes de firmar**, no uses
      estos endpoints: manda `form_template_id` en `createContract`.
  - name: Llenar un formulario por API
    description: >
      Para cuando **tu sistema** captura los datos y los manda al formulario: un
      bot de WhatsApp, un chat o tu propia app. Son las mismas llamadas que hace
      la página del formulario cuando la persona lo llena a mano, y el resultado
      es idéntico: puntaje, revisión, PDF de declaración y webhook
      `form.completed`.


      **Flujo:**

      1. `POST /formrequests/create` con `notify: ""` (así no le avisamos a
         nadie). Responde `fill_url` y `gate_id`. El **token** es el último tramo
         de `fill_url` (`…/fill/<token>`).

      2. `GET /forms/sign/{token}` para conocer los campos: el `id` de cada uno,
         su tipo, sus opciones y si es obligatorio.

      3. `PATCH /forms/sign/{token}/draft` cada vez que tengas respuestas. Solo
         cambia los campos que mandas, así que puedes enviarlas de a una, a medida
         que la persona contesta en el chat.

      4. `POST /forms/sign/{token}/upload` para los archivos (foto de la cédula,
         RUT…). Si quieres que la IA los lea y llene campos,
         `POST /forms/sign/{token}/extract`.

      5. `POST /forms/sign/{token}/mark-ready` valida los obligatorios y calcula
         el riesgo. Si falta algo responde 400 con la lista de campos.

      6. `POST /forms/sign/{token}/sign` lo envía. Te llega `form.completed` y
         lees las respuestas con `GET /forms/proxy/gates/{gate_id}`.


      Estas llamadas **no llevan el token de API**: la llave es el token del
      enlace, igual que para la persona. Trátalo como un secreto: quien lo tenga
      puede llenar ese formulario. Se cobra lo mismo que un formulario normal
      ($500 al crearlo y $300 por cada documento que la IA lee).
  - name: Verificación de identidad
    description: >
      Verificación con foto del documento y prueba de vida, sin documento que
      firmar. La persona la hace desde el celular con el enlace que recibe.


      Para pedirla **junto con la firma**, no uses estos endpoints: marca
      `signer_id_verify_liveness: "1"` en el firmante de `createContract`. La
      consulta del resultado (`detail`, `bulkStatus`, `listForContact`) y la
      decisión (`ownerDecision`) sirven para los dos casos.


      **Estados** (`status`): `pending` (aún no empieza) · `in_progress` ·
      `verified` (aprobada por el análisis) · `declined` (rechazada por el
      análisis) · `review` (dudosa: la decides tú) · `failed` · `expired`. Con
      `verified` y `declined` la decisión es automática; con `review`, `failed` y
      `expired` hace falta que decidas con `ownerDecision`.
  - name: Antecedentes
    description: >
      Consulta de antecedentes y listas restrictivas de una persona o empresa. Es
      asíncrona: crea la consulta, espera el webhook `background.completed` y
      lee el resultado.
  - name: Riesgo on-chain (KYT)
    description: |
      Analiza el riesgo de una dirección de criptomonedas (`screenWallet`) o de un
      movimiento concreto (`screenTransaction`). Es **síncrono**: la respuesta trae
      el resultado. Se cobra por consulta exitosa; los rechazos por datos
      inválidos no se cobran.

      **Decide con `recommendation`**, no con `risk_score` ni `severity`:

      | `recommendation` | Qué hacer | Cuándo sale |
      |---|---|---|
      | `approve` | Operar (p. ej. acreditar el depósito) | Exchange regulado con KYC obligatorio, o dirección sin señales de riesgo |
      | `review` | Retener y pasar a revisión humana | Exchange con KYC opcional o de alto riesgo; wallet personal con contacto con sancionados o con categorías de riesgo; exposición a PEP; o datos insuficientes |
      | `reject` | No operar y escalar a cumplimiento | La dirección misma está en listas de sanciones o atribuida a robo, ransomware, darknet, estafa o financiación del terrorismo |

      **Qué revisamos:** si la dirección figura en listas de sanciones
      internacionales (EE. UU., Unión Europea, Reino Unido, ONU, entre otras); de
      quién es (un exchange, un servicio, una entidad sancionada o nadie
      identificado); con quién tuvo contacto y en qué proporción; y de dónde vienen
      y a dónde van sus fondos.

      **Estar en una lista no es lo mismo que tener contacto** con alguien que lo
      está. `wallet_sanctioned` responde lo primero; `sanctions_hit` y
      `sanctions_exposure_pct`, lo segundo. La wallet de un exchange casi siempre
      tiene algo de contacto con sancionados (es la actividad del exchange con
      terceros, no la de tu cliente), por eso una dirección de exchange se juzga
      por el exchange (`owner`). Sin datos no significa limpia: va a `review`.

      La recomendación es el criterio de Validocus sobre los datos consultados; la
      decisión final y su registro corresponden a tu oficial de cumplimiento.

      **Buenas prácticas para depósitos:** consulta antes de acreditar. Si la API
      falla o responde `402`, trátalo como `review`. Si el `reject` es por
      sanciones, no devuelvas los fondos en automático: lo decide cumplimiento.
      Para devolver, pide una dirección de retorno a nombre del usuario (la de
      origen, si viene de un exchange, no es suya). Guarda el `id` de la consulta:
      su informe en PDF es la evidencia.
  - name: Webhooks
    description: >
      Validocus te avisa por HTTP POST a la URL que registres en el panel
      (**Perfil → Webhooks**). Tiene que ser `https://` y accesible desde
      internet. Al crearlo se muestra **una sola vez** el secreto de firma, que
      empieza por `whsec_`.


      **Verifica cada entrega.** Llega con dos headers: `X-Validocus-Signature`
      (HMAC-SHA256 en hexadecimal) y `X-Validocus-Timestamp` (epoch en segundos).
      La firma es sobre `timestamp + "." + cuerpo_crudo` con tu secreto — no
      sobre el cuerpo solo. Compara con una función de tiempo constante y
      rechaza lo que tenga más de 5 minutos.


      **Responde `2xx` apenas valides la firma** y procesa aparte: el timeout por
      intento es de 10 segundos. Lo que falla se reintenta a los 30 s, 2 min,
      10 min, 1 h y 6 h. Un mismo evento puede llegar dos veces: deduplica por
      `id`.


      El PDF **no viaja en el evento**: se consulta en `data.status_url`.
paths:
  /:
    get:
      tags: [General]
      summary: Comprobar que la API responde
      description: >
        Responde si el servicio está arriba. **No valida el token**: devuelve lo
        mismo con un token válido, con uno inválido o sin token. Para saber si tu
        token sirve, consulta `signStatus` de un documento tuyo: sin un token
        válido responde 401.
      security: []
      responses:
        "200":
          description: El servicio responde.
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean, example: true }
                  message: { type: string, example: Validocus API }

  /services/publicCatalog:
    get:
      tags: [General]
      summary: Tarifas vigentes
      description: >
        Los precios por uso y los planes vigentes, en pesos y en dólares. Es
        público: **no lleva token**. Son los mismos números con los que se
        cobra y los que publica validocus.com.


        Los montos van en **centavos de su moneda**: `190000` en `COP` son
        $1.900 y `48` en `USD` son US$0,48. Lo que se le cobra a tu cuenta sale
        de la moneda de tu cuenta. Algunos códigos son de servicios anteriores y
        siguen en la lista para integraciones que ya los usan; los vigentes son
        `DOCUMENT`, `FORM`, `FORM_AI_DOC`, `ID_VERIFY_LIVENESS`,
        `BG_CHECK_ESENCIAL`, `BG_CHECK_KYB`, `BG_CHECK_KYB_SOCIO`, `KYT_WALLET`,
        `KYT_TRANSACTION`, `AI_MONITORING` y `AI_DEEP_ANALYSIS`.


        Se guarda en caché 5 minutos y admite 60 llamadas por minuto por IP.
      security: []
      responses:
        "200":
          description: Catálogo de tarifas.
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean, example: true }
                  data:
                    type: object
                    properties:
                      prices:
                        type: array
                        description: Una fila por servicio y moneda.
                        items:
                          type: object
                          properties:
                            code: { type: string, example: BG_CHECK_ESENCIAL }
                            currency: { type: string, enum: [COP, USD], example: COP }
                            amount_cents: { type: integer, description: Precio en centavos de `currency`., example: 190000 }
                            label_es: { type: string, example: Consulta de antecedentes }
                            label_en: { type: [string, "null"], example: Background check }
                            label_fr: { type: [string, "null"], example: Vérification des antécédents }
                      plans:
                        type: array
                        description: Una fila por plan, moneda y período. El plan gratis no está en la lista.
                        items:
                          type: object
                          properties:
                            code: { type: string, enum: [STARTER, PRO, BUSINESS], example: PRO }
                            currency: { type: string, enum: [COP, USD], example: COP }
                            period: { type: string, enum: [monthly, annual], example: monthly }
                            price_cents: { type: integer, description: Precio del período en centavos., example: 11200000 }
                            allowance_cents: { type: integer, description: "Cupo de consumo de cada mes, en centavos., example: 15000000" }
                            max_signers: { type: integer, description: Firmantes por documento., example: 60 }
                      generated_at: { type: string, description: Cuándo se armó el catálogo (ISO 8601)., example: "2026-11-07T15:00:00-05:00" }
        "429":
          description: "`rate_limit_exceeded`: más de 60 llamadas por minuto desde la misma IP."
          content: { application/json: { schema: { $ref: "#/components/schemas/ErrorSimple" } } }

  /documents/createContract:
    post:
      tags: [Documentos]
      summary: Crear un documento y mandarlo a firmar
      description: |
        Crea el documento, lo manda a cada firmante (correo siempre; SMS y
        WhatsApp si el firmante tiene celular) y devuelve un enlace de firma por
        firmante. En la misma llamada puedes exigir formulario, verificación de
        identidad y videofirma.

        Se envía como **`multipart/form-data`, no como JSON**. `signers` es un
        arreglo JSON **serializado como texto** dentro de un campo del formulario.
        No fijes el `Content-Type` a mano: deja que tu cliente HTTP ponga el
        `boundary`.

        ### El PDF

        Envíalo de una de estas dos formas:

        - **`contract`** — el archivo PDF.
        - **`contract_url`** — una URL `https://` pública desde la que lo
          descargamos. Máximo 50 MB, 30 segundos de descarga, sin redirecciones y
          sin direcciones privadas. Si no se puede descargar, el documento no se
          crea.

        ### Qué se le pide a cada firmante

        | Para pedir… | Envía | Alcance |
        |---|---|---|
        | Firma electrónica | — | Siempre se pide; no hace falta ningún campo |
        | Formulario | `form_template_id` (campo del documento) | Todos los firmantes del documento llenan el mismo formulario |
        | Verificación de identidad con prueba de vida | `"signer_id_verify_liveness": "1"` | Solo ese firmante |
        | Videofirma | `"signer_video": 1` | Solo ese firmante |

        El firmante recorre todo con el mismo enlace, en este orden:
        **formulario → verificación de identidad → firma → videofirma**.

        ### Campos de cada firmante (dentro de `signers`)

        | Campo | Obligatorio | Qué es |
        |---|---|---|
        | `signer_name` | Sí | Nombres. |
        | `signer_lastname` | Sí | Apellidos. |
        | `signer_email` | Sí | Correo. Es como reconocemos al firmante entre documentos. |
        | `signer_phone` | Recomendado | Celular con indicativo (`+573001234567`). Sin él no se envían SMS ni WhatsApp. |
        | `signer_typeid` | Recomendado | Tipo de documento: `"1"` cédula de ciudadanía · `"2"` cédula de extranjería · `"3"` pasaporte · `"5"` NIT. |
        | `signer_id` | Recomendado | Número de documento. |
        | `signer_id_verify_liveness` | No | `"1"` exige verificación de identidad con foto del documento y prueba de vida. |
        | `signer_video` | No | `1` exige videofirma. |
        | `signer_message` | No | Frase que el firmante debe decir en la videofirma. |
        | `signer_access` | No | Cómo entra al enlace: `"3"` directo (por defecto) · `"1"` con código por SMS · `"2-<palabra>"` con una palabra clave que tú le das · `"4-<número>"` escribiendo su número de documento. |
        | `signer_footer` | No | Rol que aparece bajo su firma (`Arrendatario`, `Representante legal`). |
        | `signer_update_if_exist` | No | `"true"` actualiza los datos de un contacto que ya existe. Sin esto, si el contacto ya existe se conservan sus datos guardados. |
        | `positions` | No | Dónde estampar la firma: `[{"w": "<correo del firmante>", "page": 1, "top": 650, "left": 80}]`. `page` empieza en 1; `top` y `left` en puntos PDF. Sin posiciones, la firma va en la hoja de certificación al final del PDF. |

        Si el correo es nuevo, se crea el contacto; si ya existe en tu cuenta, se
        reutiliza. Si el número de documento ya pertenece a **otro** contacto de
        tu cuenta con otro correo, la llamada falla con
        `signer_document_belongs_to_different_contact`.

        ### Qué se cobra

        - El documento, al crearlo.
        - El formulario, **una vez por documento**, al crearlo: $500 (US$0,13).
        - Cada documento que la IA **lee de verdad** de lo que sube la persona
          en el formulario: $300 (US$0,08), al leerlo y **una sola vez por
          documento**; las relecturas (hasta 3) no se cobran. Cada archivo
          admite hasta 20 MB y 100 páginas. Si en ese momento no tienes saldo,
          la IA no lo lee y la persona completa esos datos a mano. Un
          formulario sin documentos solo paga los $500.
        - Los montos vigentes, en pesos y en dólares, están en
          `GET /services/publicCatalog` (`FORM` y `FORM_AI_DOC`).
        - La verificación de identidad, cuando el firmante la **empieza**. Si en
          ese momento no tienes saldo, el firmante no puede continuar: mantén
          saldo mientras haya verificaciones pendientes.
        - La videofirma no tiene costo.

        ### Varios firmantes

        - Por defecto se crea **un** documento que firman todos, y cada uno
          recibe su invitación al mismo tiempo.
        - Con `multidoc=true` se crea **un documento por firmante** (el mismo PDF
          para cada uno) y la respuesta es un arreglo.

        ### El enlace de firma

        `urls_sign` trae un enlace por firmante, en el mismo orden en que los
        enviaste. Puedes mandárselo tú o redirigir a la persona; nosotros también
        se lo enviamos. Cada enlace vale **7 días** por defecto, aparte del plazo
        del documento (`expiresInDays`). Si vence, reenvía la invitación desde el
        panel.

        Cuando termina de firmar, la persona ve su comprobante. Si enviaste
        `urlReturn`, el botón de cierre la lleva a tu sitio con
        `?document_id=<soli_id>&status=signed`. Ese `status=signed` significa que
        **ese firmante** firmó, no que el documento esté cerrado: para eso espera
        `document.completed`.
      x-codeSamples:
        - lang: cURL
          label: Solo firma
          source: |
            curl https://api.validocus.com/documents/createContract \
              -H "Authorization: Bearer $VALIDOCUS_TOKEN" \
              -F contract=@contrato.pdf \
              -F title_doc="Contrato de arrendamiento" \
              -F 'signers=[{"signer_name":"Juan","signer_lastname":"Pérez","signer_email":"juan@ejemplo.com","signer_phone":"+573001234567","signer_typeid":"1","signer_id":"1020304050"}]'
        - lang: cURL
          label: Formulario + identidad + firma + video
          source: |
            curl https://api.validocus.com/documents/createContract \
              -H "Authorization: Bearer $VALIDOCUS_TOKEN" \
              -F contract_url=https://tusitio.com/contratos/302.pdf \
              -F title_doc="Contrato de crédito" \
              -F form_template_id=66f1c2a9e4b0a1b2c3d4e5f6 \
              -F externalReference=lead-8841 \
              -F urlReturn=https://tusitio.com/gracias \
              -F 'signers=[{"signer_name":"Juan","signer_lastname":"Pérez","signer_email":"juan@ejemplo.com","signer_phone":"+573001234567","signer_typeid":"1","signer_id":"1020304050","signer_id_verify_liveness":"1","signer_video":1}]'
      parameters:
        - name: X-Validocus-Api
          in: header
          required: false
          description: >
            Opcional. Con `2026-11-03` los errores responden con su código HTTP
            real (400, 401, 402, 422, 429, 500) en vez de 200. El cuerpo es el
            mismo.
          schema: { type: string, example: "2026-11-03" }
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required: [signers]
              properties:
                contract:
                  type: string
                  format: binary
                  description: El PDF como archivo. Usa este campo o `contract_url`.
                contract_url:
                  type: string
                  description: >
                    URL `https://` pública del PDF. Usa este campo o `contract`. Si no
                    es `http(s)` responde `invalid_contract_url`.
                  example: https://tusitio.com/contratos/302.pdf
                title_doc:
                  type: string
                  description: Título visible del documento.
                  example: Contrato de arrendamiento
                desc_doc:
                  type: string
                  description: Descripción.
                  example: Apartamento 302, torre B
                signers:
                  type: string
                  description: >
                    Arreglo JSON de firmantes, serializado como texto. Los campos
                    de cada firmante están en la tabla de arriba.
                  example: >-
                    [{"signer_name":"Juan","signer_lastname":"Pérez","signer_email":"juan@ejemplo.com","signer_phone":"+573001234567","signer_typeid":"1","signer_id":"1020304050","signer_id_verify_liveness":"1","signer_video":1,"signer_footer":"Arrendatario"}]
                form_template_id:
                  type: string
                  description: >
                    Id de una plantilla de formulario de tu cuenta (búscalo con
                    `GET /forms/proxy/forms`). Todos los firmantes lo llenan antes
                    de firmar. Se cobra una vez por documento.
                  example: 66f1c2a9e4b0a1b2c3d4e5f6
                externalReference:
                  type: string
                  maxLength: 191
                  description: >
                    Tu id interno (de tu CRM, por ejemplo). Vuelve en cada webhook
                    como `external_reference`. Sin saltos de línea ni caracteres de
                    control.
                  example: lead-8841
                expiresInDays:
                  type: integer
                  minimum: 0
                  maximum: 365
                  description: Plazo para firmar, en días. `0` = no vence. Si lo omites, 30 días.
                  example: 15
                urlReturn:
                  type: string
                  maxLength: 512
                  description: >
                    A dónde vuelve el firmante al terminar. `https://`, dominio
                    público (no IP ni `localhost`), sin usuario:clave ni puerto
                    propio. Si no es válida, el documento no se crea
                    (`invalid_url_return`).
                  example: https://tusitio.com/gracias
                multidoc:
                  type: string
                  enum: ["false", "true"]
                  description: "`true` crea un documento por firmante. Por defecto `false`: un documento que firman todos."
      responses:
        "200":
          description: >
            **Siempre HTTP 200**, salvo que envíes `X-Validocus-Api`. Si `success`
            es `true`, el documento quedó creado y enviado. Con `multidoc=true` la
            respuesta es un **arreglo**, un elemento por firmante y en el mismo
            orden.
          content:
            application/json:
              schema:
                oneOf:
                  - $ref: "#/components/schemas/DocumentoCreado"
                  - title: Documentos creados (multidoc)
                    type: array
                    items: { $ref: "#/components/schemas/DocumentoCreado" }
                  - $ref: "#/components/schemas/ErrorDocumento"

  /documents/signStatus/{document_id}:
    get:
      tags: [Documentos]
      summary: Consultar el estado de un documento
      parameters:
        - name: document_id
          in: path
          required: true
          description: El `soli_id` que devolvió `createContract`.
          schema: { type: string }
          example: 6ad6d97e99094
      responses:
        "200":
          description: Estado actual. En la práctica bastan dos valores de `soli_status`, 201 (en curso) y 500 (terminado).
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean, example: true }
                  soli_id: { type: string, example: 6ad6d97e99094 }
                  pipeline_status:
                    type: string
                    description: Procesamiento interno; `completed` cuando el PDF final ya se generó. Para saber si el documento terminó mira `soli_status`.
                    example: completed
                  soli_status:
                    type: string
                    description: "`201` esperando firma (el estado normal mientras circula) · `407` venció sin que nadie firmara · `500` cerrado y firmado. Otros: `90`/`91` borrador, `100`/`101` enviado sin abrir, `200` esperando visto bueno, `202` esperando aprobación de anexos, `204` esperando carga de anexos, `205` esperando videofirma, `502` PDF generado, `503` anclado en blockchain."
                    example: "500"
                  signedPdfUrl:
                    type: string
                    description: El PDF final con todas las firmas y el anexo de auditoría. Solo aparece cuando el documento está cerrado; no hay endpoint de descarga aparte.
                    example: https://file.validocus.com/doc/6ad6d97e99094/signed.pdf
                  blockchain:
                    type: string
                    description: Llega como **texto JSON**, no como objeto — hay que parsearlo. Trae `hash` y `explorer`.
                    example: '{"hash":"0x6cbebccb…","explorer":"https://snowtrace.io/tx/0x6cbebccb…"}'
        "401":
          $ref: "#/components/responses/Unauthorized"

  /documents/deleteContract:
    post:
      tags: [Documentos]
      summary: Cancelar un documento
      description: Borrado lógico. El registro y su evidencia se conservan; los firmantes pendientes ya no pueden firmar.
      requestBody:
        required: true
        content:
          application/x-www-form-urlencoded:
            schema:
              type: object
              required: [soli]
              properties:
                soli:
                  type: string
                  description: Id del documento.
                  example: 6ad6d97e99094
      responses:
        "200":
          description: >
            **Siempre HTTP 200.** Ojo: sin un token válido este endpoint responde
            con una forma distinta a la de los demás —
            `{"tokenStatus": 0, "tokenMsj": "unauthorized"}`, sin `success`.
          content:
            application/json:
              schema:
                oneOf:
                  - title: Documento cancelado
                    type: object
                    properties:
                      success: { type: boolean, example: true }
                  - $ref: "#/components/schemas/NoAutorizadoLegacy"

  /documents/getDocumentBlockchainInfo:
    post:
      tags: [Documentos]
      summary: Evidencia en blockchain
      description: >
        Devuelve el hash SHA-256 del PDF firmado y la transacción en Avalanche.
        Es el mismo dato del campo `blockchain` de `signStatus`, así que en la
        mayoría de integraciones no hace falta llamarlo aparte.
      requestBody:
        required: true
        content:
          application/x-www-form-urlencoded:
            schema:
              type: object
              required: [soli]
              properties:
                soli: { type: string, example: 6ad6d97e99094 }
      responses:
        "200":
          description: >
            **Siempre HTTP 200.** El hash y la transacción, o `success: false`
            con `msj` si algo falla (sin token: `session_invalid`).
          content:
            application/json:
              schema:
                oneOf:
                  - title: Evidencia
                    type: object
                    description: Hash SHA-256 del PDF firmado y la transacción en Avalanche.
                  - $ref: "#/components/schemas/SesionInvalida"

  /users/contact:
    post:
      tags: [Contactos]
      summary: Crear un contacto
      description: >
        Crea un contacto en tu cuenta y devuelve su id (`idContacto`), que es el
        que piden los formularios y la verificación de identidad por separado.
        Acepta JSON o formulario.


        Si ya existe un contacto con el **mismo correo o el mismo número de
        documento**, responde `success: false` con un mensaje de texto y **no**
        devuelve el id: búscalo con `GET /users/findContact`. **Siempre HTTP
        200.**
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [c_name, c_email]
              properties:
                c_name: { type: string, description: Nombres., example: Juan }
                c_lastname: { type: string, description: Apellidos., example: Pérez }
                c_documentType:
                  type: integer
                  description: "`1` cédula de ciudadanía · `2` cédula de extranjería · `3` pasaporte · `5` NIT."
                  example: 1
                c_document: { type: string, description: Número de documento., example: "1020304050" }
                c_email: { type: string, description: Correo (obligatorio y válido)., example: juan@ejemplo.com }
                c_phone: { type: string, description: Celular con indicativo. Necesario para avisar por SMS o WhatsApp., example: "+573001234567" }
                c_lang:
                  type: string
                  enum: [es, en, fr]
                  description: Idioma de los mensajes que recibe. Por defecto `es`.
      x-codeSamples:
        - lang: cURL
          source: |
            curl https://api.validocus.com/users/contact \
              -H "Authorization: Bearer $VALIDOCUS_TOKEN" \
              -H "Content-Type: application/json" \
              -d '{"c_name":"Juan","c_lastname":"Pérez","c_documentType":1,"c_document":"1020304050","c_email":"juan@ejemplo.com","c_phone":"+573001234567","c_lang":"es"}'
      responses:
        "200":
          description: Contacto creado, o `success:false` con `msj` si ya existe o falta un dato.
          content:
            application/json:
              schema:
                oneOf:
                  - title: Contacto creado
                    type: object
                    properties:
                      success: { type: boolean, example: true }
                      idContacto: { type: integer, example: 115362 }
                      msj: { type: string, example: Se ha guardado el contacto correctamente }
                  - title: No se creó
                    type: object
                    properties:
                      success: { type: boolean, example: false }
                      msj:
                        type: string
                        example: "Error: Ya tienes un contacto registrado con el número de documento que ingresaste"

  /users/findContact:
    get:
      tags: [Contactos]
      summary: Buscar contactos
      description: >
        Busca en tus contactos por correo, número de documento o nombre. Es una
        búsqueda **aproximada**: puede devolver varios resultados, así que
        confirma el correo exacto antes de usar el `id`. **Siempre HTTP 200.**
      parameters:
        - name: search
          in: query
          required: true
          description: Correo, número de documento o nombre.
          schema: { type: string }
          example: juan@ejemplo.com
      responses:
        "200":
          description: Coincidencias. No trae `success`.
          content:
            application/json:
              schema:
                type: object
                properties:
                  results:
                    type: array
                    items:
                      type: object
                      properties:
                        id: { type: integer, example: 115362 }
                        name: { type: string, example: Juan }
                        last: { type: string, example: Pérez }
                        d_type: { type: string, description: Tipo de documento., example: "1" }
                        document: { type: string, example: "1020304050" }
                        email: { type: string, example: juan@ejemplo.com }
                        phone: { type: string, example: "+573001234567" }

  /forms/proxy/forms:
    get:
      tags: [Formularios]
      summary: Listar tus plantillas de formulario
      description: >
        Devuelve las plantillas de tu cuenta. El `id` es el `form_template_id`
        que usan `createContract` y `formrequests/create`. Filtra con
        `status=PUBLISHED` para ver solo las que se pueden enviar.
      parameters:
        - name: status
          in: query
          required: false
          schema: { type: string, enum: [PUBLISHED, DRAFT] }
          example: PUBLISHED
      responses:
        "200":
          description: Un arreglo de plantillas (sin `success`).
          content:
            application/json:
              schema:
                type: array
                items:
                  type: object
                  properties:
                    id: { type: string, example: 66f1c2a9e4b0a1b2c3d4e5f6 }
                    title: { type: string, example: Conocimiento del cliente (SARLAFT) }
                    description: { type: string }
                    status: { type: string, example: PUBLISHED }
                    schema:
                      type: array
                      description: Secciones y campos del formulario. Úsalo para traducir los ids de campo de las respuestas a su etiqueta.
                    created_at: { type: string }
                    updated_at: { type: string }

  /formrequests/create:
    post:
      tags: [Formularios]
      summary: Pedir un formulario (sin documento)
      description: >
        Invita a un contacto a llenar un formulario y devuelve el enlace público
        (`fill_url`), que no pide inicio de sesión. Puedes dejar que nosotros le
        avisemos o enviárselo tú.


        Se cobra el formulario al crear la solicitud: $500 (US$0,13), igual que
        dentro de un documento. Cada documento que la IA lee de verdad de lo que
        sube la persona suma $300 (US$0,08), al leerlo y una sola vez por
        documento (las relecturas, hasta 3, no se cobran; hasta 20 MB y 100
        páginas por archivo). Reenviar la solicitud no se cobra. Los montos
        vigentes están en `GET /services/publicCatalog`.
        Cuando la persona lo envía, llega el webhook `form.completed`. Guarda el
        `gate_id`: con él lees las respuestas.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [contact_id, form_template_id]
              properties:
                contact_id:
                  type: integer
                  description: Id del contacto (`idContacto`). Va como **número**, no como texto.
                  example: 115362
                form_template_id:
                  type: string
                  description: Id de la plantilla (`GET /forms/proxy/forms`).
                  example: 66f1c2a9e4b0a1b2c3d4e5f6
                template_name:
                  type: string
                  description: Nombre visible en el mensaje y en el panel.
                  example: Conocimiento del cliente
                notify:
                  type: string
                  description: >
                    Por dónde le avisamos: `email`, `sms` o `whatsapp`, o varios
                    separados por coma (`email,whatsapp`); `both` = los tres. Con
                    `""` (vacío) **no le avisamos**: tú le haces llegar el
                    `fill_url`. Si lo omites, por correo. El contacto debe tener
                    correo o celular según el canal.
                  example: email,whatsapp
                external_reference:
                  type: string
                  maxLength: 191
                  description: Tu id interno. Vuelve en el webhook `form.completed`.
                  example: lead-8841
      x-codeSamples:
        - lang: cURL
          source: |
            curl https://api.validocus.com/formrequests/create \
              -H "Authorization: Bearer $VALIDOCUS_TOKEN" \
              -H "Content-Type: application/json" \
              -d '{"contact_id":115362,"form_template_id":"66f1c2a9e4b0a1b2c3d4e5f6","template_name":"Conocimiento del cliente","notify":"email,whatsapp","external_reference":"lead-8841"}'
      responses:
        "200":
          description: Solicitud creada.
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean, example: true }
                  id: { type: integer, description: Id de la solicitud de formulario; llega como `form_request_id` en el webhook., example: 14 }
                  soli_ref: { type: string, description: Referencia para reenviar., example: FR-083fff01f86960b78086 }
                  fill_url: { type: string, example: "https://app.validocus.com/fill/471a4c55758b38d1e75a9d65bc7ae71c27490dc6d3d42d99" }
                  gate_id: { type: [string, "null"], description: Con este id lees las respuestas., example: 6a3b2b73c1d2e3f4a5b6c7d8 }
                  cost_charged: { type: integer, description: "Lo que se descontó, en centavos de tu moneda. `0` si entró en la cortesía de tu plan., example: 1000000" }
        "400": { $ref: "#/components/responses/ErrorValidacion" }
        "401": { $ref: "#/components/responses/NoAutorizado" }
        "402": { $ref: "#/components/responses/SaldoInsuficiente" }
        "403":
          description: "`contact_not_owned`: el contacto no es de tu cuenta."
          content: { application/json: { schema: { $ref: "#/components/schemas/ErrorSimple" } } }
        "422":
          description: "`contact_missing_channel`: el contacto no tiene correo o celular para el canal que pediste."
          content: { application/json: { schema: { $ref: "#/components/schemas/ErrorSimple" } } }
        "503":
          description: "`forms_service_unavailable`: no se pudo crear. No se cobró nada; reintenta."
          content: { application/json: { schema: { $ref: "#/components/schemas/ErrorSimple" } } }

  /formrequests/resend:
    post:
      tags: [Formularios]
      summary: Reenviar la invitación a un formulario
      description: Vuelve a avisarle al contacto con el mismo enlace. No se cobra. Máximo 10 por minuto.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [soli_ref]
              properties:
                soli_ref: { type: string, example: FR-083fff01f86960b78086 }
      responses:
        "200":
          description: Reenviado.
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean, example: true }
                  id: { type: integer, example: 14 }
                  fill_url: { type: string }
        "429":
          description: Más de 10 reenvíos por minuto.

  /formrequests/listForContact/{contactId}:
    get:
      tags: [Formularios]
      summary: Formularios pedidos a un contacto
      description: Las solicitudes de formulario de un contacto, con su estado.
      parameters:
        - name: contactId
          in: path
          required: true
          schema: { type: integer }
          example: 115362
      responses:
        "200":
          description: Lista de solicitudes.
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean, example: true }
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        id: { type: integer, example: 14 }
                        form_template_id: { type: string }
                        template_name: { type: string }
                        soli_ref: { type: string }
                        status: { type: string, description: "`sent` mientras espera; `completed` cuando la persona lo envió.", example: completed }
                        score: { type: [integer, "null"], description: "Puntaje de riesgo, si la plantilla tiene puntaje." }
                        submission_id: { type: [string, "null"] }
                        notify_channel: { type: string, example: email }
                        fill_url: { type: string }
                        created_at: { type: string }
                        completed_at: { type: [string, "null"] }

  /forms/proxy/gates/{gate_id}:
    get:
      tags: [Formularios]
      summary: Leer las respuestas de un formulario
      description: >
        Devuelve el formulario lleno: las respuestas (`responses`, por id de
        campo), el puntaje (`total_score`) y el nivel de riesgo (`bucket`), junto
        con la plantilla (`form_template.schema`) para traducir cada id de campo a
        su etiqueta. El `gate_id` lo devuelve `formrequests/create`.
      parameters:
        - name: gate_id
          in: path
          required: true
          schema: { type: string }
          example: 6a3b2b73c1d2e3f4a5b6c7d8
      responses:
        "200":
          description: El formulario con sus respuestas.
          content:
            application/json:
              schema:
                type: object
                properties:
                  responses: { type: object, description: "Respuestas: `{ \"<id de campo>\": <valor> }`." }
                  total_score: { type: [number, "null"] }
                  bucket:
                    type: [object, "null"]
                    description: Nivel de riesgo según la configuración de la plantilla.
                    properties:
                      code: { type: string, example: MEDIO }
                      label: { type: string, example: Riesgo medio }
                  form_template:
                    type: object
                    properties:
                      schema: { type: array }

  /forms/sign/{token}:
    get:
      tags: [Llenar un formulario por API]
      summary: Ver los campos y las respuestas guardadas
      description: >
        Devuelve la plantilla (`template.schema`, con el `id`, el tipo, las
        opciones y si es obligatorio cada campo), las respuestas que ya hay
        (`responses`) y el estado (`state`). `FILLING` = se puede llenar;
        `READY_FOR_SIGNING` = validado, falta enviarlo; `SIGNED` = enviado.
      security: []
      parameters:
        - $ref: "#/components/parameters/FillToken"
      x-codeSamples:
        - lang: cURL
          source: |
            curl https://api.validocus.com/forms/sign/471a4c55758b38d1e75a9d65bc7ae71c27490dc6d3d42d99
      responses:
        "200":
          description: El formulario.
          content:
            application/json:
              schema:
                type: object
                properties:
                  state: { type: string, example: FILLING }
                  template:
                    type: object
                    properties:
                      id: { type: string }
                      title: { type: string, example: Conocimiento del cliente (SARLAFT) }
                      schema: { type: array, description: "Filas de campos (`slots`). Cada campo trae `id` (el que usas en `responses`), `type`, `label`, `required` y, si aplica, `options`." }
                      sections: { type: array }
                  responses: { type: object, description: "Lo guardado hasta ahora: `{ \"<id de campo>\": <valor> }`." }
                  expires_at: { type: [string, "null"] }
        "404": { description: "`gate_not_found`: el token no existe o el formulario se anuló." }

  /forms/sign/{token}/draft:
    patch:
      tags: [Llenar un formulario por API]
      summary: Guardar respuestas
      description: >
        Guarda las respuestas que mandas y **deja intactas las demás**: puedes
        llamar una vez por cada dato que capture tu bot. Las claves son los `id`
        de campo de `GET /forms/sign/{token}`. Para borrar un archivo ya subido,
        manda `""` en su campo. Solo funciona mientras el formulario está en
        `FILLING` (o en `CHANGES_REQUESTED`, si pediste correcciones); si ya se
        envió responde 409 `gate_not_editable`.
      security: []
      parameters:
        - $ref: "#/components/parameters/FillToken"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                responses:
                  type: object
                  description: "`{ \"<id de campo>\": <valor> }`. Texto, número, fecha (`YYYY-MM-DD`), una opción de la lista o un arreglo si el campo es de selección múltiple."
              example:
                responses:
                  fld_nombre: Ana Pérez
                  fld_ciudad: Medellín
                  fld_actividad: Comercio
      x-codeSamples:
        - lang: cURL
          source: |
            curl -X PATCH https://api.validocus.com/forms/sign/$TOKEN/draft \
              -H "Content-Type: application/json" \
              -d '{"responses":{"fld_nombre":"Ana Pérez","fld_ciudad":"Medellín"}}'
      responses:
        "200": { description: Guardado. }
        "409": { description: "`gate_not_editable`: el formulario ya se envió o se anuló." }

  /forms/sign/{token}/upload:
    post:
      tags: [Llenar un formulario por API]
      summary: Subir un archivo a un campo
      description: >
        Para los campos de tipo archivo (cédula, RUT, certificado…). Hasta 20 MB,
        y un PDF hasta 100 páginas; si se pasa responde 422 (`file_too_large` o
        `file_too_many_pages`) y no se guarda. Subir **no** lo lee la IA ni se
        cobra: para eso está `extract`.
      security: []
      parameters:
        - $ref: "#/components/parameters/FillToken"
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required: [file, field_id]
              properties:
                file: { type: string, format: binary }
                field_id: { type: string, description: Id del campo de archivo., example: fld_cedula }
      x-codeSamples:
        - lang: cURL
          source: |
            curl https://api.validocus.com/forms/sign/$TOKEN/upload \
              -F "file=@cedula.pdf" -F "field_id=fld_cedula"
      responses:
        "200":
          description: Guardado en el campo.
          content:
            application/json:
              schema:
                type: object
                properties:
                  uploaded_file: { type: object, description: "Nombre, tamaño y tipo del archivo guardado." }
                  field_key: { type: string, example: fld_cedula }
        "422": { description: "`file_too_large` o `file_too_many_pages`." }

  /forms/sign/{token}/extract:
    post:
      tags: [Llenar un formulario por API]
      summary: Que la IA lea un archivo y llene campos
      description: >
        Lee el archivo ya subido a ese campo y llena con lo que encuentre los
        campos que la plantilla asocia a ese documento. Cuesta $300 (US$0,08) la
        primera lectura de cada documento; hasta 3 relecturas sin costo. Sin saldo
        no se lee y los campos se llenan a mano.
      security: []
      parameters:
        - $ref: "#/components/parameters/FillToken"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [field_id]
              properties:
                field_id: { type: string, example: fld_cedula }
      responses:
        "200": { description: "Lo que se llenó (`responses`). Si no se pudo leer, el motivo en `ai_skipped`." }

  /forms/sign/{token}/mark-ready:
    post:
      tags: [Llenar un formulario por API]
      summary: Validar el formulario
      description: >
        Revisa que estén todos los obligatorios, calcula el puntaje de riesgo y
        arma el PDF de la declaración. Según la configuración de la plantilla, el
        formulario queda en `READY_FOR_SIGNING` (falta enviarlo),
        `PENDING_OFFICER_REVIEW` (lo revisas tú en el panel) o `REVOKED` (lo
        rechazó la regla automática).
      security: []
      parameters:
        - $ref: "#/components/parameters/FillToken"
      responses:
        "200":
          description: Validado.
          content:
            application/json:
              schema:
                type: object
                properties:
                  state: { type: string, example: READY_FOR_SIGNING }
                  total_score: { type: [number, "null"] }
                  bucket: { type: [object, "null"] }
                  declaration_pdf_url: { type: [string, "null"] }
        "400":
          description: "`validation_failed`: faltan campos. Complétalos con `draft` y vuelve a llamar."
          content:
            application/json:
              example:
                error: validation_failed
                errors:
                  - field_id: fld_ciudad
                    message: Campo requerido (Ciudad)
        "409": { description: "`gate_not_editable`: el formulario ya se envió o se anuló." }

  /forms/sign/{token}/sign:
    post:
      tags: [Llenar un formulario por API]
      summary: Enviar el formulario
      description: >
        Cierra el formulario y dispara el webhook `form.completed`. Solo funciona
        después de `mark-ready` con el formulario en `READY_FOR_SIGNING`; si no,
        responde 409 `gate_not_ready`. Después lees todo con
        `GET /forms/proxy/gates/{gate_id}`.
      security: []
      parameters:
        - $ref: "#/components/parameters/FillToken"
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                signer_identity:
                  type: object
                  description: Datos de quien lo envía, para la auditoría (por ejemplo, el número de WhatsApp desde el que respondió).
                  example: { channel: whatsapp, phone: "+573001234567" }
      x-codeSamples:
        - lang: cURL
          source: |
            curl -X POST https://api.validocus.com/forms/sign/$TOKEN/sign \
              -H "Content-Type: application/json" \
              -d '{"signer_identity":{"channel":"whatsapp","phone":"+573001234567"}}'
      responses:
        "200": { description: "Enviado (`state: SIGNED`)." }
        "409": { description: "`gate_not_ready`: falta validarlo con `mark-ready`." }

  /identityverification/create:
    post:
      tags: [Verificación de identidad]
      summary: Pedir una verificación de identidad (sin documento)
      description: >
        Crea la verificación y devuelve el enlace (`validity_url`) que la persona
        abre en el celular para tomar la foto de su documento y hacer la prueba de
        vida. Se cobra al crearla; si el servicio de verificación no está
        disponible responde 503 y se devuelve el cobro.


        Indica a la persona con `signer_contact_id` (el `idContacto`) o con
        `signer_email`. Con el correo usamos el contacto de tu cuenta que lo
        tenga, o creamos uno nuevo solo con correo y celular: si quieres que
        aparezca con nombre, créalo antes con `POST /users/contact`.


        Pedir una nueva verificación para el mismo contacto **anula el enlace
        anterior**. Al terminar llegan los webhooks `identity.completed` y, cuando
        hay decisión, `identity.decided`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [notify]
              properties:
                signer_contact_id: { type: integer, description: Id del contacto. Envía este o `signer_email`., example: 115362 }
                signer_email: { type: string, description: Correo de la persona. Envía este o `signer_contact_id`., example: juan@ejemplo.com }
                signer_phone: { type: string, description: "Celular con indicativo, para avisar por SMS o WhatsApp., example: \"+573001234567\"" }
                notify:
                  type: string
                  description: >
                    Por dónde le avisamos: `email`, `sms` o `whatsapp`, o varios
                    separados por coma; `both` = los tres. Con `none` no le
                    avisamos y tú le haces llegar `validity_url`. Es obligatorio:
                    vacío responde `notify_required`.
                  example: email,whatsapp
                external_reference:
                  type: string
                  maxLength: 191
                  description: Tu id interno. Vuelve en los webhooks de identidad.
                  example: lead-8841
      x-codeSamples:
        - lang: cURL
          source: |
            curl https://api.validocus.com/identityverification/create \
              -H "Authorization: Bearer $VALIDOCUS_TOKEN" \
              -H "Content-Type: application/json" \
              -d '{"signer_contact_id":115362,"notify":"email,whatsapp","external_reference":"lead-8841"}'
      responses:
        "200":
          description: >
            Verificación creada. Ojo: un correo mal escrito también responde 200,
            con `{"success": false, "msj": "invalid_signer_email"}`.
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean, example: true }
                  id: { type: integer, description: Id de la verificación (`identity_verification_id` en los webhooks)., example: 912 }
                  validity_url: { type: string, description: Enlace para la persona., example: "https://app.validocus.com/validity/5f1c2a9e4b0a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a" }
                  cost_charged: { type: integer, description: "Precio de la verificación, en centavos de tu moneda." }
                  notified: { type: boolean, description: "`true` si pediste aviso por algún canal." }
        "400":
          description: "`notify_required` (falta `notify`) o `invalid_external_reference` (con `reason`)."
          content: { application/json: { schema: { $ref: "#/components/schemas/ErrorSimple" } } }
        "401": { $ref: "#/components/responses/NoAutorizado" }
        "402": { $ref: "#/components/responses/SaldoInsuficiente" }
        "403":
          description: "`contact_not_owned`: el contacto no es de tu cuenta."
          content: { application/json: { schema: { $ref: "#/components/schemas/ErrorSimple" } } }
        "503":
          description: "`provider_unavailable`: no se pudo crear; el cobro se devolvió. Reintenta."
          content: { application/json: { schema: { $ref: "#/components/schemas/ErrorSimple" } } }

  /identityverification/detail/{id}:
    get:
      tags: [Verificación de identidad]
      summary: Resultado de una verificación
      description: >
        Sirve para las verificaciones por separado y para las que van con una
        firma. Abajo están los campos que puedes usar; la respuesta trae otros de
        uso interno que pueden cambiar sin aviso. Las URLs de imágenes vencen en
        una hora.
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: integer }
          example: 912
      responses:
        "200":
          description: La verificación.
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean, example: true }
                  data:
                    type: object
                    properties:
                      id: { type: integer, example: 912 }
                      status: { type: string, description: Ver los estados en la descripción del grupo., example: verified }
                      soli_id: { type: [string, "null"], description: "Documento al que pertenece, si se pidió con la firma." }
                      signer_email: { type: string }
                      verified_full_name: { type: [string, "null"], description: Nombre leído del documento., example: JUAN PEREZ GOMEZ }
                      verified_doc_number: { type: [string, "null"], description: Número leído del documento., example: "1020304050" }
                      liveness_score: { type: [number, "null"], description: "Prueba de vida, de 0 a 100., example: 97.5" }
                      face_match_score: { type: [number, "null"], description: "Parecido entre la cara y la foto del documento, de 0 a 100., example: 91.2" }
                      completed_at: { type: [string, "null"] }
                      owner_decision: { type: string, enum: [pending, approved, rejected], description: "Tu decisión. Con firma, el documento no se cierra mientras esté en `pending`." }
                      owner_decision_note: { type: [string, "null"] }
                      owner_decision_at: { type: [string, "null"] }

  /identityverification/bulkStatus:
    get:
      tags: [Verificación de identidad]
      summary: Estado de varias verificaciones
      parameters:
        - name: ids
          in: query
          required: true
          description: Ids separados por coma. Máximo 50.
          schema: { type: string }
          example: 912,913,914
      responses:
        "200":
          description: Estado de cada una.
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean, example: true }
                  items:
                    type: array
                    items:
                      type: object
                      properties:
                        id: { type: integer }
                        signer_email: { type: string }
                        status: { type: string }
                        verified_full_name: { type: [string, "null"] }
                        verified_doc_number: { type: [string, "null"] }
                        completed_at: { type: [string, "null"] }

  /identityverification/listForContact/{contactId}:
    get:
      tags: [Verificación de identidad]
      summary: Verificaciones de un contacto
      parameters:
        - name: contactId
          in: path
          required: true
          schema: { type: integer }
          example: 115362
      responses:
        "200":
          description: Las verificaciones del contacto, de la más reciente a la más antigua.

  /identityverification/ownerDecision:
    post:
      tags: [Verificación de identidad]
      summary: Aprobar o rechazar una verificación
      description: >
        Tu decisión sobre una verificación. Es lo que hace falta cuando quedó en
        `review`, `failed` o `expired`, y es lo que destraba el cierre de un
        documento que pidió verificación junto con la firma. Una verificación se
        decide una sola vez (`409 already_decided`). Dispara el webhook
        `identity.decided`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [iv_id, decision, note]
              properties:
                iv_id: { type: integer, example: 912 }
                decision: { type: string, enum: [approved, rejected] }
                note: { type: string, minLength: 10, description: Por qué decides así. Mínimo 10 caracteres; queda en la evidencia., example: Documento y rostro coinciden con el cliente presencial. }
      responses:
        "200":
          description: Decisión guardada.
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean, example: true }
                  iv_id: { type: integer, example: 912 }
        "400":
          description: "`invalid_id`, `invalid_decision` o la nota no alcanza los 10 caracteres."
          content: { application/json: { schema: { $ref: "#/components/schemas/ErrorSimple" } } }
        "403":
          description: "`not_owner`: la verificación no es de tu cuenta."
          content: { application/json: { schema: { $ref: "#/components/schemas/ErrorSimple" } } }
        "409":
          description: "`already_decided`: ya se decidió antes."
          content: { application/json: { schema: { $ref: "#/components/schemas/ErrorSimple" } } }

  /backgroundchecks/create:
    post:
      tags: [Antecedentes]
      summary: Consultar antecedentes
      description: >
        Crea la consulta y responde de inmediato con `status: "processing"`. El
        resultado llega con el webhook `background.completed`; después lo lees
        con `detail` o descargas el informe en PDF. Se cobra al crear la consulta.
        Si la misma persona ya se consultó hace poco, devolvemos ese resultado sin
        cobrar (`cost_charged: 0`).
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [person_doc, person_doc_type]
              properties:
                person_doc: { type: string, description: Número de documento (o NIT)., example: "1020304050" }
                person_doc_type: { type: string, enum: [CC, CE, PP, PPT, NIT, INT], description: "`CC` cédula · `CE` cédula de extranjería · `PP` pasaporte · `PPT` permiso por protección temporal · `NIT` empresa · `INT` documento de otro país." }
                person_full_name: { type: string, description: Nombre completo o razón social. Mejora la búsqueda en listas., example: Juan Pérez Gómez }
                person_country: { type: string, description: "País del documento, código ISO de 2 letras. Por defecto `CO`., example: CO" }
                entity_type: { type: string, enum: [person, company], description: Por defecto `person`. }
                tier:
                  type: string
                  enum: [nacional]
                  description: >
                    Opcional. Hay un solo servicio, la consulta de antecedentes:
                    listas restrictivas y sanciones internacionales, PEP, medios
                    adversos y las fuentes nacionales del país. En Colombia el
                    documento es obligatorio (`document_required_for_tier`). Los
                    valores anteriores (`internacional`, `esencial`…) se siguen
                    aceptando y corren este mismo servicio.
                include_kyb:
                  type: boolean
                  description: >
                    Solo para `entity_type: company` en Colombia. Suma la revisión en
                    listas del representante legal y de cada socio con más del 5 %
                    (los que la empresa declaró en el formulario o salieron del
                    certificado de Cámara de Comercio). Se cobra por persona: el
                    representante legal más cada socio revisado. Si la empresa no
                    tiene socios cargados, se revisa solo al representante legal.
                external_reference: { type: string, maxLength: 191, description: Tu id interno. Vuelve en el webhook. }
      responses:
        "200":
          description: Consulta creada.
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean, example: true }
                  id: { type: integer, description: Id de la consulta (`background_check_id` en el webhook)., example: 3301 }
                  status: { type: string, example: processing }
                  tier: { type: string, example: nacional }
                  cost_charged: { type: integer, description: "Total en centavos de tu moneda (consulta más `include_kyb` si lo pediste). `0` si se reutilizó un resultado reciente." }
                  kyb:
                    type: object
                    description: Solo si pediste `include_kyb`.
                    properties:
                      amount_cents: { type: integer, description: Lo cobrado por la revisión del representante legal y los socios. }
                      shareholders: { type: integer, description: "Socios incluidos en la revisión. `0` si la empresa no tiene socios cargados: se revisa solo al representante legal." }
                      extra_shareholders: { type: integer, description: "Socios cobrados además del representante legal (hoy, todos los revisados)." }
        "400":
          description: "Dato inválido: `invalid_tier`, `invalid_country`, `document_required_for_tier`, `invalid_issue_date_format`, `invalid_external_reference`, `kyb_not_available` (pediste `include_kyb` para una persona o fuera de Colombia; no se cobra)."
          content: { application/json: { schema: { $ref: "#/components/schemas/ErrorSimple" } } }
        "402": { $ref: "#/components/responses/SaldoInsuficiente" }

  /backgroundchecks/detail/{id}:
    get:
      tags: [Antecedentes]
      summary: Resultado de una consulta de antecedentes
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: integer }
          example: 3301
      responses:
        "200":
          description: La consulta con sus hallazgos por fuente.

  /backgroundchecks/pdf/{id}:
    get:
      tags: [Antecedentes]
      summary: Informe de antecedentes en PDF
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: integer }
          example: 3301
      responses:
        "200":
          description: El informe en PDF.

  /kyt/config:
    get:
      tags: [Riesgo on-chain (KYT)]
      summary: Redes, activos y precios
      description: Las redes (`blockchains`) y los activos (`activos`) que se pueden consultar, y el precio de cada servicio en centavos de tu moneda.
      responses:
        "200":
          description: Configuración vigente.
          content:
            application/json:
              example:
                success: true
                blockchains: [BTC, LIGHTNING, ETH, LTC, XRP, BCH, DOGE, TRX, SOL, MATIC, BNB]
                activos: [USDT, USDC, BTC, ETH, TRX, SOL, MATIC, BNB, LTC, XRP, BCH, DOGE]
                prices: { wallet: 150000, transaction: 150000 }
                currency: COP

  /kyt/screenTransaction:
    post:
      tags: [Riesgo on-chain (KYT)]
      summary: Analizar un movimiento (p. ej. un depósito)
      description: >
        Analiza la dirección de la otra punta del movimiento. Para un depósito,
        `wallet_address` es la dirección que ENVÍA y `direction` es `INBOUND`.
        `currency` es el activo (USDT) y `blockchain` la red (TRX, MATIC…): no son
        lo mismo, USDT vive en varias redes.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [wallet_address, currency, blockchain, direction]
              properties:
                wallet_address: { type: string, description: Dirección a analizar (en un depósito, la que envía)., example: "0x4e5bc1CD2C421ECfef65395b3237F90A97178c55" }
                currency: { type: string, description: Activo que se mueve., example: USDT }
                blockchain: { type: string, description: Red. Ver `/kyt/config`., example: MATIC }
                direction: { type: string, enum: [INBOUND, OUTBOUND] }
                amount: { type: string, description: Monto del movimiento., example: "9.2" }
                tx_hash: { type: string, description: Hash de la transacción. Se guarda para auditoría. }
                counterparty_address: { type: string, description: Tu dirección (en un depósito, la que recibe). Es contexto; no se analiza. }
                contact_id: { type: integer, description: Contacto al que queda ligada la consulta. }
                external_reference: { type: string, maxLength: 191, description: Tu id interno. }
      responses:
        "200":
          description: Resultado del análisis.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/KytResultado" }
        "400":
          description: "Dato inválido (no se cobra): `activo_no_soportado`, `token_no_es_red` (mandaste USDT en `blockchain`), `blockchain_no_soportada`, `direccion_no_valida_para_la_cadena`, o un campo obligatorio faltante."
          content: { application/json: { schema: { $ref: "#/components/schemas/ErrorSimple" } } }
        "402": { $ref: "#/components/responses/SaldoInsuficiente" }

  /kyt/screenWallet:
    post:
      tags: [Riesgo on-chain (KYT)]
      summary: Analizar una dirección suelta
      description: El mismo análisis sin contexto de movimiento. La respuesta tiene la misma forma que `screenTransaction`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [wallet_address, blockchain]
              properties:
                wallet_address: { type: string, example: "0x4e5bc1CD2C421ECfef65395b3237F90A97178c55" }
                blockchain: { type: string, example: MATIC }
                contact_id: { type: integer }
                external_reference: { type: string, maxLength: 191 }
      responses:
        "200":
          description: Resultado del análisis.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/KytResultado" }
        "400":
          description: "Dato inválido (no se cobra)."
          content: { application/json: { schema: { $ref: "#/components/schemas/ErrorSimple" } } }
        "402": { $ref: "#/components/responses/SaldoInsuficiente" }

  /kyt/detail/{id}:
    get:
      tags: [Riesgo on-chain (KYT)]
      summary: Resultado completo de una consulta
      description: >
        Los mismos campos de decisión (`recommendation`, `wallet_sanctioned`,
        `owner`, `reasons`…) más el detalle en `result`: señales, origen y destino
        de los fondos, contrapartes y la actividad de la dirección. El saldo
        (`raw_response.balance`) viene en la moneda propia de la red (POL en
        MATIC, ETH en ETH…) y **no incluye tokens** como USDT.
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: integer }
          example: 9
      responses:
        "200":
          description: La consulta con su detalle.

  /kyt/pdf/{id}:
    get:
      tags: [Riesgo on-chain (KYT)]
      summary: Informe en PDF
      description: >
        Devuelve `{success, url}` con un enlace temporal al PDF, que incluye los
        criterios de la recomendación. Si el informe todavía se está generando
        responde `202` con `informe_en_preparacion`: reintenta en unos segundos.
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: integer }
          example: 9
      responses:
        "200":
          description: Enlace al PDF.
          content:
            application/json:
              example: { success: true, url: "https://…" }
        "202":
          description: El informe se está generando.

webhooks:
  document.completed:
    post:
      tags: [Webhooks]
      summary: Documento firmado
      description: El documento quedó firmado y aprobado, con el PDF y la evidencia listos. Consulta `data.status_url` para obtener el PDF.
      parameters:
        - $ref: "#/components/parameters/Firma"
        - $ref: "#/components/parameters/Timestamp"
      requestBody:
        content:
          application/json:
            schema:
              allOf:
                - $ref: "#/components/schemas/WebhookEnvelope"
                - type: object
                  properties:
                    type: { const: document.completed }
                    data:
                      type: object
                      properties:
                        document_id: { type: string, example: 6ad6d97e99094 }
                        document_name: { type: string, example: contrato-arrendamiento-apto-302 }
                        status: { type: string, example: completed }
                        completed_at:
                          type: string
                          description: Cuándo se cerró el documento, no cuándo salió el aviso.
                          example: "2026-10-23T11:41:58-05:00"
                        status_url: { type: string, example: https://api.validocus.com/documents/signStatus/6ad6d97e99094 }
      responses:
        "200":
          description: Recibido.
  document.expired:
    post:
      tags: [Webhooks]
      summary: Documento vencido
      description: >
        Pasó el plazo y nadie firmó. El barrido corre cada hora, así que
        `expired_at` (cuándo venció el plazo) puede ser hasta una hora anterior
        al aviso.
      parameters:
        - $ref: "#/components/parameters/Firma"
        - $ref: "#/components/parameters/Timestamp"
      requestBody:
        content:
          application/json:
            schema:
              allOf:
                - $ref: "#/components/schemas/WebhookEnvelope"
                - type: object
                  properties:
                    type: { const: document.expired }
                    data:
                      type: object
                      properties:
                        document_id: { type: string, example: 6ad6d97e99094 }
                        document_name: { type: string, example: contrato-arrendamiento-apto-302 }
                        status: { type: string, example: expired }
                        expired_at: { type: string, example: "2026-11-22 10:00:00" }
                        deadline_days: { type: integer, example: 30 }
                        pending_signers: { type: integer, example: 1 }
                        status_url: { type: string, example: https://api.validocus.com/documents/signStatus/6ad6d97e99094 }
      responses:
        "200":
          description: Recibido.
  form.completed:
    post:
      tags: [Webhooks]
      summary: Formulario completado
      description: >
        La persona envió un formulario pedido con `formrequests/create`. Llega una
        vez por solicitud. Los formularios que van con una firma no generan este
        evento: se cierran con el documento.


        El evento no trae las respuestas ni el puntaje de riesgo: por webhook solo
        viajan identificadores. Léelos autenticado con
        `GET /forms/proxy/gates/{gate_id}` (respuestas, puntaje y nivel) o
        `GET /formrequests/listForContact/{contactId}` (puntaje).
      parameters:
        - $ref: "#/components/parameters/Firma"
        - $ref: "#/components/parameters/Timestamp"
      requestBody:
        content:
          application/json:
            schema:
              allOf:
                - $ref: "#/components/schemas/WebhookEnvelope"
                - type: object
                  properties:
                    type: { const: form.completed }
                    data:
                      type: object
                      properties:
                        form_request_id: { type: integer, description: El `id` que devolvió `formrequests/create`., example: 14 }
                        form_template_id: { type: string, example: 66f1c2a9e4b0a1b2c3d4e5f6 }
                        template_name: { type: string, example: Conocimiento del cliente }
                        contact_id: { type: integer, example: 115362 }
                        submission_id: { type: string }
                        status: { type: string, enum: [submitted, approved, signed], description: "`submitted` enviado y pendiente de tu revisión; `approved` aprobado." }
      responses:
        "200":
          description: Recibido.
  identity.completed:
    post:
      tags: [Webhooks]
      summary: Verificación de identidad terminada
      description: >
        La persona terminó la verificación y ya hay resultado del análisis. Llega
        una vez por verificación, sea por separado o con una firma (en ese caso
        trae `soli_id`).
      parameters:
        - $ref: "#/components/parameters/Firma"
        - $ref: "#/components/parameters/Timestamp"
      requestBody:
        content:
          application/json:
            schema:
              allOf:
                - $ref: "#/components/schemas/WebhookEnvelope"
                - type: object
                  properties:
                    type: { const: identity.completed }
                    data:
                      type: object
                      properties:
                        identity_verification_id: { type: integer, example: 912 }
                        status: { type: string, enum: [verified, declined, review, expired, failed] }
                        soli_id: { type: [string, "null"], description: "Documento al que pertenece, si se pidió con la firma." }
                        signer_email: { type: string, example: juan@ejemplo.com }
                        verified_full_name: { type: [string, "null"], example: JUAN PEREZ GOMEZ }
      responses:
        "200":
          description: Recibido.
  identity.decided:
    post:
      tags: [Webhooks]
      summary: Verificación de identidad decidida
      description: >
        La verificación quedó aprobada o rechazada: automáticamente
        (`decided_by: automatic`, cuando el análisis fue concluyente) o por ti
        (`decided_by: officer`). **Puede llegar más de una vez** para la misma
        verificación —por ejemplo, una decisión automática y después la tuya—:
        quédate con la de `decided_at` más reciente. Con firma, el documento se
        cierra con **tu** decisión.
      parameters:
        - $ref: "#/components/parameters/Firma"
        - $ref: "#/components/parameters/Timestamp"
      requestBody:
        content:
          application/json:
            schema:
              allOf:
                - $ref: "#/components/schemas/WebhookEnvelope"
                - type: object
                  properties:
                    type: { const: identity.decided }
                    data:
                      type: object
                      properties:
                        identity_verification_id: { type: integer, example: 912 }
                        outcome: { type: string, enum: [approved, rejected] }
                        decided_by: { type: string, enum: [automatic, officer] }
                        decided_at: { type: string, example: "2026-11-05 10:00:00" }
                        status: { type: string, example: verified }
                        soli_id: { type: [string, "null"] }
                        contact_id: { type: integer, example: 115362 }
                        signer_email: { type: string, example: juan@ejemplo.com }
      responses:
        "200":
          description: Recibido.
  background.completed:
    post:
      tags: [Webhooks]
      summary: Consulta de antecedentes terminada
      description: Terminó una consulta de antecedentes. El detalle por fuente está en `GET /backgroundchecks/detail/{id}`.
      parameters:
        - $ref: "#/components/parameters/Firma"
        - $ref: "#/components/parameters/Timestamp"
      requestBody:
        content:
          application/json:
            schema:
              allOf:
                - $ref: "#/components/schemas/WebhookEnvelope"
                - type: object
                  properties:
                    type: { const: background.completed }
                    data:
                      type: object
                      properties:
                        background_check_id: { type: integer, example: 3301 }
                        status: { type: string, example: completed }
                        tier: { type: string, example: nacional }
                        person_full_name: { type: string, example: Juan Pérez Gómez }
                        person_doc: { type: string, example: "1020304050" }
                        person_doc_type: { type: string, example: CC }
                        findings:
                          type: object
                          description: Hallazgos por nivel.
                          properties:
                            high: { type: integer, example: 0 }
                            medium: { type: integer, example: 1 }
                            low: { type: integer, example: 2 }
                        has_report: { type: boolean, description: Si ya está el informe en PDF. }
      responses:
        "200":
          description: Recibido.

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      description: >
        Token emitido desde el panel, en el header `Authorization: Bearer …`.
        Identifica a tu cuenta y puede gastar saldo: guárdalo solo en tu
        servidor. No hay scopes: un token puede hacer todo lo que puede hacer tu
        cuenta.
  parameters:
    Firma:
      name: X-Validocus-Signature
      in: header
      required: true
      description: HMAC-SHA256 en hexadecimal de `timestamp + "." + cuerpo_crudo`, con tu secreto `whsec_…`.
      schema: { type: string }
    Timestamp:
      name: X-Validocus-Timestamp
      in: header
      required: true
      description: Epoch en segundos. Rechaza entregas de más de 5 minutos.
      schema: { type: string }
    FillToken:
      name: token
      in: path
      required: true
      description: El token del enlace del formulario, el último tramo de `fill_url` (`…/fill/<token>`). Es la llave de estas llamadas; no lleva token de API.
      schema: { type: string }
      example: 471a4c55758b38d1e75a9d65bc7ae71c27490dc6d3d42d99
  schemas:
    DocumentoCreado:
      title: Documento creado
      type: object
      properties:
        success: { type: boolean, example: true }
        soli_id:
          type: string
          description: Id del documento. Úsalo para consultar y cancelar; vuelve en los webhooks como `document_id`.
          example: 6ad6d97e99094
        urls_sign:
          type: array
          description: Un enlace por firmante, en el mismo orden en que los enviaste.
          items: { type: string }
          example: ["https://api.validocus.com/sign/Bpr7OTi"]
        msj:
          type: string
          example: La solicitud fue cargada y enviada correctamente.
    WebhookEnvelope:
      type: object
      description: Sobre común de todos los eventos. `data` cambia según `type`.
      properties:
        id:
          type: string
          description: Id del evento. Úsalo para deduplicar.
          example: evt_9f2c41d07ab84e5c93d1a6b2
        type:
          type: string
          example: document.completed
        version: { type: string, example: "1" }
        created_at: { type: string, example: "2026-10-23T11:42:07-05:00" }
        account_id: { type: integer, example: 209 }
        external_reference:
          type: [string, "null"]
          description: Lo que enviaste como referencia externa al crear el documento, el formulario, la verificación o la consulta. Viene siempre, aunque sea `null`.
          example: lead-8841
        data:
          type: object
          description: Carga específica del evento.
    KytResultado:
      type: object
      properties:
        success: { type: boolean, example: true }
        id: { type: integer, description: Id de la consulta. Guárdalo con el depósito., example: 9 }
        check_type: { type: string, enum: [wallet, transaction] }
        recommendation:
          type: string
          enum: [approve, review, reject]
          description: "El campo para decidir: `approve` operar, `review` revisión humana, `reject` no operar y escalar a cumplimiento."
        risk_score: { type: integer, nullable: true, description: "0–100 según el veredicto de Validocus. Informativo.", example: 50 }
        severity: { type: string, enum: [UNKNOWN, LOW, MEDIUM, HIGH, CRITICAL], example: MEDIUM }
        wallet_sanctioned: { type: boolean, description: La dirección MISMA está en listas de sanciones., example: false }
        owner:
          type: object
          nullable: true
          description: A quién pertenece la dirección, si se sabe.
          properties:
            name: { type: string, example: BingX }
            type: { type: string, description: "`exchange`, `sanctions`, `service`, `defi`…", example: exchange }
            kyc: { type: string, nullable: true, enum: [mandatory, optional, unknown], description: Solo para exchanges., example: optional }
        sanctions_hit: { type: integer, enum: [0, 1], description: Tuvo CONTACTO con entidades sancionadas (no es lo mismo que estar en la lista)., example: 1 }
        sanctions_exposure_pct: { type: number, description: Porcentaje de exposición a sancionados., example: 0.003636 }
        reasons:
          type: array
          items: { type: string }
          description: "Por qué salió esa recomendación: `wallet_sanctioned`, `wallet_high_risk_attribution`, `high_risk_exchange`, `high_risk_organization`, `exchange_mandatory_kyc`, `exchange_optional_kyc`, `sanctions_exposure`, `high_risk_exposure`, `pep_exposure`, `conflicting_attribution`, `risk_signals`, `no_risk_signals`, `inconclusive`."
          example: [exchange_optional_kyc, sanctions_exposure]
        cobrado: { type: integer, description: Lo cobrado en centavos de tu moneda., example: 150000 }
    ErrorSimple:
      type: object
      description: Error de los endpoints de formularios, identidad y antecedentes, que sí usan el código HTTP.
      properties:
        success: { type: boolean, example: false }
        msj: { type: string, example: contact_not_owned }
        reason: { type: string, description: Solo en algunos errores de validación. }
    SesionInvalida:
      type: object
      description: Respuesta sin un token válido en `signStatus` (HTTP 401) y `getDocumentBlockchainInfo` (HTTP 200).
      properties:
        success: { type: boolean, example: false }
        msj: { type: string, example: session_invalid }
    NoAutorizadoLegacy:
      type: object
      description: Respuesta de `deleteContract` sin un token válido (HTTP 200). Forma heredada, distinta a la del resto.
      properties:
        tokenStatus: { type: integer, example: 0 }
        tokenMsj: { type: string, example: unauthorized }
    ErrorDocumento:
      title: Error
      type: object
      description: >
        Errores de `createContract`, que llegan con **HTTP 200** salvo que envíes
        `X-Validocus-Api`: discrimina por `success` y `error_code`. Si escribes a
        soporte, envíanos el `request_id`.


        Códigos: `session_invalid` (falta el token, está mal o expiró) ·
        `insufficient_balance` (no alcanza el saldo; trae `required`, `balance`
        y `breakdown` en centavos) · `signers_limit_exceeded` (más firmantes de
        los que permite tu plan; trae `max_signers` y `signers`) ·
        `signer_document_belongs_to_different_contact` (ese número de documento
        ya es de otro contacto tuyo) · `invalid_contract_url` ·
        `invalid_url_return` · `invalid_expiry_days` ·
        `invalid_external_reference` (estos cuatro traen `reason`) ·
        `rate_limit_exceeded` (más de 60 por minuto) · `charge_failed` ·
        `internal_error` · `endpoint_not_found` (la ruta no existe: revisa
        mayúsculas y que no lleve `/v1`).


        Si el PDF no se pudo procesar o falta un dato del firmante, la respuesta
        puede traer solo `success: false` y un `msj` en texto.
      properties:
        success: { type: boolean, example: false }
        msj: { type: string, example: insufficient_balance }
        error_code: { type: string, example: insufficient_balance }
        message:
          type: string
          example: Saldo insuficiente para crear el documento. Compara `required` (costo estimado, en centavos) contra `balance` (saldo actual, en centavos); el detalle por concepto está en `breakdown`. Recarga el saldo de la cuenta para continuar.
        request_id: { type: string, example: req_90a1c2d3e4f5a6b7 }
  responses:
    Unauthorized:
      description: Token ausente o inválido (`session_invalid`). Solo `signStatus` usa el HTTP 401.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/SesionInvalida" }
    NoAutorizado:
      description: "`unauthorized`: falta el token o no sirve."
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ErrorSimple" }
    ErrorValidacion:
      description: "`invalid_input` (falta `contact_id` o `form_template_id` no es un id válido) o `invalid_external_reference` (con `reason`)."
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ErrorSimple" }
    SaldoInsuficiente:
      description: "`insufficient_balance`: no alcanza el saldo. No se creó ni se cobró nada."
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ErrorSimple" }
