Caja Horizonte records its staff’s overtime and surcharges in a shift system and keeps its books in an ERP. In this recipe you connect both to Compense’s Administrative module: you load the month’s payroll adjustments into the payroll period, wait for payroll to approve it in the panel, read the payslips and the electronic payroll status, and record the journal entry as a draft so the accountant can post it.

Everything that comes in through the API is a request or a draft: calculating, approving, and posting payroll, and transmitting electronic payroll to the DIAN, are done by people with a role in the panel.

sequenceDiagram
    autonumber
    participant T as Shift system
    participant E as Caja Horizonte ERP
    participant G as Compense Gateway
    participant N as Payroll (panel)
    participant C as Accountant (panel)
    T->>E: October overtime and surcharges
    E->>G: payroll.list_periods(year, status=ABIERTO)
    E->>G: payroll.create_novelties(period_id, items[≤500]) + Idempotency-Key
    G-)E: admin.payroll.novelty.received (per payroll adjustment)
    N->>N: calculates and approves the period
    loop until APROBADO
        E->>G: payroll.get_period(id)
    end
    E->>G: payroll.list_period_payslips(id)
    E->>G: payroll.electronic(year, month)
    E->>G: accounting.compute_taxes(items)
    E->>G: accounting.create_entry(external_id, lines) → DRAFT
    G-)E: admin.accounting.entry.drafted
    C->>C: reviews and posts the journal entry

Requirements #

  • An API key with payroll:read, payroll:write, accounting:read, and accounting:write (or the admin-erp preset).
  • The employees already exist in Compense (if there are new hires, create them first with hr.upsert_employee; see Human resources).
  • Optional: a webhook endpoint subscribed to admin.payroll.novelty.received and admin.accounting.entry.drafted.

The examples create the client in step 1 and reuse it; in Go they assume ctx and client created as in the quickstart.

1. Find the open period #

Payroll adjustments are loaded into an ABIERTO (open) or CALCULADO (calculated) period. An approved or reversed period responds 409 PERIOD_CLOSED.

import os
from compense_gateway import CompenseClient

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

periodos = client.admin.payroll.list_periods(year=2026, status="ABIERTO")
periodo = next(p for p in periodos.results if p["status"] in ("ABIERTO", "CALCULADO"))
print(periodo["id"], periodo["name"], periodo["start_date"], periodo["end_date"])

2. Load the month’s payroll adjustments #

Turn each record from the shift system into a payroll adjustment: overtime and surcharges (HED, HEN, HRN, HRDDF, HRNDF, HEDDF, HENDF) go with quantity in hours; bonuses, commissions, and deductions (BONIFICACION_NS, COMISION, DESCUENTO…) with amount. Concepts the engine calculates (salary, health, pension, withholding…) are rejected.

Send them in batches of up to 500. Each batch is all or nothing: if one row fails, none of them are loaded and the error points to the row (items[3].quantity: …). Derive the Idempotency-Key from the period and your process’s batch number, so that reprocessing the same file doesn’t duplicate hours.

from compense_gateway import IdempotencyConflictError, ValidationError

registros = [   # what the shift system exported
    {"documento": "1000000001", "concepto": "HED", "horas": 4, "fecha": "2026-10-15"},
    {"documento": "1000000001", "concepto": "HRN", "horas": 6, "fecha": "2026-10-18"},
    {"documento": "1000000002", "concepto": "HEN", "horas": 2, "fecha": "2026-10-21"},
]
items = [{"employee": r["documento"], "concept_code": r["concepto"],
          "quantity": r["horas"], "date": r["fecha"]} for r in registros]

for n, i in enumerate(range(0, len(items), 500), start=1):
    try:
        tx = client.admin.payroll.create_novelties(
            period_id=periodo["id"], items=items[i:i + 500],
            idempotency_key=f"horizonte-turnos-{periodo['id']}-lote-{n}",
        )
        print(f"Batch {n}: {tx.data['created']} payroll adjustments", tx.data.get("warnings"))
    except ValidationError as e:                   # 422: one invalid row, the batch was not loaded
        print(f"Batch {n} rejected: {e.detail}")
    except IdempotencyConflictError as e:          # 409: PERIOD_CLOSED (or key in progress)
        print(f"Batch {n}: {e.detail}")
JSON
{"status": "success", "transaction_id": "gw-…", "module": "admin",
 "data": {"created": 3, "period_id": "per-O-…", "warnings": [],
          "results": [{"id": "pnv-E-…", "employee": {"id": "emp-E-…", "name": "Laura Sandbox"},
                       "concept": {"code": "HED"}, "date": "2026-10-15", "quantity": "4",
                       "status": "PENDIENTE"}]}}

If the period already had its pre-payroll calculated, warnings tells you so: payroll has to recalculate it. To review what you’ve already loaded, use payroll.list_novelties(period=…, employee=…).

3. Wait for approval and read the payslips #

Payroll calculates and approves the period in the panel. There is no approval webhook today: poll the period with payroll.get_period (for example, once an hour on closing day) until it is APROBADO. Then read the period’s payslips: earnings (earnings), deductions (deductions), and net pay (net_pay) per employee. For the per-concept detail, use payroll.get_payslip(id). Every payslip lookup is recorded in the payroll audit log.

import time

while client.admin.payroll.get_period(periodo["id"])["status"] != "APROBADO":
    time.sleep(3600)

desprendibles = client.admin.payroll.list_period_payslips(periodo["id"])
for d in desprendibles.results:
    print(d["employee_doc"], d["earnings"], d["deductions"], d["net_pay"])

4. Check the electronic payroll #

Payroll generates and transmits the electronic payroll to the DIAN from the panel. Through the API you read the month’s status: provider, summary by status, and the documents. Use it to raise an alert if any document was rejected before you record the entry.

ne = client.admin.payroll.electronic(year=2026, month=10)
print(ne.raw.get("provider"), ne.raw.get("summary"))      # e.g. {'ACEPTADO': 2}
for doc in ne.results:
    print(doc)

The SDKs return this read as a page: the documents are in results, and the summary and the provider are in the raw body (raw).

5. Record the entry as a draft #

With the payslips approved, build the accrual journal entry: payroll expense on the debit side and, on the credit side, deductions and net pay payable. If the same accrual includes third-party services (for example, the electronic payroll operator), first calculate their withholding with the tax simulator, which is a pure query: it doesn’t write and doesn’t take an Idempotency-Key.

The journal entry comes in as DRAFT, and its external_id (your id in the ERP) makes it idempotent in the engine: if you resend the same external_id, you get the existing journal entry (created: false) instead of a new one.

from decimal import Decimal

dev = sum(Decimal(d["earnings"]) for d in desprendibles.results)
ded = sum(Decimal(d["deductions"]) for d in desprendibles.results)
neto = sum(Decimal(d["net_pay"]) for d in desprendibles.results)

# Withholding for the electronic payroll operator (third-party service)
ret = client.admin.accounting.compute_taxes(
    items=[{"concept": "RF_SERVICIOS", "base": "350000"}], date="2026-10-31", third_party="900123456",
).results[0]
retencion = Decimal(ret["amount"])

tx = client.admin.accounting.create_entry(
    date="2026-10-31",
    description="Payroll accrual October 2026",
    lines=[
        {"account": "510506", "debit": str(dev), "description": "Salaries"},
        {"account": "237005", "credit": str(ded), "description": "Payroll deductions"},
        {"account": "250505", "credit": str(neto), "description": "Salaries payable"},
        {"account": "513595", "debit": "350000", "third_party": "900123456", "description": "Electronic payroll operator"},
        {"account": "236525", "credit": str(retencion), "third_party": "900123456", "tax_concept": "RF_SERVICIOS", "base_amount": "350000"},
        {"account": "233595", "credit": str(Decimal("350000") - retencion), "third_party": "900123456"},
    ],
    external_id="HORIZONTE-ERP-NOM-2026-10",
    voucher_type="CC",
    idempotency_key="horizonte-erp-nom-2026-10",
)
print(tx.data["id"], tx.data["status"], tx.data.get("created"))      # … DRAFT True

The accounts in the example are illustrative: use the ones from your chart of accounts. The journal entry must balance (debits = credits) for the accountant to be able to post it; the panel also validates detail-level accounts, required third party and cost center, and an open period. post: true responds 422 POST_NOT_ALLOWED.

6. Track the journal entry until it’s posted #

A journal entry from the ERP carries source_module: "gateway" and the panel doesn’t edit it: if the accountant finds an error, they void it, and you fix it in the ERP and resend it with the same external_id. To find out whether it has been posted, query by external_id:

entradas = client.admin.accounting.list_entries(external_id="HORIZONTE-ERP-NOM-2026-10", source="gateway")
for e in entradas.results:
    print(e["id"], e["status"], e.get("number"))

Events #

EventWhenUse it to
admin.payroll.novelty.receivedEach payroll adjustment was loadedMark in the shift system which records are already in (novelty_id, employee_id, concept_code, period_id)
admin.accounting.entry.draftedThe draft journal entry was createdStore the entry_id next to your external_id

Events are emitted in the same transaction as the record, and their event_id is derived from the Idempotency-Key: a retry doesn’t duplicate the callback. Neither of them means money moved. The full catalog is in Events.

Try it in the sandbox #

To testDo this
Open periodpayroll.list_periods returns per-O-… (October 2026, ABIERTO)
Closed periodLoad payroll adjustments into per-C-… (September 2026, APROBADO) → 409 PERIOD_CLOSED
Invalid rowA system concept (SALARIO, SALUD…) or a date outside October → 422 and the batch isn’t loaded
Nonexistent employeeemployee 9999999999 → 422 on that row
PayslipsAny per-… period: Laura Sandbox (1000000001) and Andrés Sandbox (1000000002)
Electronic payrollpayroll.electronic(year=2026, month=10) → ACEPTADO summary
SimulatorApplies a flat 4% rate to each base
Journal entryAlways DRAFT; same external_id → same id; post: true → 422

In the sandbox the October period stays ABIERTO (there’s no one to approve it): to test step 3 without waiting, read the payslips directly.

Checklist #

  • You only load payroll adjustments into ABIERTO or CALCULADO periods and handle 409 PERIOD_CLOSED.
  • Batches of up to 500 with one Idempotency-Key per period and batch.
  • On a 422 you fix the flagged row and resend the whole batch.
  • You wait for APROBADO before recording the entry and you check the month’s electronic payroll.
  • The tax simulator is called without an Idempotency-Key.
  • The journal entry balances, comes in as DRAFT, and carries a stable external_id from your ERP.