Recipe
Loan collections
Connect the compensation fund’s teller system and payroll file to the loan portfolio: quote, record each payment with a unique reference, handle REFERENCE_REUSED and idempotent retries, record extra payments, and hand over the paid-in-full certificate as a PDF.
On this page
At the Caja Horizonte branch offices, affiliates pay their loan installments in cash, and every month the payroll deduction file (libranza) arrives as well. All that money comes in outside the platform, so the family compensation fund (caja de compensación) has to report it to Compense to keep the balance, delinquency, the affiliate’s app, and the paid-in-full certificate (paz y salvo) up to date. This recipe connects the teller system and the payroll file process to the Loans module.
You need one key with loans:read for lookups at the teller window and another with loans:collect
to record payments (keep them separate), plus an endpoint subscribed to payment.received,
payment.applied, and loan.paid_off.
The teller window flow #
sequenceDiagram
autonumber
actor A as Affiliate
participant T as Teller (Caja Horizonte)
participant G as Compense Gateway
A->>T: "I'm here to pay my loan" (ID number)
T->>G: loans.list(document) and installment_breakdown
G-->>T: loans, next installment, breakdown
A->>T: hands over the cash
Note over T: prints receipt HORIZONTE-TAQ-OFI01-…
T->>G: register_payment(reference=receipt, Idempotency-Key)
G-->>T: receipt: allocation, balance after, paz_y_salvo
G--)T: webhooks payment.received / payment.applied
alt loan paid off
G--)T: webhook loan.paid_off
T->>G: certificate(pdf=true)
T-->>A: paid-in-full certificate as PDF
endThe reference: your receipt is the key #
The whole design rests on one idea: the receipt number is the natural key of the payment. Compense identifies each collection by loan + channel + reference:
| Situation | Result |
|---|---|
HORIZONTE-TAQ-OFI01-2026-000981 arrives for the first time | The payment is applied: 201, replayed: false. |
| It arrives again with the same amount (double click, retry, reprocessing) | Same receipt, replayed: true; it is not applied twice and webhooks are not re-sent. |
| It arrives again with a different amount | 409 REFERENCE_REUSED: someone mistyped or reused a number. |
Practical rules:
- Use the printed receipt number that the office keeps as the
reference(max. 100 characters) and make it unique per channel:HORIZONTE-TAQ-<office>-<year>-<sequence>for the teller window, and the bank or payroll reference for files. - Also use an
Idempotency-Keyderived from the same reference (taq-<reference>). The two layers complement each other: the key protects the immediate retry at the edge; the reference protects you even if the key changes (for example, when you reprocess an entire file on another day).
1. Look up at the teller window: how much is owed and how it would be applied #
loans = client.loans.list("1000000003", status="ACTIVE")
loan = loans.results[0]
loan_id = loan["loan_id"]
cuota = loan["next_installment"]
print(loan["loan_number"], loan["status"], "installment", cuota["number"], cuota["pending_amount"],
"overdue:", cuota["days_overdue"], "days")
desglose = client.loans.installment_breakdown(loan_id, cuota["number"], document="1000000003")
for paso in desglose["waterfall"]:
print(f'{paso["concept"]:>20} {paso["amount"]}')const loans = await client.loans.list("1000000003", { status: "ACTIVE" });
const loan = loans.results[0];
const loanId = String(loan.loan_id);
const cuota = loan.next_installment as Record<string, unknown>;
console.log(loan.loan_number, loan.status, "installment", cuota.number, cuota.pending_amount, "overdue:", cuota.days_overdue);
const desglose = await client.loans.installmentBreakdown(loanId, Number(cuota.number), { document: "1000000003" });
for (const paso of desglose.waterfall as Array<Record<string, unknown>>) console.log(paso.concept, paso.amount);$loans = $client->loans->list('1000000003', status: 'ACTIVE');
$loan = $loans->results[0];
$loanId = $loan['loan_id'];
$cuota = $loan['next_installment'];
echo $loan['loan_number'], ' installment ', $cuota['number'], ' ', $cuota['pending_amount'], ' overdue ', $cuota['days_overdue'], PHP_EOL;
$desglose = $client->loans->installmentBreakdown($loanId, (int) $cuota['number'], document: '1000000003');
foreach ($desglose['waterfall'] as $paso) {
echo str_pad($paso['concept'], 20), ' ', $paso['amount'], PHP_EOL;
}Page loans = await client.Loans.ListAsync("1000000003", "ACTIVE");
var loan = loans.Results[0];
string loanId = loan["loan_id"]!.GetValue<string>();
var cuota = loan["next_installment"]!.AsObject();
int numero = cuota["number"]!.GetValue<int>();
Console.WriteLine($"{loan["loan_number"]} installment {numero} {cuota["pending_amount"]} overdue {cuota["days_overdue"]}");
var desglose = await client.Loans.InstallmentBreakdownAsync(loanId, numero, document: "1000000003");
foreach (var paso in desglose["waterfall"]!.AsArray())
{
Console.WriteLine($"{paso!["concept"],20} {paso["amount"]}");
}loans, err := client.Loans.List(ctx, "1000000003", &compense.LoanListParams{Status: "ACTIVE"})
if err != nil {
log.Fatal(err)
}
loan := loans.Results[0]
loanID := fmt.Sprint(loan["loan_id"])
cuota, _ := loan["next_installment"].(map[string]any)
numero, _ := cuota["number"].(json.Number).Int64() // the SDK decodes numbers as json.Number
fmt.Println(loan["loan_number"], "installment", numero, cuota["pending_amount"], "overdue", cuota["days_overdue"])
desglose, err := client.Loans.InstallmentBreakdown(ctx, loanID, int(numero),
&compense.InstallmentBreakdownParams{Document: "1000000003"})
if err != nil {
log.Fatal(err)
}
fmt.Println(desglose["waterfall"])curl -s "https://sandbox.dev.compense.co/gw/v1/gateway/loans/?document=1000000003&status=ACTIVE" \
-H "X-API-Key: $COMPENSE_API_KEY" | jq '.results[0] | {loan_id, loan_number, status, next_installment}'
curl -s "https://sandbox.dev.compense.co/gw/v1/gateway/loans/$LOAN_ID/installments/4/breakdown/?document=1000000003" \
-H "X-API-Key: $COMPENSE_API_KEY" | jq .waterfallIf the affiliate has an older overdue installment, the breakdown of the following ones comes back with
payable: false and blocked_reason: the teller must collect the overdue one first. For “how much
to be paid in full today?”, use the payoff quote (step 4).
2. Record the teller payment #
The teller receives the cash, the system prints the receipt, and it records the payment with that reference. This is the block with the most cases to handle correctly:
| Result | What the teller system does |
|---|---|
200/201 with replayed: false | Payment applied: print the receipt with the allocation and the balance. |
200 with replayed: true | It was already applied (double click or retry): reprint the same receipt. |
409, title: Idempotency conflict | The previous attempt is still in progress: retry in a few seconds with the same key. |
409, title: Request rejected by engine | REFERENCE_REUSED: that receipt was already used with a different amount. Don’t retry: escalate to the supervisor. |
422 | Invalid amount, more than the debt (AMOUNT_EXCEEDS_DEBT), loan already paid off, blocked installment… Show detail to the teller. |
502/504 or network down | Retry with the same key and the same reference; never generate a new receipt for the same money. |
from compense_gateway import IdempotencyConflictError, UpstreamError, ValidationError
comprobante = "HORIZONTE-TAQ-OFI01-2026-000981"
def registrar_taquilla(loan_id: str, documento: str, monto: str) -> dict:
try:
tx = client.loans.register_payment(
loan_id, amount=monto, reference=comprobante, channel="CASH",
value_date="2026-11-02", document=documento, idempotency_key=f"taq-{comprobante}",
)
return {"ok": True, "recibo": tx.data, "ya_estaba": tx.data.get("replayed") or tx.idempotent_replay}
except IdempotencyConflictError as e:
if e.title == "Idempotency conflict":
return {"ok": False, "accion": "REINTENTAR_MISMA_LLAVE"}
return {"ok": False, "accion": "ESCALAR", "motivo": e.detail, "trace_id": e.trace_id} # REFERENCE_REUSED
except ValidationError as e:
return {"ok": False, "accion": "CORREGIR", "motivo": e.detail}
except UpstreamError:
return {"ok": False, "accion": "REINTENTAR_MISMA_LLAVE"}
print(registrar_taquilla(loan_id, "1000000003", "467009.88"))import { IdempotencyConflictError, UpstreamError, ValidationError } from "@compense/sdk";
const comprobante = "HORIZONTE-TAQ-OFI01-2026-000981";
async function registrarTaquilla(loanId: string, documento: string, monto: string) {
try {
const tx = await client.loans.registerPayment(loanId, {
amount: monto, reference: comprobante, channel: "CASH",
valueDate: "2026-11-02", document: documento, idempotencyKey: `taq-${comprobante}`,
});
return { ok: true, recibo: tx.data, yaEstaba: Boolean(tx.data.replayed) || tx.idempotentReplay };
} catch (err) {
if (err instanceof IdempotencyConflictError) {
return err.title === "Idempotency conflict"
? { ok: false, accion: "REINTENTAR_MISMA_LLAVE" }
: { ok: false, accion: "ESCALAR", motivo: err.detail, traceId: err.traceId };
}
if (err instanceof ValidationError) return { ok: false, accion: "CORREGIR", motivo: err.detail };
if (err instanceof UpstreamError) return { ok: false, accion: "REINTENTAR_MISMA_LLAVE" };
throw err;
}
}
console.log(await registrarTaquilla(loanId, "1000000003", "467009.88"));use Compense\Sdk\Exception\IdempotencyConflictException;
use Compense\Sdk\Exception\UpstreamException;
use Compense\Sdk\Exception\ValidationException;
$comprobante = 'HORIZONTE-TAQ-OFI01-2026-000981';
try {
$tx = $client->loans->registerPayment(
$loanId, amount: '467009.88', reference: $comprobante, channel: 'CASH',
valueDate: '2026-11-02', document: '1000000003', idempotencyKey: "taq-{$comprobante}",
);
$resultado = ['ok' => true, 'recibo' => $tx->data, 'ya_estaba' => ($tx->data['replayed'] ?? false) || $tx->idempotentReplay];
} catch (IdempotencyConflictException $e) {
$resultado = $e->title === 'Idempotency conflict'
? ['ok' => false, 'accion' => 'REINTENTAR_MISMA_LLAVE']
: ['ok' => false, 'accion' => 'ESCALAR', 'motivo' => $e->detail, 'trace_id' => $e->traceId];
} catch (ValidationException $e) {
$resultado = ['ok' => false, 'accion' => 'CORREGIR', 'motivo' => $e->detail];
} catch (UpstreamException) {
$resultado = ['ok' => false, 'accion' => 'REINTENTAR_MISMA_LLAVE'];
}
print_r($resultado);const string comprobante = "HORIZONTE-TAQ-OFI01-2026-000981";
try
{
Transaction tx = await client.Loans.RegisterPaymentAsync(
loanId, amount: "467009.88", reference: comprobante, channel: "CASH",
valueDate: "2026-11-02", document: "1000000003", idempotencyKey: $"taq-{comprobante}");
bool yaEstaba = (tx.Data["replayed"]?.GetValue<bool>() ?? false) || tx.IdempotentReplay;
Console.WriteLine($"Applied. Balance: {tx.Data["outstanding_principal_after"]} (already applied: {yaEstaba})");
}
catch (IdempotencyConflictException e) when (e.Title == "Idempotency conflict")
{
Console.WriteLine("RETRY with the same key");
}
catch (IdempotencyConflictException e)
{
Console.WriteLine($"ESCALATE (receipt reused): {e.Detail} {e.TraceId}");
}
catch (ValidationException e)
{
Console.WriteLine($"FIX: {e.Detail}");
}
catch (UpstreamException)
{
Console.WriteLine("RETRY with the same key");
}comprobante := "HORIZONTE-TAQ-OFI01-2026-000981"
tx, err := client.Loans.RegisterPayment(ctx, loanID, &compense.LoanPaymentParams{
Amount: "467009.88", Reference: comprobante, Channel: "CASH",
ValueDate: "2026-11-02", Document: "1000000003",
}, compense.WithIdempotencyKey("taq-"+comprobante))
var apiErr *compense.APIError
errors.As(err, &apiErr)
switch {
case err == nil:
fmt.Println("Applied. Balance:", tx.Data["outstanding_principal_after"], "replayed:", tx.Data["replayed"])
case compense.IsIdempotencyConflict(err) && apiErr.Title == "Idempotency conflict",
compense.IsUpstreamError(err):
fmt.Println("RETRY with the same key")
case compense.IsIdempotencyConflict(err):
fmt.Println("ESCALATE (receipt reused):", apiErr.Detail, apiErr.TraceID)
case compense.IsValidationError(err):
fmt.Println("FIX:", apiErr.Detail)
default:
log.Fatal(err)
}COMPROBANTE="HORIZONTE-TAQ-OFI01-2026-000981"
curl -s -w '\nHTTP %{http_code}\n' -X POST \
"https://sandbox.dev.compense.co/gw/v1/gateway/loans/$LOAN_ID/payments/" \
-H "X-API-Key: $COMPENSE_API_KEY" \
-H "Idempotency-Key: taq-$COMPROBANTE" \
-H "Content-Type: application/json" \
-d '{"document": "1000000003", "amount": "467009.88", "channel": "CASH",
"reference": "'"$COMPROBANTE"'", "value_date": "2026-11-02"}'
# 409 with "title": "Request rejected by engine" → receipt reused with a different amountThe receipt includes the allocation following the payment priority order (iva, collection_fees,
insurance, overdue_interest, current_interest, principal), the installments it was applied to,
the outstanding principal afterwards, and loan_status_after: what the affiliate needs to see printed.
3. Process the payroll deduction file (libranza) #
The monthly payroll deduction file is a batch of collections on the ACH_LIBRANZA channel. Each
row’s reference is the one the file carries (paying employer + period + employee), so
reprocessing the whole file is safe: rows already applied come back with replayed: true.
import csv
resumen = {"aplicadas": 0, "ya_estaban": 0, "revisar": []}
with open("nomina-horizonte-2026-10.csv", newline="", encoding="utf-8") as fh:
for fila in csv.DictReader(fh):
ref = f'NOM-{fila["pagaduria"]}-2026-10-{fila["documento"]}'
try:
tx = client.loans.register_payment(
fila["credito"], amount=fila["valor"], reference=ref, channel="ACH_LIBRANZA",
value_date=fila["fecha_pago"], document=fila["documento"], idempotency_key=f"nom-{ref}",
)
resumen["ya_estaban" if tx.data.get("replayed") else "aplicadas"] += 1
except (IdempotencyConflictError, ValidationError) as e:
resumen["revisar"].append((ref, e.status, e.detail))
print(resumen)import { readFile } from "node:fs/promises";
const resumen = { aplicadas: 0, yaEstaban: 0, revisar: [] as unknown[] };
const [, ...filas] = (await readFile("nomina-horizonte-2026-10.csv", "utf8")).trim().split("\n");
for (const linea of filas) {
const [credito, documento, pagaduria, valor, fechaPago] = linea.split(",");
const ref = `NOM-${pagaduria}-2026-10-${documento}`;
try {
const tx = await client.loans.registerPayment(credito, {
amount: valor, reference: ref, channel: "ACH_LIBRANZA",
valueDate: fechaPago, document: documento, idempotencyKey: `nom-${ref}`,
});
if (tx.data.replayed) resumen.yaEstaban++; else resumen.aplicadas++;
} catch (err) {
if (err instanceof IdempotencyConflictError || err instanceof ValidationError) resumen.revisar.push([ref, err.status, err.detail]);
else throw err;
}
}
console.log(resumen);$resumen = ['aplicadas' => 0, 'ya_estaban' => 0, 'revisar' => []];
$fh = fopen('nomina-horizonte-2026-10.csv', 'r');
$cabeceras = fgetcsv($fh);
while (($fila = fgetcsv($fh)) !== false) {
$f = array_combine($cabeceras, $fila);
$ref = "NOM-{$f['pagaduria']}-2026-10-{$f['documento']}";
try {
$tx = $client->loans->registerPayment(
$f['credito'], amount: $f['valor'], reference: $ref, channel: 'ACH_LIBRANZA',
valueDate: $f['fecha_pago'], document: $f['documento'], idempotencyKey: "nom-{$ref}",
);
($tx->data['replayed'] ?? false) ? $resumen['ya_estaban']++ : $resumen['aplicadas']++;
} catch (IdempotencyConflictException | ValidationException $e) {
$resumen['revisar'][] = [$ref, $e->status, $e->detail];
}
}
print_r($resumen);int aplicadas = 0, yaEstaban = 0;
var revisar = new List<string>();
foreach (var linea in File.ReadLines("nomina-horizonte-2026-10.csv").Skip(1))
{
var c = linea.Split(','); // credito,documento,pagaduria,valor,fecha_pago
string referencia = $"NOM-{c[2]}-2026-10-{c[1]}";
try
{
Transaction tx = await client.Loans.RegisterPaymentAsync(
c[0], amount: c[3], reference: referencia, channel: "ACH_LIBRANZA",
valueDate: c[4], document: c[1], idempotencyKey: $"nom-{referencia}");
if (tx.Data["replayed"]?.GetValue<bool>() == true) yaEstaban++; else aplicadas++;
}
catch (CompenseApiException e) when (e is IdempotencyConflictException or ValidationException)
{
revisar.Add($"{referencia} {e.Status} {e.Detail}");
}
}
Console.WriteLine($"applied={aplicadas} already_applied={yaEstaban} to_review={revisar.Count}");f, err := os.Open("nomina-horizonte-2026-10.csv")
if err != nil {
log.Fatal(err)
}
defer f.Close()
filas, err := csv.NewReader(f).ReadAll() // credito,documento,pagaduria,valor,fecha_pago
if err != nil {
log.Fatal(err)
}
aplicadas, yaEstaban := 0, 0
var revisar []string
for _, c := range filas[1:] {
ref := fmt.Sprintf("NOM-%s-2026-10-%s", c[2], c[1])
tx, err := client.Loans.RegisterPayment(ctx, c[0], &compense.LoanPaymentParams{
Amount: compense.Amount(c[3]), Reference: ref, Channel: "ACH_LIBRANZA",
ValueDate: c[4], Document: c[1],
}, compense.WithIdempotencyKey("nom-"+ref))
switch {
case compense.IsIdempotencyConflict(err), compense.IsValidationError(err):
revisar = append(revisar, fmt.Sprint(ref, " ", err))
case err != nil:
log.Fatal(err)
case tx.Data["replayed"] == true:
yaEstaban++
default:
aplicadas++
}
}
fmt.Println("applied", aplicadas, "already applied", yaEstaban, "to review", len(revisar))tail -n +2 nomina-horizonte-2026-10.csv | while IFS=, read -r CREDITO DOC PAGADURIA VALOR FECHA; do
REF="NOM-$PAGADURIA-2026-10-$DOC"
curl -s -o /dev/null -w "$REF %{http_code}\n" -X POST \
"https://sandbox.dev.compense.co/gw/v1/gateway/loans/$CREDITO/payments/" \
-H "X-API-Key: $COMPENSE_API_KEY" -H "Idempotency-Key: nom-$REF" \
-H "Content-Type: application/json" \
-d '{"document": "'"$DOC"'", "amount": "'"$VALOR"'", "channel": "ACH_LIBRANZA",
"reference": "'"$REF"'", "value_date": "'"$FECHA"'"}'
doneThe credito column may contain the loan_number (CRD-00123): the gateway accepts either the UUID
or the number. Always send document so that a mistyped loan number doesn’t end up credited to
someone else’s loan (it would be a 404).
4. Extra payment and full payoff #
When the affiliate wants to pay down principal, quote first and show them the effect before you collect; if they want to pay off the loan, quote today’s payoff amount.
# Principal prepayment: quote, confirm with the affiliate, and record
cot = client.loans.quote_extra_payment(loan_id, amount="1000000", document="1000000003")
print(cot["allocation"], cot["outstanding_principal_after"], cot["scenarios"])
abono = client.loans.register_extra_payment(
loan_id, amount="1000000", reference="HORIZONTE-TAQ-OFI01-2026-000982", mode="reduce_term",
channel="CASH", document="1000000003", idempotency_key="taq-HORIZONTE-TAQ-OFI01-2026-000982")
# Full payoff: the amount must be exactly today's quote
total = client.loans.quote_payoff(loan_id, document="1000000003")["payoff_amount_today"]
paz = client.loans.register_payoff(
loan_id, reference="HORIZONTE-TAQ-OFI01-2026-000983", amount=total, channel="CASH",
document="1000000003", idempotency_key="taq-HORIZONTE-TAQ-OFI01-2026-000983")
print(paz.data["loan_status_after"], paz.data["paz_y_salvo"])const cot = await client.loans.quoteExtraPayment(loanId, { amount: "1000000", document: "1000000003" });
console.log(cot.allocation, cot.outstanding_principal_after, cot.scenarios);
await client.loans.registerExtraPayment(loanId, {
amount: "1000000", reference: "HORIZONTE-TAQ-OFI01-2026-000982", mode: "reduce_term",
channel: "CASH", document: "1000000003", idempotencyKey: "taq-HORIZONTE-TAQ-OFI01-2026-000982",
});
const { payoff_amount_today } = await client.loans.quotePayoff(loanId, { document: "1000000003" });
const paz = await client.loans.registerPayoff(loanId, {
reference: "HORIZONTE-TAQ-OFI01-2026-000983", amount: String(payoff_amount_today), channel: "CASH",
document: "1000000003", idempotencyKey: "taq-HORIZONTE-TAQ-OFI01-2026-000983",
});
console.log(paz.data.loan_status_after, paz.data.paz_y_salvo);$cot = $client->loans->quoteExtraPayment($loanId, amount: '1000000', document: '1000000003');
$client->loans->registerExtraPayment(
$loanId, amount: '1000000', reference: 'HORIZONTE-TAQ-OFI01-2026-000982', mode: 'reduce_term',
channel: 'CASH', document: '1000000003', idempotencyKey: 'taq-HORIZONTE-TAQ-OFI01-2026-000982');
$total = $client->loans->quotePayoff($loanId, document: '1000000003')['payoff_amount_today'];
$paz = $client->loans->registerPayoff(
$loanId, reference: 'HORIZONTE-TAQ-OFI01-2026-000983', amount: $total, channel: 'CASH',
document: '1000000003', idempotencyKey: 'taq-HORIZONTE-TAQ-OFI01-2026-000983');
echo $paz->data['loan_status_after'], PHP_EOL;var cot = await client.Loans.QuoteExtraPaymentAsync(loanId, "1000000", "1000000003");
await client.Loans.RegisterExtraPaymentAsync(
loanId, "1000000", "HORIZONTE-TAQ-OFI01-2026-000982", mode: "reduce_term", channel: "CASH",
document: "1000000003", idempotencyKey: "taq-HORIZONTE-TAQ-OFI01-2026-000982");
var cotTotal = await client.Loans.QuotePayoffAsync(loanId, "1000000003");
Transaction paz = await client.Loans.RegisterPayoffAsync(
loanId, "HORIZONTE-TAQ-OFI01-2026-000983", cotTotal["payoff_amount_today"]!.GetValue<string>(),
channel: "CASH", document: "1000000003", idempotencyKey: "taq-HORIZONTE-TAQ-OFI01-2026-000983");
Console.WriteLine($"{paz.Data["loan_status_after"]} {paz.Data["paz_y_salvo"]}");cot, err := client.Loans.QuoteExtraPayment(ctx, loanID,
&compense.ExtraPaymentQuoteParams{Amount: "1000000", Document: "1000000003"})
if err != nil {
log.Fatal(err)
}
fmt.Println(cot["allocation"], cot["outstanding_principal_after"])
_, err = client.Loans.RegisterExtraPayment(ctx, loanID, &compense.ExtraPaymentParams{
Amount: "1000000", Reference: "HORIZONTE-TAQ-OFI01-2026-000982", Mode: "reduce_term",
Channel: "CASH", Document: "1000000003",
}, compense.WithIdempotencyKey("taq-HORIZONTE-TAQ-OFI01-2026-000982"))
if err != nil {
log.Fatal(err)
}
cotTotal, err := client.Loans.QuotePayoff(ctx, loanID, &compense.LoanParams{Document: "1000000003"})
if err != nil {
log.Fatal(err)
}
paz, err := client.Loans.RegisterPayoff(ctx, loanID, &compense.PayoffParams{
Reference: "HORIZONTE-TAQ-OFI01-2026-000983",
Amount: compense.Amount(fmt.Sprint(cotTotal["payoff_amount_today"])),
Channel: "CASH", Document: "1000000003",
}, compense.WithIdempotencyKey("taq-HORIZONTE-TAQ-OFI01-2026-000983"))
if err != nil {
log.Fatal(err)
}
fmt.Println(paz.Data["loan_status_after"], paz.Data["paz_y_salvo"])TOTAL=$(curl -s "https://sandbox.dev.compense.co/gw/v1/gateway/loans/$LOAN_ID/payoff/quote/?document=1000000003" \
-H "X-API-Key: $COMPENSE_API_KEY" | jq -r .payoff_amount_today)
curl -X POST "https://sandbox.dev.compense.co/gw/v1/gateway/loans/$LOAN_ID/payoff/" \
-H "X-API-Key: $COMPENSE_API_KEY" \
-H "Idempotency-Key: taq-HORIZONTE-TAQ-OFI01-2026-000983" \
-H "Content-Type: application/json" \
-d '{"document": "1000000003", "amount": "'"$TOTAL"'", "channel": "CASH",
"reference": "HORIZONTE-TAQ-OFI01-2026-000983"}'If the day changed between the quote and the payment (or late interest accrued), the payoff responds
422 PAYOFF_AMOUNT_MISMATCH with the correct amount in the message: quote again and collect the
difference. Keep in mind that today the extra payment’s mode is recorded but the installment schedule
is not regenerated (see Loans).
5. Hand over the paid-in-full certificate #
When the loan reaches zero (a response with paz_y_salvo: true or the loan.paid_off webhook),
generate the PDF certificate and give it to the affiliate. The pdf_url is signed and temporary:
download it and file it in your document management system instead of storing the link.
cert = client.loans.certificate(loan_id, document="1000000003", pdf=True)
if cert["paz_y_salvo"] and cert.get("pdf_url"):
erp.archivar_pdf(loan_id, cert["pdf_url"])
else:
print("Not paid in full yet, or the PDF failed:", cert["status"], cert.get("pdf_error"))const cert = await client.loans.certificate(loanId, { document: "1000000003", pdf: true });
if (cert.paz_y_salvo && cert.pdf_url) await erp.archivarPdf(loanId, String(cert.pdf_url));
else console.log("Not paid in full yet, or the PDF failed:", cert.status, cert.pdf_error);$cert = $client->loans->certificate($loanId, document: '1000000003', pdf: true);
if (($cert['paz_y_salvo'] ?? false) && !empty($cert['pdf_url'])) {
$erp->archivarPdf($loanId, $cert['pdf_url']);
}var cert = await client.Loans.CertificateAsync(loanId, "1000000003", pdf: true);
bool paz = cert["paz_y_salvo"]?.GetValue<bool>() ?? false;
string? pdf = cert["pdf_url"]?.GetValue<string>();
if (paz && pdf is not null) await erp.ArchivarPdfAsync(loanId, pdf);cert, err := client.Loans.Certificate(ctx, loanID, &compense.CertificateParams{
Document: "1000000003", PDF: compense.Bool(true),
})
if err != nil {
log.Fatal(err)
}
if pdf, ok := cert["pdf_url"].(string); ok && cert["paz_y_salvo"] == true {
erp.ArchivarPDF(loanID, pdf)
}curl -s "https://sandbox.dev.compense.co/gw/v1/gateway/loans/$LOAN_ID/certificate/?document=1000000003&pdf=true" \
-H "X-API-Key: $COMPENSE_API_KEY" | jq '{status, paz_y_salvo, total_debt, pdf_url}'The same certificate without paz_y_salvo works as a debt certificate as of a given date
(as_of), the one banks ask for when buying out a loan.
6. Reconcile the day’s cash #
At closing, each office compares its cash count with what was recorded:
- Webhooks:
payment.receivedcarries thechannel,reference,amount, andvalue_dateof each collection;payment.appliedcarries how it was allocated;loan.paid_offflags loans paid off. Match onresource.referenceagainst the office’s receipts. See Events. - History:
client.loans.payments(loan_id, document=…)lists each loan’s receipts with their reference and status, useful for auditing a specific case. - A collection that the office took in but that doesn’t show up as recorded is re-sent with its
same reference: if it was already there, it comes back with
replayed: true; if not, it is applied.
In the sandbox #
1000000001has a loan that is current,1000000003one with installment 4 overdue (12 days), and1000000004one inDEFAULTEDwith installments 3 to 5 past due (testblocked_reasonthere).- Writes validate the way the engine does (
AMOUNT_EXCEEDS_DEBT,PAYOFF_AMOUNT_MISMATCH,INVALID_CHANNEL,REFERENCE_REQUIRED…) but don’t keep state: the balance returned by reads doesn’t change andREFERENCE_REUSEDcan’t be reproduced (the same reference always returns the samereceipt_id). Test that case with a mock on your side. pdf_urlcomes back asnull. More in Test data.