This guide is for the technical team at an entity (a family compensation fund (caja de compensación), fintech or healthcare provider (IPS)) that integrates its ERP with Compense’s Administrative Management (Gestión Administrativa) module: HR, payroll, accounting and treasury. Everything is server-to-server with the entity’s key; no end user is involved. The examples use Caja Horizonte’s ERP, which identifies its documents with HORIZONTE-ERP-… references.

The company's surface, not the affiliate's

Here the resources belong to the entity: employees, adjustments, journal entries, third parties, collections and bank statements. The paths hang from /v1/gateway/admin/… and, in the SDKs, from client.admin (in .NET client.Admin, in Go client.Admin) with four groups: hr, payroll, accounting and treasury (in .NET Hr, Payroll, Accounting, Treasury; in Go HR, Payroll, Accounting, Treasury). The entity must have contracted the module; otherwise every call returns 403 with «La entidad no tiene contratado el módulo Gestión Administrativa.» (“The entity has not contracted the Administrative Management module.”).

What goes through the API and what stays in the panel #

AreaThrough the APIStays in the panel (person with a role)
HRSearch and read employees and contracts; create or update employees (upsert by document); record sick leave, leaves of absence, permissions and vacationsApprove or reject payroll adjustments and contracts, link panel users, change an employee’s bank account
PayrollLoad payroll adjustments (overtime, surcharges, bonuses, deductions), one at a time or in batches; read periods, payslips and the electronic payroll statusCalculate, approve, reverse and post the payroll; generate and transmit the electronic payroll to the DIAN
AccountingCreate draft journal entries; read journal entries; create or update third parties; simulate taxes and withholdingsPost, reverse or void journal entries; close periods; chart of accounts; third parties’ bank details
TreasuryRecord draft collections, disbursements and receivables; load bank statements and trigger the auto-matchPost or void the documents; close the reconciliation; bank accounts

Why the line is drawn here. An API key isn’t a person: it has no role, no segregation of duties and no second factor. Anything that approves, settles or posts—and therefore moves money or closes the books—requires someone with a role in the panel. What your ERP sends comes in as a request (HR adjustments, in SOLICITADA status) or as a draft (journal entries and treasury documents, in DRAFT status), and is audited with the integration user and the gateway channel.

flowchart LR
    ERP[Caja Horizonte ERP] -->|POST via API| B[Request / draft<br/>SOLICITADA · DRAFT]
    B --> P{Compense panel<br/>person with a role}
    P -->|approves / posts| OK[Approved · posted]
    P -->|rejects / voids| X[Rejected · voided]
    B -.->|webhook admin.*| ERP

Bank accounts. Changing a bank account is the classic vector for payment-diversion fraud. The API accepts an employee’s payroll account only when the employee is created; any later change, or any bank detail for a third party, returns 422 and must be done in the panel, which leaves an alert in the audit log.

Sensitive data (Law 1581 of 2012). You can send the diagnosis (ICD-10) for a sick leave, but it is never returned through the API: health-related adjustments read as «Ausencia justificada» (“Justified absence”), with category OTRA.

Scopes #

ScopeAllows
hr:readEmployees, contracts and HR adjustments (read)
hr:writeCreate or update employees; record HR adjustments
payroll:readPayroll adjustments, periods, payslips and electronic payroll (read)
payroll:writeLoad payroll adjustments
accounting:readJournal entries, third parties and the tax simulator
accounting:writeDraft journal entries; create or update third parties
treasury:writeDraft collections, disbursements and receivables; bank statements and auto-match

There’s an admin-erp preset with all seven. The read-only and full presets do not include them (this is employee and salary data): they’re granted explicitly. payroll:write and treasury:write are on the list of scopes that will require a request signature once that protection is turned on.

Conventions #

  • Send Idempotency-Key on every POST (except the tax simulator, which is a lookup). A retry with the same key returns the original response with idempotent_replay: true.
  • Employees and third parties are identified by id (UUID) or by document number; treasury bank accounts, by id or by account number.
  • Amounts in COP as decimal strings ("1500000.00"); dates as YYYY-MM-DD.
  • Reads return the engine’s body; writes return the standard envelope with "module": "admin".

Free-form fields in each SDK #

The module’s bodies are broad (they reuse the panel’s forms), so the SDKs take only the required fields as arguments and accept the rest as free-form fields, using the contract’s snake_case names:

SDKHow free-form fields are passed
PythonAdditional keyword arguments (**fields): upsert_employee(id_number="…", first_name="Laura")
JavaScript / TSA fields object: upsertEmployee({ idNumber: "…", fields: { first_name: "Laura" } })
PHPNamed arguments in snake_case (variadic): upsertEmployee(idNumber: '…', first_name: 'Laura')
.NETA fields dictionary: new Dictionary<string, object?> { ["first_name"] = "Laura" }
GoA Fields map: Fields: map[string]any{"first_name": "Laura"}

Null values are dropped, and the SDKs convert the amounts in the amount, subtotal, opening_balance and closing_balance fields to decimal strings.

HR #

Create or update employees #

POST/v1/gateway/admin/hr/employees/ performs an upsert by id_number: if the employee doesn’t exist it creates them (201, created: true); if they exist, it updates only the fields you send (200, created: false). Fields: those on the employee record (first_name, middle_name, last_name, second_last_name, birth_date, sex, email_personal, email_work, phone, address, city, department, area_id, position_id, manager_id, status, hire_date, eps_code, pension_fund_code, severance_fund_code…). user_id is rejected (NOT_ALLOWED_BY_API), as is changing the bank details of an existing employee (BANK_CHANGE_NOT_ALLOWED).

import os
from compense_gateway import CompenseClient

client = CompenseClient(api_key=os.environ["COMPENSE_API_KEY"], environment="sandbox")

tx = client.admin.hr.upsert_employee(
    id_number="1032456789",
    id_type="CC",
    first_name="Laura",
    last_name="Pérez",
    hire_date="2026-11-01",
    email_work="laura.perez@horizonte.example",
    phone="3001234567",
    bank_name="Banco Uno", bank_account_type="AHORROS", bank_account_number="123456789",  # only on creation
    idempotency_key="horizonte-erp-emp-1032456789-v1",
)
print(tx.data["id"], tx.data["created"])

Look up employees and contracts #

hr.list_employees filters by q (name, document, code or email), status (ACTIVO or RETIRADO), document (exact match), area and position. The employee record and the contracts (salary, type, status and dates) are requested by id or document.

activos = client.admin.hr.list_employees(status="ACTIVO", area="Tesorería")
ficha = client.admin.hr.get_employee("1000000001")
contratos = client.admin.hr.list_employee_contracts("1000000001")
print(ficha["full_name"], [c["salary"] for c in contratos.results])

HR adjustments #

Sick leave, leaves of absence, permissions and vacations. They always stay SOLICITADA: HR validates them in the panel (an approve_now is ignored). Maximum days, overlaps with other adjustments and vacation balance are validated. Types (type_code) in the default catalog: VACACIONES, PERMISO_REMUNERADO, CITA_MEDICA, CALAMIDAD, LUTO, MATERNIDAD, PATERNIDAD, INCAPACIDAD_COMUN, INCAPACIDAD_LABORAL, LICENCIA_REMUNERADA, LICENCIA_NO_REMUNERADA, AUSENCIA and SUSPENSION (your entity may have its own).

tx = client.admin.hr.create_novelty(
    employee="1000000001", type_code="INCAPACIDAD_COMUN", start_date="2026-10-20",
    days=3, certificate_number="INC-998877", issuing_entity="Employee EPS",
    diagnosis_code="J069",                    # stored, but never returned
    idempotency_key="horizonte-erp-inc-2026-0457",
)
print(tx.data["number"], tx.data["status"], tx.data["category"])   # … SOLICITADA OTRA

pendientes = client.admin.hr.list_novelties(status="SOLICITADA", from_="2026-10-01", to="2026-10-31")

Payroll #

Load payroll adjustments #

POST/v1/gateway/admin/payroll/novelties/ accepts one payroll adjustment (fields at the root) or a batch in items (up to 500, all or nothing: an error points to items[i].field).

  • Overtime and surcharges (HED, HEN, HRN, HRDDF, HRNDF, HEDDF, HENDF) take quantity in hours.
  • Bonuses, commissions and other earnings (BONIFICACION_S, BONIFICACION_NS, COMISION, AUXILIO_NS, OTRO_DEVENGO…) and deductions (DESCUENTO, ANTICIPO, SINDICATO) take amount.
  • Concepts the engine calculates (salary, health, pension, withholding, loans…) are rejected.
  • period_id is optional: if you send it, the period must be ABIERTO or CALCULADO (otherwise 409 PERIOD_CLOSED) and the date must fall within it; without date the period’s start date is used. If the period already had its pre-payroll calculated, the response includes a warning: payroll must recalculate it.
periodos = client.admin.payroll.list_periods(year=2026, status="ABIERTO")
periodo = periodos.results[0]["id"]

# A single payroll adjustment
client.admin.payroll.create_novelties(
    period_id=periodo, employee="1000000001", concept_code="HED", quantity=4, date="2026-10-15",
    idempotency_key="horizonte-erp-he-2026-10-1000000001",
)

# A batch (all or nothing)
tx = client.admin.payroll.create_novelties(
    period_id=periodo,
    items=[
        {"employee": "1000000001", "concept_code": "BONIFICACION_NS", "amount": "150000", "date": "2026-10-15"},
        {"employee": "1000000002", "concept_code": "DESCUENTO", "amount": "20000", "date": "2026-10-16",
         "description": "Authorized deduction"},
    ],
    idempotency_key="horizonte-erp-lote-2026-10-A",
)
print(tx.data["created"], tx.data["warnings"])

Periods, payslips and electronic payroll #

  • payroll.list_periods(status, kind, year) and payroll.get_period(id): status (ABIERTO, CALCULADO, APROBADO…), dates and totals.
  • payroll.list_period_payslips(period_id, employee=…): the period’s payslips (earnings, deductions, net pay, contributions and provisions). payroll.get_payslip(id) returns the breakdown by concept.
  • payroll.electronic(year, month, status): the month’s electronic payroll status (DIAN): provider, summary by status and documents. Generation and transmission are done in the panel.
  • payroll.list_novelties(employee, period, status, concept, from, to): what you’ve already loaded.

Every payslip lookup is recorded in the payroll log (who looked up salaries). The complete walkthrough, from loading payroll adjustments to the journal entry, is in the recipe From payroll to accounting.

Accounting #

Draft journal entries #

POST/v1/gateway/admin/accounting/entries/ creates a journal entry always in DRAFT (post: true returns 422 POST_NOT_ALLOWED). The accountant reviews it and posts it in the panel, where double entry, posting accounts, required third party and cost center, and an open period are validated.

  • external_id (your id in the ERP) makes creation idempotent in the engine: resending the same external_id returns the existing journal entry (200, created: false). If the accountant voids the draft, you can resend it with the same external_id.
  • Journal entries from the ERP carry source_module: "gateway": the panel doesn’t edit them; they’re corrected in the ERP and resent.
  • Lines: account (code), debit or credit, and optionally third_party (id or document), cost_center, description, tax_concept, base_amount, document_ref and due_date.
tx = client.admin.accounting.create_entry(
    date="2026-10-27",
    description="October services sale",
    lines=[
        {"account": "13050501", "debit": "1500000", "third_party": "900123456"},
        {"account": "41750101", "credit": "1500000"},
    ],
    external_id="HORIZONTE-ERP-AS-2026-000123",
    voucher_type="CC",
    reference="FV-1234",
    idempotency_key="horizonte-erp-as-2026-000123",
)
print(tx.data["id"], tx.data["status"], tx.data["total_debit"])   # … DRAFT 1500000.00

To find out what the accountant has already posted, query accounting.list_entries with status, voucher_type, date_from, date_to, external_id, source=gateway, q and offset (pages of 100 with a total count), or accounting.get_entry(id) for the lines, the hash and the status.

Third parties #

accounting.upsert_third_party performs an upsert by document type and number. id_type accepts the DIAN code (31, 13, 22…) or the alias (NIT, CC, CE, PA, TI, NUIP…). The NIT check digit is calculated (if you send it and it doesn’t match, 422). Bank details (bank_*) and user_id_link return 422 BANK_DATA_NOT_ALLOWED; in reads, the bank account comes back masked (••••4321).

tx = client.admin.accounting.upsert_third_party(
    id_type="NIT", id_number="900123456",
    business_name="Papelería La Esquina SAS", is_supplier=True,
    email="pagos@laesquina.example", responsibilities=["O-13"],
    idempotency_key="horizonte-erp-tp-900123456",
)
proveedores = client.admin.accounting.list_third_parties(role="supplier", q="esquina", brief=True)
tercero = client.admin.accounting.get_third_party("900123456")

Tax and withholding simulator #

POST/v1/gateway/admin/accounting/taxes/compute/ is a pure lookup: it doesn’t write, doesn’t consume quota and doesn’t take an Idempotency-Key (the SDKs don’t send one or retry the call). Use it to calculate withholdings before building the journal entry’s lines.

calculo = client.admin.accounting.compute_taxes(
    items=[{"concept": "RF_SERVICIOS", "base": "2000000", "iva": "380000"}],
    date="2026-10-27", third_party="900123456",
)
for r in calculo.results:
    print(r["concept"], r["base"], r["rate"], r["amount"])

Treasury #

All documents stay in DRAFT; the treasurer or the accountant posts them in the panel.

OperationDocumentMain fields
POST/v1/gateway/admin/treasury/receipts/Collection (cash receipt)third_party, bank_account, date, amount, method, reference, applications ([{document, amount}] or [{account, document_ref, amount}])
POST/v1/gateway/admin/treasury/payments/Disbursement (supplier payment)third_party, bank_account, date, amount, method (TRANSFERENCIA, PSE, CHEQUE, EFECTIVO, OTRO), reference, applications ([{invoice, amount}])
POST/v1/gateway/admin/treasury/receivables/Receivable (accounts receivable)third_party, issue_date, due_date, description, income_account, subtotal, iva_concept, cost_center
POST/v1/gateway/admin/treasury/statements/Bank statementbank_account, opening_balance, closing_balance, date_from, date_to and lines ([{date, description, reference, amount}], signed amounts) or content (CSV)
POST/v1/gateway/admin/treasury/statements/{statement_id}/auto-match/Auto-matchNo body

Collection, disbursement and receivable #

recaudo = client.admin.treasury.create_receipt(
    third_party="80111222", bank_account="0011223344", date="2026-10-27",
    amount="850000", method="TRANSFERENCIA", reference="TRX-998877",
    idempotency_key="horizonte-erp-rc-TRX-998877",
)
egreso = client.admin.treasury.create_payment(
    third_party="900123456", bank_account="0011223344", date="2026-10-28",
    amount="1200000", method="PSE", reference="PSE-4455",
    idempotency_key="horizonte-erp-eg-PSE-4455",
)
cobro = client.admin.treasury.create_receivable(
    third_party="900123456", issue_date="2026-10-27", due_date="2026-11-26",
    description="Event hall rental, October", income_account="41552001", subtotal="500000",
    idempotency_key="horizonte-erp-cxc-2026-10-031",
)
print(recaudo.data["status"], egreso.data["status"], cobro.data["total"])   # DRAFT DRAFT …

Import the statement and reconcile #

Load the bank statement with lines (signed amounts: positive comes in, negative goes out) or with content (a CSV with date/amount or debit/credit columns). Then trigger the auto-match, which pairs each statement line with a posted bank movement by exact amount, date proximity and reference. The auto-match doesn’t create journal entries or close the reconciliation: the treasurer reviews and closes it in the panel.

sequenceDiagram
    autonumber
    participant E as Caja Horizonte ERP
    participant G as Compense Gateway
    participant T as Treasurer (panel)
    E->>G: POST treasury/statements/ {bank_account, lines}
    G-->>E: 201 statement OPEN, lines UNMATCHED
    G-)E: webhook admin.treasury.statement.imported
    E->>G: POST treasury/statements/{id}/auto-match/
    G-->>E: {matched: N}
    T->>T: reviews pending items and closes the reconciliation
tx = client.admin.treasury.import_statement(
    bank_account="0011223344",
    opening_balance="0",
    lines=[
        {"date": "2026-10-02", "description": "Deposit", "reference": "TRX-998877", "amount": "850000"},
        {"date": "2026-10-03", "description": "Fee", "reference": "C1", "amount": "-12000.50"},
    ],
    idempotency_key="horizonte-erp-ext-0011223344-2026-10",
)
extracto = tx.data["id"]

cruce = client.admin.treasury.auto_match_statement(
    extracto, idempotency_key=f"horizonte-erp-cruce-{extracto}")
print(cruce.data["matched"])

Common errors #

HTTPEngine codeWhen
401—X-API-Key is missing
403—Key without the scope, IP not allowed, or Administrative Management module not contracted
404NOT_FOUNDEmployee, period, payslip, journal entry or third party doesn’t exist
409PERIOD_CLOSEDThe payroll period is approved or reversed
409—Idempotency conflict, or a business conflict (the adjustment overlaps with another one)
422VALIDATION_ERRORdetail states the field and the reason, in Spanish
422POST_NOT_ALLOWEDYou tried to post a journal entry through the API
422BANK_CHANGE_NOT_ALLOWED / BANK_DATA_NOT_ALLOWEDBank details for an existing employee or for a third party
422NOT_ALLOWED_BY_APIFields reserved for the panel (for example user_id)
429—Per-minute limit or monthly quota
502 / 504—The engine failed or didn’t respond (quote the trace_id)
JSON
{"type": "https://errors.compense.co/gateway/upstream-error", "title": "Request rejected by engine",
 "status": 422,
 "detail": "Por API los comprobantes se crean en borrador; el contador los contabiliza en el panel.",
 "trace_id": "9f1c2e7a4b8d4e6fa1c0d2e3f4a5b6c7"}

Events #

They’re emitted in the same transaction as the record, with an event_id derived from the Idempotency-Key (a retry doesn’t duplicate the callback). None of them means money moved: they’re requests or drafts. All of them carry channel: "gateway" and the standard envelope; never diagnoses or bank details.

Administrative #

EventSinceWhen it's emitted
admin.hr.novelty.received2026-10-27An HR adjustment was recorded.
admin.payroll.novelty.received2026-10-27A payroll adjustment was uploaded.
admin.accounting.entry.drafted2026-10-27A draft journal entry was created.
admin.treasury.document.registered2026-10-27A draft collection, outgoing payment or billing statement was recorded (kind).
admin.treasury.statement.imported2026-10-27A bank statement was uploaded.
Eventresource fields
admin.payroll.novelty.receivednovelty_id, employee_id, concept_code, date, quantity, amount, period_id, status
admin.hr.novelty.receivednovelty_id, number, employee_id, category (OTRA if health-related), dates, status
admin.accounting.entry.draftedentry_id, external_id, voucher_type, date, totals, status
admin.treasury.document.registereddocument_id, kind (receipt, payment or receivable), third_party_id, date, amount, status, reference
admin.treasury.statement.importedstatement_id, bank_account_id, date range, lines, closing_balance

There’s currently no event when HR approves or rejects an adjustment, or when payroll approves the period: check the status with the read operations.

Sandbox #

With a sandbox key, responses are deterministic and don’t touch real data:

  • Test employees: CC 1000000001 (Laura Sandbox) and CC 1000000002 (Andrés Sandbox) already exist: an upsert returns created: false and sending bank details returns 422. Any other document is “created”. CC 9999999999 → 404.
  • Periods: payroll.list_periods returns one ABIERTO period (per-O-…, October 2026) and one APROBADO period (per-C-…, September 2026): loading payroll adjustments into it returns 409 PERIOD_CLOSED. System concepts (SALARIO, SALUD…) → 422.
  • Journal entries: always DRAFT; same external_id → same id; post: true → 422.
  • Third parties: NIT 900000001 already exists (created: false); bank details → 422.
  • Treasury: DRAFT documents; amount <= 0 → 422; the auto-match returns matched: 0.
  • The tax simulator applies a flat 4 % rate to each base.

What’s next #