Guía de implementación
Créditos
Originación de crédito de punta a punta: radicar la solicitud, trabajar la bandeja, decidir (APPROVE/REJECT), desembolsar, seguir los estados y recibir los eventos credits.* por webhook. Con los perfiles deterministas del sandbox.
En esta página
El módulo de créditos expone el motor de originación de Compense (reglas, scoring y
desembolso) a tu sistema: radicas la solicitud de un afiliado, tu analista o el motor la decide y
la desembolsas. Cada paso devuelve un transaction_id rastreable y emite un webhook. Lo que pasa
después del desembolso (plan de pagos, recaudo, paz y salvo) vive en Cartera.
Antes de empezar, el afiliado debe existir en el registro de tu entidad: ver Afiliados.
Operaciones y scopes #
| Operación | Método SDK (Python) | Scope |
|---|---|---|
POST/v1/gateway/credits/application/ | client.credits.submit_application(...) | credits:submit |
GET/v1/gateway/credits/applications/ | client.credits.list_applications(...) | credits:read |
GET/v1/gateway/credits/applications/{app_id}/ | client.credits.get_application(app_id) | credits:read |
POST/v1/gateway/credits/applications/{app_id}/decision/ | client.credits.decide_application(app_id, ...) | credits:decide |
POST/v1/gateway/credits/applications/{app_id}/disburse/ | client.credits.disburse_application(app_id) | credits:disburse |
Separa las llaves por función: el canal que radica (web, oficina, aliado) solo necesita
credits:submit; el back-office que decide, credits:read + credits:decide; y el proceso de
tesorería que desembolsa, credits:disburse. Nota que la radicación usa la ruta singular
/credits/application/ y el resto la plural /credits/applications/.
El flujo completo #
sequenceDiagram
autonumber
participant S as Tu sistema
participant G as Gateway Compense
participant M as Motor de créditos
participant W as Tu receptor de webhooks
S->>G: POST /credits/application/ (Idempotency-Key)
G->>M: radicar
M-->>G: application_id, status
G-->>S: sobre {transaction_id, data.application_id}
M--)W: credits.application.received
S->>G: POST /credits/applications/{id}/decision/ (APPROVE)
G-->>S: sobre {data.status: APPROVED}
M--)W: credits.decision
S->>G: POST /credits/applications/{id}/disburse/
G-->>S: sobre {data.disbursement_id}
M--)W: credits.disbursedEstados de la solicitud #
stateDiagram-v2 [*] --> SUBMITTED: radicar SUBMITTED --> IN_REVIEW: revisión manual SUBMITTED --> REQUIRES_DOCS: faltan documentos REQUIRES_DOCS --> IN_REVIEW SUBMITTED --> APPROVED: APPROVE IN_REVIEW --> APPROVED: APPROVE SUBMITTED --> REJECTED: REJECT o reglas IN_REVIEW --> REJECTED: REJECT APPROVED --> DISBURSING: desembolsar DISBURSING --> DISBURSED DISBURSED --> ACTIVE: crédito vivo (pasa a Cartera) SUBMITTED --> CANCELLED REJECTED --> [*] ACTIVE --> [*]
| Estado | Significado |
|---|---|
SUBMITTED | Radicada y aceptada para proceso (en el sandbox se reporta como RECEIVED). |
IN_REVIEW | En la bandeja de un analista (revisión manual). |
REQUIRES_DOCS | El analista pidió documentos al afiliado. |
APPROVED | Aprobada: lista para desembolsar. |
REJECTED | Rechazada por un analista o por las reglas del motor (capacidad, mora, políticas). |
DISBURSING | Se envió la orden de desembolso al medio de pago. |
DISBURSED | Fondos desembolsados. |
ACTIVE | Crédito vivo: desde aquí lo operas en Cartera. |
CANCELLED | Anulada. |
Solo una solicitud APPROVED se puede desembolsar: si no está aprobada, el motor responde 422, y
si ya se está desembolsando o ya se desembolsó, 409 (en el sandbox ambos casos responden 409).
1. Radicar la solicitud #
| Campo (SDK) | En el cuerpo | Obligatorio | Notas |
|---|---|---|---|
affiliate | afiliado | sí | {document_type, document_number}. En el cable la clave es afiliado: los SDK la llenan desde affiliate / Affiliate. |
product_code | product_code | sí | Código del producto de crédito de tu entidad (p. ej. LIBRANZA_12M). |
amount | amount | sí | Monto solicitado en COP como texto decimal ("3500000"). Ver Montos. |
term_months | term_months | sí | Plazo en meses, de 1 a 120. |
external_reference | external_reference | sí | Tu id de la solicitud; vuelve en las respuestas y sirve para conciliar. |
purpose | purpose | no | Destino del crédito. |
extra | (se fusiona en el cuerpo) | no | Campos adicionales que tu motor exija y el SDK no modela. |
from compense_gateway import AffiliateRef
tx = client.credits.submit_application(
affiliate=AffiliateRef("CC", "1000000001"),
product_code="LIBRANZA_12M",
amount="3500000",
term_months=12,
external_reference="HORIZONTE-CRED-2026-000123",
purpose="Compra de cartera",
idempotency_key="horizonte-cred-2026-000123",
)
app_id = tx.data["application_id"] # el id del motor: guárdalo junto a tu referencia
print(tx.transaction_id, app_id, tx.data["status"], tx.warnings)import { AffiliateRef } from "@compense/sdk";
const tx = await client.credits.submitApplication({
affiliate: new AffiliateRef("CC", "1000000001"),
productCode: "LIBRANZA_12M",
amount: "3500000",
termMonths: 12,
externalReference: "HORIZONTE-CRED-2026-000123",
purpose: "Compra de cartera",
idempotencyKey: "horizonte-cred-2026-000123",
});
const appId = String(tx.data.application_id);
console.log(tx.transactionId, appId, tx.data.status, tx.warnings);use Compense\Sdk\Model\AffiliateRef;
$tx = $client->credits->submitApplication(
affiliate: new AffiliateRef('CC', '1000000001'),
productCode: 'LIBRANZA_12M',
amount: '3500000',
termMonths: 12,
externalReference: 'HORIZONTE-CRED-2026-000123',
purpose: 'Compra de cartera',
idempotencyKey: 'horizonte-cred-2026-000123',
);
$appId = $tx->data['application_id'];
echo $tx->transactionId, ' ', $appId, ' ', $tx->data['status'], PHP_EOL;Transaction tx = await client.Credits.SubmitApplicationAsync(
affiliate: new AffiliateRef("CC", "1000000001"),
productCode: "LIBRANZA_12M",
amount: "3500000",
termMonths: 12,
externalReference: "HORIZONTE-CRED-2026-000123",
purpose: "Compra de cartera",
idempotencyKey: "horizonte-cred-2026-000123");
string appId = tx.Data["application_id"]!.GetValue<string>();
Console.WriteLine($"{tx.TransactionId} {appId} {tx.Data["status"]}");tx, err := client.Credits.SubmitApplication(ctx, &compense.CreditApplicationParams{
Affiliate: compense.AffiliateRef{DocumentType: "CC", DocumentNumber: "1000000001"},
ProductCode: "LIBRANZA_12M",
Amount: "3500000",
TermMonths: 12,
ExternalReference: "HORIZONTE-CRED-2026-000123",
Purpose: "Compra de cartera",
}, compense.WithIdempotencyKey("horizonte-cred-2026-000123"))
if err != nil {
log.Fatal(err)
}
appID, _ := tx.Data["application_id"].(string)
fmt.Println(tx.TransactionID, appID, tx.Data["status"])curl -X POST https://sandbox.dev.compense.co/gw/v1/gateway/credits/application/ \
-H "X-API-Key: $COMPENSE_API_KEY" \
-H "Idempotency-Key: horizonte-cred-2026-000123" \
-H "Content-Type: application/json" \
-d '{
"afiliado": {"document_type": "CC", "document_number": "1000000001"},
"product_code": "LIBRANZA_12M",
"amount": "3500000",
"term_months": 12,
"external_reference": "HORIZONTE-CRED-2026-000123",
"purpose": "Compra de cartera"
}'{
"status": "success",
"transaction_id": "a1b2c3d4-0000-4a1b-9c2d-000000000001",
"module": "credits",
"data": {
"application_id": "sbx-E-2cf1b69c533d",
"status": "RECEIVED",
"profile": "eligible",
"environment": "sandbox"
}
}Hay tres identificadores y conviene guardar los tres en tu sistema:
| Id | Quién lo genera | Para qué |
|---|---|---|
external_reference | Tú | Tu llave de negocio; la ves en tu ERP. |
data.application_id | El motor | El id de la solicitud: lo usan decidir, desembolsar, el detalle y los webhooks (resource.application_id). |
transaction_id | El gateway | El registro de esa llamada: soporte y transactions.get. Cada paso (radicar, decidir, desembolsar) tiene el suyo. |
Si tu llave tiene affiliates:upsert, el bloque afiliado también registra al afiliado al vuelo; si
ese registro falla, la radicación sigue y llega un aviso en warnings (ver
Afiliados).
2. Trabajar la bandeja #
GET/v1/gateway/credits/applications/
devuelve una página estilo DRF (count, next, previous,
results). Filtros: page, page_size (máx. 100, por defecto 25), search (documento o nombre)
y status. Más en Paginación.
page_no = 1
while True:
page = client.credits.list_applications(status="IN_REVIEW", page=page_no, page_size=50)
for app in page: # Page itera sobre results
print(app["application_id"], app.get("document_number"), app["status"])
if not page.next:
break
page_no += 1const page = await client.credits.listApplications({ status: "IN_REVIEW", pageSize: 50 });
for await (const app of page) { // recorre todas las páginas
console.log(app.application_id, app.document_number, app.status);
}$pageNo = 1;
do {
$page = $client->credits->listApplications(page: $pageNo, pageSize: 50, status: 'IN_REVIEW');
foreach ($page as $app) {
echo $app['application_id'], ' ', $app['status'], PHP_EOL;
}
$pageNo++;
} while ($page->hasNext());int pageNo = 1;
Page page;
do
{
page = await client.Credits.ListApplicationsAsync(page: pageNo, pageSize: 50, status: "IN_REVIEW");
foreach (var app in page)
{
Console.WriteLine($"{app["application_id"]} {app["status"]}");
}
pageNo++;
} while (page.Next is not null);it := compense.NewPageIterator(ctx, func(ctx context.Context, page int) (*compense.Page, error) {
return client.Credits.ListApplications(ctx, &compense.ListApplicationsParams{
Status: "IN_REVIEW", Page: page, PageSize: 50,
})
})
for it.Next() {
app := it.Item()
fmt.Println(app["application_id"], app["status"])
}
if err := it.Err(); err != nil {
log.Fatal(err)
}curl "https://sandbox.dev.compense.co/gw/v1/gateway/credits/applications/?status=IN_REVIEW&page=1&page_size=50" \
-H "X-API-Key: $COMPENSE_API_KEY"3. Consultar el detalle #
GET/v1/gateway/credits/applications/{app_id}/
devuelve el cuerpo del motor tal cual (en los SDK, un
diccionario/objeto JSON sin tipar: dict, JsonObject, array, map[string]any).
detail = client.credits.get_application(app_id)
print(detail["status"], detail.get("reason_code"))const detail = await client.credits.getApplication(appId);
console.log(detail.status, detail.reason_code);$detail = $client->credits->getApplication($appId);
echo $detail['status'], ' ', $detail['reason_code'] ?? '-', PHP_EOL;var detail = await client.Credits.GetApplicationAsync(appId);
Console.WriteLine($"{detail["status"]} {detail["reason_code"]}");detail, err := client.Credits.GetApplication(ctx, appID)
if err != nil {
log.Fatal(err)
}
fmt.Println(detail["status"], detail["reason_code"])curl https://sandbox.dev.compense.co/gw/v1/gateway/credits/applications/$APP_ID/ \
-H "X-API-Key: $COMPENSE_API_KEY"4. Decidir: aprobar o rechazar #
POST/v1/gateway/credits/applications/{app_id}/decision/
recibe decision (APPROVE o REJECT), approved_amount
(obligatorio al aprobar; puede ser menor que el solicitado) y reason (texto libre que queda en
la auditoría). Cada SDK trae las constantes:
| Python | TypeScript | PHP | .NET | Go |
|---|---|---|---|---|
DECISION_APPROVE / DECISION_REJECT | DECISION_APPROVE / DECISION_REJECT | Decision::APPROVE / Decision::REJECT | Decisions.Approve / Decisions.Reject | compense.DecisionApprove / compense.DecisionReject |
from compense_gateway import DECISION_APPROVE
decision = client.credits.decide_application(
app_id,
decision=DECISION_APPROVE,
approved_amount="3000000",
reason="Capacidad de pago verificada",
idempotency_key="horizonte-cred-2026-000123-decision",
)
print(decision.data["status"]) # APPROVED
# Rechazar:
# client.credits.decide_application(app_id, decision=DECISION_REJECT, reason="Score insuficiente")import { DECISION_APPROVE } from "@compense/sdk";
const decision = await client.credits.decideApplication(appId, {
decision: DECISION_APPROVE,
approvedAmount: "3000000",
reason: "Capacidad de pago verificada",
idempotencyKey: "horizonte-cred-2026-000123-decision",
});
console.log(decision.data.status);use Compense\Sdk\Model\Decision;
$decision = $client->credits->decideApplication(
$appId,
decision: Decision::APPROVE,
approvedAmount: '3000000',
reason: 'Capacidad de pago verificada',
idempotencyKey: 'horizonte-cred-2026-000123-decision',
);
echo $decision->data['status'], PHP_EOL;Transaction decision = await client.Credits.DecideApplicationAsync(
appId, Decisions.Approve,
approvedAmount: "3000000",
reason: "Capacidad de pago verificada",
idempotencyKey: "horizonte-cred-2026-000123-decision");
Console.WriteLine(decision.Data["status"]);decision, err := client.Credits.DecideApplication(ctx, appID, &compense.CreditDecisionParams{
Decision: compense.DecisionApprove,
ApprovedAmount: "3000000",
Reason: "Capacidad de pago verificada",
}, compense.WithIdempotencyKey("horizonte-cred-2026-000123-decision"))
if err != nil {
log.Fatal(err)
}
fmt.Println(decision.Data["status"])curl -X POST https://sandbox.dev.compense.co/gw/v1/gateway/credits/applications/$APP_ID/decision/ \
-H "X-API-Key: $COMPENSE_API_KEY" \
-H "Idempotency-Key: horizonte-cred-2026-000123-decision" \
-H "Content-Type: application/json" \
-d '{"decision": "APPROVE", "approved_amount": "3000000", "reason": "Capacidad de pago verificada"}'5. Desembolsar #
POST/v1/gateway/credits/applications/{app_id}/disburse/
no lleva cuerpo y mueve dinero. Usa una
Idempotency-Key estable derivada de tu solicitud (no la generada al azar por el SDK): si la
respuesta se pierde por un corte de red, reintentas con la misma llave y el gateway te devuelve el
desembolso original en vez de ordenar otro.
disb = client.credits.disburse_application(
app_id, idempotency_key="horizonte-cred-2026-000123-desembolso")
print(disb.transaction_id, disb.data["status"], disb.data.get("disbursement_id"), disb.idempotent_replay)const disb = await client.credits.disburseApplication(appId, {
idempotencyKey: "horizonte-cred-2026-000123-desembolso",
});
console.log(disb.transactionId, disb.data.status, disb.data.disbursement_id, disb.idempotentReplay);$disb = $client->credits->disburseApplication($appId, idempotencyKey: 'horizonte-cred-2026-000123-desembolso');
echo $disb->transactionId, ' ', $disb->data['status'], ' ', $disb->data['disbursement_id'] ?? '-', PHP_EOL;Transaction disb = await client.Credits.DisburseApplicationAsync(
appId, idempotencyKey: "horizonte-cred-2026-000123-desembolso");
Console.WriteLine($"{disb.TransactionId} {disb.Data["status"]} {disb.Data["disbursement_id"]} {disb.IdempotentReplay}");disb, err := client.Credits.DisburseApplication(ctx, appID,
compense.WithIdempotencyKey("horizonte-cred-2026-000123-desembolso"))
if err != nil {
log.Fatal(err)
}
fmt.Println(disb.TransactionID, disb.Data["status"], disb.Data["disbursement_id"], disb.IdempotentReplay)curl -X POST https://sandbox.dev.compense.co/gw/v1/gateway/credits/applications/$APP_ID/disburse/ \
-H "X-API-Key: $COMPENSE_API_KEY" \
-H "Idempotency-Key: horizonte-cred-2026-000123-desembolso"{
"status": "success",
"transaction_id": "b7e1c0d2-5a3f-4c9e-8d21-000000000003",
"module": "credits",
"data": {"application_id": "sbx-E-2cf1b69c533d", "disbursement_id": "dsb-E-506a3cd0df6b", "status": "DISBURSED"}
}La confirmación definitiva llega por el webhook credits.disbursed. La receta
Desembolsar un crédito y conciliar muestra el ciclo
completo con tu ERP.
Firma de alto valor (opcional) #
credits:decide y credits:disburse están entre los scopes que admiten firma HMAC de la
petición. Solo aplica si Compense activó la firma en tu llave (te lo indica al emitirla y te
entrega un secreto de firma); con la firma activa, toda decisión o desembolso sin una cabecera
X-Compense-Signature válida responde 401.
Los SDK no firman las peticiones
Ninguno de los cinco SDK implementa X-Compense-Signature. Si tu llave tiene la firma activa, haz
esas dos llamadas con tu propio cliente HTTP siguiendo el esquema de abajo, o pide una llave sin
firma para ellas.
El formato es X-Compense-Signature: t=<unix>,v2=<hex>, donde v2 es el HMAC-SHA256 (en
hexadecimal) con tu secreto de firma sobre estas cinco líneas unidas por \n: el timestamp, el
método en mayúsculas, la ruta tal como la recibe el gateway (empieza por /v1/gateway/), la
Idempotency-Key (vacía si no envías) y el cuerpo crudo. El timestamp no puede diferir más de
5 minutos del reloj del gateway.
APP_ID="sbx-E-2cf1b69c533d"
IDEM="horizonte-cred-2026-000123-desembolso"
RUTA="/v1/gateway/credits/applications/$APP_ID/disburse/"
T=$(date +%s)
CUERPO="" # disburse no lleva cuerpo
FIRMA=$(printf '%s\n%s\n%s\n%s\n%s' "$T" "POST" "$RUTA" "$IDEM" "$CUERPO" \
| openssl dgst -sha256 -hmac "$COMPENSE_SIGNING_SECRET" -hex | sed 's/^.* //')
curl -X POST "https://sandbox.dev.compense.co/gw$RUTA" \
-H "X-API-Key: $COMPENSE_API_KEY" \
-H "Idempotency-Key: $IDEM" \
-H "X-Compense-Signature: t=$T,v2=$FIRMA"Errores de negocio típicos #
| HTTP | Excepción (Python) | title | Cuándo |
|---|---|---|---|
401 / 403 | AuthError | — | Llave inválida, de otro entorno o sin el scope del paso (credits:decide, credits:disburse…). |
404 | NotFoundError | — | El app_id no existe. En el sandbox, también al radicar para el documento 9999999999. |
409 | IdempotencyConflictError | Idempotency conflict | La misma Idempotency-Key sigue en proceso: reintenta con la misma llave. |
409 | IdempotencyConflictError | Request rejected by engine | La solicitud ya se está desembolsando o ya se desembolsó (en el sandbox, también cuando no está aprobada). No reintentes con otra llave: consulta el detalle. |
422 | ValidationError | — | Cuerpo inválido: falta un obligatorio, amount mal formado, term_months fuera de 1–120, APPROVE sin approved_amount; desembolsar una solicitud que no está APPROVED; o el afiliado no está en el registro de tu entidad (sincronízalo primero con Afiliados). Revisa detail y errors. |
502 / 504 | UpstreamError | — | El motor falló o no respondió. Reintenta con la misma Idempotency-Key. |
Un 409 no siempre es un conflicto de idempotencia: distingue los dos casos por title (y lee
detail). Todas las excepciones traen trace_id, igual a la cabecera X-Request-ID: cítalo a
soporte. Más en Errores y Reintentos.
Un rechazo de negocio (afiliado con mora, sin capacidad, monto fuera de política) no es un error
HTTP: la radicación o la decisión responden 200 con data.status: "REJECTED" y un reason_code.
Sandbox: perfiles deterministas #
Con una llave de sandbox el motor es simulado: el resultado depende solo del documento del afiliado y del monto, sin estado ni dinero real.
| Documento | Perfil | Al radicar | Al decidir | Al desembolsar |
|---|---|---|---|---|
1000000001 | Elegible | RECEIVED | APPROVED | DISBURSED |
1000000002 | No elegible | REJECTED · NOT_ELIGIBLE | REJECTED | 409 |
1000000003 | Revisión manual | RECEIVED | IN_REVIEW · MANUAL_REVIEW | 409 |
1000000004 | Con deudas | REJECTED · OUTSTANDING_DEBT | REJECTED | 409 |
9999999999 | No existe | 404 | — | — |
| cualquier otro | Elegible | como 1000000001 |
- Umbral de monto: un afiliado elegible con
amountmayor a 50.000.000 COP se rechaza al radicar conAMOUNT_EXCEEDS_LIMIT, y sigue rechazado en la decisión y el desembolso. Úsalo para probar tu caso de “monto demasiado alto”. - El resultado de la decisión lo fija el perfil, no el
decisionque envíes: decidirREJECTsobre1000000001también respondeAPPROVED. - Los
application_iddel sandbox tienen la formasbx-<perfil>-<hash>y son deterministas porexternal_reference: radicar dos veces con la misma referencia devuelve el mismo id. - El sandbox no guarda estado: el detalle de una solicitud ya desembolsada sigue mostrando
APPROVED. Para recorrer el ciclo del desembolso (PENDING → PROCESSING → DISBURSED, oFAILEDcon el monto mágico22.22) usaclient.sandbox.simulate.credit_disbursement; ver Simuladores.
Webhooks #
Los eventos namespaced de créditos llegan con el sobre {event, version, tenant_id, occurred_at, resource}:
Créditos #
| Evento | Desde | Cuándo se emite |
|---|---|---|
credits.application.received | 2026-06-01 | Se radicó una solicitud de crédito en el gateway y quedó aceptada para proceso. |
credits.decision | 2026-06-01 | Un asesor o el motor resolvió la solicitud (decision, approved o rejected). |
credits.disbursed | 2026-06-01 | Se desembolsaron los fondos del crédito aprobado. |
{
"event": "credits.disbursed",
"version": "1",
"tenant_id": "horizonte",
"occurred_at": "2026-11-02T15:00:00Z",
"resource": {
"application_id": "6f1d2c3b-8a9e-4b7c-9d0e-1a2b3c4d5e6f",
"disbursement_id": "DSB-2026-000777",
"amount": "3000000.00",
"currency": "COP",
"method": "ach_credit",
"disbursed_at": "2026-11-02T15:00:00Z",
"status": "DISBURSED"
}
}credits.decision trae en resource el decision (approved o rejected), quién decidió, los
montos aprobados (null si se rechazó) y los motivos.
Además existen los eventos clásicos del ciclo de crédito (categoría general). Son anteriores
al sobre: sus campos vienen en el nivel superior del cuerpo, no en resource (léelos de
payload en tu SDK).
Ciclo de crédito clásico (categoría general, sin sobre namespaced) #
| Evento | Desde | Cuándo se emite |
|---|---|---|
application.submitted | — | El solicitante terminó y envió la solicitud. |
application.scored | — | Resultados de buró, scoring y reglas del motor de decisión. |
application.approved | — | Aprobada por un analista o automáticamente por el motor de reglas. |
application.rejected | — | Rechazada por el motor de riesgo, cumplimiento o un analista. |
application.manual_review_required | — | Enviada a la bandeja de revisión humana. |
credit.activated | — | El crédito quedó activo tras el desembolso. |
credit.disbursed | — | El proveedor confirmó la transferencia de los fondos. |
credit.restructured | — | Se aprobó un cambio de condiciones del crédito. |
credit.written_off | — | El crédito se castigó como pérdida irrecuperable. |
installment.due_soon | — | Una cuota vence en los próximos 5 días. |
compliance.exception_raised | — | Alerta SARLAFT, UIAF u otra excepción regulatoria. |
compliance.ros_submitted | — | Se envió un reporte de operación sospechosa (ROS) a la UIAF. |
Suscríbete a unos u otros, no a ambos para el mismo hecho, y deduplica por X-Webhook-Id. Ver
Webhooks y Verificación.