Caja Horizonte originates payroll-deduction loans (libranzas) from its ERP. The analyst approves in the ERP, treasury orders the disbursement, and accounting needs to know, without asking anyone, when the money went out, how much, and against which application. This recipe builds that circuit end to end with the gateway: submit → decide → disburse → receive credits.disbursed → reconcile.

You need:

  • An API key with credits:submit, credits:read, credits:decide and credits:disburse (or, better, one per function) and another with webhooks:manage to subscribe the endpoint.
  • A public HTTPS endpoint in your ERP to receive webhooks.
  • The SDKs installed (Installation) and familiarity with the Credits module.

The circuit #

sequenceDiagram
  autonumber
  participant ERP as Caja Horizonte ERP
  participant G as Compense Gateway
  participant M as Credit engine
  participant H as Webhook handler (ERP)
  ERP->>G: submit_application (external_reference, Idempotency-Key)
  G-->>ERP: transaction_id + data.application_id
  Note over ERP: store external_reference ↔ application_id
  ERP->>G: decide_application APPROVE (approved_amount)
  G-->>ERP: data.status = APPROVED
  ERP->>G: disburse_application (stable Idempotency-Key)
  G->>M: disburse
  G-->>ERP: transaction_id + data.disbursement_id
  M--)H: credits.disbursed {application_id, disbursement_id, amount}
  H->>H: verify signature, deduplicate by X-Webhook-Id
  H->>ERP: look up by application_id, compare amount, mark DESEMBOLSADA
  Note over ERP,G: daily job: whatever didn't arrive by webhook is reconciled with transactions.get

The reconciliation table #

The credits.disbursed webhook carries application_id, disbursement_id, amount, currency, method, disbursed_at and status, but not your external_reference or the gateway’s transaction_id. That’s why the most important step is to store, at submission time, the mapping between your ids and Compense’s:

Column in your ERPWhere it comes fromWhat it’s for
external_referenceYou (HORIZONTE-CRED-2026-000123)Business key.
application_iddata.application_id from the submissionKey the webhook arrives with.
tx_radicacion, tx_decision, tx_desembolsotransaction_id of each envelopeSupport and transactions.get.
monto_aprobadoWhat you sent in approved_amountCompare with resource.amount.
disbursement_iddata.disbursement_id from the disbursementCompare with resource.disbursement_id.
estadoYour state machineRADICADA → APROBADA → DESEMBOLSO_SOLICITADO → DESEMBOLSADA.
ultimo_eventoX-Webhook-IdDeduplicate repeated deliveries.

The Idempotency-Key values are derived from external_reference (…-radicacion, …-decision, …-desembolso), so a retry from the ERP never submits or disburses twice, even if the process crashes halfway through.

1. Subscribe the endpoint #

Once per environment. The secret (whsec_…) is shown only in this response: store it in your secrets manager. Details in Subscription.

tx = client.webhooks.endpoints.create(
    url="https://erp.horizonte.example/compense/webhooks",
    events=["credits.decision", "credits.disbursed"],
    label="ERP Caja Horizonte",
)
print(tx.data["id"], tx.data["secret"])     # store the secret right away

2. Originate: submit, decide and disburse #

In real life each step happens at a different moment (submission in the channel, the decision in the back office, the disbursement at the treasury close). Here they run back to back so you can follow the whole thread; what matters is what gets stored after each response. erp represents your data layer.

from compense_gateway import AffiliateRef, DECISION_APPROVE

ref = "HORIZONTE-CRED-2026-000123"

sub = client.credits.submit_application(
    affiliate=AffiliateRef("CC", "1000000001"), product_code="LIBRANZA_12M",
    amount="3500000", term_months=12, external_reference=ref,
    idempotency_key=f"{ref}-radicacion",
)
app_id = sub.data["application_id"]
erp.guardar(ref, application_id=app_id, tx_radicacion=sub.transaction_id, estado="RADICADA")

dec = client.credits.decide_application(
    app_id, decision=DECISION_APPROVE, approved_amount="3000000",
    reason="Repayment capacity verified", idempotency_key=f"{ref}-decision",
)
erp.guardar(ref, tx_decision=dec.transaction_id, monto_aprobado="3000000", estado="APROBADA")

dis = client.credits.disburse_application(app_id, idempotency_key=f"{ref}-desembolso")
erp.guardar(ref, tx_desembolso=dis.transaction_id,
            disbursement_id=dis.data.get("disbursement_id"), estado="DESEMBOLSO_SOLICITADO")

If any of the three calls fails because of the network or a 502/504, repeat the same call with the same key: either it runs for the first time or you get the original response with idempotent_replay: true. A 422 on the disbursement means the application isn’t approved, and a 409 with title: Request rejected by engine means it’s already being disbursed or has already been disbursed: don’t retry in either case; check the detail.

3. Receive credits.disbursed and reconcile #

The handler does five things, in this order:

  1. Verifies the signature over the raw body (never over re-serialized JSON) and responds 401 if it isn’t valid.
  2. Ignores tests (is_test): responds 200 to them without touching your accounting.
  3. Deduplicates by X-Webhook-Id: delivery is at least once.
  4. Reconciles: looks up the application by resource.application_id, compares resource.amount with the approved amount, and stores disbursement_id and disbursed_at.
  5. Responds 2xx quickly (under 10 s). If your database fails, respond 5xx and Compense will retry (Webhook retries).
Webhook handler
import os
from decimal import Decimal

from flask import Flask, request
from compense_gateway import verify_webhook, WebhookVerificationError

app = Flask(__name__)
SECRETOS = [os.environ["COMPENSE_WEBHOOK_SECRET"]]     # [new, old] while you rotate


@app.post("/compense/webhooks")
def recibir():
    try:
        ev = verify_webhook(request.get_data(), request.headers, secret=SECRETOS)
    except WebhookVerificationError:
        return "", 401
    if ev.is_test or erp.evento_aplicado(ev.event_id):
        return "", 200

    if ev.event == "credits.disbursed":
        r = ev.resource
        sol = erp.buscar_por_application_id(r["application_id"])
        if sol is None:
            erp.registrar_huerfano(ev.event_id, ev.payload)        # not originated by the ERP
        elif Decimal(r["amount"]) != Decimal(sol.monto_aprobado):
            erp.marcar_diferencia(sol.external_reference, r["amount"], ev.event_id)
        else:
            erp.marcar_desembolsada(sol.external_reference, disbursement_id=r["disbursement_id"],
                                    disbursed_at=r["disbursed_at"], evento=ev.event_id)
    erp.registrar_evento(ev.event_id)
    return "", 200

The same event_id can arrive more than once (retries, manual replays with id <original>-retry-<hex>): that’s why registrar_evento goes at the end and evento_aplicado at the start. Compare amounts as decimals, never as floats (Amounts).

4. The daily reconciliation job #

Webhooks are the main channel, but an endpoint that’s down or paused can leave gaps. A daily job reviews the applications still in DESEMBOLSO_SOLICITADO after, say, an hour, and checks them against two sources: the record of the disbursement call (transactions.get with tx_desembolso) and the application’s status in the engine (credits.get_application).

for sol in erp.pendientes_de_desembolso(mas_viejas_que_minutos=60):
    tx = client.transactions.get(sol.tx_desembolso)
    detalle = client.credits.get_application(sol.application_id)
    if tx.status == "FAILED":
        erp.alertar(sol.external_reference, f"Disbursement failed: {tx.raw.get('error')}")
    elif detalle["status"] in ("DISBURSED", "ACTIVE"):
        erp.marcar_desembolsada(sol.external_reference, disbursement_id=sol.disbursement_id,
                                disbursed_at=None, evento=f"conciliacion-{tx.transaction_id}")

To see which deliveries failed or were abandoned (and resend them), use the delivery history: Webhook retries.

5. Try it in the sandbox #

  1. Submit with 1000000001 (eligible). For the unhappy paths: 1000000004 is rejected at submission (OUTSTANDING_DEBT), 1000000003 stays IN_REVIEW and its disbursement responds 409, and an amount above 50,000,000 is rejected with AMOUNT_EXCEEDS_LIMIT. See Sandbox profiles.
  2. Walk through the disbursement lifecycle with the simulator: each call advances one step (PENDING → PROCESSING → DISBURSED); with amount: "22.22" it ends in FAILED and with "33.33" in RETURNED. That’s how you test how your ERP reacts to each outcome.
  3. Fire a test event of credits.disbursed at your endpoint with send_test.
paso = client.sandbox.simulate.credit_disbursement(app_id)                       # PENDING → PROCESSING
paso = client.sandbox.simulate.credit_disbursement(app_id, status=paso["status"])  # → DISBURSED
fallo = client.sandbox.simulate.credit_disbursement(app_id, status="PROCESSING", amount="22.22")
print(paso["status"], fallo["status"], fallo.get("reason_code"))

prueba = client.webhooks.endpoints.send_test("whe_sbx_…", event_type="credits.disbursed")
print(prueba.data)

The sandbox doesn't POST to your URL

With a sandbox key, send_test and replays respond "simulated": true but do not send a real HTTP request to your endpoint. To test the handler end to end: sign a delivery yourself with the whsec_sbx_… secret (the handler’s cURL tab) or request a key for the dev environment, which does deliver signed webhooks. More in Testing webhooks and in the sandbox CLI, whose compense listen command forwards deliveries to your machine.

Pre-production checklist #

  • Every write uses an Idempotency-Key derived from external_reference and the step.
  • You store application_id and the three transaction_id values as soon as you receive each response.
  • The handler verifies the signature over the raw body, ignores is_test, deduplicates by X-Webhook-Id and responds in under 10 s.
  • You compare resource.amount with the approved amount as a decimal.
  • While you rotate the secret, the handler accepts [new, old] (Rotation).
  • The daily job reconciles whatever didn’t arrive by webhook and raises alerts for FAILED.
  • If your key has high-value signing enabled, the decision and disbursement calls carry X-Compense-Signature (the SDKs don’t implement it; see Credits).