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:

OperationSDK method (Python)ScopeUsed for
POST/v1/gateway/affiliates/client.affiliates.cross(records)affiliates:upsertRegister or update a batch of affiliates in JSON.
POST/v1/gateway/affiliates/upload/client.affiliates.upload(file)affiliates:upsertLoad the base from a CSV or Excel file (multipart).
GET/v1/gateway/affiliates/{document}/client.affiliates.get(document)affiliates:readCheck 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 fieldName in error messagesExample
document_numberdocumento"1032456789" (also "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" 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 fieldAccepted aliases
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

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 in extra_data.external_id.
  • Dates: accepts AAAA-MM-DD, DD/MM/AAAA, DD-MM-AAAA, AAAAMMDD, MM/DD/AAAA and DD/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, ACTIVE or A → ACTIVE.
  • Unknown columns (your branch code, your internal id…) aren’t lost: they are stored in the affiliate’s extra_data for 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"))

The response is the transaction envelope with the batch counts in 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 is the total number of rows received; created + updated are the ones that went in; rejected are the ones that failed the required-field validation. errors mixes those rejections (with row, 1-based, and document_number) with any errors the registry returns when saving.
  • If there were rejections, the transaction stays in PROCESSING state (if everything went in, COMPLETED); you can check it later with transactions.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 responds 422; an Excel file on a server without Excel support responds 400 asking for CSV.

A typical Caja Horizonte CSV (exported from Excel, with ; and Spanish headers):

Text
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

How 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"])

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)

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:

JSON
{
  "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 #

HTTPException (Python)WhenWhat to do
401 / 403AuthErrorThe key is missing, belongs to another environment or lacks affiliates:upsert / affiliates:read.Check the key and its scopes with system.health.
404NotFoundErrorgetAffiliate: the document isn’t an affiliate of your entity.Register it with cross.
409IdempotencyConflictErrorYou resent the same Idempotency-Key while the original was still being processed.Wait and retry with the same key.
413CompenseAPIErrorFile larger than 25 MB.Split it into several files.
422ValidationErrorBatch of more than 1,000 rows, empty or unreadable file.Shrink the batch or check the file.
502 / 504UpstreamErrorThe 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 #

EventSinceWhen it's emitted
affiliates.synced2026-06-01An affiliate sync finished (summary of affiliates created, updated and withdrawn).
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"
  }
}

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.

What’s next #