Guía de implementación
Cartera
La vida del crédito desembolsado por API: préstamos del afiliado, plan de cuotas y desglose por prelación, historial de pagos, registro de recaudos con referencia única (REFERENCE_REUSED), abonos extraordinarios, pago total y certificado o paz y salvo en PDF.
En esta página
El módulo loans empieza donde termina Créditos: una vez desembolsado, el
préstamo tiene un plan de cuotas, intereses, mora y saldo. Desde tu sistema puedes consultar
todo eso (para tu oficina virtual, tu call center o tu ERP) y reportar los recaudos que tu
entidad recibe por fuera de la plataforma: la taquilla, el descuento de nómina o libranza, el
archivo del banco, un abono extraordinario o el pago total.
Requisito: tu entidad debe tener contratado el módulo de créditos. Si no, toda ruta de cartera
responde 403 (MODULE_NOT_CONTRACTED).
Scopes #
| Scope | Para qué |
|---|---|
loans:read | Préstamos, detalle, plan de cuotas, cuota y desglose, historial de pagos, cotizaciones y certificado. |
loans:collect | Registrar pagos, abonos extraordinarios y pagos totales: mueve saldos. |
Pide llaves separadas para consultar y para recaudar. loans:collect admite además la firma HMAC
opcional X-Compense-Signature cuando tu llave la tiene activa (los SDK no la implementan; el
esquema está en Créditos).
Cómo se identifican el afiliado y el préstamo #
document: el documento del titular. En las lecturas va como parámetro de consulta; en las escrituras, en el cuerpo. EnGET /loans/es obligatorio (422 DOCUMENT_REQUIREDsi falta).loan_id: el UUID del préstamo o su número (loan_number, p. ej.CRD-00123).- En las rutas
/loans/{loan_id}/...eldocumentes opcional, pero si lo envías el préstamo debe ser de ese afiliado; si no lo es, la respuesta es404(no se revela de quién es). Envíalo siempre: es tu control contra cruzar préstamos de dos afiliados. - Los nombres del titular salen enmascarados (
L*** P***) y nunca se devuelve el correo.
Operaciones #
| Operación | 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 |
Todos cuelgan de client.loans ($client->loans, client.Loans). Las lecturas devuelven el cuerpo
del motor tal cual; las escrituras, el sobre estándar con module: "loans" y el recibo en data.
Estados del préstamo y de las cuotas #
stateDiagram-v2 [*] --> DISBURSED: desembolso DISBURSED --> ACTIVE ACTIVE --> IN_ARREARS: cuota vencida IN_ARREARS --> ACTIVE: se pone al día IN_ARREARS --> DEFAULTED: mora prolongada DEFAULTED --> IN_RECOVERY: cobranza ACTIVE --> RESTRUCTURED: cambio de condiciones RESTRUCTURED --> ACTIVE DEFAULTED --> WRITTEN_OFF: castigo ACTIVE --> PAID_OFF: saldo en cero IN_ARREARS --> PAID_OFF: pago total PAID_OFF --> [*] WRITTEN_OFF --> [*]
ACTIVE como filtro de GET /loans/?status=ACTIVE significa “cartera viva” (todo lo que no está
saldado ni castigado); cualquier otro valor filtra por ese estado exacto. Cada cuota del plan está en
PENDING, PARTIAL, OVERDUE, PAID o CANCELLED. Un préstamo PAID_OFF o WRITTEN_OFF no
admite recaudo (422 LOAN_ALREADY_PAID / LOAN_NOT_COLLECTIBLE).
1. Préstamos del afiliado y detalle #
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 (o usa el 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***"}
}]
}El detalle agrega payoff_amount_today (lo que costaría saldarlo hoy) y paz_y_salvo.
2. Plan de cuotas, una cuota y su desglose #
El plan (
GET/v1/gateway/loans/{loan_id}/installments/
) trae cada cuota con lo pendiente y, en next_payable_number,
la primera cuota que se puede pagar. El desglose (
GET/v1/gateway/loans/{loan_id}/installments/{number}/breakdown/
) responde
cómo se aplicaría un pago a esa cuota según la prelación legal:
IVA → Gastos de cobranza → Seguro → Interés de mora → Interés corriente → Capital
Sin amount, el desglose muestra lo pendiente de la cuota. Si hay una cuota más antigua en mora,
las siguientes salen con payable: false y un blocked_reason (“Primero paga la cuota 3, que está
en mora.”): no se puede saltar una cuota vencida.
plan = client.loans.installments(loan_id, document="1000000001")
print("Próxima pagable:", 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("Próxima pagable:", 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 'Próxima pagable: ', $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($"Próxima pagable: {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("Próxima pagable:", 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. Historial de pagos #
GET/v1/gateway/loans/{loan_id}/payments/
lista los recibos del préstamo: canal, referencia, monto, monto
aplicado, fecha valor y estado. Es lo que concilias contra tu recaudo.
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. Registrar un recaudo #
POST/v1/gateway/loans/{loan_id}/payments/
aplica un pago que tu entidad ya recibió (taquilla, nómina,
banco) con la misma prelación que usa la app del afiliado, cuota por cuota, y deja recibo,
transacción y auditoría.
| Campo | Obligatorio | Notas |
|---|---|---|
amount | sí | COP como texto decimal; mayor que cero y no mayor a lo que se puede aplicar hoy. |
reference | sí | Número del comprobante o del recaudo (máx. 100 caracteres). Es la llave del recibo. |
channel | no (MANUAL) | CASH (taquilla), ACH_LIBRANZA (nómina o libranza), BANK_FILE (recaudo bancario) o MANUAL. Se aceptan alias como TAQUILLA, NOMINA, LIBRANZA, TRANSFER, BANCO, CHEQUE, OTRO. |
value_date | no (hoy) | Fecha valor AAAA-MM-DD; no puede ser futura. La mora se calcula a esa fecha. |
installment_number | no | Cuota que quieres pagar primero (no puedes saltarte una más antigua en mora). |
document | no | Si viene, el préstamo debe ser de ese afiliado. |
La referencia es la llave natural del recibo #
El recibo se identifica por préstamo + canal + referencia:
- La misma referencia con el mismo monto devuelve el mismo recibo con
replayed: true: no se aplica dos veces ni se re-emiten webhooks. Puedes reprocesar un archivo de nómina completo sin miedo. - La misma referencia con otro monto responde
409 REFERENCE_REUSED: es casi siempre un error de digitación o dos comprobantes con el mismo número. No reintentes: revísalo.
Esta llave vive en el motor y es adicional a la Idempotency-Key del borde: usa las dos. La
referencia sobrevive aunque cambie la Idempotency-Key (por ejemplo, si regeneras el lote).
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 # la petición original sigue en proceso: reintenta luego con la MISMA llave
else:
print("Referencia reutilizada con otro monto:", 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") {
// la original sigue en proceso: reintenta luego con la MISMA llave
} else {
console.log("Referencia reutilizada con otro monto:", 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 'Referencia reutilizada con otro monto: ', $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($"Referencia reutilizada con otro monto: {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("Referencia reutilizada con otro monto:", 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
}
}Si el monto supera lo aplicable hoy, la respuesta es 422 AMOUNT_EXCEEDS_DEBT (cotiza el pago total
para saber el tope). La receta Recaudo de cartera integra todo esto
con la taquilla de una caja.
5. Abono extraordinario #
Primero simulas (
GET/v1/gateway/loans/{loan_id}/extra-payment/quote/
, amount obligatorio) y luego registras
(
POST/v1/gateway/loans/{loan_id}/extra-payment/
). El abono cubre primero las cuotas en mora (completas) y el
resto va a capital, sin cobrar intereses de cuotas futuras. El modo indica qué hacer con el capital
abonado:
mode | Efecto buscado |
|---|---|
reduce_term (por defecto) | Mantener el valor de la cuota y acortar el plazo. |
reduce_installment | Mantener el plazo y bajar el valor de la cuota. |
La cotización devuelve la asignación (allocation), el saldo resultante, max_payable,
exceeds_debt, allowed_modes y un escenario por modo (scenarios.reduce_term y
scenarios.reduce_installment).
Hoy el plan no se regenera
El mode queda registrado en el recibo, pero el motor todavía no recalcula el plan de cuotas tras un
abono (es el mismo comportamiento del abono desde la app): el capital baja y las cuotas siguientes se
van cubriendo con él. Ten en cuenta que los escenarios de la cotización son orientativos.
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"}'Un mode distinto de los dos válidos responde 422 INVALID_MODE.
6. Pago total anticipado #
Cotiza (
GET/v1/gateway/loans/{loan_id}/payoff/quote/
) y registra (
POST/v1/gateway/loans/{loan_id}/payoff/
). La cotización
trae payoff_amount_today (capital vivo + intereses causados + mora + seguros + cargos), su
breakdown y hasta cuándo es válida. En el registro amount es opcional; si lo envías debe ser
exactamente payoff_amount_today (si no, 422 PAYOFF_AMOUNT_MISMATCH y el valor correcto en el
mensaje). La fecha valor del pago total es siempre hoy, y el préstamo queda 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. Certificado de deuda y paz y salvo #
GET/v1/gateway/loans/{loan_id}/certificate/
certifica la deuda a una fecha (as_of, por defecto hoy):
capital vivo, interés causado, deuda total, cuotas pagadas y pendientes, estado y paz_y_salvo
(true cuando el crédito está PAID_OFF y sin saldo). Con pdf=true genera además el PDF (el
mismo que ve el afiliado en la app) y devuelve un pdf_url firmado y temporal; descárgalo y
archívalo en tu gestor documental. Si el almacenamiento falla, los datos siguen siendo válidos y
llega pdf_error en lugar de 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"No existe todavía un documento de paz y salvo separado: por API, el paz y salvo es este certificado
con paz_y_salvo: true (y su PDF).
Errores #
Todas las respuestas de error son application/problem+json con trace_id; el detail trae el
mensaje del motor en español. Ver Errores.
| HTTP | Código del motor | Cuándo |
|---|---|---|
403 | MODULE_NOT_CONTRACTED | La entidad no tiene el módulo de créditos. |
404 | — | Préstamo inexistente, o no es del document enviado; afiliado sin registro (AFFILIATE_NOT_FOUND). |
409 | REFERENCE_REUSED | Misma referencia (en el mismo préstamo y canal) con otro monto. |
409 | — | Misma Idempotency-Key con la petición original aún en proceso (title: Idempotency conflict). |
422 | DOCUMENT_REQUIRED | GET /loans/ sin document. |
422 | INVALID_AMOUNT, INVALID_CHANNEL, REFERENCE_REQUIRED, REFERENCE_TOO_LONG, INVALID_DATE, INVALID_INSTALLMENT, INVALID_MODE | Validación del cuerpo. |
422 | AMOUNT_EXCEEDS_DEBT | El monto supera lo aplicable hoy. |
422 | PAYOFF_AMOUNT_MISMATCH | El monto del pago total no es el de hoy: vuelve a cotizar. |
422 | LOAN_ALREADY_PAID, LOAN_NOT_COLLECTIBLE | Crédito saldado o en un estado que no admite recaudo (p. ej. castigado). |
422 | OLDER_INSTALLMENT_OVERDUE, INSTALLMENT_NOT_PAYABLE, INSTALLMENT_NOT_FOUND | El installment_number no se puede pagar. |
429 | — | Límite por minuto o cuota mensual de la llave. |
502 / 504 | — | El motor falló o no respondió: reintenta con la misma Idempotency-Key. |
En los SDK, los 409 llegan como la excepción de conflicto (IdempotencyConflictError,
IdempotencyConflictException, compense.IsIdempotencyConflict(err)): distingue el
REFERENCE_REUSED por el title (Request rejected by engine) y el detail.
Webhooks #
Todos llegan con el sobre {event, version, tenant_id, occurred_at, resource} y se escriben en la
misma transacción que el cambio: no hay webhook de un pago que no quedó.
Cartera (pagos y mora) #
| Evento | Desde | Cuándo se emite |
|---|---|---|
payment.received | 2026-10-27 | Se registró un pago del crédito (cuota, abono o pago total) con su canal y reference. |
payment.applied | 2026-10-27 | El pago se distribuyó por la prelación (capital, intereses, mora, seguros, impuestos). |
installment.overdue | 2026-10-27 | Una cuota pasó a mora (se emite una vez por cuota, con days_overdue). |
loan.paid_off | 2026-10-27 | El crédito quedó en cero (paz y salvo). |
| Evento | 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 |
Un recaudo registrado emite payment.received y payment.applied (y loan.paid_off si deja el
crédito en cero). installment.overdue lo produce el cálculo diario de mora, una vez por cuota. El
event_id es determinista por recibo o por cuota: un reintento nunca produce un segundo webhook del
mismo hecho. Usa resource.reference para cruzar el evento con tu comprobante.
Sandbox #
Con una llave de sandbox las respuestas son simuladas y deterministas (nunca tocan el motor real):
| Documento | Préstamo simulado |
|---|---|
1000000001 | 1 préstamo ACTIVE al día (cuotas 1 a 3 pagadas). |
1000000003 | 1 préstamo IN_ARREARS: la cuota 4 lleva 12 días en mora. |
1000000004 | 1 préstamo DEFAULTED: cuotas 3 a 5 en mora (95 días). |
1000000002 | Sin préstamos (lista vacía). |
9999999999 | 404 (afiliado no existe). |
- El préstamo simulado es de 5.000.000 COP a 12 meses, con cuota de 467.009,88. Su
loan_id(lna-…) y su número (SBX-…) salen deGET /loans/?document=…. - Las escrituras validan como el motor (monto, referencia, canal, tope de deuda, monto del pago
total) y devuelven el mismo formato, pero no cambian lo que devuelven las lecturas: el sandbox
no guarda estado. La misma referencia devuelve el mismo
receipt_id; por esoREFERENCE_REUSEDno se puede reproducir en el sandbox. pdf_urlllega ennullen el sandbox. Ver Datos de prueba y, para hacer avanzar la mora en el tiempo, Relojes de prueba.