Implementation guide
Affiliates
Cross-check and register your affiliate base with JSON batches or a CSV/Excel file, look up an affiliate by document and receive the affiliates.synced event. With normalization of Spanish headers.
On this page
The affiliates module is the entry point for any integration: credits, subsidies, monetary allowance and loan portfolio all resolve the affiliate by their document, and the engine rejects the operation if that document isn’t in your entity’s registry. Here you learn how to keep that registry up to date from your system (core, ERP, SOFIA or a spreadsheet) and how to query it.
There are three operations:
| Operation | SDK method (Python) | Scope | Used for |
|---|---|---|---|
POST/v1/gateway/affiliates/ | client.affiliates.cross(records) | affiliates:upsert | Register or update a batch of affiliates in JSON. |
POST/v1/gateway/affiliates/upload/ | client.affiliates.upload(file) | affiliates:upsert | Load the base from a CSV or Excel file (multipart). |
GET/v1/gateway/affiliates/{document}/ | client.affiliates.get(document) | affiliates:read | Check whether a document belongs to an affiliate of your entity. |
Ask for separate keys for reading and for writing (least privilege): a nightly sync job needs
affiliates:upsert; your customer-service front end only needs affiliates:read.
How loading works #
The gateway doesn’t require your internal format: it takes your rows “as is”, normalizes them,
validates the required fields row by row and upserts them in batches into your entity’s registry.
An incomplete row does not abort the batch: it ends up in errors and the rest are processed.
flowchart LR
A[Your system<br/>JSON or CSV/XLSX] --> B[Gateway<br/>normalizes headers and formats]
B --> C{Does it have the 5<br/>required fields?}
C -- yes --> D[Upsert into the<br/>entity's registry]
C -- no --> E[Row rejected<br/>in data.errors]
D --> F[Response: accepted, created,<br/>updated, rejected]
E --> F
D -.-> G((Webhook<br/>affiliates.synced))Required fields #
To create a valid affiliate, each row needs these five fields (under the canonical name or any of its Spanish aliases):
| Canonical field | Name in error messages | Example |
|---|---|---|
document_number | documento | "1032456789" (also "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" or "15/03/1990" |
An AffiliateRef alone isn't enough to register
AffiliateRef (document type and number) is the minimal reference that credits and subsidies use.
If you send only that to cross, the row is rejected with Faltan campos obligatorios: nombres, apellidos, fecha_nacimiento. To register, send the full record (in .NET, with AffiliateRef { Extra = ... };
in the other SDKs, with a dictionary/object).
Normalization of Spanish headers #
Keys are compared in lowercase with spaces turned into _, so Fecha Nacimiento,
fecha_nacimiento and FECHA_NACIMIENTO all map to the same field. These are the aliases the
gateway recognizes:
| Canonical field | Accepted aliases |
|---|---|
| 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 |
Besides renaming, the gateway cleans up the most common Colombian formats:
- Document: strips dots, hyphens and spaces (
1.032.456.789-0→10324567890). The original value is kept inextra_data.external_id. - Dates: accepts
AAAA-MM-DD,DD/MM/AAAA,DD-MM-AAAA,AAAAMMDD,MM/DD/AAAAandDD/MM/AA. A date that can’t be parsed counts as missing. - Salary:
"$2.500.000"→2500000(whole pesos). - Gender:
masculino/hombre/m→M;femenino/mujer/f→F; any other value →O. - Employment status:
ACTIVO,ACTIVEorA→ACTIVE. - Unknown columns (your branch code, your internal id…) aren’t lost: they are stored in the
affiliate’s
extra_datafor traceability.
salary and employment_status aren’t required, but the subsidy eligibility and credit repayment
capacity engines use them: send them if you have them.
Cross-check a batch (JSON) #
POST/v1/gateway/affiliates/
takes an array of rows. The maximum per call is 1.000 rows
(the gateway’s max_records_per_push limit; above it, it responds 422 Máximo 1000 afiliados por lote.). If your base is larger, split it into batches of up to 1,000 and give each batch its own
stable Idempotency-Key, derived from your cutoff (for example horizonte-afiliados-2026-11-02-lote-007):
that way a retry of the same batch never duplicates work.
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"}, # no birth date: this row is rejected
],
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("Row", 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("Row", 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, ' created=', $tx->data['created'], ' rejected=', $tx->data['rejected'], PHP_EOL;
foreach ($tx->data['errors'] ?? [] as $err) {
echo 'Row ', $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} created={tx.Data["created"]} rejected={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"}
]'The response is the transaction envelope with the batch counts
in 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"}
]
}
}acceptedis the total number of rows received;created+updatedare the ones that went in;rejectedare the ones that failed the required-field validation.errorsmixes those rejections (withrow, 1-based, anddocument_number) with any errors the registry returns when saving.- If there were rejections, the transaction stays in
PROCESSINGstate (if everything went in,COMPLETED); you can check it later withtransactions.get. - Fix only the rejected rows and resend them in a new batch, with a different
Idempotency-Key: if you reuse the original batch’s key, the gateway returns the original response (idempotent_replay: true) without processing anything.
Upload a CSV or Excel file #
POST/v1/gateway/affiliates/upload/
takes multipart/form-data with the file in the file field.
It applies exactly the same normalization and validation as the JSON batch.
- Formats: CSV (default) or Excel
.xlsx/.xls. The gateway decides by the file name’s extension, so name it correctly. - CSV: UTF-8 (with or without BOM). The delimiter is auto-detected among
,;tab and|; the first row holds the headers; empty rows are ignored. - Excel: the active sheet is read; the first row holds the headers.
- Size: up to 25 MB. Above that,
413. An empty file, one with no data rows or an unreadable one responds422; an Excel file on a server without Excel support responds400asking for CSV.
A typical Caja Horizonte CSV (exported from Excel, with ; and Spanish headers):
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;ACTIVOHow to pass the file in each SDK:
# A file opened in binary mode, or a (name, content, MIME) tuple to set the name and type.
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";
// Accepts Blob/File, Uint8Array (a Buffer works), ArrayBuffer or the CSV text.
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);// A path, an open stream (fopen), an SplFileInfo or a PSR-7 stream.
$tx = $client->affiliates->upload(
'afiliados-horizonte-2026-11.csv',
idempotencyKey: 'horizonte-afiliados-archivo-2026-11',
);
echo $tx->data['filename'], ' created=', $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"]} created={tx.Data["created"]} rejected={tx.Data["rejected"]}");f, err := os.Open("afiliados-horizonte-2026-11.csv")
if err != nil {
log.Fatal(err)
}
defer f.Close()
// Any io.Reader; the SDK buffers it in memory so it can retry.
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"The response carries the same counts as the JSON batch plus filename. The file goes through the
same internal split into batches of 1,000, so a 30,000-row Excel file is processed in a single call.
File idempotency
The Idempotency-Key identifies the operation, not the content: if you upload a corrected file
with the same key, you get the first file’s response. Derive the key from the cutoff and a
fingerprint of the file (for example horizonte-afiliados-2026-11-<short sha256>).
Look up an affiliate by document #
GET/v1/gateway/affiliates/{document}/
tells you whether a document belongs to your entity. The document is
normalized just like during loading (1.032.456.789 works). It returns an AffiliateLookup with
exists, document_number (normalized) and affiliate (the record as returned by the entity’s
registry). If there’s no match, the response is 404 (in the SDKs, the “not found” exception).
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("Not an affiliate:", 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("Not an affiliate:", 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 'Not an affiliate: ', $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($"Not an affiliate: {e.Detail} {e.TraceId}");
}lookup, err := client.Affiliates.Get(ctx, "1.032.456.789")
switch {
case compense.IsNotFound(err):
fmt.Println("Not an affiliate")
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", ...}}A common pattern is to look up the document before submitting a credit or a subsidy: if it doesn’t
exist, register it with cross (or ask the person to become an affiliate) before sending the
application.
On-the-fly registration on submit #
If your key has affiliates:upsert and the submission of a credit or a
subsidy carries the afiliado block, the gateway tries to register or update
that affiliate before sending the application. If that registration fails, the submission is
not blocked: the response comes back with a notice in warnings:
{
"status": "success",
"transaction_id": "…",
"module": "credits",
"data": { "application_id": "…", "status": "RECEIVED" },
"warnings": ["Inline affiliate upsert failed; the submission continued without syncing the affiliate."]
}Treat that notice as a pending task: sync the full affiliate with cross. The most robust approach
is still to keep the base up to date with a periodic load.
Typical errors #
| HTTP | Exception (Python) | When | What to do |
|---|---|---|---|
401 / 403 | AuthError | The key is missing, belongs to another environment or lacks affiliates:upsert / affiliates:read. | Check the key and its scopes with system.health. |
404 | NotFoundError | getAffiliate: the document isn’t an affiliate of your entity. | Register it with cross. |
409 | IdempotencyConflictError | You resent the same Idempotency-Key while the original was still being processed. | Wait and retry with the same key. |
413 | CompenseAPIError | File larger than 25 MB. | Split it into several files. |
422 | ValidationError | Batch of more than 1,000 rows, empty or unreadable file. | Shrink the batch or check the file. |
502 / 504 | UpstreamError | The affiliate registry didn’t respond. | Retry with the same key (the SDK already retries 502/503/504). |
Incomplete rows are not an HTTP error: they come back in data.errors with 200. See
Errors for the application/problem+json format and the trace_id.
The affiliates.synced webhook #
Every time the registry finishes saving a batch (the ones you send through cross or upload, and
any syncs running from other sources), affiliates.synced is emitted with the summary. Use it to
close the loop on your nightly job without having to poll.
Affiliates #
| Event | Since | When it's emitted |
|---|---|---|
affiliates.synced | 2026-06-01 | An affiliate sync finished (summary of affiliates created, updated and withdrawn). |
{
"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"
}
}Since large batches are saved in chunks of 1,000, a load of 2,500 affiliates produces several
events (one per chunk): add up the counts. Subscribe and verify the signature as explained in
Webhooks and Verification.
Sandbox #
The deterministic catalog of test documents (1000000001 eligible, 9999999999 nonexistent…)
governs credits, subsidies, monetary allowance and loan portfolio. Affiliates, on the other hand, are
cross-checked against the affiliate registry of your key’s tenant: with a sandbox key, first load
your test affiliates with cross or upload and then look them up. See the test documents in
Test data.