Implementation guide
Loan portfolio
The life of a disbursed loan through the API: an affiliate’s loans, installment schedule and breakdown by payment priority, payment history, collection recording with a unique reference (REFERENCE_REUSED), extra payments, full payoff, and a debt certificate or paz y salvo (clearance certificate) in PDF.
On this page
The loans module picks up where Credit leaves off: once disbursed, the loan
has an installment schedule, interest, arrears and a balance. From your system you can query all
of that (for your online branch, your call center or your ERP) and report the collections your
entity receives outside the platform: the cash desk, payroll or libranza (payroll-deduction)
deductions, the bank file, an extra payment or a full payoff.
Requirement: your entity must have the credit module enabled. Otherwise, every loan portfolio route
returns 403 (MODULE_NOT_CONTRACTED).
Scopes #
| Scope | Used for |
|---|---|
loans:read | Loans, details, installment schedule, single installment and breakdown, payment history, quotes and certificate. |
loans:collect | Recording payments, extra payments and full payoffs: moves balances. |
Request separate API keys for querying and for collecting. loans:collect also supports the optional
X-Compense-Signature HMAC signature when it’s enabled on your API key (the SDKs don’t implement it;
the scheme is described in Credit).
How the affiliate and the loan are identified #
document: the primary member’s ID number. On reads it’s a query parameter; on writes, it goes in the body. It’s required onGET /loans/(422 DOCUMENT_REQUIREDif missing).loan_id: the loan’s UUID or its number (loan_number, e.g.CRD-00123).- On
/loans/{loan_id}/...routes,documentis optional, but if you send it the loan must belong to that affiliate; if it doesn’t, the response is404(the owner isn’t revealed). Always send it: it’s your safeguard against mixing up two affiliates’ loans. - The holder’s names are masked (
L*** P***) and the email address is never returned.
Operations #
| Operation | Python | TypeScript | PHP | .NET | Go |
|---|---|---|---|---|---|
GET/v1/gateway/loans/ | list | list | list | ListAsync | List |
GET/v1/gateway/loans/{loan_id}/ | get | get | get | GetAsync | Get |
GET/v1/gateway/loans/{loan_id}/installments/ | installments | installments | installments | InstallmentsAsync | Installments |
GET/v1/gateway/loans/{loan_id}/installments/{number}/ | get_installment | getInstallment | getInstallment | GetInstallmentAsync | GetInstallment |
GET/v1/gateway/loans/{loan_id}/installments/{number}/breakdown/ | installment_breakdown | installmentBreakdown | installmentBreakdown | InstallmentBreakdownAsync | InstallmentBreakdown |
GET/v1/gateway/loans/{loan_id}/payments/ | payments | payments | payments | PaymentsAsync | Payments |
POST/v1/gateway/loans/{loan_id}/payments/ | register_payment | registerPayment | registerPayment | RegisterPaymentAsync | RegisterPayment |
GET/v1/gateway/loans/{loan_id}/extra-payment/quote/ | quote_extra_payment | quoteExtraPayment | quoteExtraPayment | QuoteExtraPaymentAsync | QuoteExtraPayment |
POST/v1/gateway/loans/{loan_id}/extra-payment/ | register_extra_payment | registerExtraPayment | registerExtraPayment | RegisterExtraPaymentAsync | RegisterExtraPayment |
GET/v1/gateway/loans/{loan_id}/payoff/quote/ | quote_payoff | quotePayoff | quotePayoff | QuotePayoffAsync | QuotePayoff |
POST/v1/gateway/loans/{loan_id}/payoff/ | register_payoff | registerPayoff | registerPayoff | RegisterPayoffAsync | RegisterPayoff |
GET/v1/gateway/loans/{loan_id}/certificate/ | certificate | certificate | certificate | CertificateAsync | Certificate |
They all hang off client.loans ($client->loans, client.Loans). Reads return the engine’s
response body as is; writes return the standard envelope with module: "loans" and the receipt in
data.
Loan and installment statuses #
stateDiagram-v2 [*] --> DISBURSED: disbursement DISBURSED --> ACTIVE ACTIVE --> IN_ARREARS: installment past due IN_ARREARS --> ACTIVE: brought current IN_ARREARS --> DEFAULTED: prolonged arrears DEFAULTED --> IN_RECOVERY: collections ACTIVE --> RESTRUCTURED: change of terms RESTRUCTURED --> ACTIVE DEFAULTED --> WRITTEN_OFF: write-off ACTIVE --> PAID_OFF: balance at zero IN_ARREARS --> PAID_OFF: full payoff PAID_OFF --> [*] WRITTEN_OFF --> [*]
ACTIVE as a filter on GET /loans/?status=ACTIVE means “live portfolio” (everything that isn’t paid
off or written off); any other value filters by that exact status. Each installment in the schedule
is PENDING, PARTIAL, OVERDUE, PAID or CANCELLED. A PAID_OFF or WRITTEN_OFF loan doesn’t
accept collections (422 LOAN_ALREADY_PAID / LOAN_NOT_COLLECTIBLE).
1. An affiliate’s loans and details #
loans = client.loans.list("1000000001", status="ACTIVE")
for loan in loans:
print(loan["loan_id"], loan["loan_number"], loan["status"],
loan["outstanding_principal"], loan["next_installment"])
loan_id = loans.results[0]["loan_id"] # UUID (or use the loan_number)
detail = client.loans.get(loan_id, document="1000000001")
print(detail["payoff_amount_today"], detail["days_overdue"], detail["paz_y_salvo"])const loans = await client.loans.list("1000000001", { status: "ACTIVE" });
for (const loan of loans) {
console.log(loan.loan_id, loan.loan_number, loan.status, loan.outstanding_principal, loan.next_installment);
}
const loanId = String(loans.results[0].loan_id);
const detail = await client.loans.get(loanId, { document: "1000000001" });
console.log(detail.payoff_amount_today, detail.days_overdue, detail.paz_y_salvo);$loans = $client->loans->list('1000000001', status: 'ACTIVE');
foreach ($loans as $loan) {
echo $loan['loan_id'], ' ', $loan['loan_number'], ' ', $loan['status'], ' ', $loan['outstanding_principal'], PHP_EOL;
}
$loanId = $loans->results[0]['loan_id'];
$detail = $client->loans->get($loanId, document: '1000000001');
echo $detail['payoff_amount_today'], ' ', $detail['days_overdue'], PHP_EOL;Page loans = await client.Loans.ListAsync("1000000001", "ACTIVE");
foreach (var loan in loans)
{
Console.WriteLine($"{loan["loan_id"]} {loan["loan_number"]} {loan["status"]} {loan["outstanding_principal"]}");
}
string loanId = loans.Results[0]["loan_id"]!.GetValue<string>();
var detail = await client.Loans.GetAsync(loanId, "1000000001");
Console.WriteLine($"{detail["payoff_amount_today"]} {detail["days_overdue"]} {detail["paz_y_salvo"]}");loans, err := client.Loans.List(ctx, "1000000001", &compense.LoanListParams{Status: "ACTIVE"})
if err != nil {
log.Fatal(err)
}
for _, loan := range loans.Results {
fmt.Println(loan["loan_id"], loan["loan_number"], loan["status"], loan["outstanding_principal"])
}
loanID, _ := loans.Results[0]["loan_id"].(string)
detail, err := client.Loans.Get(ctx, loanID, &compense.LoanParams{Document: "1000000001"})
if err != nil {
log.Fatal(err)
}
fmt.Println(detail["payoff_amount_today"], detail["days_overdue"], detail["paz_y_salvo"])curl "https://sandbox.dev.compense.co/gw/v1/gateway/loans/?document=1000000001&status=ACTIVE" \
-H "X-API-Key: $COMPENSE_API_KEY"
curl "https://sandbox.dev.compense.co/gw/v1/gateway/loans/$LOAN_ID/?document=1000000001" \
-H "X-API-Key: $COMPENSE_API_KEY"{
"document": "1000000003",
"count": 1,
"results": [{
"loan_id": "lna-M-cf959250f146",
"loan_number": "SBX-CF959250",
"status": "IN_ARREARS",
"currency": "COP",
"approved_amount": "5000000.00",
"outstanding_principal": "3848489.67",
"monthly_payment": "467009.88",
"installments_total": 12,
"installments_paid": 3,
"days_overdue": 12,
"overdue_installments": 1,
"overdue_amount": "467009.88",
"next_installment": {"number": 4, "due_date": "2026-10-15", "amount_due": "467009.88",
"pending_amount": "467009.88", "is_overdue": true, "days_overdue": 12},
"holder": {"document": "1000000003", "name": "S*** L***"}
}]
}The details add payoff_amount_today (what it would cost to pay it off today) and paz_y_salvo.
2. Installment schedule, a single installment and its breakdown #
The schedule (
GET/v1/gateway/loans/{loan_id}/installments/
) returns each installment with what’s still pending
and, in next_payable_number, the first installment that can be paid. The breakdown
(
GET/v1/gateway/loans/{loan_id}/installments/{number}/breakdown/
) tells you how a payment would be applied to that
installment according to the legal payment priority:
VAT (IVA) → Collection fees → Insurance → Default interest → Current interest → Principal
Without amount, the breakdown shows what’s pending on the installment. If there’s an older overdue
installment, the later ones come back with payable: false and a blocked_reason (“Primero paga la
cuota 3, que está en mora.” — “First pay installment 3, which is overdue.”): you can’t skip an overdue
installment.
plan = client.loans.installments(loan_id, document="1000000001")
print("Next payable:", plan.raw.get("next_payable_number"))
for cuota in plan:
print(cuota["number"], cuota["scheduled_date"], cuota["status"], cuota["pending_amount"])
cuota4 = client.loans.get_installment(loan_id, 4, document="1000000001")
desglose = client.loans.installment_breakdown(loan_id, 4, amount="467009.88", document="1000000001")
for paso in desglose["waterfall"]:
print(paso["concept"], paso["amount"])const plan = await client.loans.installments(loanId, { document: "1000000001" });
console.log("Next payable:", plan.raw.next_payable_number);
for (const cuota of plan) console.log(cuota.number, cuota.scheduled_date, cuota.status, cuota.pending_amount);
const cuota4 = await client.loans.getInstallment(loanId, 4, { document: "1000000001" });
const desglose = await client.loans.installmentBreakdown(loanId, 4, { amount: "467009.88", document: "1000000001" });
for (const paso of desglose.waterfall as Array<Record<string, unknown>>) console.log(paso.concept, paso.amount);$plan = $client->loans->installments($loanId, document: '1000000001');
echo 'Next payable: ', $plan->raw['next_payable_number'] ?? '-', PHP_EOL;
foreach ($plan as $cuota) {
echo $cuota['number'], ' ', $cuota['scheduled_date'], ' ', $cuota['status'], ' ', $cuota['pending_amount'], PHP_EOL;
}
$cuota4 = $client->loans->getInstallment($loanId, 4, document: '1000000001');
$desglose = $client->loans->installmentBreakdown($loanId, 4, amount: '467009.88', document: '1000000001');
foreach ($desglose['waterfall'] as $paso) {
echo $paso['concept'], ': ', $paso['amount'], PHP_EOL;
}Page plan = await client.Loans.InstallmentsAsync(loanId, "1000000001");
Console.WriteLine($"Next payable: {plan.Raw["next_payable_number"]}");
foreach (var cuota in plan)
{
Console.WriteLine($"{cuota["number"]} {cuota["scheduled_date"]} {cuota["status"]} {cuota["pending_amount"]}");
}
var cuota4 = await client.Loans.GetInstallmentAsync(loanId, 4, "1000000001");
var desglose = await client.Loans.InstallmentBreakdownAsync(loanId, 4, "467009.88", "1000000001");
foreach (var paso in desglose["waterfall"]!.AsArray())
{
Console.WriteLine($"{paso!["concept"]}: {paso["amount"]}");
}plan, err := client.Loans.Installments(ctx, loanID, &compense.LoanInstallmentsParams{Document: "1000000001"})
if err != nil {
log.Fatal(err)
}
fmt.Println("Next payable:", plan.Raw["next_payable_number"])
for _, cuota := range plan.Results {
fmt.Println(cuota["number"], cuota["scheduled_date"], cuota["status"], cuota["pending_amount"])
}
desglose, err := client.Loans.InstallmentBreakdown(ctx, loanID, 4,
&compense.InstallmentBreakdownParams{Document: "1000000001", Amount: "467009.88"})
if err != nil {
log.Fatal(err)
}
fmt.Println(desglose["waterfall"])curl "https://sandbox.dev.compense.co/gw/v1/gateway/loans/$LOAN_ID/installments/?document=1000000001" \
-H "X-API-Key: $COMPENSE_API_KEY"
curl "https://sandbox.dev.compense.co/gw/v1/gateway/loans/$LOAN_ID/installments/4/breakdown/?amount=467009.88&document=1000000001" \
-H "X-API-Key: $COMPENSE_API_KEY"{
"installment": {"number": 4, "scheduled_date": "2026-10-15", "status": "PENDING",
"total_amount": "467009.88", "pending_amount": "467009.88"},
"payable": true,
"blocked_reason": "",
"amount_to_apply": "467009.88",
"waterfall": [
{"concept": "IVA", "amount": "0.00"},
{"concept": "Gastos de cobranza", "amount": "0.00"},
{"concept": "Seguro de vida", "amount": "0.00"},
{"concept": "Interés de mora", "amount": "0.00"},
{"concept": "Interés corriente", "amount": "69272.81"},
{"concept": "Capital", "amount": "397737.07"}
],
"applied_installments": [{"number": 4, "amount": "467009.88"}],
"result_after_payment": {"outstanding_balance_before": "3848489.67",
"outstanding_balance_after": "3450752.60", "loan_status_after": "ACTIVE"}
}3. Payment history #
GET/v1/gateway/loans/{loan_id}/payments/
lists the loan’s receipts: channel, reference, amount, applied amount,
value date and status. This is what you reconcile against your collections.
historial = client.loans.payments(loan_id, document="1000000001")
for rec in historial["results"]:
print(rec["value_date"], rec["channel"], rec["reference"], rec["amount"], rec["status"])const historial = await client.loans.payments(loanId, { document: "1000000001" });
for (const rec of historial.results as Array<Record<string, unknown>>) {
console.log(rec.value_date, rec.channel, rec.reference, rec.amount, rec.status);
}$historial = $client->loans->payments($loanId, document: '1000000001');
foreach ($historial['results'] as $rec) {
echo $rec['value_date'], ' ', $rec['channel'], ' ', $rec['reference'], ' ', $rec['amount'], PHP_EOL;
}var historial = await client.Loans.PaymentsAsync(loanId, "1000000001");
foreach (var rec in historial["results"]!.AsArray())
{
Console.WriteLine($"{rec!["value_date"]} {rec["channel"]} {rec["reference"]} {rec["amount"]}");
}historial, err := client.Loans.Payments(ctx, loanID, &compense.LoanParams{Document: "1000000001"})
if err != nil {
log.Fatal(err)
}
fmt.Println(historial["count"], historial["results"])curl "https://sandbox.dev.compense.co/gw/v1/gateway/loans/$LOAN_ID/payments/?document=1000000001" \
-H "X-API-Key: $COMPENSE_API_KEY"4. Recording a collection #
POST/v1/gateway/loans/{loan_id}/payments/
applies a payment your entity has already received (cash desk,
payroll, bank) using the same priority as the affiliate app, installment by installment, and leaves a
receipt, a transaction and an audit trail.
| Field | Required | Notes |
|---|---|---|
amount | yes | COP as a decimal string; greater than zero and no more than what can be applied today. |
reference | yes | Voucher or collection number (max. 100 characters). It’s the receipt’s key. |
channel | no (MANUAL) | CASH (cash desk), ACH_LIBRANZA (payroll or libranza deduction), BANK_FILE (bank collection) or MANUAL. Aliases such as TAQUILLA, NOMINA, LIBRANZA, TRANSFER, BANCO, CHEQUE, OTRO are accepted. |
value_date | no (today) | Value date AAAA-MM-DD; it can’t be in the future. Arrears are calculated as of that date. |
installment_number | no | The installment you want to pay first (you can’t skip an older overdue one). |
document | no | If present, the loan must belong to that affiliate. |
The reference is the receipt’s natural key #
The receipt is identified by loan + channel + reference:
- The same reference with the same amount returns the same receipt with
replayed: true: it isn’t applied twice and webhooks aren’t re-sent. You can safely reprocess an entire payroll file. - The same reference with a different amount returns
409 REFERENCE_REUSED: it’s almost always a typo or two vouchers with the same number. Don’t retry: look into it.
This key lives in the engine and is in addition to the Idempotency-Key at the edge: use both.
The reference survives even if the Idempotency-Key changes (for example, if you regenerate the
batch).
from compense_gateway import IdempotencyConflictError
try:
tx = client.loans.register_payment(
loan_id,
amount="467009.88",
reference="HORIZONTE-REC-2026-000981",
channel="CASH",
value_date="2026-11-02",
document="1000000001",
idempotency_key="horizonte-rec-2026-000981",
)
recibo = tx.data
print(recibo["receipt_id"], recibo["replayed"], recibo["allocation"], recibo["outstanding_principal_after"])
except IdempotencyConflictError as e:
if e.title == "Idempotency conflict":
pass # the original request is still in progress: retry later with the SAME key
else:
print("Reference reused with a different amount:", e.detail, e.trace_id) # REFERENCE_REUSEDimport { IdempotencyConflictError } from "@compense/sdk";
try {
const tx = await client.loans.registerPayment(loanId, {
amount: "467009.88",
reference: "HORIZONTE-REC-2026-000981",
channel: "CASH",
valueDate: "2026-11-02",
document: "1000000001",
idempotencyKey: "horizonte-rec-2026-000981",
});
console.log(tx.data.receipt_id, tx.data.replayed, tx.data.allocation, tx.data.outstanding_principal_after);
} catch (err) {
if (!(err instanceof IdempotencyConflictError)) throw err;
if (err.title === "Idempotency conflict") {
// the original is still in progress: retry later with the SAME key
} else {
console.log("Reference reused with a different amount:", err.detail, err.traceId);
}
}use Compense\Sdk\Exception\IdempotencyConflictException;
try {
$tx = $client->loans->registerPayment(
$loanId,
amount: '467009.88',
reference: 'HORIZONTE-REC-2026-000981',
channel: 'CASH',
valueDate: '2026-11-02',
document: '1000000001',
idempotencyKey: 'horizonte-rec-2026-000981',
);
echo $tx->data['receipt_id'], ' ', $tx->data['outstanding_principal_after'], PHP_EOL;
} catch (IdempotencyConflictException $e) {
if ($e->title !== 'Idempotency conflict') {
echo 'Reference reused with a different amount: ', $e->detail, ' ', $e->traceId, PHP_EOL;
}
}try
{
Transaction tx = await client.Loans.RegisterPaymentAsync(
loanId,
amount: "467009.88",
reference: "HORIZONTE-REC-2026-000981",
channel: "CASH",
valueDate: "2026-11-02",
document: "1000000001",
idempotencyKey: "horizonte-rec-2026-000981");
Console.WriteLine($"{tx.Data["receipt_id"]} {tx.Data["replayed"]} {tx.Data["outstanding_principal_after"]}");
}
catch (IdempotencyConflictException e) when (e.Title != "Idempotency conflict")
{
Console.WriteLine($"Reference reused with a different amount: {e.Detail} {e.TraceId}");
}tx, err := client.Loans.RegisterPayment(ctx, loanID, &compense.LoanPaymentParams{
Amount: "467009.88",
Reference: "HORIZONTE-REC-2026-000981",
Channel: "CASH",
ValueDate: "2026-11-02",
Document: "1000000001",
}, compense.WithIdempotencyKey("horizonte-rec-2026-000981"))
var apiErr *compense.APIError
switch {
case errors.As(err, &apiErr) && apiErr.StatusCode == 409 && apiErr.Title != "Idempotency conflict":
fmt.Println("Reference reused with a different amount:", apiErr.Detail, apiErr.TraceID)
case err != nil:
log.Fatal(err)
default:
fmt.Println(tx.Data["receipt_id"], tx.Data["replayed"], tx.Data["outstanding_principal_after"])
}curl -X POST https://sandbox.dev.compense.co/gw/v1/gateway/loans/$LOAN_ID/payments/ \
-H "X-API-Key: $COMPENSE_API_KEY" \
-H "Idempotency-Key: horizonte-rec-2026-000981" \
-H "Content-Type: application/json" \
-d '{"document": "1000000001", "amount": "467009.88", "channel": "CASH",
"reference": "HORIZONTE-REC-2026-000981", "value_date": "2026-11-02"}'{
"status": "success",
"transaction_id": "e3a9b1c2-…",
"module": "loans",
"data": {
"payment_id": "pay-E-e1e014410c50", "receipt_id": "rcp-E-e1e014410c50",
"kind": "installment", "status": "APPLIED", "replayed": false,
"loan_id": "lna-E-995d6ed1a308", "loan_number": "SBX-995D6ED1",
"channel": "CASH", "reference": "HORIZONTE-REC-2026-000981", "value_date": "2026-11-02",
"amount": "467009.88", "applied_amount": "467009.88", "currency": "COP",
"allocation": {"iva": "0.00", "collection_fees": "0.00", "insurance": "0.00",
"overdue_interest": "0.00", "current_interest": "69272.81", "principal": "397737.07"},
"applied_installments": [{"number": 4, "amount": "467009.88", "principal": "397737.07", "status": "PAID"}],
"outstanding_principal_after": "3450752.60",
"loan_status_after": "ACTIVE",
"paz_y_salvo": false
}
}If the amount exceeds what can be applied today, the response is 422 AMOUNT_EXCEEDS_DEBT (quote the
full payoff to find the cap). The Loan collection recipe ties all of
this together with the cash desk of a family compensation fund (caja de compensación).
5. Extra payment #
First you simulate (
GET/v1/gateway/loans/{loan_id}/extra-payment/quote/
, amount required) and then you
record (
POST/v1/gateway/loans/{loan_id}/extra-payment/
). The extra payment first covers overdue
installments (in full) and the rest goes to principal, without charging interest on future
installments. The mode indicates what to do with the principal paid down:
mode | Intended effect |
|---|---|
reduce_term (default) | Keep the installment amount and shorten the term. |
reduce_installment | Keep the term and lower the installment amount. |
The quote returns the allocation (allocation), the resulting balance, max_payable,
exceeds_debt, allowed_modes and one scenario per mode (scenarios.reduce_term and
scenarios.reduce_installment).
The schedule isn't regenerated yet
The mode is recorded on the receipt, but the engine doesn’t yet recalculate the installment schedule
after an extra payment (same behavior as an extra payment from the app): principal goes down and
subsequent installments are covered with it. Keep in mind that the quote’s scenarios are indicative.
cot = client.loans.quote_extra_payment(loan_id, amount="1000000", document="1000000001")
print(cot["allocation"], cot["outstanding_principal_after"], cot["allowed_modes"])
tx = client.loans.register_extra_payment(
loan_id,
amount="1000000",
reference="HORIZONTE-ABONO-2026-000077",
mode="reduce_term",
channel="BANK_FILE",
document="1000000001",
idempotency_key="horizonte-abono-2026-000077",
)
print(tx.data["kind"], tx.data["mode"], tx.data["outstanding_principal_after"])const cot = await client.loans.quoteExtraPayment(loanId, { amount: "1000000", document: "1000000001" });
console.log(cot.allocation, cot.outstanding_principal_after, cot.allowed_modes);
const tx = await client.loans.registerExtraPayment(loanId, {
amount: "1000000",
reference: "HORIZONTE-ABONO-2026-000077",
mode: "reduce_term",
channel: "BANK_FILE",
document: "1000000001",
idempotencyKey: "horizonte-abono-2026-000077",
});
console.log(tx.data.kind, tx.data.mode, tx.data.outstanding_principal_after);$cot = $client->loans->quoteExtraPayment($loanId, amount: '1000000', document: '1000000001');
echo $cot['outstanding_principal_after'], PHP_EOL;
$tx = $client->loans->registerExtraPayment(
$loanId,
amount: '1000000',
reference: 'HORIZONTE-ABONO-2026-000077',
mode: 'reduce_term',
channel: 'BANK_FILE',
document: '1000000001',
idempotencyKey: 'horizonte-abono-2026-000077',
);
echo $tx->data['kind'], ' ', $tx->data['mode'], ' ', $tx->data['outstanding_principal_after'], PHP_EOL;var cot = await client.Loans.QuoteExtraPaymentAsync(loanId, "1000000", "1000000001");
Console.WriteLine($"{cot["outstanding_principal_after"]} {cot["allowed_modes"]}");
Transaction tx = await client.Loans.RegisterExtraPaymentAsync(
loanId,
amount: "1000000",
reference: "HORIZONTE-ABONO-2026-000077",
mode: "reduce_term",
channel: "BANK_FILE",
document: "1000000001",
idempotencyKey: "horizonte-abono-2026-000077");
Console.WriteLine($"{tx.Data["kind"]} {tx.Data["mode"]} {tx.Data["outstanding_principal_after"]}");cot, err := client.Loans.QuoteExtraPayment(ctx, loanID,
&compense.ExtraPaymentQuoteParams{Amount: "1000000", Document: "1000000001"})
if err != nil {
log.Fatal(err)
}
fmt.Println(cot["allocation"], cot["outstanding_principal_after"], cot["allowed_modes"])
tx, err := client.Loans.RegisterExtraPayment(ctx, loanID, &compense.ExtraPaymentParams{
Amount: "1000000",
Reference: "HORIZONTE-ABONO-2026-000077",
Mode: "reduce_term",
Channel: "BANK_FILE",
Document: "1000000001",
}, compense.WithIdempotencyKey("horizonte-abono-2026-000077"))
if err != nil {
log.Fatal(err)
}
fmt.Println(tx.Data["kind"], tx.Data["mode"], tx.Data["outstanding_principal_after"])curl "https://sandbox.dev.compense.co/gw/v1/gateway/loans/$LOAN_ID/extra-payment/quote/?amount=1000000&document=1000000001" \
-H "X-API-Key: $COMPENSE_API_KEY"
curl -X POST https://sandbox.dev.compense.co/gw/v1/gateway/loans/$LOAN_ID/extra-payment/ \
-H "X-API-Key: $COMPENSE_API_KEY" \
-H "Idempotency-Key: horizonte-abono-2026-000077" \
-H "Content-Type: application/json" \
-d '{"document": "1000000001", "amount": "1000000", "channel": "BANK_FILE",
"reference": "HORIZONTE-ABONO-2026-000077", "mode": "reduce_term"}'A mode other than the two valid ones returns 422 INVALID_MODE.
6. Early full payoff #
Quote (
GET/v1/gateway/loans/{loan_id}/payoff/quote/
) and record (
POST/v1/gateway/loans/{loan_id}/payoff/
). The quote returns
payoff_amount_today (outstanding principal + accrued interest + arrears + insurance + fees), its
breakdown and how long it’s valid. When recording, amount is optional; if you send it, it must
be exactly payoff_amount_today (otherwise, 422 PAYOFF_AMOUNT_MISMATCH with the correct value in
the message). The value date of a full payoff is always today, and the loan ends up PAID_OFF.
cot = client.loans.quote_payoff(loan_id, document="1000000001")
print(cot["payoff_amount_today"], cot["breakdown"])
tx = client.loans.register_payoff(
loan_id,
reference="HORIZONTE-PAZ-2026-000012",
amount=cot["payoff_amount_today"],
channel="CASH",
document="1000000001",
idempotency_key="horizonte-paz-2026-000012",
)
print(tx.data["loan_status_after"], tx.data["paz_y_salvo"]) # PAID_OFF Trueconst cot = await client.loans.quotePayoff(loanId, { document: "1000000001" });
console.log(cot.payoff_amount_today, cot.breakdown);
const tx = await client.loans.registerPayoff(loanId, {
reference: "HORIZONTE-PAZ-2026-000012",
amount: String(cot.payoff_amount_today),
channel: "CASH",
document: "1000000001",
idempotencyKey: "horizonte-paz-2026-000012",
});
console.log(tx.data.loan_status_after, tx.data.paz_y_salvo);$cot = $client->loans->quotePayoff($loanId, document: '1000000001');
$tx = $client->loans->registerPayoff(
$loanId,
reference: 'HORIZONTE-PAZ-2026-000012',
amount: $cot['payoff_amount_today'],
channel: 'CASH',
document: '1000000001',
idempotencyKey: 'horizonte-paz-2026-000012',
);
echo $tx->data['loan_status_after'], ' ', var_export($tx->data['paz_y_salvo'], true), PHP_EOL;var cot = await client.Loans.QuotePayoffAsync(loanId, "1000000001");
string total = cot["payoff_amount_today"]!.GetValue<string>();
Transaction tx = await client.Loans.RegisterPayoffAsync(
loanId,
reference: "HORIZONTE-PAZ-2026-000012",
amount: total,
channel: "CASH",
document: "1000000001",
idempotencyKey: "horizonte-paz-2026-000012");
Console.WriteLine($"{tx.Data["loan_status_after"]} {tx.Data["paz_y_salvo"]}");cot, err := client.Loans.QuotePayoff(ctx, loanID, &compense.LoanParams{Document: "1000000001"})
if err != nil {
log.Fatal(err)
}
tx, err := client.Loans.RegisterPayoff(ctx, loanID, &compense.PayoffParams{
Reference: "HORIZONTE-PAZ-2026-000012",
Amount: compense.Amount(fmt.Sprint(cot["payoff_amount_today"])),
Channel: "CASH",
Document: "1000000001",
}, compense.WithIdempotencyKey("horizonte-paz-2026-000012"))
if err != nil {
log.Fatal(err)
}
fmt.Println(tx.Data["loan_status_after"], tx.Data["paz_y_salvo"])curl "https://sandbox.dev.compense.co/gw/v1/gateway/loans/$LOAN_ID/payoff/quote/?document=1000000001" \
-H "X-API-Key: $COMPENSE_API_KEY"
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: horizonte-paz-2026-000012" \
-H "Content-Type: application/json" \
-d '{"document": "1000000001", "amount": "3848489.67", "channel": "CASH",
"reference": "HORIZONTE-PAZ-2026-000012"}'7. Debt certificate and paz y salvo #
GET/v1/gateway/loans/{loan_id}/certificate/
certifies the debt as of a date (as_of, today by default):
outstanding principal, accrued interest, total debt, paid and pending installments, status and
paz_y_salvo (true when the loan is PAID_OFF with no balance). With pdf=true it also generates
the PDF (the same one the affiliate sees in the app) and returns a signed, temporary pdf_url;
download it and file it in your document management system. If storage fails, the data is still valid
and you get pdf_error instead of pdf_url.
cert = client.loans.certificate(loan_id, document="1000000001", as_of="2026-11-02", pdf=True)
print(cert["total_debt"], cert["paz_y_salvo"], cert.get("pdf_url") or cert.get("pdf_error"))const cert = await client.loans.certificate(loanId, { document: "1000000001", asOf: "2026-11-02", pdf: true });
console.log(cert.total_debt, cert.paz_y_salvo, cert.pdf_url ?? cert.pdf_error);$cert = $client->loans->certificate($loanId, document: '1000000001', asOf: '2026-11-02', pdf: true);
echo $cert['total_debt'], ' ', var_export($cert['paz_y_salvo'], true), ' ', $cert['pdf_url'] ?? '-', PHP_EOL;var cert = await client.Loans.CertificateAsync(loanId, "1000000001", "2026-11-02", pdf: true);
Console.WriteLine($"{cert["total_debt"]} {cert["paz_y_salvo"]} {cert["pdf_url"]}");cert, err := client.Loans.Certificate(ctx, loanID, &compense.CertificateParams{
Document: "1000000001", AsOf: "2026-11-02", PDF: compense.Bool(true),
})
if err != nil {
log.Fatal(err)
}
fmt.Println(cert["total_debt"], cert["paz_y_salvo"], cert["pdf_url"])curl "https://sandbox.dev.compense.co/gw/v1/gateway/loans/$LOAN_ID/certificate/?document=1000000001&as_of=2026-11-02&pdf=true" \
-H "X-API-Key: $COMPENSE_API_KEY"There’s no separate paz y salvo document yet: through the API, the paz y salvo is this certificate
with paz_y_salvo: true (and its PDF).
Errors #
All error responses are application/problem+json with a trace_id; detail carries the engine’s
message in Spanish. See Errors.
| HTTP | Engine code | When |
|---|---|---|
403 | MODULE_NOT_CONTRACTED | The entity doesn’t have the credit module. |
404 | — | The loan doesn’t exist, or doesn’t belong to the document sent; affiliate not registered (AFFILIATE_NOT_FOUND). |
409 | REFERENCE_REUSED | Same reference (on the same loan and channel) with a different amount. |
409 | — | Same Idempotency-Key while the original request is still in progress (title: Idempotency conflict). |
422 | DOCUMENT_REQUIRED | GET /loans/ without document. |
422 | INVALID_AMOUNT, INVALID_CHANNEL, REFERENCE_REQUIRED, REFERENCE_TOO_LONG, INVALID_DATE, INVALID_INSTALLMENT, INVALID_MODE | Body validation. |
422 | AMOUNT_EXCEEDS_DEBT | The amount exceeds what can be applied today. |
422 | PAYOFF_AMOUNT_MISMATCH | The payoff amount isn’t today’s: quote again. |
422 | LOAN_ALREADY_PAID, LOAN_NOT_COLLECTIBLE | Loan paid off or in a status that doesn’t accept collections (e.g. written off). |
422 | OLDER_INSTALLMENT_OVERDUE, INSTALLMENT_NOT_PAYABLE, INSTALLMENT_NOT_FOUND | The installment_number can’t be paid. |
429 | — | The API key’s per-minute limit or monthly quota. |
502 / 504 | — | The engine failed or didn’t respond: retry with the same Idempotency-Key. |
In the SDKs, 409s arrive as the conflict exception (IdempotencyConflictError,
IdempotencyConflictException, compense.IsIdempotencyConflict(err)): tell REFERENCE_REUSED apart
by the title (Request rejected by engine) and the detail.
Webhooks #
They all arrive with the envelope {event, version, tenant_id, occurred_at, resource} and are written
in the same transaction as the change: there’s never a webhook for a payment that didn’t go through.
Loan portfolio (payments and arrears) #
| Event | Since | When it's emitted |
|---|---|---|
payment.received | 2026-10-27 | A loan payment (installment, extra payment or full payoff) was recorded with its channel and reference. |
payment.applied | 2026-10-27 | The payment was allocated by priority order (principal, interest, late interest, insurance, taxes). |
installment.overdue | 2026-10-27 | An installment became overdue (emitted once per installment, with days_overdue). |
loan.paid_off | 2026-10-27 | The loan balance reached zero (paid in full). |
| Event | resource |
|---|---|
payment.received | credit_id, loan_number, payment_id, receipt_id, affiliate_id, kind, channel, reference, amount, value_date, received_at |
payment.applied | applied_to_capital, applied_to_interest, applied_to_penalties, applied_to_insurance, applied_to_taxes, installments[], outstanding_principal_after, loan_status_after |
loan.paid_off | credit_id, loan_number, affiliate_id, last_payment_id, channel, paid_off_at |
installment.overdue | credit_id, loan_number, affiliate_id, installment_number, amount, due_date, days_overdue |
A recorded collection emits payment.received and payment.applied (and loan.paid_off if it
brings the loan to zero). installment.overdue is produced by the daily arrears calculation, once per
installment. The event_id is deterministic per receipt or per installment: a retry never produces a
second webhook for the same fact. Use resource.reference to match the event with your voucher.
Sandbox #
With a sandbox API key, responses are simulated and deterministic (they never touch the real engine):
| Document | Simulated loan |
|---|---|
1000000001 | 1 ACTIVE loan, up to date (installments 1 to 3 paid). |
1000000003 | 1 IN_ARREARS loan: installment 4 is 12 days overdue. |
1000000004 | 1 DEFAULTED loan: installments 3 to 5 overdue (95 days). |
1000000002 | No loans (empty list). |
9999999999 | 404 (affiliate doesn’t exist). |
- The simulated loan is 5,000,000 COP over 12 months, with an installment of 467,009.88. Its
loan_id(lna-…) and number (SBX-…) come fromGET /loans/?document=…. - Writes validate like the engine (amount, reference, channel, debt cap, payoff amount) and return the
same format, but don’t change what reads return: the sandbox doesn’t keep state. The same
reference returns the same
receipt_id; that’s whyREFERENCE_REUSEDcan’t be reproduced in the sandbox. pdf_urlcomes back asnullin the sandbox. See Test data and, to move arrears forward in time, Test clocks.