Recipe
From payroll to accounting
Load the month’s payroll adjustments from an external system, track the payroll period, read payslips and electronic payroll, and record the journal entry as a draft with the tax simulator and an idempotent external_id.
On this page
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 entryRequirements #
- An API key with
payroll:read,payroll:write,accounting:read, andaccounting:write(or theadmin-erppreset). - 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.receivedandadmin.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"])import { CompenseClient } from "@compense/sdk";
const client = new CompenseClient({ apiKey: process.env.COMPENSE_API_KEY!, environment: "sandbox" });
const periodos = await client.admin.payroll.listPeriods({ year: 2026, status: "ABIERTO" });
const periodo = periodos.results.find((p) => p.status === "ABIERTO" || p.status === "CALCULADO")!;
console.log(periodo.id, periodo.name, periodo.start_date, periodo.end_date);<?php
require __DIR__ . '/vendor/autoload.php';
use Compense\Sdk\CompenseClient;
$client = new CompenseClient(apiKey: getenv('COMPENSE_API_KEY'), environment: 'sandbox');
$periodos = $client->admin->payroll->listPeriods(status: 'ABIERTO', year: 2026);
$abiertos = array_values(array_filter($periodos->results, fn ($p) => in_array($p['status'], ['ABIERTO', 'CALCULADO'], true)));
$periodo = $abiertos[0];
echo $periodo['id'], ' ', $periodo['name'], PHP_EOL;using System.Globalization;
using System.Text.Json.Nodes;
using Compense.Sdk;
using Compense.Sdk.Models;
using var client = new CompenseClient(
Environment.GetEnvironmentVariable("COMPENSE_API_KEY")!, CompenseEnvironment.Sandbox);
Page periodos = await client.Admin.Payroll.ListPeriodsAsync(status: "ABIERTO", year: 2026);
JsonObject periodo = periodos.Results.First(p =>
p["status"]!.GetValue<string>() is "ABIERTO" or "CALCULADO");
string periodoId = periodo["id"]!.GetValue<string>();
Console.WriteLine($"{periodoId} {periodo["name"]}");periodos, err := client.Admin.Payroll.ListPeriods(ctx, &compense.PayrollPeriodListParams{Year: 2026, Status: "ABIERTO"})
if err != nil {
log.Fatal(err)
}
var periodoID string
for _, p := range periodos.Results {
if p["status"] == "ABIERTO" || p["status"] == "CALCULADO" {
periodoID, _ = p["id"].(string)
break
}
}
fmt.Println(periodoID)curl "https://sandbox.dev.compense.co/gw/v1/gateway/admin/payroll/periods/?year=2026&status=ABIERTO" \
-H "X-API-Key: $COMPENSE_API_KEY"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}")import { IdempotencyConflictError, ValidationError } from "@compense/sdk";
const 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" },
];
const items = registros.map((r) => ({ employee: r.documento, concept_code: r.concepto, quantity: r.horas, date: r.fecha }));
for (let i = 0, n = 1; i < items.length; i += 500, n++) {
try {
const tx = await client.admin.payroll.createNovelties({
periodId: String(periodo.id),
items: items.slice(i, i + 500),
idempotencyKey: `horizonte-turnos-${periodo.id}-lote-${n}`,
});
console.log(`Batch ${n}: ${tx.data.created} payroll adjustments`, tx.data.warnings);
} catch (err) {
if (err instanceof ValidationError || err instanceof IdempotencyConflictError) console.log(`Batch ${n}: ${err.detail}`);
else throw err;
}
}use Compense\Sdk\Exception\IdempotencyConflictException;
use Compense\Sdk\Exception\ValidationException;
$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 = array_map(fn ($r) => [
'employee' => $r['documento'], 'concept_code' => $r['concepto'], 'quantity' => $r['horas'], 'date' => $r['fecha'],
], $registros);
foreach (array_chunk($items, 500) as $n => $lote) {
try {
$tx = $client->admin->payroll->createNovelties(
periodId: $periodo['id'],
items: $lote,
idempotencyKey: "horizonte-turnos-{$periodo['id']}-lote-" . ($n + 1),
);
echo 'Batch ', $n + 1, ': ', $tx->data['created'], " payroll adjustments\n";
} catch (ValidationException | IdempotencyConflictException $e) {
echo 'Batch ', $n + 1, ': ', $e->detail, PHP_EOL;
}
}var registros = new[] // 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"),
};
var items = registros.Select(r => new Dictionary<string, object?>
{
["employee"] = r.Documento, ["concept_code"] = r.Concepto, ["quantity"] = r.Horas, ["date"] = r.Fecha,
}).ToList();
int n = 0;
foreach (var lote in items.Chunk(500))
{
n++;
try
{
Transaction tx = await client.Admin.Payroll.CreateNoveltiesAsync(
periodoId, items: lote, idempotencyKey: $"horizonte-turnos-{periodoId}-lote-{n}");
Console.WriteLine($"Batch {n}: {tx.Data["created"]} payroll adjustments");
}
catch (CompenseApiException e) when (e is ValidationException or IdempotencyConflictException)
{
Console.WriteLine($"Batch {n}: {e.Detail}");
}
}type registro struct {
Documento, Concepto, Fecha string
Horas int
}
registros := []registro{ // what the shift system exported
{"1000000001", "HED", "2026-10-15", 4},
{"1000000001", "HRN", "2026-10-18", 6},
{"1000000002", "HEN", "2026-10-21", 2},
}
items := make([]map[string]any, 0, len(registros))
for _, r := range registros {
items = append(items, map[string]any{
"employee": r.Documento, "concept_code": r.Concepto, "quantity": r.Horas, "date": r.Fecha,
})
}
for i, n := 0, 1; i < len(items); i, n = i+500, n+1 {
fin := min(i+500, len(items))
tx, err := client.Admin.Payroll.CreateNovelties(ctx, &compense.PayrollNoveltiesParams{
PeriodID: periodoID, Items: items[i:fin],
}, compense.WithIdempotencyKey(fmt.Sprintf("horizonte-turnos-%s-lote-%d", periodoID, n)))
var apiErr *compense.APIError
switch {
case err == nil:
fmt.Printf("Batch %d: %v payroll adjustments\n", n, tx.Data["created"])
case errors.As(err, &apiErr) && (compense.IsValidationError(err) || compense.IsIdempotencyConflict(err)):
fmt.Printf("Batch %d: %s\n", n, apiErr.Detail)
default:
log.Fatal(err)
}
}curl -X POST https://sandbox.dev.compense.co/gw/v1/gateway/admin/payroll/novelties/ \
-H "X-API-Key: $COMPENSE_API_KEY" \
-H "Idempotency-Key: horizonte-turnos-$PERIODO-lote-1" \
-H "Content-Type: application/json" \
-d '{"period_id": "'"$PERIODO"'", "items": [
{"employee": "1000000001", "concept_code": "HED", "quantity": 4, "date": "2026-10-15"},
{"employee": "1000000001", "concept_code": "HRN", "quantity": 6, "date": "2026-10-18"},
{"employee": "1000000002", "concept_code": "HEN", "quantity": 2, "date": "2026-10-21"}
]}'{"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"])while ((await client.admin.payroll.getPeriod(String(periodo.id))).status !== "APROBADO") {
await new Promise((r) => setTimeout(r, 3_600_000));
}
const desprendibles = await client.admin.payroll.listPeriodPayslips(String(periodo.id));
for (const d of desprendibles.results) console.log(d.employee_doc, d.earnings, d.deductions, d.net_pay);while ($client->admin->payroll->getPeriod($periodo['id'])['status'] !== 'APROBADO') {
sleep(3600);
}
$desprendibles = $client->admin->payroll->listPeriodPayslips($periodo['id']);
foreach ($desprendibles->results as $d) {
echo $d['employee_doc'], ' ', $d['earnings'], ' ', $d['deductions'], ' ', $d['net_pay'], PHP_EOL;
}while ((await client.Admin.Payroll.GetPeriodAsync(periodoId))["status"]!.GetValue<string>() != "APROBADO")
await Task.Delay(TimeSpan.FromHours(1));
Page desprendibles = await client.Admin.Payroll.ListPeriodPayslipsAsync(periodoId);
foreach (JsonObject d in desprendibles.Results)
Console.WriteLine($"{d["employee_doc"]} {d["earnings"]} {d["deductions"]} {d["net_pay"]}");for {
p, err := client.Admin.Payroll.GetPeriod(ctx, periodoID)
if err != nil {
log.Fatal(err)
}
if p["status"] == "APROBADO" {
break
}
time.Sleep(time.Hour)
}
desprendibles, err := client.Admin.Payroll.ListPeriodPayslips(ctx, periodoID, nil)
if err != nil {
log.Fatal(err)
}
for _, d := range desprendibles.Results {
fmt.Println(d["employee_doc"], d["earnings"], d["deductions"], d["net_pay"])
}curl https://sandbox.dev.compense.co/gw/v1/gateway/admin/payroll/periods/$PERIODO/ \
-H "X-API-Key: $COMPENSE_API_KEY"
curl https://sandbox.dev.compense.co/gw/v1/gateway/admin/payroll/periods/$PERIODO/payslips/ \
-H "X-API-Key: $COMPENSE_API_KEY"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)const ne = await client.admin.payroll.electronic({ year: 2026, month: 10 });
console.log(ne.raw.provider, ne.raw.summary, ne.results);$ne = $client->admin->payroll->electronic(year: 2026, month: 10);
echo $ne->raw['provider'] ?? '-', ' ', json_encode($ne->raw['summary'] ?? []), PHP_EOL;Page ne = await client.Admin.Payroll.ElectronicAsync(2026, 10);
Console.WriteLine($"{ne.Raw["provider"]} {ne.Raw["summary"]}");ne, err := client.Admin.Payroll.Electronic(ctx, &compense.ElectronicPayrollParams{Year: 2026, Month: 10})
if err != nil {
log.Fatal(err)
}
fmt.Println(ne.Raw["provider"], ne.Raw["summary"], len(ne.Results))curl "https://sandbox.dev.compense.co/gw/v1/gateway/admin/payroll/electronic/?year=2026&month=10" \
-H "X-API-Key: $COMPENSE_API_KEY"
# {"year": 2026, "month": 10, "enabled": true, "provider": "…", "summary": {"ACEPTADO": 2}, "results": []}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 Trueconst suma = (k: string) => desprendibles.results.reduce((s, d) => s + Number(d[k]), 0);
const [dev, ded, neto] = [suma("earnings"), suma("deductions"), suma("net_pay")];
// Withholding for the electronic payroll operator (third-party service)
const calculo = await client.admin.accounting.computeTaxes({
items: [{ concept: "RF_SERVICIOS", base: "350000" }],
date: "2026-10-31",
thirdParty: "900123456",
});
const retencion = Number(calculo.results[0].amount);
const tx = await client.admin.accounting.createEntry({
date: "2026-10-31",
description: "Payroll accrual October 2026",
lines: [
{ account: "510506", debit: dev.toFixed(2), description: "Salaries" },
{ account: "237005", credit: ded.toFixed(2), description: "Payroll deductions" },
{ account: "250505", credit: neto.toFixed(2), description: "Salaries payable" },
{ account: "513595", debit: "350000", third_party: "900123456", description: "Electronic payroll operator" },
{ account: "236525", credit: retencion.toFixed(2), third_party: "900123456", tax_concept: "RF_SERVICIOS", base_amount: "350000" },
{ account: "233595", credit: (350000 - retencion).toFixed(2), third_party: "900123456" },
],
fields: { external_id: "HORIZONTE-ERP-NOM-2026-10", voucher_type: "CC" },
idempotencyKey: "horizonte-erp-nom-2026-10",
});
console.log(tx.data.id, tx.data.status, tx.data.created);$suma = fn (string $k) => array_sum(array_map(fn ($d) => (float) $d[$k], $desprendibles->results));
$fmt = fn (float $v) => number_format($v, 2, '.', '');
[$dev, $ded, $neto] = [$suma('earnings'), $suma('deductions'), $suma('net_pay')];
// Withholding for the electronic payroll operator (third-party service)
$calculo = $client->admin->accounting->computeTaxes(
items: [['concept' => 'RF_SERVICIOS', 'base' => '350000']], date: '2026-10-31', thirdParty: '900123456',
);
$retencion = (float) $calculo->results[0]['amount'];
$tx = $client->admin->accounting->createEntry(
date: '2026-10-31',
description: 'Payroll accrual October 2026',
lines: [
['account' => '510506', 'debit' => $fmt($dev), 'description' => 'Salaries'],
['account' => '237005', 'credit' => $fmt($ded), 'description' => 'Payroll deductions'],
['account' => '250505', 'credit' => $fmt($neto), 'description' => 'Salaries payable'],
['account' => '513595', 'debit' => '350000', 'third_party' => '900123456', 'description' => 'Electronic payroll operator'],
['account' => '236525', 'credit' => $fmt($retencion), 'third_party' => '900123456', 'tax_concept' => 'RF_SERVICIOS', 'base_amount' => '350000'],
['account' => '233595', 'credit' => $fmt(350000 - $retencion), 'third_party' => '900123456'],
],
idempotencyKey: 'horizonte-erp-nom-2026-10',
external_id: 'HORIZONTE-ERP-NOM-2026-10',
voucher_type: 'CC',
);
echo $tx->data['id'], ' ', $tx->data['status'], PHP_EOL;decimal Suma(string k) => desprendibles.Results.Sum(d => decimal.Parse(d[k]!.GetValue<string>(), CultureInfo.InvariantCulture));
string F(decimal v) => v.ToString("0.00", CultureInfo.InvariantCulture);
decimal dev = Suma("earnings"), ded = Suma("deductions"), neto = Suma("net_pay");
// Withholding for the electronic payroll operator (third-party service)
Page calculo = await client.Admin.Accounting.ComputeTaxesAsync(
new[] { new Dictionary<string, object?> { ["concept"] = "RF_SERVICIOS", ["base"] = "350000" } },
date: "2026-10-31", thirdParty: "900123456");
decimal retencion = decimal.Parse(calculo.Results[0]["amount"]!.GetValue<string>(), CultureInfo.InvariantCulture);
Transaction tx = await client.Admin.Accounting.CreateEntryAsync(
"2026-10-31",
"Payroll accrual October 2026",
new[]
{
new Dictionary<string, object?> { ["account"] = "510506", ["debit"] = F(dev), ["description"] = "Salaries" },
new Dictionary<string, object?> { ["account"] = "237005", ["credit"] = F(ded), ["description"] = "Payroll deductions" },
new Dictionary<string, object?> { ["account"] = "250505", ["credit"] = F(neto), ["description"] = "Salaries payable" },
new Dictionary<string, object?> { ["account"] = "513595", ["debit"] = "350000", ["third_party"] = "900123456", ["description"] = "Electronic payroll operator" },
new Dictionary<string, object?> { ["account"] = "236525", ["credit"] = F(retencion), ["third_party"] = "900123456", ["tax_concept"] = "RF_SERVICIOS", ["base_amount"] = "350000" },
new Dictionary<string, object?> { ["account"] = "233595", ["credit"] = F(350000m - retencion), ["third_party"] = "900123456" },
},
new Dictionary<string, object?> { ["external_id"] = "HORIZONTE-ERP-NOM-2026-10", ["voucher_type"] = "CC" },
idempotencyKey: "horizonte-erp-nom-2026-10");
Console.WriteLine($"{tx.Data["id"]} {tx.Data["status"]}");suma := func(k string) float64 {
t := 0.0
for _, d := range desprendibles.Results {
s, _ := d[k].(string)
v, _ := strconv.ParseFloat(s, 64)
t += v
}
return t
}
f := func(v float64) string { return strconv.FormatFloat(v, 'f', 2, 64) }
dev, ded, neto := suma("earnings"), suma("deductions"), suma("net_pay")
// Withholding for the electronic payroll operator (third-party service)
calculo, err := client.Admin.Accounting.ComputeTaxes(ctx, &compense.ComputeTaxesParams{
Items: []map[string]any{{"concept": "RF_SERVICIOS", "base": "350000"}}, Date: "2026-10-31", ThirdParty: "900123456",
})
if err != nil {
log.Fatal(err)
}
retStr, _ := calculo.Results[0]["amount"].(string)
retencion, _ := strconv.ParseFloat(retStr, 64)
asiento, err := client.Admin.Accounting.CreateEntry(ctx, &compense.EntryParams{
Date: "2026-10-31",
Description: "Payroll accrual October 2026",
Lines: []map[string]any{
{"account": "510506", "debit": f(dev), "description": "Salaries"},
{"account": "237005", "credit": f(ded), "description": "Payroll deductions"},
{"account": "250505", "credit": f(neto), "description": "Salaries payable"},
{"account": "513595", "debit": "350000", "third_party": "900123456", "description": "Electronic payroll operator"},
{"account": "236525", "credit": f(retencion), "third_party": "900123456", "tax_concept": "RF_SERVICIOS", "base_amount": "350000"},
{"account": "233595", "credit": f(350000 - retencion), "third_party": "900123456"},
},
Fields: map[string]any{"external_id": "HORIZONTE-ERP-NOM-2026-10", "voucher_type": "CC"},
}, compense.WithIdempotencyKey("horizonte-erp-nom-2026-10"))
if err != nil {
log.Fatal(err)
}
fmt.Println(asiento.Data["id"], asiento.Data["status"])# 1) Simulate the withholding (no Idempotency-Key)
curl -X POST https://sandbox.dev.compense.co/gw/v1/gateway/admin/accounting/taxes/compute/ \
-H "X-API-Key: $COMPENSE_API_KEY" \
-H "Content-Type: application/json" \
-d '{"date": "2026-10-31", "third_party": "900123456", "items": [{"concept": "RF_SERVICIOS", "base": "350000"}]}'
# 2) Record as a draft
curl -X POST https://sandbox.dev.compense.co/gw/v1/gateway/admin/accounting/entries/ \
-H "X-API-Key: $COMPENSE_API_KEY" \
-H "Idempotency-Key: horizonte-erp-nom-2026-10" \
-H "Content-Type: application/json" \
-d '{"external_id": "HORIZONTE-ERP-NOM-2026-10", "voucher_type": "CC", "date": "2026-10-31",
"description": "Payroll accrual October 2026",
"lines": [
{"account": "510506", "debit": "8000000.00", "description": "Salaries"},
{"account": "237005", "credit": "640000.00", "description": "Payroll deductions"},
{"account": "250505", "credit": "7360000.00", "description": "Salaries payable"},
{"account": "513595", "debit": "350000", "third_party": "900123456"},
{"account": "236525", "credit": "14000.00", "third_party": "900123456", "tax_concept": "RF_SERVICIOS", "base_amount": "350000"},
{"account": "233595", "credit": "336000.00", "third_party": "900123456"}
]}'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"))const entradas = await client.admin.accounting.listEntries({ externalId: "HORIZONTE-ERP-NOM-2026-10", source: "gateway" });
for (const e of entradas.results) console.log(e.id, e.status, e.number);$entradas = $client->admin->accounting->listEntries(externalId: 'HORIZONTE-ERP-NOM-2026-10', source: 'gateway');
foreach ($entradas->results as $e) {
echo $e['id'], ' ', $e['status'], PHP_EOL;
}Page entradas = await client.Admin.Accounting.ListEntriesAsync(externalId: "HORIZONTE-ERP-NOM-2026-10", source: "gateway");
foreach (JsonObject e in entradas.Results) Console.WriteLine($"{e["id"]} {e["status"]}");entradas, err := client.Admin.Accounting.ListEntries(ctx, &compense.EntryListParams{
ExternalID: "HORIZONTE-ERP-NOM-2026-10", Source: "gateway",
})
if err != nil {
log.Fatal(err)
}
for _, e := range entradas.Results {
fmt.Println(e["id"], e["status"])
}curl "https://sandbox.dev.compense.co/gw/v1/gateway/admin/accounting/entries/?external_id=HORIZONTE-ERP-NOM-2026-10&source=gateway" \
-H "X-API-Key: $COMPENSE_API_KEY"Events #
| Event | When | Use it to |
|---|---|---|
admin.payroll.novelty.received | Each payroll adjustment was loaded | Mark in the shift system which records are already in (novelty_id, employee_id, concept_code, period_id) |
admin.accounting.entry.drafted | The draft journal entry was created | Store 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 test | Do this |
|---|---|
| Open period | payroll.list_periods returns per-O-… (October 2026, ABIERTO) |
| Closed period | Load payroll adjustments into per-C-… (September 2026, APROBADO) → 409 PERIOD_CLOSED |
| Invalid row | A system concept (SALARIO, SALUD…) or a date outside October → 422 and the batch isn’t loaded |
| Nonexistent employee | employee 9999999999 → 422 on that row |
| Payslips | Any per-… period: Laura Sandbox (1000000001) and Andrés Sandbox (1000000002) |
| Electronic payroll | payroll.electronic(year=2026, month=10) → ACEPTADO summary |
| Simulator | Applies a flat 4% rate to each base |
| Journal entry | Always 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
ABIERTOorCALCULADOperiods and handle409 PERIOD_CLOSED. - Batches of up to 500 with one
Idempotency-Keyper period and batch. - On a
422you fix the flagged row and resend the whole batch. - You wait for
APROBADObefore 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 stableexternal_idfrom your ERP.