Guía de implementación
Afiliados
Cruza y registra la base de afiliados por lotes JSON o por archivo CSV/Excel, consulta un afiliado por documento y recibe el evento affiliates.synced. Con normalización de cabeceras en español.
En esta página
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ón | Método SDK (Python) | Scope | Para qué |
|---|---|---|---|
POST/v1/gateway/affiliates/ | client.affiliates.cross(records) | affiliates:upsert | Registrar o actualizar un lote de afiliados en JSON. |
POST/v1/gateway/affiliates/upload/ | client.affiliates.upload(file) | affiliates:upsert | Cargar la base desde un archivo CSV o Excel (multipart). |
GET/v1/gateway/affiliates/{document}/ | client.affiliates.get(document) | affiliates:read | Consultar 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ónico | Nombre en los mensajes de error | Ejemplo |
|---|---|---|
document_number | documento | "1032456789" (también "1.032.456.789") |
document_type | tipo_documento | CC, CE, TI, PA, RC, NIT, PEP, PPT |
first_name | nombres | "María" |
last_name | apellidos | "Gómez" |
birth_date | fecha_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ónico | Alias aceptados |
|---|---|
| documento | document_number, documento, cedula, cédula, nro_documento, numero_documento, num_documento, identificacion, identificación, id_afiliado, afiliado_id, doc, nit |
document_type | tipo_documento, tipo_doc, tipodocumento |
first_name | nombre, nombres, primer_nombre, primernombre |
last_name | apellido, apellidos, primer_apellido |
email | correo, correo_electronico, mail, e_mail |
phone_number | telefono, teléfono, celular, movil, móvil, phone, tel |
birth_date | fecha_nacimiento, fecha_nac, fechanacimiento, birthdate, nacimiento |
gender | genero, género, sexo |
city | ciudad, municipio |
department | departamento, depto |
street_address | direccion, dirección, direccion_residencia |
salary | salario, sueldo, ingreso, ingresos |
employment_status | estado_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 enextra_data.external_id. - Fechas: acepta
AAAA-MM-DD,DD/MM/AAAA,DD-MM-AAAA,AAAAMMDD,MM/DD/AAAAyDD/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,ACTIVEoA→ACTIVE. - Columnas desconocidas (tu código de oficina, tu id interno…) no se pierden: se guardan en
extra_datadel 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"))const tx = await 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" },
],
{ idempotencyKey: "horizonte-afiliados-2026-11-02-lote-001" },
);
console.log(tx.transactionId, tx.data.created, tx.data.updated, tx.data.rejected);
for (const err of (tx.data.errors as Array<Record<string, unknown>>) ?? []) {
console.log("Fila", err.row, err.document_number, err.error);
}$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'],
], idempotencyKey: 'horizonte-afiliados-2026-11-02-lote-001');
echo $tx->transactionId, ' creados=', $tx->data['created'], ' rechazados=', $tx->data['rejected'], PHP_EOL;
foreach ($tx->data['errors'] ?? [] as $err) {
echo 'Fila ', $err['row'] ?? '-', ': ', $err['error'] ?? '', PHP_EOL;
}var lote = new[]
{
new AffiliateRef("CC", "1032456789")
{
Extra = new Dictionary<string, object?>
{
["nombres"] = "María", ["apellidos"] = "Gómez", ["fecha_nacimiento"] = "15/03/1990",
["correo"] = "maria.gomez@example.co", ["salario"] = "$2.500.000", ["estado_laboral"] = "ACTIVO",
},
},
new AffiliateRef("CE", "998877")
{
Extra = new Dictionary<string, object?>
{
["nombres"] = "Andrés", ["apellidos"] = "Pérez", ["fecha_nacimiento"] = "1985-07-02",
["celular"] = "3001234567",
},
},
new AffiliateRef("CC", "52987654")
{
Extra = new Dictionary<string, object?> { ["nombres"] = "Luisa", ["apellidos"] = "Rojas" },
},
};
Transaction tx = await client.Affiliates.CrossAsync(lote,
idempotencyKey: "horizonte-afiliados-2026-11-02-lote-001");
Console.WriteLine($"{tx.TransactionId} creados={tx.Data["created"]} rechazados={tx.Data["rejected"]}");tx, err := client.Affiliates.Cross(ctx, []map[string]any{
{"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"},
}, compense.WithIdempotencyKey("horizonte-afiliados-2026-11-02-lote-001"))
if err != nil {
log.Fatal(err)
}
fmt.Println(tx.TransactionID, tx.Data["created"], tx.Data["updated"], tx.Data["rejected"])curl -X POST https://sandbox.dev.compense.co/gw/v1/gateway/affiliates/ \
-H "X-API-Key: $COMPENSE_API_KEY" \
-H "Idempotency-Key: horizonte-afiliados-2026-11-02-lote-001" \
-H "Content-Type: application/json" \
-d '[
{"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"}
]'La respuesta es el sobre de transacción con los conteos del lote
en data:
{
"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"}
]
}
}acceptedes el total de filas recibidas;created+updatedlas que entraron;rejectedlas que fallaron la validación de obligatorios.errorsmezcla esos rechazos (conrow, 1-based, ydocument_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 contransactions.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 responde422; un Excel en un servidor sin soporte de Excel responde400pidiendo CSV.
Un CSV típico de Caja Horizonte (exportado de Excel, con ; y cabeceras en español):
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;ACTIVOCó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"])import { readFile } from "node:fs/promises";
// Acepta Blob/File, Uint8Array (un Buffer sirve), ArrayBuffer o el texto del CSV.
const tx = await client.affiliates.upload(await readFile("afiliados-horizonte-2026-11.csv"), {
filename: "afiliados-horizonte-2026-11.csv",
contentType: "text/csv",
idempotencyKey: "horizonte-afiliados-archivo-2026-11",
});
console.log(tx.data.filename, tx.data.created, tx.data.updated, tx.data.rejected);// Una ruta, un stream abierto (fopen), un SplFileInfo o un stream PSR-7.
$tx = $client->affiliates->upload(
'afiliados-horizonte-2026-11.csv',
idempotencyKey: 'horizonte-afiliados-archivo-2026-11',
);
echo $tx->data['filename'], ' creados=', $tx->data['created'], PHP_EOL;await using FileStream file = File.OpenRead("afiliados-horizonte-2026-11.csv");
Transaction tx = await client.Affiliates.UploadAsync(
file, "afiliados-horizonte-2026-11.csv", "text/csv",
idempotencyKey: "horizonte-afiliados-archivo-2026-11");
Console.WriteLine($"{tx.Data["filename"]} creados={tx.Data["created"]} rechazados={tx.Data["rejected"]}");f, err := os.Open("afiliados-horizonte-2026-11.csv")
if err != nil {
log.Fatal(err)
}
defer f.Close()
// Cualquier io.Reader; el SDK lo guarda en memoria para poder reintentar.
tx, err := client.Affiliates.Upload(ctx, "afiliados-horizonte-2026-11.csv", f,
compense.WithIdempotencyKey("horizonte-afiliados-archivo-2026-11"))
if err != nil {
log.Fatal(err)
}
fmt.Println(tx.Data["filename"], tx.Data["created"], tx.Data["rejected"])curl -X POST https://sandbox.dev.compense.co/gw/v1/gateway/affiliates/upload/ \
-H "X-API-Key: $COMPENSE_API_KEY" \
-H "Idempotency-Key: horizonte-afiliados-archivo-2026-11" \
-F "file=@afiliados-horizonte-2026-11.csv;type=text/csv"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)import { NotFoundError } from "@compense/sdk";
try {
const lookup = await client.affiliates.get("1.032.456.789");
console.log(lookup.exists, lookup.documentNumber, lookup.affiliate.first_name);
} catch (err) {
if (err instanceof NotFoundError) console.log("No es afiliado:", err.detail, err.traceId);
else throw err;
}use Compense\Sdk\Exception\NotFoundException;
try {
$lookup = $client->affiliates->get('1.032.456.789');
echo $lookup->documentNumber, ' ', $lookup->affiliate['first_name'] ?? '-', PHP_EOL;
} catch (NotFoundException $e) {
echo 'No es afiliado: ', $e->detail, ' ', $e->traceId, PHP_EOL;
}try
{
AffiliateLookup lookup = await client.Affiliates.GetAsync("1.032.456.789");
Console.WriteLine($"{lookup.Exists} {lookup.DocumentNumber} {lookup.Affiliate["first_name"]}");
}
catch (NotFoundException e)
{
Console.WriteLine($"No es afiliado: {e.Detail} {e.TraceId}");
}lookup, err := client.Affiliates.Get(ctx, "1.032.456.789")
switch {
case compense.IsNotFound(err):
fmt.Println("No es afiliado")
case err != nil:
log.Fatal(err)
default:
fmt.Println(lookup.Exists, lookup.DocumentNumber, lookup.Affiliate["first_name"])
}curl https://sandbox.dev.compense.co/gw/v1/gateway/affiliates/1032456789/ \
-H "X-API-Key: $COMPENSE_API_KEY"
# {"exists": true, "document_number": "1032456789", "affiliate": {"first_name": "María", ...}}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:
{
"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 #
| HTTP | Excepción (Python) | Cuándo | Qué hacer |
|---|---|---|---|
401 / 403 | AuthError | Falta la llave, es de otro entorno o no tiene affiliates:upsert / affiliates:read. | Revisa la llave y sus scopes con system.health. |
404 | NotFoundError | getAffiliate: el documento no es afiliado de tu entidad. | Regístralo con cross. |
409 | IdempotencyConflictError | Reenviaste la misma Idempotency-Key mientras la original seguía en proceso. | Espera y reintenta con la misma llave. |
413 | CompenseAPIError | Archivo de más de 25 MB. | Pártelo en varios archivos. |
422 | ValidationError | Lote de más de 1.000 filas, archivo vacío o ilegible. | Reduce el lote o revisa el archivo. |
502 / 504 | UpstreamError | El 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 #
| Evento | Desde | Cuándo se emite |
|---|---|---|
affiliates.synced | 2026-06-01 | Terminó una sincronización de afiliados (resumen de creados, actualizados y retirados). |
{
"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.