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 #

ScopePara qué
loans:readPréstamos, detalle, plan de cuotas, cuota y desglose, historial de pagos, cotizaciones y certificado.
loans:collectRegistrar 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. En GET /loans/ es obligatorio (422 DOCUMENT_REQUIRED si falta).
  • loan_id: el UUID del préstamo o su número (loan_number, p. ej. CRD-00123).
  • En las rutas /loans/{loan_id}/... el document es opcional, pero si lo envías el préstamo debe ser de ese afiliado; si no lo es, la respuesta es 404 (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ónPythonTypeScriptPHP.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

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"])
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***"}
  }]
}

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

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.

CampoObligatorioNotas
amountsíCOP como texto decimal; mayor que cero y no mayor a lo que se puede aplicar hoy.
referencesíNúmero del comprobante o del recaudo (máx. 100 caracteres). Es la llave del recibo.
channelno (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_dateno (hoy)Fecha valor AAAA-MM-DD; no puede ser futura. La mora se calcula a esa fecha.
installment_numbernoCuota que quieres pagar primero (no puedes saltarte una más antigua en mora).
documentnoSi 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_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
  }
}

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:

modeEfecto buscado
reduce_term (por defecto)Mantener el valor de la cuota y acortar el plazo.
reduce_installmentMantener 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"])

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 True

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

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.

HTTPCódigo del motorCuándo
403MODULE_NOT_CONTRACTEDLa 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).
409REFERENCE_REUSEDMisma 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).
422DOCUMENT_REQUIREDGET /loans/ sin document.
422INVALID_AMOUNT, INVALID_CHANNEL, REFERENCE_REQUIRED, REFERENCE_TOO_LONG, INVALID_DATE, INVALID_INSTALLMENT, INVALID_MODEValidación del cuerpo.
422AMOUNT_EXCEEDS_DEBTEl monto supera lo aplicable hoy.
422PAYOFF_AMOUNT_MISMATCHEl monto del pago total no es el de hoy: vuelve a cotizar.
422LOAN_ALREADY_PAID, LOAN_NOT_COLLECTIBLECrédito saldado o en un estado que no admite recaudo (p. ej. castigado).
422OLDER_INSTALLMENT_OVERDUE, INSTALLMENT_NOT_PAYABLE, INSTALLMENT_NOT_FOUNDEl 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) #

EventoDesdeCuándo se emite
payment.received2026-10-27Se registró un pago del crédito (cuota, abono o pago total) con su canal y reference.
payment.applied2026-10-27El pago se distribuyó por la prelación (capital, intereses, mora, seguros, impuestos).
installment.overdue2026-10-27Una cuota pasó a mora (se emite una vez por cuota, con days_overdue).
loan.paid_off2026-10-27El crédito quedó en cero (paz y salvo).
Eventoresource
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

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

DocumentoPréstamo simulado
10000000011 préstamo ACTIVE al día (cuotas 1 a 3 pagadas).
10000000031 préstamo IN_ARREARS: la cuota 4 lleva 12 días en mora.
10000000041 préstamo DEFAULTED: cuotas 3 a 5 en mora (95 días).
1000000002Sin préstamos (lista vacía).
9999999999404 (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 de GET /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 eso REFERENCE_REUSED no se puede reproducir en el sandbox.
  • pdf_url llega en null en el sandbox. Ver Datos de prueba y, para hacer avanzar la mora en el tiempo, Relojes de prueba.

Lo que sigue #