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 #

OperationSDK 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.disbursed

Application 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 --> [*]
StateMeaning
SUBMITTEDSubmitted and accepted for processing (the sandbox reports it as RECEIVED).
IN_REVIEWIn an analyst’s queue (manual review).
REQUIRES_DOCSThe analyst requested documents from the affiliate.
APPROVEDApproved: ready to disburse.
REJECTEDRejected by an analyst or by the engine’s rules (repayment capacity, arrears, policies).
DISBURSINGThe disbursement order was sent to the payment method.
DISBURSEDFunds disbursed.
ACTIVELive loan: from here on you operate it in Loan portfolio.
CANCELLEDVoided.

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 bodyRequiredNotes
affiliateafiliadoyes{document_type, document_number}. On the wire the key is afiliado: the SDKs fill it from affiliate / Affiliate.
product_codeproduct_codeyesCode of your entity’s credit product (e.g. LIBRANZA_12M).
amountamountyesRequested amount in COP as a decimal string ("3500000"). See Amounts.
term_monthsterm_monthsyesTerm in months, from 1 to 120.
external_referenceexternal_referenceyesYour application id; it comes back in the responses and helps you reconcile.
purposepurposenoWhat the credit is for.
extra(merged into the body)noAdditional 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)
JSON
{
  "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:

IdGenerated byUsed for
external_referenceYouYour business key; the one you see in your ERP.
data.application_idThe engineThe application’s id: used by decide, disburse, the detail endpoint and the webhooks (resource.application_id).
transaction_idThe gatewayThe 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 += 1

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

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:

PythonTypeScriptPHP.NETGo
DECISION_APPROVE / DECISION_REJECTDECISION_APPROVE / DECISION_REJECTDecision::APPROVE / Decision::REJECTDecisions.Approve / Decisions.Rejectcompense.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")

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

Shell
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 #

HTTPException (Python)titleWhen
401 / 403AuthError—Invalid key, key from another environment, or key without the step’s scope (credits:decide, credits:disburse…).
404NotFoundError—The app_id doesn’t exist. In the sandbox, also when submitting for document 9999999999.
409IdempotencyConflictErrorIdempotency conflictThe same Idempotency-Key is still being processed: retry with the same key.
409IdempotencyConflictErrorRequest rejected by engineThe 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.
422ValidationError—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 / 504UpstreamError—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.

DocumentProfileOn submitOn decideOn disburse
1000000001EligibleRECEIVEDAPPROVEDDISBURSED
1000000002Not eligibleREJECTED · NOT_ELIGIBLEREJECTED409
1000000003Manual reviewRECEIVEDIN_REVIEW · MANUAL_REVIEW409
1000000004With debtsREJECTED · OUTSTANDING_DEBTREJECTED409
9999999999Doesn’t exist404——
any otherEligiblelike 1000000001
  • Amount threshold: an eligible affiliate with an amount greater than 50,000,000 COP is rejected on submit with AMOUNT_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 decision you send: deciding REJECT on 1000000001 also responds APPROVED.
  • Sandbox application_ids have the form sbx-<profile>-<hash> and are deterministic per external_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, or FAILED with the magic amount 22.22) use client.sandbox.simulate.credit_disbursement; see Simulators.

Webhooks #

The namespaced credit events arrive with the {event, version, tenant_id, occurred_at, resource} envelope:

Credits #

EventSinceWhen it's emitted
credits.application.received2026-06-01A credit application was submitted through the gateway and accepted for processing.
credits.decision2026-06-01An advisor or the engine decided the application (decision, approved or rejected).
credits.disbursed2026-06-01The approved credit’s funds were disbursed.
JSON
{
  "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) #

EventSinceWhen 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.

What’s next #