{"title":"Changelog del canal VerifyCol API","format":"markdown","source":"docs/CHANGELOG-API.md","policy_path":"/docs","content":"# Changelog del canal VerifyCol API\n\nLos cambios del canal por API key (`/api/v1/gateway/...`), del más reciente al\nmás viejo. Se anota lo que un integrador **puede notar desde afuera**: claves\nnuevas en una respuesta, códigos de estado, cambios de cobro, endpoints,\ncabeceras. El trabajo interno no entra acá.\n\nLa política que define qué cambio es compatible y cuál no está en la sección\n«Versionado» de la referencia (`https://api.verifycol.co/docs`). Resumida: mientras exista\n`/api/v1/`, no rompemos lo que ya funciona; **agregar** claves, valores de una\nlista de códigos, parámetros opcionales, endpoints y cabeceras **es\ncompatible**, y por eso se publica acá en vez de avisarse una por una. La\nexcepción: un cambio por seguridad o por protección de datos personales (Ley\n1581 de 2012) puede salir sin período de transición; se anuncia acá con el\nrótulo «⚠ Cambio INCOMPATIBLE».\n\nEste archivo se sirve tal cual en `GET /api/v1/gateway/docs/changelog`.\n\nLas entradas anteriores al 2026-09-25 citan `GET /api/v1/gateway/docs`; hoy\npide sesión de la aplicación web: lo equivalente está en la referencia\n(`https://api.verifycol.co/docs`).\n\n---\n\n## 2026-10-06\n\nRige desde el despliegue de este cambio.\n\n### Sandbox: las fuentes sin escenario se omiten con `not_in_sandbox`\n\nCon una key `vf_test_`, un reporte ya no consulta las fuentes que el sandbox\nno simula: quedan en `omitted_sources` con `reason: \"not_in_sandbox\"` (valor\nnuevo de la lista), no se consultan y no se cobran del saldo de prueba. Antes\nentraban al reporte, fallaban con `sandbox_sin_caso` y el reporte de un sujeto\nlimpio (por ejemplo, `900000003`) cerraba `partial`; ahora cierra `completed`.\n\n- Vale también para las que pidas en `selected_endpoints`, igual que\n  `not_in_plan`. Si ninguna de las que pediste tiene escenario, la creación\n  responde `422` y el estimate trae el bloqueo `no_applicable_sources`.\n- `POST /api/v1/gateway/api/reports/estimate` con una key `vf_test_` declara\n  las mismas omisiones y cotiza lo mismo que cobra la creación.\n- Qué fuentes tienen escenario lo publica la referencia (`/docs`, sección\n  «Ambientes»). Con una key `vf_live_` nada cambia: el motivo no existe en\n  producción.\n\n### Sandbox: la narrativa de riesgo es un ejemplo fijo\n\nEn un reporte de prueba, `risk_narrative` es un texto de ejemplo fijo que\nempieza con «EJEMPLO DEL SANDBOX DE VERIFYCOL» y concuerda con el\n`overall_status` del reporte; `risk_analysis.sandbox` es `true` y\n`narrative_status` llega a `completed`. No lo redacta la inteligencia\nartificial y no consume la cuota de análisis de tu organización. En\nproducción nada cambia.\n\n### La referencia documenta los prerrequisitos y el ambiente de los webhooks\n\nSin cambios en lo que viaja por la red. `/docs` y la especificación pública\ndocumentan ahora:\n\n- Los webhooks reciben los eventos de los dos ambientes y las cabeceras no lo\n  distinguen: filtra por `environment` o `test_mode` del cuerpo.\n- Los prerrequisitos del canal: los Términos y Condiciones del usuario que\n  creó la key (si no, `422`), el plan Pro para API keys y webhooks, el scope\n  `webhooks:manage`, el vencimiento de la key `vf_live_` y la gracia de la\n  rotación.\n- El `402` `PLAN_UPGRADE_REQUIRED` de `POST …/webhooks/`,\n  `POST …/{webhook_id}/rotate-secret`, `POST …/{webhook_id}/test` y\n  `POST …/webhooks/deliveries/{delivery_id}/retry`, que ya existía y la\n  especificación no declaraba.\n- El `202` de `POST /api/v1/gateway/api/reports/` con su forma\n  (`ConsentimientoPendienteResponse`: `status: \"pending_titular_consent\"`,\n  `consent_id`, `consent_status`, `environment`, `message` y, en el sandbox,\n  `sandbox`), y el `503` con `Retry-After` que responde cuando el servicio\n  está saturado. Ya los respondía; un cliente generado ahora los tipa.\n- La excepción de la política de versionado (arriba).\n\n---\n\n## 2026-10-05\n\nRige desde el despliegue de este cambio.\n\n### Acción requerida: si recalculas `evidence_sha256`, quita también `soportes`\n\nDesde el despliegue de este cambio, cada fuente de\n`GET …/api/reports/{report_id}` y de los webhooks `report.*` trae la clave\n`soportes` **siempre**, aunque sea `null` (ver abajo), en todos los reportes,\npidas o no documentos de soporte. El `evidence_sha256` de los reportes ya\nemitidos no cambia, pero el procedimiento del 2026-10-02 para recalcularlo,\nque sólo quitaba `anexo_estado`, deja de coincidir en todos. El procedimiento\ncompleto, sobre el cuerpo del webhook o del `GET …/api/reports/{report_id}`:\ntoma `id`, `status`, `overall_status`, `subject_document_type`,\n`subject_document_number`, `subject_full_name`, `sources`, `total_sources`,\n`completed_sources`, `successful_sources`, `failed_sources` y `completed_at`\n(la que falte, `null`); agrega `pdf_detail` (el que mandaste al crear el\nreporte, o `\"summary\"`); en cada fuente quita `anexo_estado` y `soportes` y\npon `error` en `null`; serializa como JSON con las claves ordenadas, sin\nespacios y lo no ASCII escapado (`\\uXXXX`), en UTF-8, y saca el SHA-256 en\nhexadecimal.\n\n### Nuevo: documentos de soporte de cada fuente (`soportes`)\n\nCada consulta puede entregar, además de sus datos, el documento que la\nrespalda: el PDF que emitió la entidad o, si la entidad no emite PDF, una\ncaptura fiel de la página que respondió.\n\n- **Cuáles fuentes:** las que dicen `entrega_soportes: true` (clave nueva) en\n  `GET /api/v1/gateway/services/`. Hoy: Procuraduría (ordinario y especial),\n  Contraloría (persona natural y jurídica), Registraduría, SENA, ADRES\n  (afiliados compensados y BDUA) y Policía (antecedentes y medidas\n  correctivas). `true` dice que la fuente **puede** entregarlos: cada soporte\n  trae su `estado`, y puede venir `no_disponible`. No cambia el costo.\n  `has_download` del proveedor queda obsoleto; se conserva por\n  compatibilidad.\n- **Cómo se piden en el reporte:** `soportes` en\n  `POST /api/v1/gateway/api/reports/`: `sin`, `descargables` (se guarda una\n  copia y se descarga desde el reporte) o `anexos` (además, se agregan al\n  final del PDF del reporte). Distingue mayúsculas, como `pdf_detail`:\n  `\"Anexos\"` responde `422`. Omitido: `sin`, salvo los alias de SENA (abajo).\n  Entra en la identidad del pedido (`Idempotency-Key` y la ventana\n  anti-duplicados). Pedirlos puede alargar el reporte.\n- **Qué devuelve:** cada fuente de `GET …/api/reports/{report_id}` y del\n  webhook de cierre trae `soportes` (`null` si no se pidieron o la fuente no\n  los entregó): por cada uno, `j`, `tipo` (`oficial` o `captura`), `emisor`,\n  `titulo`, `sha256` (el del PDF que se descarga), `origen_sha256`, `bytes`,\n  `obtenido_en`, `omisiones`, `estado` (`pendiente`, `guardado` o\n  `no_disponible`) y `url`. `url` es una ruta relativa al host (antepón la\n  URL base de la API) y viene mientras el soporte está `pendiente` o\n  `guardado`. Los reportes SENA anteriores que pidieron los PDF\n  (`descargar_pdfs`) también los traen. `anexo_estado` ahora vale para\n  cualquier fuente con soportes.\n- **Una captura** es la página que respondió el portal, impresa a PDF, con la\n  respuesta original adjunta dentro del PDF (`respuesta_portal.txt`): su\n  SHA-256 es `origen_sha256`. `omisiones` dice qué se quitó (por ejemplo,\n  `ip_consulta`: la IP de salida de la consulta).\n- **Rutas nuevas:**\n  `GET /api/v1/gateway/api/reports/{report_id}/fuentes/{indice}/soportes`\n  (todos en un PDF; si hay uno solo, ese mismo) y `…/soportes/{j}`.\n  - **`200`** `application/pdf`, con `Cache-Control: no-store`.\n  - **`404`** si el reporte no existe o es de otro ambiente, la fuente no\n    tiene soportes o el soporte no está disponible. Si la copia todavía se\n    está descargando, la ruta intenta completarla en el momento (puede tardar\n    hasta ~20 s); si no alcanza, el `404` trae `Retry-After: 30`.\n  - En `…/soportes` (todos), si la fuente tiene dos o más soportes que no se\n    pudieron unir en un PDF, el `404` trae `error.reason: SOPORTES_NO_UNIDOS`\n    y la cabecera `X-Error-Code: SOPORTES_NO_UNIDOS`: descárgalos uno por uno\n    con `…/soportes/{j}`.\n  - **`410`** si el reporte fue suprimido a solicitud del titular.\n- **El `ETag`** de `GET …/api/reports/{report_id}` cambia una vez en todos los\n  reportes: la respuesta suma la clave `soportes`.\n- **El `evidence_sha256` de los reportes ya emitidos no cambia; lo que cambia\n  es cómo se recalcula** (ver «Acción requerida», arriba). `soportes` no entra\n  en el hash, igual que `anexo_estado`: su `estado` y su `url` cambian cuando\n  la copia se guarda, después del webhook de cierre.\n- **Consulta directa:** cada elemento de `documents` de esas diez fuentes\n  acepta `soportes: true`. El resultado de la tarea los declara en\n  `results[].soportes`: los datos de cada documento (`tipo`, `sha256`,\n  `bytes`, `disponible_hasta`, entre otros), no el archivo; ahí `estado` vale\n  `listo` o `no_disponible` (con `motivo`), y no hay `j` ni `url`. Con\n  `?incluir_soportes=true` en `GET …/verifycol/tasks/{task_id}`, cada soporte\n  `listo` trae su PDF en `base64`; comparte con `?incluir_pdf=true` un tope\n  por respuesta, y lo que no entra sale con `omitted_reason`. En SENA,\n  `soportes: true` equivale a `descargar_pdfs: true`.\n- **Obsoletos (siguen funcionando igual):** en el reporte, `anexar_pdfs` y\n  `descargar_pdfs` de `extra_fields` (valen sólo para SENA y sólo si no se\n  envía `soportes`), y las rutas `…/fuentes/{indice}/certificados` y\n  `…/certificados/{j}` (su `404` «uno por uno» también trae ahora\n  `error.reason` y `X-Error-Code`). Su retiro se anunciará acá con fecha; desde\n  entonces esas rutas responderán con las cabeceras `Deprecation` y `Sunset`.\n\n### Cambio: con la key de prueba, los webhooks se leen pero no se gestionan\n\nLos webhooks son de la organización y no tienen ambiente: reciben los eventos\nreales y los del sandbox. Con una key `vf_test_`:\n\n- `POST /api/v1/gateway/api/webhooks/`, `PATCH …/webhooks/{webhook_id}`,\n  `POST …/{webhook_id}/rotate-secret`, `DELETE …/{webhook_id}` y\n  `POST …/webhooks/deliveries/{delivery_id}/retry` responden **`403`**\n  (`FORBIDDEN`), aunque la key tenga el scope `webhooks:manage`. Esas\n  operaciones se hacen con una key `vf_live_`.\n- `GET …/webhooks/{webhook_id}/deliveries` lista sólo las entregas de\n  reportes de prueba (`total` incluido).\n- Listar, ver un webhook y el ping (`POST …/{webhook_id}/test`) no cambian.\n\nCon una key `vf_live_`, nada cambia.\n\n### ⚠ Cambio INCOMPATIBLE — `GET …/api/consents/{consent_id}` enmascara el documento y quita dos claves\n\nEsto **rompe** a quien leía `user_id` u `organization_id` de esta respuesta, o\nel documento completo. Lo decimos con todas las letras porque la política del\ncanal sólo declara compatible **agregar**.\n\n- `subject_document_number` viaja **enmascarado**: sólo se ven los últimos 4\n  caracteres (`••••••0001`); si tiene 4 o menos, va oculto entero. Si\n  guardabas el documento completo desde esta respuesta, deja de hacerlo:\n  tómalo del reporte o de tu propio registro.\n- Se quitan `user_id` y `organization_id`.\n- Las demás claves no cambian, `ip_address` y `user_agent` incluidas.\n\nNo hay período de transición: es una corrección de protección de datos\npersonales (Ley 1581 de 2012) y no podemos dejar una ventana con el documento\nen claro. Dejar las dos claves en `null` tampoco te evitaría el cambio:\n`organization_id` estaba declarado como texto obligatorio, así que un cliente\ngenerado desde la referencia lo rechazaría igual.\n\n### Corrección: una key de una organización inactiva responde `401`\n\nUna API key de una organización inactiva o eliminada seguía autenticando si\nla organización se había desactivado por fuera del flujo normal. Ahora\nresponde **`401`** con `error.code: INVALID_API_KEY`, la misma respuesta que\nuna key revocada: no dice en qué estado está la organización. Las keys de las\norganizaciones activas no cambian.\n\n### Corrección: el reporte suprimido responde `410`, no `500`\n\n`GET /api/v1/gateway/api/reports/{report_id}` y `…/{report_id}/pdf`, sobre un\nreporte suprimido a solicitud del titular (Ley 1581), respondían `500`. Ahora\nresponden **`410`** con `error.code: GONE` (código nuevo), igual que las rutas\nde soportes, y la referencia lo declara. El de otro ambiente o de otra\norganización sigue respondiendo `404`, como un id inexistente.\n\n- El `410` que ya publicaban `…/fuentes/{indice}/certificados` y\n  `…/certificados/{j}` (entrada del 2026-09-30) cambia de código: su\n  `error.code` pasa de `\"ERROR\"` a `\"GONE\"`. El status y `detail` no cambian.\n  Si ramificabas por `error.code` en esas rutas, cambia la comparación; si\n  ramificabas por el status HTTP, no tienes que hacer nada.\n- `POST /api/v1/gateway/api/reports/` también declara el `410`: lo responde el\n  reintento con la misma `Idempotency-Key` cuando el reporte original fue\n  suprimido. No se crea ni se cobra un reporte nuevo.\n\n---\n\n## 2026-10-04\n\n### Nuevo `error_code`: `not_dispatched`\n\nUna fuente que esperaba un dato de otra (por ejemplo, una lista que espera el\nnombre del titular) y que el reporte —o el lote, en una consulta masiva— venció\nantes de poder enviarla cierra con `status: \"failed\"` y `error_code:\n\"not_dispatched\"`, con el texto «No se consultó: la consulta agotó su tiempo de\nespera antes de que esta fuente se pudiera enviar.». Antes cerraba con `timeout` («Fuente no disponible\ntemporalmente…»), que no era cierto: a la fuente no se le preguntó. Como toda\nfuente `failed`, se reembolsa y nunca cuenta como limpia; `retryable` sigue en\n`true`. El código aparece en la lista `codes` de `GET /api/v1/gateway/docs`.\n\n### Un reporte que vence recoge lo que la fuente ya había terminado\n\nCuando un reporte pasa su tiempo máximo, antes de dar por vencida una fuente\nse le pregunta una vez su resultado: si ya había terminado, la fuente cierra\ncon ese resultado (`completed` o `not_found`) en vez de `failed / timeout`, y\nse cobra como cualquier consulta entregada. Al cerrar un reporte vencido, las\nfuentes «sin registros» (`not_found`) y las canceladas (`canceled`) ya no se\nreembolsan: se cobran, como estaba documentado y como en cualquier otro\ncierre.\n\n---\n\n## 2026-10-02\n\n### `sources[].error` trae el texto del PDF, nunca el técnico\n\n`sources[].error` de `GET /api/v1/gateway/api/reports/{id}` y de los webhooks\n`report.completed`, `report.partial` y `report.failed` trae el mismo texto que\nimprime el PDF: en español y sin detalles técnicos. Antes podía traer el texto\ncrudo de la fuente o de una excepción interna (por ejemplo «El resolutor de IA\nno devolvió un código utilizable.»). Lo que cambia y lo que no:\n- **`error_code` y `retryable` no cambian** por esto (el `retryable` de\n  `captcha_provider` sí, abajo). Programa contra ellos: la redacción de `error`\n  puede cambiar.\n- `missing_required_field` sigue diciendo qué dato faltó y qué fuente debía\n  aportarlo; una fuente `canceled` dice «Cancelado por el cliente».\n- **`evidence_sha256` cambia una sola vez, sólo en los reportes con alguna\n  fuente con `error` no nulo** (en los demás es el mismo de siempre): `error`\n  ya no entra en el hash, va en `null`. La causa de la falla queda cubierta por\n  `error_code`, que sí entra, y un cambio de redacción ya no mueve el hash. Si\n  guardaste el hash de un reporte con fuentes fallidas, el que recalcules hoy\n  no te va a coincidir con ese. Para recalcularlo sobre el cuerpo del webhook\n  o del `GET …/api/reports/{id}`: toma `id`, `status`, `overall_status`,\n  `subject_document_type`, `subject_document_number`, `subject_full_name`,\n  `sources`, `total_sources`, `completed_sources`, `successful_sources`,\n  `failed_sources` y `completed_at`; agrega `pdf_detail` (el que mandaste al\n  crear el reporte, o `\"summary\"`); en cada fuente quita `anexo_estado`\n  (desde el 2026-10-05, también `soportes`: ver esa entrada) y pon `error` en\n  `null`; serializa como JSON con las claves ordenadas, sin espacios y lo no\n  ASCII escapado (`\\uXXXX`), en UTF-8, y saca el SHA-256 en hexadecimal.\n- En la consulta directa, `GET …/verifycol/tasks/{task_id}`,\n  `results[].error` también pasa a traer el texto presentable.\n- El `503` con `PROVIDER_UNAVAILABLE` (la fuente no respondió tras los\n  reintentos, o está pausada por fallas seguidas) dice «Fuente no disponible\n  temporalmente. Intenta nuevamente más tarde.», en vez de «Provider\n  unavailable after N attempts: …» con el error de red o «Source … temporarily\n  unavailable (circuit breaker open)». El `Retry-After` no cambia.\n\n### `captcha_provider` pasa a la categoría `source_unavailable`\n\n`error_code: \"captcha_provider\"` (falló el servicio que resuelve el captcha de\nla fuente) se presenta como fuente no disponible: «Fuente no disponible\ntemporalmente. Intenta nuevamente más tarde.». Antes era un error interno con\n«La consulta no llegó a hacerse», y no era cierto: la fuente sí se consultó. El\n`error_code` no cambia y la fuente se sigue reembolsando.\n- **`retryable` de `captcha_provider` pasa a `true`**: es un fallo transitorio,\n  como lo marca la consulta directa. `blocked` y `undetermined` no cambian.\n\n### Clave nueva: `data.no_verificados` en RUNT y RUNT Persona\n\nEn `runt-consultar` y `runt-persona-consultar`, `data.no_verificados` es la\nlista de lo que se pidió y no se pudo leer, con rutas relativas a `data`. Un\ncampo de esa lista no dice «no tiene»: dice «no se sabe». El campo mismo\npuede llegar ausente o como lista vacía; lo que dice que no se leyó es\n`no_verificados`.\n- **RUNT:** `has_active_soat` y `soat_vigencia` cuando el SOAT no se pudo\n  leer. Con el modo completo, también cada lista que no se pudo leer:\n  `responsabilidad_civil`, `rtms_normales`, `rtms_otros`, `solicitudes`,\n  `limitaciones_propiedad`, `garantias`, `prendas`, `normalizacion` y\n  `permisos_pcr`.\n- **RUNT Persona:** con el modo completo, `licencias`, `sicov`,\n  `certificados_medicos`, `pagos_ansv`, `certificados_aptitud` y\n  `solicitudes`, y los objetos `multas` y `validacion_identidad`. Una entrada\n  que nombra un objeto vale para todos sus campos: `multas` cubre\n  `multas.tieneMultas` y `multas.nroPazYSalvo`.\n- **No viene** cuando no falta nada, ni cuando el RUNT informa que el\n  documento no es propietario activo del vehículo (`status: NO_PROPIETARIO`).\n  Sin el modo completo, las listas no se piden y no aparecen.\n- El orden de la lista no es contractual.\n\n### El PDF dice «No verificado» cuando falta el dato\n\nEn el PDF del reporte y en el de cada persona de una verificación masiva:\n- Un indicador del veredicto que no vino ya no sale «No» en verde: dice «No\n  verificado», en gris. El caso real es Contraloría en «ESTADO NO\n  DETERMINADO»: la fila «Reportado» decía «No» en verde junto al aviso del\n  certificado degradado.\n- Los campos que trae `data.no_verificados` y llegaron vacíos dicen «No\n  verificado» en vez de no aparecer. Un campo que sí trae su dato lo\n  muestra.\n\nNo cambian el veredicto ni el cobro. En la API, sólo la clave nueva\n`data.no_verificados`.\n\n## 2026-10-01\n\n### `consultation.date` y `consultation.time` en hora de Colombia\n\nEn RUNT (vehículo), RUNT Persona y Rama Judicial (por nombre y por radicado),\n`data.consultation.date` y `data.consultation.time` pasan a la hora de Colombia\n(America/Bogota, UTC−5). Antes estaban en UTC sin decirlo: una consulta hecha\nentre las 19:00 y las 23:59 COT mostraba la fecha del día siguiente y la hora\ncon 5 horas de más. No cambian:\n- `data.consultation.datetime_iso`, que sigue siendo el instante en UTC (ISO\n  8601, `+00:00`) y es el campo recomendado para procesar por máquina, igual\n  que `consulted_at`;\n- el formato de cada fuente: `dd/mm/aaaa` en RUNT y RUNT Persona, `aaaa-mm-dd`\n  en Rama Judicial.\n\nContraloría, Policía y Procuraduría ya daban la hora del certificado. Si\ninterpretabas `date`/`time` como UTC, usa `datetime_iso`. Las consultas\nanteriores al despliegue conservan los valores viejos.\n\n## 2026-09-30\n\n### Cambia lo que se cobra — un lote cobrado por ítem devuelve cada ítem fallido y reintentable\n\nPor el camino directo (`POST /api/v1/gateway/services/{provider}/{endpoint}`),\nen un lote cobrado por ítem se devuelve cada ítem que falló por una causa\nreintentable (`success: false` con `retryable: true`); un lote con todos sus\nítems fallidos así se devuelve completo, como antes. Hasta hoy el crédito era\npor tarea: un lote de 20 con 19 éxitos y 1 captcha se cobraba entero. Desde hoy\ndevuelve el crédito de ese ítem. Cambia a favor del cliente, en todas las\nfuentes que cobran por ítem.\n\n- **Cuánto vuelve:** lo que se cobró por ítem el día de la consulta\n  (`credits_charged` del cobro dividido por los documentos enviados), aunque el\n  precio haya cambiado después.\n- **Qué se sigue cobrando:** el ítem con `success: true` (hubo resultado), el\n  fallo con `retryable: false` y la tarea cancelada (`REVOKED`). Si la tarea\n  devuelve menos ítems que documentos enviados, los que faltan se cobran.\n- **Fuentes de costo fijo** (no por ítem): sin cambios, siguen la regla de la\n  tarea.\n- **Una sola devolución por tarea:** llega como una transacción de `refund`\n  por el total de los ítems devueltos, por el mismo camino de siempre\n  (`GET /api/v1/gateway/api/credits/transactions?transaction_type=refund`),\n  conciliada cada 10 minutos sobre las tareas de más de 1 hora.\n\n### Los 400 por validación ya no anteponen \"422: \" al mensaje\n\nLos 400 del gateway por validación ya no anteponen \"422: \" al mensaje; el\ncódigo HTTP no cambia. Por ejemplo, el tope de documentos respondía\n`\"422: Máximo 10 items por request, se enviaron 11\"` con estado `400`; ahora\nel mensaje es `\"Máximo 10 items por request, se enviaron 11\"`, con el mismo\n`400`. Lo mismo pasa con el `402` de créditos insuficientes y el `404` de\nfuente inexistente de `POST /api/v1/gateway/services/...`: pierden el\n`\"402: \"` y el `\"404: \"` del texto, y conservan su código.\n\n### Nuevo: descarga de los certificados oficiales de una fuente del reporte\n\n`GET /api/v1/gateway/api/reports/{report_id}/fuentes/{indice}/certificados`\ndevuelve en un solo PDF los certificados oficiales que la fuente `indice` (su\nposición en `sources`) entregó para ese reporte, y\n`GET /api/v1/gateway/api/reports/{report_id}/fuentes/{indice}/certificados/{j}`\nel certificado `j` tal como lo emitió la fuente. Hoy aplica a SENA —\ncertificados de formación, cuando la consulta pidió los PDF.\n\n- **`200`** `application/pdf`, con `Cache-Control: no-store`; el nombre del\n  archivo no lleva el documento del titular.\n- **`404`** si el reporte no existe o es de otro ambiente, si la fuente no\n  tiene certificados o si la copia no está disponible. Mientras la copia se\n  descarga, el `404` trae `Retry-After: 30`. Si los certificados no pudieron\n  unirse en un solo PDF, el `404` de `.../certificados` lo dice y cada uno se\n  baja con `.../certificados/{j}`.\n- **`410`** si el reporte fue suprimido a solicitud del titular.\n- La plataforma web ofrece la misma descarga desde el reporte y desde cada\n  persona de una verificación masiva\n  (`/api/v1/gateway/batch-reports/{batch_id}/sujetos/{indice}/fuentes/{codigo}/certificados[/{j}]`,\n  por la posición de la persona en el lote: el documento nunca va en la ruta).\n  Si el titular de esa persona tiene un bloqueo activo de supresión u oposición\n  (Ley 1581), responde `410` mientras el bloqueo siga activo.\n\n### Clave nueva: `sources[].anexo_estado`\n\nCada fuente del reporte (en `GET /api/v1/gateway/api/reports/{report_id}` y\nen el webhook de cierre) trae `anexo_estado`: `pendiente` (la copia de los\ncertificados todavía se está descargando), `guardado` (los originales están\nguardados y se descargan unidos con `.../certificados`; si no se pudieron unir,\nesa ruta responde `404` con el detalle «descárgalos uno por uno», y cada uno se\nbaja con `.../certificados/{j}`; si la fuente ya no tenía alguno o no coincidió\ncon su sha256, el unido trae sólo los que coincidieron y `.../certificados/{j}`\nde los demás responde `404`) o `no_disponible` (la fuente ya no los tenía, o no\ncoincidieron con el sha256 declarado); `null` en las fuentes sin\ncertificados. **No entra en `evidence_sha256`**: la copia puede guardarse\ndespués del cierre del reporte, y el hash de la evidencia no cambia por eso.\n\n## 2026-09-29\n\n### Nueva fuente: SENA — certificados de formación\n\n`POST /api/v1/gateway/services/verifycol/sena-certificados` consulta los\ncertificados de formación que el SENA tiene registrados para una persona. Lo\nque un integrador nota:\n- **Costo y tope:** 1 crédito por documento, de 1 a 10 documentos por consulta\n  (`documents[]`). Más de 10, o ninguno, responde `400` sin cobrar. Tipos: CC,\n  CE, TI, PA y PPT.\n- **Opt-in:** no entra sola en el reporte unificado ni en el estimado; hay que\n  pedirla en `selected_endpoints`. Un reporte sin `selected_endpoints` no la\n  consulta ni la cobra.\n- **`descargar_pdfs`** (opcional, `false` por defecto): con `true` se descargan\n  además los certificados oficiales en PDF, se extraen sus datos y el bloque\n  `titular` dice si el nombre es consistente entre ellos y si el documento\n  impreso coincide con el consultado. Cuesta lo mismo; tarda más.\n- **`?incluir_pdf=true`** en `GET …/verifycol/tasks/{task_id}` trae los PDFs: el\n  unido en `results[].data.meta.pdf_base64` y cada original en\n  `results[].data.certificados[j].pdf.base64`.\n- **24 h:** el resultado se sirve durante 24 h.\n- Las consultas de Verificación Masiva van por un carril masivo propio. No\n  cambia nada para el integrador.\n\n### Una lista vencida no se cobra\n\nUna lista restrictiva (OFAC, ONU, PEP o una internacional) cuyos datos no están\nvigentes responde `match_status: \"unverifiable\"`: no certifica ausencia de\ncoincidencias. Desde hoy esa consulta no se cobra. Cambia lo que se cobra:\n- **Canal directo** (`POST /api/v1/gateway/services/...`): responde 200 con\n  `credits_charged: 0`. No aparece una transacción de `refund`, porque no hubo\n  cobro.\n- **Reporte unificado:** el crédito de esa fuente vuelve al cerrar el reporte.\n  Se ve en `credits_refunded` del reporte y de la fuente (`sources[]`), y en el\n  webhook `report.*`. La política pública ya lo decía; hasta hoy no se cumplía.\n- Si la lista vencida trae una coincidencia (`is_match: true`), se entregó un\n  hallazgo y se cobra, como antes.\n- El veredicto no cambia: la lista vencida sigue dejando el reporte en\n  `warning`.\n\n## 2026-09-28\n\n### El primer apellido de RUNT persona sale de ADRES, ADRES BDUA o Policía\n\nYa no depende de una sola fuente. Si `selected_endpoints` incluye\n`runt-persona-consultar` y al menos una de `adres-afiliados-compensados`,\n`adres-bdua` o `policia-antecedentes`, y no enviás `primer_apellido`, RUNT\npersona toma el primer apellido de la más confiable que lo traiga, en ese orden.\nSi una falla o no lo trae, se usa la siguiente. Lo que cambia:\n- De ADRES BDUA (los dos apellidos juntos) y de Policía («Apellidos y Nombres»)\n  se toma sólo cuando no hay duda: un apellido compuesto dudoso no se adivina y\n  se pasa a la siguiente fuente.\n- Se envía sin tildes y con la Ñ, la forma en que el RUNT guarda los apellidos.\n- RUNT persona arranca apenas responde la más confiable que tiene el apellido;\n  sólo espera si una más confiable sigue respondiendo.\n- `derived_inputs` de RUNT persona lista las tres fuentes.\n- Si ninguna lo trae, RUNT persona cierra `failed` con\n  `error_code: \"missing_required_field\"`, se reembolsa y el reporte cierra\n  `partial`. El `error` nombra las fuentes que no lo trajeron.\n- Sin `selected_endpoints` no cambia nada.\n\n### RUNT persona puede tomar el primer apellido de ADRES afiliados compensados\n\nSi `selected_endpoints` incluye `runt-persona-consultar` **y**\n`adres-afiliados-compensados` y no enviás `primer_apellido`, RUNT persona ya no\nse omite: espera a ADRES y se consulta con el primer apellido que ADRES\ndevuelve (el portal lo da separado, con la Ñ). Si enviás el apellido, se usa el\ntuyo. Lo que cambia:\n- RUNT persona aparece en `sources` y en el costo, donde antes aparecía en\n  `omitted_sources`. Arranca apenas la fuente que aporta el apellido responde\n  (no espera a las demás), así que el reporte tarda algo más sólo si esa fuente\n  es la última en responder.\n- Si ADRES no trae el apellido (la persona no tiene periodos compensados), RUNT\n  persona cierra `failed` con `error_code: \"missing_required_field\"`, se\n  reembolsa y el reporte cierra `partial` (evento `report.partial`). Es una\n  fuente informativa: no cambia `overall_status`.\n- En `GET /api/v1/gateway/services/`, RUNT persona trae\n  `derived_inputs: {\"primer_apellido\": [\"adres-afiliados-compensados\"]}`.\n- Sin `selected_endpoints` no cambia nada: RUNT persona sin apellido se sigue\n  omitiendo.\n\n### Fuentes que esperan un dato de otra: `nombre_completo`, `missing_required_field` y `derived_inputs`\n\nAlgunas fuentes esperan un dato que aporta otra del mismo reporte (las listas\nrestrictivas y Rama Judicial esperan el nombre del titular). Cuatro cambios:\n- **`extra_fields.nombre_completo` ahora alcanza.** Era el nombre documentado,\n  pero las listas lo ignoraban, quedaban esperando y cerraban `failed`. Ahora se\n  consultan con ese nombre desde el primer momento.\n- **Una fuente que no recibió el dato que esperaba cierra `failed` con\n  `error_code: \"missing_required_field\"`** (antes `unexpected`). El `error` dice\n  qué faltó y qué fuente debía aportarlo. Se reembolsa como cualquier `failed`,\n  el reporte cierra `partial` y se emite el evento `report.partial`. Es un valor\n  nuevo en la lista de `error_code`: tratá la lista como abierta.\n- **Un `primer_apellido` que queda vacío al normalizar** (por ejemplo `\"---\"`)\n  cuenta como no enviado. La fuente se omite igual que sin el campo y no se\n  despacha: antes salía y el RUNT la rechazaba.\n- **`GET /api/v1/gateway/services/` trae `derived_inputs` en cada fuente:** los\n  campos que otra fuente del mismo reporte puede aportar, con sus códigos.\n  Vacío cuando no hay ninguno (el primero, RUNT persona, está en la entrada de\n  arriba).\n\n### El reporte deja de consultar y cobrar fuentes que no aceptan el tipo de documento del titular\n\nEl reporte elegía las fuentes por los datos que tenía, sin mirar el tipo de\ndocumento. Por eso:\n- a una **CC** con `fecha_expedicion` se le consultaban Migración Colombia PPT\n  y cédula de extranjería con el número de la cédula. La respuesta era «sin\n  registros» y **se cobraba**;\n- a un **NIT** se le consultaban fuentes de persona natural (Contraloría\n  natural, Policía antecedentes, ADRES afiliación, RUNT persona). Fallaban, el\n  crédito se devolvía y el reporte cerraba `partial`.\n\nAhora cada fuente sólo entra si acepta el tipo del titular: no se consulta ni\nse cobra. Aplica al reporte, al estimate, a Masiva y a Monitoreo. En Masiva, el\nestimado se sigue calculando por lote; lo que baja es el cobro. Lo que cambia\npara el integrador:\n- **Sin `selected_endpoints`,** la fuente que no aplica simplemente no aparece.\n  `sources`, `total_sources` y `credits_reserved` bajan en esos casos.\n- **Con `selected_endpoints`,** la fuente que no aplica aparece en\n  `omitted_sources` con el valor nuevo `reason: \"document_type_not_supported\"`\n  y un `hint` que dice qué tipos acepta. Agregar un valor a esa lista es\n  compatible; programá contra `reason` y tratá la lista como abierta.\n- **Migración Colombia PPT deja de entrar al reporte:** sólo acepta PPT, que el\n  reporte no admite. Se pide por su consulta directa.\n\nLos reportes ya creados no cambian.\n\n## 2026-09-27\n\n### Cada fuente tiene su propia operación en la referencia\n\nLa consulta directa se documentaba como una operación genérica,\n`POST /api/v1/gateway/services/{provider_slug}/{endpoint_slug}`. Ahora cada\nfuente tiene su operación en su ruta real\n(`POST /api/v1/gateway/services/verifycol/contraloria-natural`, etc.), agrupada\npor tipo de fuente. Cada una trae:\n- el esquema exacto de su cuerpo, con un ejemplo que usa documentos del\n  sandbox;\n- a quién aplica y si puede dar hallazgo o es informativa;\n- el costo y el tope de documentos por consulta;\n- si responde de inmediato o con `202`;\n- desde qué plan está disponible;\n- cómo entra al reporte unificado.\n\nLo mismo con las consultas asíncronas y el catálogo de cargos:\n`GET …/verifycol/tasks/{task_id}`, `DELETE …/verifycol/tasks-delete/{task_id}`\ny `GET …/verifycol/catalog-positions?system_id=`.\n\n**Las rutas y lo que viaja por la red no cambiaron.** Si generaste un cliente\ndesde `/api/v1/public/openapi.json`, esto sí cambia:\n- `querySource` y `readSourceResource` salen de la spec. Hay un método por\n  fuente: `queryContraloriaNatural`, `queryOfacSearch`, etc., más\n  `listPositions`.\n- El último segmento de las tareas se llama `{task_id}` (antes\n  `{resource_id}`).\n- Los tags de la consulta directa cambian: «Consulta directa: cómo funciona» y\n  uno por grupo de fuentes.\n\n### La referencia documenta la cabecera `X-Data-Authorization`\n\nCon una key `vf_live_`, una consulta directa sin\n`X-Data-Authorization: true` responde **400**: la cabecera declara que contás\ncon la autorización del titular (Ley 1581). Ya era así, pero la referencia no\nlo decía. Ahora cada fuente la declara como obligatoria. Con una key\n`vf_test_` no hace falta.\n\n### Correcciones en la referencia\n\n- **El 200 de `GET …/reports/{report_id}/pdf`** se declara como\n  `application/pdf` (binario). Antes figuraba además como JSON.\n- **El ejemplo del reporte** ya no combina `environment: \"test\"` con\n  `test_mode: false`.\n- **`error.scope` del 429:** los textos nombran los tres valores (`key`,\n  `organization`, `ip`).\n\n## 2026-09-25\n\n### Referencia pública de la API en `https://api.verifycol.co/docs`\n\nLa especificación OpenAPI del canal ahora tiene una página: introducción\n(autenticación, sandbox, flujo, créditos, límites, idempotencia, errores y\nfirma de webhooks), cada operación con ejemplos y snippets, y los cuerpos de\ncada webhook. Es pública y no pide credenciales. No permite enviar peticiones\ndesde el navegador.\n\n### Cambia la especificación `/api/v1/public/openapi.json`, no lo que viaja por la red\n\nLas rutas, los cuerpos, los códigos de estado y las cabeceras no cambiaron. Sí\ncambió cómo se describen, y eso se nota si generaste un cliente desde la spec:\n\n- **`operationId` nuevos y estables**: `createReport`, `getReport`,\n  `estimateReport`, `downloadReportPdf`, `listReports`, `getConsent`,\n  `getCreditBalance`, `listCreditTransactions`, `createWebhook`, etc. Los\n  nombres de método de un cliente regenerado cambian.\n- **Tags de cara al cliente**: «Reportes de verificación», «Consentimiento del\n  titular», «Créditos», «Configuración de webhooks» y «Consulta directa por\n  fuente (avanzado)».\n- **La consulta directa por fuente** publica los verbos que las fuentes usan de\n  verdad (POST para consultar; GET para catálogos y el estado de una consulta\n  asíncrona; DELETE para cancelarla). El último segmento de la ruta se llama\n  `{resource_id}` (antes `{extra_path}`).\n- **Errores tipados**: toda respuesta 4xx/5xx declara el sobre `ErrorEnvelope`\n  (`detail`, `success`, `error.code`, `error.message`, `error.reason`), que es\n  la forma que el canal ya devolvía.\n- **Nuevo bloque `webhooks`** (OpenAPI 3.1) con los cuerpos de\n  `report.completed`, `report.partial`, `report.failed`, `task.completed` y\n  `webhook.test`, y sus cabeceras de firma.\n- **`servers`** con la URL de la API.\n- `CreditBalanceResponse` se llama así en la spec (antes con el prefijo del\n  módulo).\n- **Salieron de la spec** las rutas de `/api/v1/gateway/docs/*` (la\n  documentación de la documentación). La colección de Postman y este registro\n  de cambios se enlazan desde la referencia.\n\n### La referencia documenta las fuentes y corrige la guía de límites\n\n- **Sección «Fuentes»:** las 28 fuentes, en cinco grupos. Por cada una: qué\n  verifica, a quién aplica, qué datos pide en el reporte, créditos, tiempo\n  típico y desde qué plan está disponible. La consulta directa trae un ejemplo\n  de cuerpo por cada forma de consulta.\n- **La guía de límites calcula el cupo diario por API key**, que es el que\n  primero corta: con una key y el cupo por defecto, entre 86 y 400 reportes\n  por día. Antes calculaba con el cupo de la organización. Cada key tiene su\n  propio cupo, con el de la organización como techo.\n- **`error.scope` del 429** puede ser `key`, `organization` o `ip` (el freno por\n  IP). La spec ya lo declara.\n- **La colección de Postman** describe cada fuente con el mismo texto que la\n  referencia.\n\n### El catálogo de `/api/v1/gateway/docs/` pide sesión\n\n`GET /api/v1/gateway/docs/`, `/providers`, `/providers/{slug}` y `/quickstart`\nresponden **401** sin la sesión de la aplicación web: los consume la pantalla\n«Documentación» de la app. Para integrar, la referencia es\n`https://api.verifycol.co/docs`. Siguen públicas, sin credenciales, la\ncolección de Postman (`/api/v1/gateway/docs/postman`) y este registro de\ncambios (`/api/v1/gateway/docs/changelog`).\n\n---\n\n## 2026-09-15\n\n### La rotación deja viva la key anterior 24 h — y `grace_hours: 0` la mata en el acto\n\n`POST /api/v1/gateway/api-keys/{id}/rotate` (panel, con sesión) ya no corta el\nservicio en el acto: la key anterior **sigue autenticando 24 horas**\n(o hasta `expires_at`, lo que llegue primero). La respuesta trae dos claves\nnuevas: `grace_hours` y `previous_key_expires_at`. Quien necesite el corte\ninmediato —una key filtrada— manda el body `{\"grace_hours\": 0}`. Rotar una key\nrevocada o vencida responde **409** (antes 200 con una key que nunca\nautenticaba).\n\n### Claves nuevas en las respuestas de API keys (endpoints del panel, con sesión JWT)\n\n`GET /api/v1/gateway/api-keys` y `GET /api/v1/gateway/api-keys/{id}` devuelven `last_used_ip`,\n`previous_key_expires_at` y `scopes` (este último existía en el esquema y no\nse llenaba).\n\n### `allowed_ips` se valida más y se devuelve normalizado\n\nSe rechazan `0.0.0.0/0` y `::/0` (no restringen nada; para abrir la key se deja\nla lista vacía) y más de 50 entradas. Una entrada con bits de host puestos se\nguarda en forma canónica (`203.0.113.9/24` → `203.0.113.0/24`) y las\nduplicadas se descartan. Se aceptan comas, punto y coma y espacios como\nseparadores dentro de una entrada.\n\n### Los 403 del canal traen `error.reason`\n\n`IP_NOT_ALLOWED` u `ORIGIN_NOT_ALLOWED`, aditiva; `error.code` sigue siendo\n`FORBIDDEN`.\n\n### Toda key de producción nueva vence\n\nSin `expires_at`, una key `live` nace con hoy + 365 días; más lejos es 422. Por\nPATCH se puede extender (de nuevo hasta 365 días), nunca quitar. Los\nresponsables de la organización reciben correo al crear, rotar, reemplazar y\nrevocar una key, 30, 7 y 1 día antes del vencimiento y al vencer. Las keys\ncreadas antes de hoy no cambian.\n\n### `GET /docs/quickstart` publica `authentication.security`\n\nFormatos de `allowed_ips`, tope, expiración, avisos, `reason` y buenas\nprácticas, en un solo bloque.\n\n---\n\n## 2026-09-10\n\n### Cambia lo que se cobra — la consulta directa devuelve el crédito cuando la fuente no entregó nada\n\nPor el camino directo (`POST /api/v1/gateway/services/{provider}/{endpoint}`)\nel crédito se cobra al aceptar la tarea (`202`). Hasta hoy se devolvía **sólo**\nsi la tarea terminaba en `FAILED` (murió sin producir resultado). Pero una\nfuente que no pudo consultarse —`captcha`, `rate_limited`, `blocked`,\n`portal_down`, `timeout`, `network`, `undetermined`— **no muere**: devuelve un\nitem de fallo y la tarea termina en `COMPLETED` con `successful: false`. Ese\ncrédito **se cobraba**, aunque el item dijera `retryable: true`.\n\nDesde hoy se devuelve. La regla, leída de `results[]` en `GET /tasks/{task_id}`:\n\n- Tarea `FAILED` → vuelve todo el crédito de la tarea (como antes).\n- Tarea `COMPLETED` con **todos** sus items `success: false` y\n  `retryable: true` → vuelve todo el crédito de la tarea (**nuevo**).\n- Cualquier otro caso se cobra: al menos un item con `success: true` (hubo\n  resultado), al menos un fallo con `retryable: false` (reenviar lo mismo da lo\n  mismo: un PDF que no es un RUT, un documento que la fuente no acepta), o una\n  tarea cancelada (`REVOKED`).\n\nEl crédito es **por tarea**, no por item: un lote de 20 con 19 éxitos y 1\nfallo reintentable se cobra entero, no se prorratea. En el canal directo casi todos los\nlotes son de 1, que es el caso que este cambio arregla.\n\nLa devolución no es inmediata: se concilia cada 10 minutos sobre las tareas de\nmás de 1 hora, y aparece en\n`GET /api/v1/gateway/api/credits/transactions?transaction_type=refund`.\n\n**Si tu integración reintenta por su cuenta los `retryable: true`, ahora el\nprimer intento no te cuesta.** Y si conciliás contra el `202`, vas a ver\ndevoluciones que antes no existían: son estas.\n\n---\n\n## 2026-09-09\n\n### Un campo requerido que falta se responde en la puerta, y el error dice cómo se escribe\n\nCuando al body le falta un campo que el endpoint declara **requerido**, el canal\nya no reenvía la petición a la fuente: contesta él. **El status y el código no\ncambian** — sigue siendo `422` con `error_code: \"validation_error\"`, igual que\nantes—, así que quien ya ramifica por ese código no toca nada.\n\nLo que cambia es lo que se puede leer. Cada entrada de `detail` suma un `hint`\ncon la regla de escritura que publica el catálogo, que hasta hoy sólo estaba en\nla documentación:\n\n```jsonc\n{\n  \"detail\": [{\n    \"type\": \"missing\",\n    \"loc\": [\"body\", \"documents\", 0, \"primer_apellido\"],\n    \"msg\": \"Field required\",\n    \"hint\": \"Primer apellido del ciudadano. OBLIGATORIO desde el 2026-09: ...\n             No lo pases a ASCII: `PENA` por `PEÑA` devuelve un \\\"no inscrito\\\" falso.\"\n  }],\n  \"error\": \"Faltan campos requeridos en 1 documento(s) de 'runt-persona-consultar': primer_apellido. ...\",\n  \"error_code\": \"validation_error\"\n}\n```\n\nAgregar una clave es compatible: `type`, `loc` y `msg` siguen donde estaban.\n\n**En el ambiente de prueba pasa lo mismo.** Antes el sandbox no miraba los\ncampos del documento, así que un body incompleto volvía `202` con una key de\nprueba y `422` con una real. Ahora los dos contestan `422`: si su integración\npasa en el sandbox, ese body pasa en producción.\n\nComo siempre, un `422` **no consume créditos**, y ahora tampoco consume tiempo\nde la fuente.\n\n---\n\n## 2026-09-05\n\n### ⚠ Cambio INCOMPATIBLE — `runt-persona-consultar` exige `primer_apellido`\n\nEl formulario público del RUNT agregó el campo **primer apellido** y su backend\nlo exige: la consulta por documento **no prospera sin él**. No es una decisión\nnuestra y no había forma de absorberla — el dato no se puede derivar del número\nde documento.\n\nEsto **rompe** a quien ya llamaba a `runt-persona-consultar`. Lo decimos con\ntodas las letras porque la política del canal sólo declara compatibles los\nparámetros **opcionales**, y éste es requerido.\n\n**Qué cambia**\n\n```jsonc\n// ANTES\n{\"documents\": [{\"tipo_documento\": \"CC\", \"numero_documento\": \"1000000001\"}]}\n\n// AHORA\n{\"documents\": [{\"tipo_documento\": \"CC\", \"numero_documento\": \"1000000001\",\n                \"primer_apellido\": \"PEREZ\"}]}\n```\n\nSin el campo, la fuente responde **422** y no se cobra: la validación corre\nantes de tocar el portal.\n\n**Cómo se escribe** — son las reglas que publica el propio RUNT, y la trampa es\nla inversa de la que uno espera:\n\n- Va **sólo el primer apellido**, no el nombre completo; completo si es\n  compuesto (`DE LA CRUZ`).\n- Se **conservan** los espacios, las tildes, la ñ y la diéresis.\n- Se **eliminan** los guiones, apóstrofes y demás símbolos, **sin dejar espacio\n  en su lugar**: por `D'LUCA` va `DLUCA`, por `DIAZ-PEÑA` va `DIAZPEÑA`.\n- **No lo pases a ASCII.** `PENA` por `PEÑA` devuelve un \"no inscrito\" falso —\n  peor que un error, porque parece un resultado.\n\n**Por los otros caminos**\n\n- **Reporte** (`POST /gateway/reports`): mandá `primer_apellido` en\n  `extra_fields`. Si falta, la fuente sale en `omitted_sources` con\n  `reason: \"missing_required_field\"` — **no se consulta y no se cobra**, el\n  reporte sigue con las demás.\n- **Verificación Masiva**: la plantilla de Excel trae la columna *Primer\n  Apellido*. La fila que la deje vacía pierde **esa fuente**, no el lote.\n- **Monitoreo continuo**: el apellido se guarda con el miembro y se reusa en\n  cada corrida.\n\n**Además**: `runt-persona-consultar` y `adres-bdua` ya **no publican `NIT`**\nentre sus tipos de documento. Nunca lo aceptaron —son fuentes de personas\nnaturales— y ofrecerlo era mandar a una consulta que fallaba. Con el apellido\nde por medio es además un contrasentido: un NIT no tiene primer apellido.\n\n---\n\n## 2026-09-03\n\n### Cambia lo que se cobra — una fuente sin registros ya no se reembolsa\n\nUna fuente que se consultó bien y **no encontró registros** cierra ahora con\n`status: \"not_found\"` en vez de `failed`, y **se cobra**. La consulta se hizo y\nel portal contestó: \"no hay nada\" es el resultado, no una falla del servicio.\n\nQué cambia en la práctica:\n\n- Un reporte cuyas fuentes respondan todas \"sin registros\" —el sujeto sin una\n  sola anotación, el caso más común y más limpio— pasa de costar 0 (todo\n  reembolsado) a costar su precio de lista.\n- Ese mismo reporte cerraba `partial`, con `overall_status: warning` y \"fuentes\n  caídas\" que nunca se cayeron. Ahora cierra `completed`.\n- `error_code: \"no_result\"` **se conserva** (es contra lo que se programa). Lo\n  que cambió es que la explicación viaja por `status_message` y no por `error`,\n  que queda en `null`.\n\n**Si tu integración cuenta `sources[].status == \"failed\"` para decidir si\nreintenta o para conciliar, revisala.**\n\n### `sources[].status`, documentado — y son siete\n\nEl vocabulario de `sources[].status` nunca se había publicado. Son\n`pending`, `submitted`, `deferred`, `completed`, `not_found`, `failed` y\n`canceled`, cada uno con si es terminal y si se cobra. Está en\n`source_statuses` de `GET /api/v1/gateway/docs`.\n\n### `external_reference`: tu propio identificador en el reporte\n\nSe acepta al crear (también en el payload diferido del 202 del consentimiento\ndel titular), viaja en la respuesta y en la fila del listado, y se filtra con\n`?external_reference=` por **igualdad exacta** (`OC-1` no devuelve `OC-12`). No\nes única a propósito: el mismo legajo puede verificarse más de una vez.\n\n### `ETag` / `If-None-Match` en el detalle del reporte\n\n`GET /api/v1/gateway/api/reports/{id}` devuelve `ETag` y responde **304** si\nmandás `If-None-Match` y nada cambió. Ahorra transferencia (el cuerpo lleva el\n`data` de cada fuente), no las llamadas a las fuentes.\n\n### La frescura de cada fuente, con nombre propio\n\n`sources[]` publica `data_warning`, `last_verified` y `source_updated_at`,\nsellados al cerrar el reporte. Contestan \"cuando emitieron este reporte, ¿la\nlista contra la que comprobaron estaba vigente?\". Aditivo: `data` no cambió.\n\n### `min_score` fuera de rango es 422\n\nUn `extra_fields.min_score` inválido se rechaza con 422 antes de cobrar, en vez\nde recortarse en silencio a otro valor.\n\n### El listado valida `status`\n\n`GET /api/v1/gateway/api/reports/?status=<valor>` con un estado fuera del\nvocabulario responde **422** con la lista de valores válidos. Antes respondía\n200 con una lista vacía, indistinguible de \"no tenés reportes en ese estado\".\nEl filtro además normaliza mayúsculas y espacios: `PROCESSING` y `processing`\nson lo mismo.\n\n### `Deprecation` / `Sunset` se emiten de verdad\n\nLas cabeceras de deprecación (RFC 8594) ya se estampan en las respuestas del\ncanal, incluidas sus rutas públicas. Hoy ninguna ruta está deprecada, así que no\nvas a verlas: cuando aparezcan, son reales.\n\n### Este changelog\n\n`GET /api/v1/gateway/docs/changelog`.\n\n---\n\n## 2026-09-02\n\n### Conciliación por API: el libro de créditos tiene tipo y referencia\n\n`GET /api/v1/gateway/api/credits/transactions?reference=<report_id>` devuelve\nlos movimientos de ese reporte y nada más: la reserva (`consumption`) y, si\nalguna fuente falló, la devolución (`refund`). Los tipos son `purchase`,\n`bonus`, `refund`, `adjustment` (suman) y `consumption` (resta).\n\n### `X-Correlation-ID` de extremo a extremo\n\nLa traza que mandás viaja hasta las llamadas a las fuentes y queda persistida en\nel reporte, así que una línea de tu factura se puede atar a la llamada que la\nprodujo.\n\n### Idempotencia: `Idempotent-Replayed` y el `reason` del 409\n\n- El replay de una `Idempotency-Key` responde 201 con la cabecera\n  `Idempotent-Replayed: true`; la creación original no la lleva. Es la única\n  forma de distinguirlos, porque el status y el `id` son los mismos.\n- El 409 conserva `error.code: \"CONFLICT\"` y agrega `error.reason`:\n  `IDEMPOTENCY_CONFLICT` (mismo key, otro cuerpo → usá una key nueva) o\n  `IDEMPOTENCY_IN_PROGRESS` (hay una creación en curso; viene con `Retry-After`\n  → reintentá con LA MISMA key).\n- **Con `Idempotency-Key` ya no aplica la deduplicación de 30 s**: la clave es\n  la identidad del pedido, así que dos claves distintas son dos verificaciones y\n  se cobran las dos aunque el cuerpo sea idéntico.\n- Reintentar una creación que respondió 202 (consentimiento del titular) con la\n  misma clave devuelve el mismo `consent_id` y **no** le manda otro correo al\n  titular.\n\n### Webhooks\n\n- Operables con la misma API key: ver, reactivar, historial de entregas,\n  reintento manual y ping de prueba. Dar de alta, rotar el secret y borrar\n  requieren el scope **`webhooks:manage`**, que es opt-in y se otorga key por\n  key. Cambiar la `url` de uno existente sigue siendo sólo por panel.\n- Rotación de secret **con gracia**: el secret anterior firma 24 h más y las dos\n  firmas viajan en el mismo header separadas por coma. Verificá si **cualquiera**\n  coincide.\n- Modo de payload **`thin`**: el cuerpo no lleva datos del titular; el detalle se\n  trae con `GET /api/v1/gateway/api/reports/{id}`. El default sigue siendo\n  `full`.\n- El aviso trae **`evidence_sha256`** (el hash de la evidencia que imprime el\n  PDF, no de los bytes del archivo — el PDF se renderiza en cada descarga y no\n  sería reproducible).\n- La entrega se crea donde el reporte cierra: pollear ya no la retrasaba hasta\n  una hora.\n- `consent_id` viaja siempre en los eventos de reporte (con `null` cuando no\n  hubo consentimiento del titular de por medio).\n\n### Créditos: un reporte cobrado siempre cierra o se reembolsa\n\nY una fuente que respondió pero no pudo verificar nada (`undetermined`, por\nejemplo una lista cuyos datos no están vigentes) no se cobra.\n\n### Rate limit\n\n- Toda respuesta trae `X-RateLimit-Limit` / `X-RateLimit-Remaining` y\n  `X-RateLimit-Limit-Day` / `X-RateLimit-Remaining-Day`, todas describiendo el\n  contador **por API key**. Antes `Limit` y `Remaining` describían cubos\n  distintos y el par no se podía interpretar.\n- `X-RateLimit-Reset` **ya no se envía** (describía otro contador).\n- `Retry-After` ya no viaja en respuestas exitosas.\n- El 429 tiene una sola forma, con `scope` (`ip | key | organization`), `limit`,\n  `current` y `retry_after`.\n\n### Seguridad de las API keys\n\nRevocar una key es definitivo. Las allowlists de IP y de origen se validan de\nverdad. El 401 responde lo mismo para una key inexistente, malformada, revocada\no vencida: distinguirlas era un oráculo para quien prueba claves.\n\n---\n\n## 2026-08-31\n\n### El 422 volvió a ser 422\n\n24 sitios degradaban a 400 un rechazo que el esquema declara como 422. El\nOpenAPI público quedó alineado con lo que el canal responde de verdad.\n\n---\n\n## 2026-08-24 · 2026-08-26\n\n### El tipo de documento se valida antes de cobrar\n\nEl canal admite **CC, CE, NIT y PA**. Cualquier otro se rechaza antes del cobro:\n`estimate` responde 200 con `can_proceed: false` y\n`blockers: [\"unsupported_document_type\"]`, y la creación responde 400. Antes un\ntipo inexistente cotizaba y cobraba igual, y las fuentes fallaban una por una.\nSe aceptan en minúscula y con espacios.\n\n### La deduplicación sin clave compara el CONJUNTO de fuentes\n\nDos creaciones sin `Idempotency-Key` para el mismo sujeto dentro de 30 s\ndeduplican sólo si consultan **el mismo conjunto** de fuentes. Antes se comparaba\nla cantidad, así que dos selecciones distintas de 3 fuentes devolvían el mismo\nreporte.\n\n### Colección de Postman descargable\n\n`GET /api/v1/gateway/docs/postman`. Pública: se puede revisar antes de tener\ncredenciales.\n\n---\n\n## 2026-08-23\n\n### Un solo sobre de error, donde había tres\n\nTodo error del canal responde `detail` + `success` + `error`, sin importar qué\ncapa lo produjo. **Superset**: ninguna clave existente cambió de significado ni\ndesapareció. Antes se podían recibir dos formas distintas de 429 según cuál de\nlos dos limitadores cortara primero.\n\n### Listado de reportes y ledger de créditos por API key\n\n`GET /api/v1/gateway/api/reports/` (filtros `status`, `origin`, `date_from`,\n`date_to`, `search`) y `GET /api/v1/gateway/api/credits/transactions`. Sin\nlistado, un `report_id` ya cobrado que se perdía era irrecuperable desde la API.\n\n### El 202 del consentimiento del titular deja de ser un callejón\n\n`POST /api/v1/gateway/api/reports/` puede responder **202** con\n`{status: \"pending_titular_consent\", consent_id}`: no hay reporte todavía y no\nse cobró nada. El `consent_id` se consulta en\n`GET /api/v1/gateway/api/consents/{consent_id}`, que devuelve el `report_id`\ncuando el titular acepta. **Programá los dos códigos: un cliente que asuma 201\nse rompe.**\n"}