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 #

ScopeUsed for
loans:readLoans, details, installment schedule, single installment and breakdown, payment history, quotes and certificate.
loans:collectRecording 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 on GET /loans/ (422 DOCUMENT_REQUIRED if missing).
  • loan_id: the loan’s UUID or its number (loan_number, e.g. CRD-00123).
  • On /loans/{loan_id}/... routes, document is optional, but if you send it the loan must belong to that affiliate; if it doesn’t, the response is 404 (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 #

OperationPythonTypeScriptPHP.NETGo
GET/v1/gateway/loans/listlistlistListAsyncList
GET/v1/gateway/loans/{loan_id}/getgetgetGetAsyncGet
GET/v1/gateway/loans/{loan_id}/installments/installmentsinstallmentsinstallmentsInstallmentsAsyncInstallments
GET/v1/gateway/loans/{loan_id}/installments/{number}/get_installmentgetInstallmentgetInstallmentGetInstallmentAsyncGetInstallment
GET/v1/gateway/loans/{loan_id}/installments/{number}/breakdown/installment_breakdowninstallmentBreakdowninstallmentBreakdownInstallmentBreakdownAsyncInstallmentBreakdown
GET/v1/gateway/loans/{loan_id}/payments/paymentspaymentspaymentsPaymentsAsyncPayments
POST/v1/gateway/loans/{loan_id}/payments/register_paymentregisterPaymentregisterPaymentRegisterPaymentAsyncRegisterPayment
GET/v1/gateway/loans/{loan_id}/extra-payment/quote/quote_extra_paymentquoteExtraPaymentquoteExtraPaymentQuoteExtraPaymentAsyncQuoteExtraPayment
POST/v1/gateway/loans/{loan_id}/extra-payment/register_extra_paymentregisterExtraPaymentregisterExtraPaymentRegisterExtraPaymentAsyncRegisterExtraPayment
GET/v1/gateway/loans/{loan_id}/payoff/quote/quote_payoffquotePayoffquotePayoffQuotePayoffAsyncQuotePayoff
POST/v1/gateway/loans/{loan_id}/payoff/register_payoffregisterPayoffregisterPayoffRegisterPayoffAsyncRegisterPayoff
GET/v1/gateway/loans/{loan_id}/certificate/certificatecertificatecertificateCertificateAsyncCertificate

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"])
JSON
{
  "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"])
JSON
{
  "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"])

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.

FieldRequiredNotes
amountyesCOP as a decimal string; greater than zero and no more than what can be applied today.
referenceyesVoucher or collection number (max. 100 characters). It’s the receipt’s key.
channelno (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_dateno (today)Value date AAAA-MM-DD; it can’t be in the future. Arrears are calculated as of that date.
installment_numbernoThe installment you want to pay first (you can’t skip an older overdue one).
documentnoIf 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_REUSED
JSON
{
  "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:

modeIntended effect
reduce_term (default)Keep the installment amount and shorten the term.
reduce_installmentKeep 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"])

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 True

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"))

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.

HTTPEngine codeWhen
403MODULE_NOT_CONTRACTEDThe 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).
409REFERENCE_REUSEDSame 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).
422DOCUMENT_REQUIREDGET /loans/ without document.
422INVALID_AMOUNT, INVALID_CHANNEL, REFERENCE_REQUIRED, REFERENCE_TOO_LONG, INVALID_DATE, INVALID_INSTALLMENT, INVALID_MODEBody validation.
422AMOUNT_EXCEEDS_DEBTThe amount exceeds what can be applied today.
422PAYOFF_AMOUNT_MISMATCHThe payoff amount isn’t today’s: quote again.
422LOAN_ALREADY_PAID, LOAN_NOT_COLLECTIBLELoan paid off or in a status that doesn’t accept collections (e.g. written off).
422OLDER_INSTALLMENT_OVERDUE, INSTALLMENT_NOT_PAYABLE, INSTALLMENT_NOT_FOUNDThe 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) #

EventSinceWhen it's emitted
payment.received2026-10-27A loan payment (installment, extra payment or full payoff) was recorded with its channel and reference.
payment.applied2026-10-27The payment was allocated by priority order (principal, interest, late interest, insurance, taxes).
installment.overdue2026-10-27An installment became overdue (emitted once per installment, with days_overdue).
loan.paid_off2026-10-27The loan balance reached zero (paid in full).
Eventresource
payment.receivedcredit_id, loan_number, payment_id, receipt_id, affiliate_id, kind, channel, reference, amount, value_date, received_at
payment.appliedapplied_to_capital, applied_to_interest, applied_to_penalties, applied_to_insurance, applied_to_taxes, installments[], outstanding_principal_after, loan_status_after
loan.paid_offcredit_id, loan_number, affiliate_id, last_payment_id, channel, paid_off_at
installment.overduecredit_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):

DocumentSimulated loan
10000000011 ACTIVE loan, up to date (installments 1 to 3 paid).
10000000031 IN_ARREARS loan: installment 4 is 12 days overdue.
10000000041 DEFAULTED loan: installments 3 to 5 overdue (95 days).
1000000002No loans (empty list).
9999999999404 (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 from GET /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 why REFERENCE_REUSED can’t be reproduced in the sandbox.
  • pdf_url comes back as null in the sandbox. See Test data and, to move arrears forward in time, Test clocks.

What’s next #