El módulo de afiliados es la puerta de entrada de cualquier integración: créditos, subsidios, cuota monetaria y cartera resuelven al afiliado por su documento, y el motor rechaza la operación si ese documento no está en el registro de tu entidad. Aquí aprendes a mantener ese registro al día desde tu sistema (core, ERP, SOFIA o una hoja de cálculo) y a consultarlo.

Son tres operaciones:

OperaciónMétodo SDK (Python)ScopePara qué
POST/v1/gateway/affiliates/client.affiliates.cross(records)affiliates:upsertRegistrar o actualizar un lote de afiliados en JSON.
POST/v1/gateway/affiliates/upload/client.affiliates.upload(file)affiliates:upsertCargar la base desde un archivo CSV o Excel (multipart).
GET/v1/gateway/affiliates/{document}/client.affiliates.get(document)affiliates:readConsultar si un documento es afiliado de tu entidad.

Pide llaves separadas para leer y para escribir (mínimo privilegio): un job nocturno de sincronización necesita affiliates:upsert; tu front de atención solo affiliates:read.

Cómo funciona la carga #

El gateway no exige tu formato interno: recibe tus filas “tal cual”, las normaliza, valida los campos obligatorios fila por fila y hace upsert por lotes en el registro de tu entidad. Una fila incompleta no aborta el lote: queda en errors y las demás se procesan.

flowchart LR
  A[Tu sistema<br/>JSON o CSV/XLSX] --> B[Gateway<br/>normaliza cabeceras y formatos]
  B --> C{¿Tiene los 5<br/>campos obligatorios?}
  C -- sí --> D[Upsert en el registro<br/>de la entidad]
  C -- no --> E[Fila rechazada<br/>en data.errors]
  D --> F[Respuesta: accepted, created,<br/>updated, rejected]
  E --> F
  D -.-> G((Webhook<br/>affiliates.synced))

Campos obligatorios #

Para crear un afiliado válido cada fila necesita estos cinco campos (en el nombre canónico o en cualquiera de sus alias en español):

Campo canónicoNombre en los mensajes de errorEjemplo
document_numberdocumento"1032456789" (también "1.032.456.789")
document_typetipo_documentoCC, CE, TI, PA, RC, NIT, PEP, PPT
first_namenombres"María"
last_nameapellidos"Gómez"
birth_datefecha_nacimiento"1990-03-15" o "15/03/1990"

Un AffiliateRef solo no basta para registrar

AffiliateRef (tipo y número de documento) es la referencia mínima que usan créditos y subsidios. Si envías a cross solo eso, la fila se rechaza con Faltan campos obligatorios: nombres, apellidos, fecha_nacimiento. Para registrar, envía el registro completo (en .NET, con AffiliateRef { Extra = ... }; en los demás SDK, con un diccionario/objeto).

Normalización de cabeceras en español #

Las claves se comparan en minúsculas y con los espacios convertidos en _, así que Fecha Nacimiento, fecha_nacimiento y FECHA_NACIMIENTO llegan al mismo campo. Estos son los alias que reconoce el gateway:

Campo canónicoAlias aceptados
documentodocument_number, documento, cedula, cédula, nro_documento, numero_documento, num_documento, identificacion, identificación, id_afiliado, afiliado_id, doc, nit
document_typetipo_documento, tipo_doc, tipodocumento
first_namenombre, nombres, primer_nombre, primernombre
last_nameapellido, apellidos, primer_apellido
emailcorreo, correo_electronico, mail, e_mail
phone_numbertelefono, teléfono, celular, movil, móvil, phone, tel
birth_datefecha_nacimiento, fecha_nac, fechanacimiento, birthdate, nacimiento
gendergenero, género, sexo
cityciudad, municipio
departmentdepartamento, depto
street_addressdireccion, dirección, direccion_residencia
salarysalario, sueldo, ingreso, ingresos
employment_statusestado_laboral, estado

Además de renombrar, el gateway limpia los formatos colombianos más comunes:

  • Documento: quita puntos, guiones y espacios (1.032.456.789-0 → 10324567890). El valor original se conserva en extra_data.external_id.
  • Fechas: acepta AAAA-MM-DD, DD/MM/AAAA, DD-MM-AAAA, AAAAMMDD, MM/DD/AAAA y DD/MM/AA. Una fecha que no se pueda leer cuenta como faltante.
  • Salario: "$2.500.000" → 2500000 (pesos enteros).
  • Género: masculino/hombre/m → M; femenino/mujer/f → F; cualquier otro valor → O.
  • Estado laboral: ACTIVO, ACTIVE o A → ACTIVE.
  • Columnas desconocidas (tu código de oficina, tu id interno…) no se pierden: se guardan en extra_data del afiliado para trazabilidad.

salary y employment_status no son obligatorios, pero los motores de elegibilidad de subsidios y de capacidad de pago de créditos los usan: envíalos si los tienes.

Cruzar un lote (JSON) #

POST/v1/gateway/affiliates/ recibe un arreglo de filas. El máximo por llamada es 1.000 filas (el límite max_records_per_push del gateway; por encima responde 422 Máximo 1000 afiliados por lote.). Si tu base es más grande, pártela en lotes de hasta 1.000 y dale a cada lote su propia Idempotency-Key estable, derivada de tu corte (por ejemplo horizonte-afiliados-2026-11-02-lote-007): así un reintento del mismo lote nunca duplica trabajo.

tx = client.affiliates.cross(
    [
        {"document_type": "CC", "document_number": "1032456789", "nombres": "María",
         "apellidos": "Gómez", "fecha_nacimiento": "15/03/1990", "correo": "maria.gomez@example.co",
         "salario": "$2.500.000", "estado_laboral": "ACTIVO"},
        {"document_type": "CE", "document_number": "998877", "nombres": "Andrés",
         "apellidos": "Pérez", "fecha_nacimiento": "1985-07-02", "celular": "3001234567"},
        {"document_type": "CC", "document_number": "52987654", "nombres": "Luisa",
         "apellidos": "Rojas"},  # sin fecha de nacimiento: se rechaza esta fila
    ],
    idempotency_key="horizonte-afiliados-2026-11-02-lote-001",
)
print(tx.transaction_id, tx.data["created"], tx.data["updated"], tx.data["rejected"])
for err in tx.data["errors"]:
    print("Fila", err.get("row"), err.get("document_number"), err.get("error"))

La respuesta es el sobre de transacción con los conteos del lote en data:

JSON
{
  "status": "success",
  "transaction_id": "7c0e9a52-1d4b-4f8e-9a51-3b2f6c1d0a11",
  "module": "affiliates",
  "data": {
    "accepted": 3,
    "created": 1,
    "updated": 1,
    "rejected": 1,
    "errors_count": 1,
    "errors": [
      {"row": 3, "document_number": "52987654", "error": "Faltan campos obligatorios: fecha_nacimiento"}
    ]
  }
}
  • accepted es el total de filas recibidas; created + updated las que entraron; rejected las que fallaron la validación de obligatorios. errors mezcla esos rechazos (con row, 1-based, y document_number) con los errores que devuelva el registro al guardar.
  • Si hubo rechazos, la transacción queda en estado PROCESSING (si todo entró, COMPLETED); la puedes consultar después con transactions.get.
  • Corrige solo las filas rechazadas y reenvíalas en un lote nuevo, con otra Idempotency-Key: si reutilizas la del lote original, el gateway te devuelve la respuesta original (idempotent_replay: true) sin procesar nada.

Cargar un archivo CSV o Excel #

POST/v1/gateway/affiliates/upload/ recibe multipart/form-data con el archivo en el campo file. Aplica exactamente la misma normalización y validación que el lote JSON.

  • Formatos: CSV (por defecto) o Excel .xlsx/.xls. El gateway decide por la extensión del nombre de archivo, así que nómbralo bien.
  • CSV: UTF-8 (con o sin BOM). El delimitador se detecta solo entre , ; tabulador y |; la primera fila son las cabeceras; las filas vacías se ignoran.
  • Excel: se lee la hoja activa; la primera fila son las cabeceras.
  • Tamaño: hasta 25 MB. Por encima, 413. Un archivo vacío, sin filas de datos o ilegible responde 422; un Excel en un servidor sin soporte de Excel responde 400 pidiendo CSV.

Un CSV típico de Caja Horizonte (exportado de Excel, con ; y cabeceras en español):

Texto
tipo_documento;documento;nombres;apellidos;fecha_nacimiento;correo;salario;estado_laboral
CC;1.032.456.789;María;Gómez;15/03/1990;maria.gomez@example.co;$2.500.000;ACTIVO
CE;998877;Andrés;Pérez;02/07/1985;;$1.800.000;ACTIVO

Cómo pasar el archivo en cada SDK:

# Un archivo abierto en binario, o una tupla (nombre, contenido, MIME) para fijar nombre y tipo.
with open("afiliados-horizonte-2026-11.csv", "rb") as fh:
    tx = client.affiliates.upload(
        ("afiliados-horizonte-2026-11.csv", fh, "text/csv"),
        idempotency_key="horizonte-afiliados-archivo-2026-11",
    )
print(tx.data["filename"], tx.data["created"], tx.data["updated"], tx.data["rejected"])

La respuesta trae los mismos conteos que el lote JSON más filename. El archivo pasa por la misma partición interna en lotes de 1.000, así que un Excel de 30.000 filas se procesa en una sola llamada.

Idempotencia de archivos

La Idempotency-Key identifica la operación, no el contenido: si subes un archivo corregido con la misma llave, recibes la respuesta del primero. Deriva la llave del corte y de una huella del archivo (por ejemplo horizonte-afiliados-2026-11-<sha256 corto>).

Consultar un afiliado por documento #

GET/v1/gateway/affiliates/{document}/ responde si un documento pertenece a tu entidad. El documento se normaliza igual que en la carga (1.032.456.789 sirve). Devuelve un AffiliateLookup con exists, document_number (normalizado) y affiliate (el registro tal como lo devuelve el registro de la entidad). Si no hay coincidencia, la respuesta es 404 (en los SDK, la excepción de “no encontrado”).

from compense_gateway import NotFoundError

try:
    lookup = client.affiliates.get("1.032.456.789")
    print(lookup.exists, lookup.document_number, lookup.affiliate.get("first_name"))
except NotFoundError as e:
    print("No es afiliado:", e.detail, e.trace_id)

Un patrón común es consultar antes de radicar un crédito o un subsidio: si el documento no existe, regístralo con cross (o pide al afiliado que se afilie) antes de enviar la solicitud.

Registro al vuelo al radicar #

Si tu llave tiene affiliates:upsert y la radicación de un crédito o un subsidio trae el bloque afiliado, el gateway intenta registrar o actualizar a ese afiliado antes de enviar la solicitud. Si ese registro falla, la radicación no se bloquea: la respuesta llega con un aviso en warnings:

JSON
{
  "status": "success",
  "transaction_id": "…",
  "module": "credits",
  "data": { "application_id": "…", "status": "RECEIVED" },
  "warnings": ["Inline affiliate upsert failed; the submission continued without syncing the affiliate."]
}

Trata ese aviso como una tarea pendiente: sincroniza al afiliado completo con cross. Lo más robusto sigue siendo mantener la base al día con una carga periódica.

Errores típicos #

HTTPExcepción (Python)CuándoQué hacer
401 / 403AuthErrorFalta la llave, es de otro entorno o no tiene affiliates:upsert / affiliates:read.Revisa la llave y sus scopes con system.health.
404NotFoundErrorgetAffiliate: el documento no es afiliado de tu entidad.Regístralo con cross.
409IdempotencyConflictErrorReenviaste la misma Idempotency-Key mientras la original seguía en proceso.Espera y reintenta con la misma llave.
413CompenseAPIErrorArchivo de más de 25 MB.Pártelo en varios archivos.
422ValidationErrorLote de más de 1.000 filas, archivo vacío o ilegible.Reduce el lote o revisa el archivo.
502 / 504UpstreamErrorEl registro de afiliados no respondió.Reintenta con la misma llave (el SDK ya reintenta 502/503/504).

Las filas incompletas no son un error HTTP: llegan en data.errors con 200. Ver Errores para el formato application/problem+json y el trace_id.

El webhook affiliates.synced #

Cada vez que el registro termina de guardar un lote (los que envías por cross o upload, y las sincronizaciones que corran desde otras fuentes), se emite affiliates.synced con el resumen. Úsalo para cerrar el ciclo de tu job nocturno sin tener que consultar.

Afiliados #

EventoDesdeCuándo se emite
affiliates.synced2026-06-01Terminó una sincronización de afiliados (resumen de creados, actualizados y retirados).
JSON
{
  "event": "affiliates.synced",
  "version": "1",
  "tenant_id": "horizonte",
  "occurred_at": "2026-11-02T03:30:00Z",
  "resource": {
    "sync_id": "SYNC-2026-11-02-A1B2C3",
    "source": "gateway",
    "counts": {"created": 12, "updated": 340, "deactivated": 0},
    "as_of": "2026-11-02T03:30:00Z",
    "status": "completed"
  }
}

Como los lotes grandes se guardan en tramos de 1.000, una carga de 2.500 afiliados produce varios eventos (uno por tramo): suma los counts. Suscríbete y verifica la firma como se explica en Webhooks y Verificación.

Sandbox #

El catálogo determinista de documentos de prueba (1000000001 elegible, 9999999999 inexistente…) gobierna créditos, subsidios, cuota monetaria y cartera. Los afiliados, en cambio, se cruzan contra el registro de afiliados del tenant de tu llave: con una llave de sandbox, carga primero tus afiliados de prueba con cross o upload y luego consúltalos. Consulta los documentos de prueba en Datos de prueba.

Lo que sigue #