Implementation guide
Credits
End-to-end credit origination: submit the application, work the queue, decide (APPROVE/REJECT), disburse, track the states and receive the credits.* events by webhook. With the sandbox’s deterministic profiles.
On this page
The credits module exposes Compense’s origination engine (rules, scoring and disbursement) to
your system: you submit an affiliate’s application, your analyst or the engine decides it, and you
disburse it. Every step returns a traceable transaction_id and emits a webhook. What happens after
the disbursement (payment schedule, collection, payoff certificate) lives in
Loan portfolio.
Before you start, the affiliate must exist in your entity’s registry: see Affiliates.
Operations and scopes #
| Operation | SDK method (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 |
Split your keys by function: the channel that submits (web, branch office, partner) only needs
credits:submit; the back office that decides needs credits:read + credits:decide; and the
treasury process that disburses needs credits:disburse. Note that submission uses the singular
path /credits/application/ and everything else uses the plural /credits/applications/.
The complete flow #
sequenceDiagram
autonumber
participant S as Your system
participant G as Compense Gateway
participant M as Credit engine
participant W as Your webhook receiver
S->>G: POST /credits/application/ (Idempotency-Key)
G->>M: submit
M-->>G: application_id, status
G-->>S: envelope {transaction_id, data.application_id}
M--)W: credits.application.received
S->>G: POST /credits/applications/{id}/decision/ (APPROVE)
G-->>S: envelope {data.status: APPROVED}
M--)W: credits.decision
S->>G: POST /credits/applications/{id}/disburse/
G-->>S: envelope {data.disbursement_id}
M--)W: credits.disbursedApplication states #
stateDiagram-v2 [*] --> SUBMITTED: submit SUBMITTED --> IN_REVIEW: manual review SUBMITTED --> REQUIRES_DOCS: missing documents REQUIRES_DOCS --> IN_REVIEW SUBMITTED --> APPROVED: APPROVE IN_REVIEW --> APPROVED: APPROVE SUBMITTED --> REJECTED: REJECT or rules IN_REVIEW --> REJECTED: REJECT APPROVED --> DISBURSING: disburse DISBURSING --> DISBURSED DISBURSED --> ACTIVE: live loan (moves to Loan portfolio) SUBMITTED --> CANCELLED REJECTED --> [*] ACTIVE --> [*]
| State | Meaning |
|---|---|
SUBMITTED | Submitted and accepted for processing (the sandbox reports it as RECEIVED). |
IN_REVIEW | In an analyst’s queue (manual review). |
REQUIRES_DOCS | The analyst requested documents from the affiliate. |
APPROVED | Approved: ready to disburse. |
REJECTED | Rejected by an analyst or by the engine’s rules (repayment capacity, arrears, policies). |
DISBURSING | The disbursement order was sent to the payment method. |
DISBURSED | Funds disbursed. |
ACTIVE | Live loan: from here on you operate it in Loan portfolio. |
CANCELLED | Voided. |
Only an APPROVED application can be disbursed: if it isn’t approved, the engine responds 422,
and if it is already being disbursed or was already disbursed, 409 (in the sandbox both cases
respond 409).
1. Submit the application #
| Field (SDK) | In the body | Required | Notes |
|---|---|---|---|
affiliate | afiliado | yes | {document_type, document_number}. On the wire the key is afiliado: the SDKs fill it from affiliate / Affiliate. |
product_code | product_code | yes | Code of your entity’s credit product (e.g. LIBRANZA_12M). |
amount | amount | yes | Requested amount in COP as a decimal string ("3500000"). See Amounts. |
term_months | term_months | yes | Term in months, from 1 to 120. |
external_reference | external_reference | yes | Your application id; it comes back in the responses and helps you reconcile. |
purpose | purpose | no | What the credit is for. |
extra | (merged into the body) | no | Additional fields your engine requires that the SDK doesn’t model. |
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="Debt consolidation",
idempotency_key="horizonte-cred-2026-000123",
)
app_id = tx.data["application_id"] # the engine's id: store it next to your reference
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: "Debt consolidation",
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: 'Debt consolidation',
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: "Debt consolidation",
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: "Debt consolidation",
}, 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": "Debt consolidation"
}'{
"status": "success",
"transaction_id": "a1b2c3d4-0000-4a1b-9c2d-000000000001",
"module": "credits",
"data": {
"application_id": "sbx-E-2cf1b69c533d",
"status": "RECEIVED",
"profile": "eligible",
"environment": "sandbox"
}
}There are three identifiers, and you should store all three in your system:
| Id | Generated by | Used for |
|---|---|---|
external_reference | You | Your business key; the one you see in your ERP. |
data.application_id | The engine | The application’s id: used by decide, disburse, the detail endpoint and the webhooks (resource.application_id). |
transaction_id | The gateway | The record of that call: support and transactions.get. Each step (submit, decide, disburse) has its own. |
If your key has affiliates:upsert, the afiliado block also registers the affiliate on the fly; if
that registration fails, the submission still goes through and you get a notice in warnings (see
Affiliates).
2. Work the queue #
GET/v1/gateway/credits/applications/
returns a DRF-style page (count, next, previous,
results). Filters: page, page_size (max. 100, default 25), search (document or name)
and status. More in Pagination.
page_no = 1
while True:
page = client.credits.list_applications(status="IN_REVIEW", page=page_no, page_size=50)
for app in page: # Page iterates over 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) { // walks through every page
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. Get the detail #
GET/v1/gateway/credits/applications/{app_id}/
returns the engine’s body as is (in the SDKs, an untyped JSON
dictionary/object: 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. Decide: approve or reject #
POST/v1/gateway/credits/applications/{app_id}/decision/
takes decision (APPROVE or REJECT), approved_amount
(required when approving; it can be lower than the requested amount) and reason (free text
that is kept in the audit trail). Every SDK ships the constants:
| 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="Repayment capacity verified",
idempotency_key="horizonte-cred-2026-000123-decision",
)
print(decision.data["status"]) # APPROVED
# Reject:
# client.credits.decide_application(app_id, decision=DECISION_REJECT, reason="Insufficient score")import { DECISION_APPROVE } from "@compense/sdk";
const decision = await client.credits.decideApplication(appId, {
decision: DECISION_APPROVE,
approvedAmount: "3000000",
reason: "Repayment capacity verified",
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: 'Repayment capacity verified',
idempotencyKey: 'horizonte-cred-2026-000123-decision',
);
echo $decision->data['status'], PHP_EOL;Transaction decision = await client.Credits.DecideApplicationAsync(
appId, Decisions.Approve,
approvedAmount: "3000000",
reason: "Repayment capacity verified",
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: "Repayment capacity verified",
}, 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": "Repayment capacity verified"}'5. Disburse #
POST/v1/gateway/credits/applications/{app_id}/disburse/
has no body and moves money. Use a stable
Idempotency-Key derived from your application (not the one the SDK generates at random): if the
response is lost to a network outage, you retry with the same key and the gateway returns the
original disbursement instead of ordering a new one.
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"}
}The final confirmation arrives through the credits.disbursed webhook. The recipe
Disburse a credit and reconcile shows the full cycle
with your ERP.
High-value signing (optional) #
credits:decide and credits:disburse are among the scopes that support HMAC request
signing. It only applies if Compense enabled signing on your key (you are told when the key is
issued and given a signing secret); with signing enabled, any decision or disbursement without a
valid X-Compense-Signature header responds 401.
The SDKs don't sign requests
None of the five SDKs implements X-Compense-Signature. If your key has signing enabled, make
those two calls with your own HTTP client following the scheme below, or ask for a key without
signing for them.
The format is X-Compense-Signature: t=<unix>,v2=<hex>, where v2 is the HMAC-SHA256 (in
hexadecimal) computed with your signing secret over these five lines joined by \n: the timestamp,
the uppercase method, the path exactly as the gateway receives it (it starts with /v1/gateway/),
the Idempotency-Key (empty if you don’t send one) and the raw body. The timestamp can’t differ by
more than 5 minutes from the gateway’s clock.
APP_ID="sbx-E-2cf1b69c533d"
IDEM="horizonte-cred-2026-000123-desembolso"
RUTA="/v1/gateway/credits/applications/$APP_ID/disburse/"
T=$(date +%s)
CUERPO="" # disburse has no body
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"Typical business errors #
| HTTP | Exception (Python) | title | When |
|---|---|---|---|
401 / 403 | AuthError | — | Invalid key, key from another environment, or key without the step’s scope (credits:decide, credits:disburse…). |
404 | NotFoundError | — | The app_id doesn’t exist. In the sandbox, also when submitting for document 9999999999. |
409 | IdempotencyConflictError | Idempotency conflict | The same Idempotency-Key is still being processed: retry with the same key. |
409 | IdempotencyConflictError | Request rejected by engine | The application is already being disbursed or was already disbursed (in the sandbox, also when it isn’t approved). Don’t retry with a different key: check the detail. |
422 | ValidationError | — | Invalid body: a required field is missing, amount is malformed, term_months is outside 1–120, APPROVE without approved_amount; disbursing an application that isn’t APPROVED; or the affiliate isn’t in your entity’s registry (sync it first with Affiliates). Check detail and errors. |
502 / 504 | UpstreamError | — | The engine failed or didn’t respond. Retry with the same Idempotency-Key. |
A 409 isn’t always an idempotency conflict: tell the two cases apart by title (and read
detail). Every exception carries trace_id, equal to the X-Request-ID header: quote it to
support. More in Errors and Retries.
A business rejection (affiliate in arrears, no repayment capacity, amount outside policy) is not
an HTTP error: the submission or the decision responds 200 with data.status: "REJECTED" and a
reason_code.
Sandbox: deterministic profiles #
With a sandbox key the engine is simulated: the result depends only on the affiliate’s document and the amount, with no state and no real money.
| Document | Profile | On submit | On decide | On disburse |
|---|---|---|---|---|
1000000001 | Eligible | RECEIVED | APPROVED | DISBURSED |
1000000002 | Not eligible | REJECTED · NOT_ELIGIBLE | REJECTED | 409 |
1000000003 | Manual review | RECEIVED | IN_REVIEW · MANUAL_REVIEW | 409 |
1000000004 | With debts | REJECTED · OUTSTANDING_DEBT | REJECTED | 409 |
9999999999 | Doesn’t exist | 404 | — | — |
| any other | Eligible | like 1000000001 |
- Amount threshold: an eligible affiliate with an
amountgreater than 50,000,000 COP is rejected on submit withAMOUNT_EXCEEDS_LIMIT, and stays rejected on decide and disburse. Use it to test your “amount too high” case. - The decision’s outcome is set by the profile, not by the
decisionyou send: decidingREJECTon1000000001also respondsAPPROVED. - Sandbox
application_ids have the formsbx-<profile>-<hash>and are deterministic perexternal_reference: submitting twice with the same reference returns the same id. - The sandbox doesn’t keep state: the detail of an already disbursed application still shows
APPROVED. To walk through the disbursement cycle (PENDING → PROCESSING → DISBURSED, orFAILEDwith the magic amount22.22) useclient.sandbox.simulate.credit_disbursement; see Simulators.
Webhooks #
The namespaced credit events arrive with the {event, version, tenant_id, occurred_at, resource}
envelope:
Credits #
| Event | Since | When it's emitted |
|---|---|---|
credits.application.received | 2026-06-01 | A credit application was submitted through the gateway and accepted for processing. |
credits.decision | 2026-06-01 | An advisor or the engine decided the application (decision, approved or rejected). |
credits.disbursed | 2026-06-01 | The approved credit’s funds were disbursed. |
{
"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 carries in resource the decision (approved or rejected), who decided, the
approved amounts (null if it was rejected) and the reasons.
There are also the classic credit-lifecycle events (general category). They predate the
envelope: their fields come at the top level of the body, not in resource (read them from
payload in your SDK).
Classic credit lifecycle (general category, no namespaced envelope) #
| Event | Since | When it's emitted |
|---|---|---|
application.submitted | — | The applicant completed and submitted the application. |
application.scored | — | Credit bureau, scoring and decision-engine rule results are available. |
application.approved | — | Approved by an analyst or automatically by the rules engine. |
application.rejected | — | Rejected by the risk engine, compliance or an analyst. |
application.manual_review_required | — | Sent to the human review queue. |
credit.activated | — | The credit became active after disbursement. |
credit.disbursed | — | The provider confirmed the funds transfer. |
credit.restructured | — | A change to the credit’s terms was approved. |
credit.written_off | — | The credit was written off as an unrecoverable loss. |
installment.due_soon | — | An installment is due within the next 5 days. |
compliance.exception_raised | — | A SARLAFT or UIAF alert, or another regulatory exception, was raised. |
compliance.ros_submitted | — | A suspicious transaction report (ROS) was sent to the UIAF. |
Subscribe to one set or the other, not both for the same fact, and deduplicate by X-Webhook-Id. See
Webhooks and Verification.