Recipe
Disburse a credit and reconcile
Full Caja Horizonte case: submit, decide on and disburse a payroll-deduction loan (libranza) from the ERP, receive credits.disbursed by webhook and reconcile against the ERP by external_reference, application_id and transaction_id. With the handler in all five languages and how to test it in the sandbox.
On this page
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:decideandcredits:disburse(or, better, one per function) and another withwebhooks:manageto 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.getThe 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 ERP | Where it comes from | What it’s for |
|---|---|---|
external_reference | You (HORIZONTE-CRED-2026-000123) | Business key. |
application_id | data.application_id from the submission | Key the webhook arrives with. |
tx_radicacion, tx_decision, tx_desembolso | transaction_id of each envelope | Support and transactions.get. |
monto_aprobado | What you sent in approved_amount | Compare with resource.amount. |
disbursement_id | data.disbursement_id from the disbursement | Compare with resource.disbursement_id. |
estado | Your state machine | RADICADA → APROBADA → DESEMBOLSO_SOLICITADO → DESEMBOLSADA. |
ultimo_evento | X-Webhook-Id | Deduplicate 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 awayconst tx = await client.webhooks.endpoints.create({
url: "https://erp.horizonte.example/compense/webhooks",
events: ["credits.decision", "credits.disbursed"],
label: "ERP Caja Horizonte",
});
console.log(tx.data.id, tx.data.secret);$tx = $client->webhooks->endpoints->create(
url: 'https://erp.horizonte.example/compense/webhooks',
events: ['credits.decision', 'credits.disbursed'],
label: 'ERP Caja Horizonte',
);
echo $tx->data['id'], ' ', $tx->data['secret'], PHP_EOL;Transaction tx = await client.Webhooks.Endpoints.CreateAsync(
"https://erp.horizonte.example/compense/webhooks",
new[] { "credits.decision", "credits.disbursed" },
label: "ERP Caja Horizonte");
Console.WriteLine($"{tx.Data["id"]} {tx.Data["secret"]}");tx, err := client.Webhooks.Endpoints.Create(ctx, &compense.WebhookEndpointCreateParams{
URL: "https://erp.horizonte.example/compense/webhooks",
Events: []string{"credits.decision", "credits.disbursed"},
Label: "ERP Caja Horizonte",
})
if err != nil {
log.Fatal(err)
}
fmt.Println(tx.Data["id"], tx.Data["secret"])curl -X POST https://sandbox.dev.compense.co/gw/v1/gateway/webhooks/endpoints/ \
-H "X-API-Key: $COMPENSE_API_KEY" \
-H "Idempotency-Key: horizonte-webhook-erp-1" \
-H "Content-Type: application/json" \
-d '{"url": "https://erp.horizonte.example/compense/webhooks",
"events": ["credits.decision", "credits.disbursed"], "label": "ERP Caja Horizonte"}'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")import { AffiliateRef, DECISION_APPROVE } from "@compense/sdk";
const ref = "HORIZONTE-CRED-2026-000123";
const sub = await client.credits.submitApplication({
affiliate: new AffiliateRef("CC", "1000000001"), productCode: "LIBRANZA_12M",
amount: "3500000", termMonths: 12, externalReference: ref,
idempotencyKey: `${ref}-radicacion`,
});
const appId = String(sub.data.application_id);
await erp.guardar(ref, { applicationId: appId, txRadicacion: sub.transactionId, estado: "RADICADA" });
const dec = await client.credits.decideApplication(appId, {
decision: DECISION_APPROVE, approvedAmount: "3000000",
reason: "Repayment capacity verified", idempotencyKey: `${ref}-decision`,
});
await erp.guardar(ref, { txDecision: dec.transactionId, montoAprobado: "3000000", estado: "APROBADA" });
const dis = await client.credits.disburseApplication(appId, { idempotencyKey: `${ref}-desembolso` });
await erp.guardar(ref, {
txDesembolso: dis.transactionId, disbursementId: dis.data.disbursement_id, estado: "DESEMBOLSO_SOLICITADO",
});use Compense\Sdk\Model\AffiliateRef;
use Compense\Sdk\Model\Decision;
$ref = 'HORIZONTE-CRED-2026-000123';
$sub = $client->credits->submitApplication(
affiliate: new AffiliateRef('CC', '1000000001'), productCode: 'LIBRANZA_12M',
amount: '3500000', termMonths: 12, externalReference: $ref,
idempotencyKey: "{$ref}-radicacion",
);
$appId = $sub->data['application_id'];
$erp->guardar($ref, ['application_id' => $appId, 'tx_radicacion' => $sub->transactionId, 'estado' => 'RADICADA']);
$dec = $client->credits->decideApplication(
$appId, decision: Decision::APPROVE, approvedAmount: '3000000',
reason: 'Repayment capacity verified', idempotencyKey: "{$ref}-decision",
);
$erp->guardar($ref, ['tx_decision' => $dec->transactionId, 'monto_aprobado' => '3000000', 'estado' => 'APROBADA']);
$dis = $client->credits->disburseApplication($appId, idempotencyKey: "{$ref}-desembolso");
$erp->guardar($ref, [
'tx_desembolso' => $dis->transactionId,
'disbursement_id' => $dis->data['disbursement_id'] ?? null,
'estado' => 'DESEMBOLSO_SOLICITADO',
]);const string reference = "HORIZONTE-CRED-2026-000123";
Transaction sub = await client.Credits.SubmitApplicationAsync(
new AffiliateRef("CC", "1000000001"), "LIBRANZA_12M", "3500000", 12, reference,
idempotencyKey: $"{reference}-radicacion");
string appId = sub.Data["application_id"]!.GetValue<string>();
await erp.GuardarAsync(reference, applicationId: appId, txRadicacion: sub.TransactionId, estado: "RADICADA");
Transaction dec = await client.Credits.DecideApplicationAsync(
appId, Decisions.Approve, approvedAmount: "3000000",
reason: "Repayment capacity verified", idempotencyKey: $"{reference}-decision");
await erp.GuardarAsync(reference, txDecision: dec.TransactionId, montoAprobado: "3000000", estado: "APROBADA");
Transaction dis = await client.Credits.DisburseApplicationAsync(appId, idempotencyKey: $"{reference}-desembolso");
await erp.GuardarAsync(reference, txDesembolso: dis.TransactionId,
disbursementId: dis.Data["disbursement_id"]?.GetValue<string>(), estado: "DESEMBOLSO_SOLICITADO");ref := "HORIZONTE-CRED-2026-000123"
sub, err := client.Credits.SubmitApplication(ctx, &compense.CreditApplicationParams{
Affiliate: compense.AffiliateRef{DocumentType: "CC", DocumentNumber: "1000000001"},
ProductCode: "LIBRANZA_12M", Amount: "3500000", TermMonths: 12, ExternalReference: ref,
}, compense.WithIdempotencyKey(ref+"-radicacion"))
if err != nil {
log.Fatal(err)
}
appID, _ := sub.Data["application_id"].(string)
erp.Guardar(ref, map[string]any{"application_id": appID, "tx_radicacion": sub.TransactionID, "estado": "RADICADA"})
dec, err := client.Credits.DecideApplication(ctx, appID, &compense.CreditDecisionParams{
Decision: compense.DecisionApprove, ApprovedAmount: "3000000", Reason: "Repayment capacity verified",
}, compense.WithIdempotencyKey(ref+"-decision"))
if err != nil {
log.Fatal(err)
}
erp.Guardar(ref, map[string]any{"tx_decision": dec.TransactionID, "monto_aprobado": "3000000", "estado": "APROBADA"})
dis, err := client.Credits.DisburseApplication(ctx, appID, compense.WithIdempotencyKey(ref+"-desembolso"))
if err != nil {
log.Fatal(err)
}
erp.Guardar(ref, map[string]any{"tx_desembolso": dis.TransactionID,
"disbursement_id": dis.Data["disbursement_id"], "estado": "DESEMBOLSO_SOLICITADO"})REF="HORIZONTE-CRED-2026-000123"
GW="https://sandbox.dev.compense.co/gw/v1/gateway"
APP_ID=$(curl -s -X POST "$GW/credits/application/" \
-H "X-API-Key: $COMPENSE_API_KEY" -H "Idempotency-Key: $REF-radicacion" \
-H "Content-Type: application/json" \
-d '{"afiliado": {"document_type": "CC", "document_number": "1000000001"},
"product_code": "LIBRANZA_12M", "amount": "3500000", "term_months": 12,
"external_reference": "'"$REF"'"}' | jq -r .data.application_id)
curl -s -X POST "$GW/credits/applications/$APP_ID/decision/" \
-H "X-API-Key: $COMPENSE_API_KEY" -H "Idempotency-Key: $REF-decision" \
-H "Content-Type: application/json" \
-d '{"decision": "APPROVE", "approved_amount": "3000000", "reason": "Repayment capacity verified"}'
curl -s -X POST "$GW/credits/applications/$APP_ID/disburse/" \
-H "X-API-Key: $COMPENSE_API_KEY" -H "Idempotency-Key: $REF-desembolso"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:
- Verifies the signature over the raw body (never over re-serialized JSON) and responds
401if it isn’t valid. - Ignores tests (
is_test): responds200to them without touching your accounting. - Deduplicates by
X-Webhook-Id: delivery is at least once. - Reconciles: looks up the application by
resource.application_id, comparesresource.amountwith the approved amount, and storesdisbursement_idanddisbursed_at. - Responds
2xxquickly (under 10 s). If your database fails, respond5xxand Compense will retry (Webhook retries).
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 "", 200import express from "express";
import { verifyWebhook, WebhookVerificationError, type WebhookEvent } from "@compense/sdk/webhooks";
const app = express();
const SECRETOS = [process.env.COMPENSE_WEBHOOK_SECRET!]; // [new, old] while you rotate
app.post("/compense/webhooks", express.raw({ type: "*/*" }), async (req, res) => {
let ev: WebhookEvent;
try {
ev = verifyWebhook(req.body, req.headers, SECRETOS);
} catch (err) {
if (err instanceof WebhookVerificationError) return res.sendStatus(401);
throw err;
}
if (ev.isTest || (await erp.eventoAplicado(ev.eventId))) return res.sendStatus(200);
if (ev.event === "credits.disbursed") {
const r = ev.resource as Record<string, string>;
const sol = await erp.buscarPorApplicationId(r.application_id);
if (!sol) await erp.registrarHuerfano(ev.eventId, ev.payload);
else if (Number(r.amount) !== Number(sol.montoAprobado)) await erp.marcarDiferencia(sol.externalReference, r.amount, ev.eventId);
else await erp.marcarDesembolsada(sol.externalReference, r.disbursement_id, r.disbursed_at, ev.eventId);
}
await erp.registrarEvento(ev.eventId);
res.sendStatus(200);
});<?php
require __DIR__ . '/vendor/autoload.php';
use Compense\Sdk\Webhook\Webhook;
use Compense\Sdk\Webhook\WebhookVerificationException;
try {
$ev = Webhook::verify(
file_get_contents('php://input'), // RAW body
getallheaders(),
[getenv('COMPENSE_WEBHOOK_SECRET')], // [new, old] while you rotate
);
} catch (WebhookVerificationException) {
http_response_code(401);
exit;
}
if ($ev->isTest() || $erp->eventoAplicado($ev->eventId)) {
http_response_code(200);
exit;
}
if ($ev->event() === 'credits.disbursed') {
$r = $ev->resource();
$sol = $erp->buscarPorApplicationId($r['application_id']);
if ($sol === null) {
$erp->registrarHuerfano($ev->eventId, $ev->payload);
} elseif (bccomp($r['amount'], $sol['monto_aprobado'], 2) !== 0) {
$erp->marcarDiferencia($sol['external_reference'], $r['amount'], $ev->eventId);
} else {
$erp->marcarDesembolsada($sol['external_reference'], $r['disbursement_id'], $r['disbursed_at'], $ev->eventId);
}
}
$erp->registrarEvento($ev->eventId);
http_response_code(200);using Compense.Sdk.Webhooks;
var app = WebApplication.CreateBuilder(args).Build();
string[] secretos = { Environment.GetEnvironmentVariable("COMPENSE_WEBHOOK_SECRET")! };
app.MapPost("/compense/webhooks", async (HttpRequest request, Erp erp) =>
{
using var buffer = new MemoryStream();
await request.Body.CopyToAsync(buffer); // RAW body
WebhookEvent ev;
try
{
ev = CompenseWebhooks.Verify(buffer.ToArray(), request.Headers, secretos);
}
catch (WebhookVerificationException)
{
return Results.Unauthorized();
}
if (ev.IsTest || await erp.EventoAplicadoAsync(ev.EventId)) return Results.Ok();
if (ev.Event == "credits.disbursed")
{
var r = ev.Resource;
var sol = await erp.BuscarPorApplicationIdAsync(r["application_id"]!.GetValue<string>());
decimal monto = decimal.Parse(r["amount"]!.GetValue<string>(), System.Globalization.CultureInfo.InvariantCulture);
if (sol is null) await erp.RegistrarHuerfanoAsync(ev.EventId, ev.Payload);
else if (monto != sol.MontoAprobado) await erp.MarcarDiferenciaAsync(sol.ExternalReference, monto, ev.EventId);
else await erp.MarcarDesembolsadaAsync(sol.ExternalReference, r["disbursement_id"]!.GetValue<string>(), ev.EventId);
}
await erp.RegistrarEventoAsync(ev.EventId);
return Results.Ok();
});
app.Run();package main
import (
"context"
"fmt"
"net/http"
"os"
"github.com/Appiasoft/compense-saas-sdk-go/webhook"
)
func main() {
secretos := []string{os.Getenv("COMPENSE_WEBHOOK_SECRET")} // [new, old] while you rotate
// Handler verifies the signature (401 if it fails) and responds 500 if the function returns
// an error, so that Compense retries the delivery.
http.Handle("/compense/webhooks", webhook.Handler(secretos,
func(ctx context.Context, ev *webhook.Event) error {
if ev.IsTest() || erp.EventoAplicado(ctx, ev.ID) {
return nil
}
if ev.Event() == "credits.disbursed" {
r := ev.Resource()
appID := fmt.Sprint(r["application_id"])
sol, err := erp.BuscarPorApplicationID(ctx, appID)
switch {
case err != nil:
return err
case sol == nil:
erp.RegistrarHuerfano(ctx, ev.ID, ev.Payload)
case fmt.Sprint(r["amount"]) != sol.MontoAprobado:
erp.MarcarDiferencia(ctx, sol.ExternalReference, fmt.Sprint(r["amount"]), ev.ID)
default:
erp.MarcarDesembolsada(ctx, sol.ExternalReference, fmt.Sprint(r["disbursement_id"]), ev.ID)
}
}
return erp.RegistrarEvento(ctx, ev.ID)
}))
http.ListenAndServe(":8080", nil)
}# Sign a delivery yourself and send it to your local handler: HMAC-SHA256 of the raw
# body with the endpoint's secret (in the sandbox, the whsec_sbx_… that create returned).
CUERPO='{"event":"credits.disbursed","version":"1","tenant_id":"horizonte","occurred_at":"2026-11-02T15:00:00Z","resource":{"application_id":"sbx-E-2cf1b69c533d","disbursement_id":"dsb-E-506a3cd0df6b","amount":"3000000.00","currency":"COP","method":"ach_credit","disbursed_at":"2026-11-02T15:00:00Z","status":"DISBURSED"}}'
FIRMA=$(printf '%s' "$CUERPO" | openssl dgst -sha256 -hmac "$COMPENSE_WEBHOOK_SECRET" -hex | sed 's/^.* //')
curl -X POST http://localhost:8080/compense/webhooks \
-H "Content-Type: application/json" \
-H "X-Webhook-Event: credits.disbursed" \
-H "X-Webhook-Id: local-0001" \
-H "X-Webhook-Timestamp: $(date +%s)" \
-H "X-Webhook-Signature: sha256=$FIRMA" \
--data-binary "$CUERPO"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}")for (const sol of await erp.pendientesDeDesembolso({ masViejasQueMinutos: 60 })) {
const tx = await client.transactions.get(sol.txDesembolso);
const detalle = await client.credits.getApplication(sol.applicationId);
if (tx.status === "FAILED") await erp.alertar(sol.externalReference, `Disbursement failed: ${tx.raw.error}`);
else if (detalle.status === "DISBURSED" || detalle.status === "ACTIVE") {
await erp.marcarDesembolsada(sol.externalReference, sol.disbursementId, null, `conciliacion-${tx.transactionId}`);
}
}foreach ($erp->pendientesDeDesembolso(60) as $sol) {
$tx = $client->transactions->get($sol['tx_desembolso']);
$detalle = $client->credits->getApplication($sol['application_id']);
if ($tx->status === 'FAILED') {
$erp->alertar($sol['external_reference'], 'Disbursement failed: ' . ($tx->raw['error'] ?? ''));
} elseif (in_array($detalle['status'], ['DISBURSED', 'ACTIVE'], true)) {
$erp->marcarDesembolsada($sol['external_reference'], $sol['disbursement_id'], null, "conciliacion-{$tx->transactionId}");
}
}foreach (var sol in await erp.PendientesDeDesembolsoAsync(TimeSpan.FromMinutes(60)))
{
TransactionStatus tx = await client.Transactions.GetAsync(sol.TxDesembolso);
var detalle = await client.Credits.GetApplicationAsync(sol.ApplicationId);
string estado = detalle["status"]?.GetValue<string>() ?? "";
if (tx.Status == "FAILED") await erp.AlertarAsync(sol.ExternalReference, $"Disbursement failed: {tx.Raw["error"]}");
else if (estado is "DISBURSED" or "ACTIVE")
await erp.MarcarDesembolsadaAsync(sol.ExternalReference, sol.DisbursementId, $"conciliacion-{tx.TransactionId}");
}pendientes, err := erp.PendientesDeDesembolso(ctx, 60*time.Minute)
if err != nil {
log.Fatal(err)
}
for _, sol := range pendientes {
tx, err := client.Transactions.Get(ctx, sol.TxDesembolso)
if err != nil {
log.Println(err)
continue
}
detalle, err := client.Credits.GetApplication(ctx, sol.ApplicationID)
if err != nil {
log.Println(err)
continue
}
switch {
case tx.Status == "FAILED":
erp.Alertar(ctx, sol.ExternalReference, fmt.Sprint("Disbursement failed: ", tx.Raw["error"]))
case detalle["status"] == "DISBURSED" || detalle["status"] == "ACTIVE":
erp.MarcarDesembolsada(ctx, sol.ExternalReference, sol.DisbursementID, "conciliacion-"+tx.TransactionID)
}
}# For a pending application: the record of the call and the status in the engine.
curl -s "$GW/transactions/$TX_DESEMBOLSO/" -H "X-API-Key: $COMPENSE_API_KEY" | jq '{status, reference, error}'
curl -s "$GW/credits/applications/$APP_ID/" -H "X-API-Key: $COMPENSE_API_KEY" | jq '{status, reason_code}'To see which deliveries failed or were abandoned (and resend them), use the delivery history: Webhook retries.
5. Try it in the sandbox #
- Submit with
1000000001(eligible). For the unhappy paths:1000000004is rejected at submission (OUTSTANDING_DEBT),1000000003staysIN_REVIEWand its disbursement responds409, and anamountabove 50,000,000 is rejected withAMOUNT_EXCEEDS_LIMIT. See Sandbox profiles. - Walk through the disbursement lifecycle with the simulator: each call advances one step
(
PENDING → PROCESSING → DISBURSED); withamount: "22.22"it ends inFAILEDand with"33.33"inRETURNED. That’s how you test how your ERP reacts to each outcome. - Fire a test event of
credits.disbursedat your endpoint withsend_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)let paso = await client.sandbox.simulate.creditDisbursement(appId);
paso = await client.sandbox.simulate.creditDisbursement(appId, { status: String(paso.status) });
const fallo = await client.sandbox.simulate.creditDisbursement(appId, { status: "PROCESSING", amount: "22.22" });
console.log(paso.status, fallo.status, fallo.reason_code);
const prueba = await client.webhooks.endpoints.sendTest("whe_sbx_…", { eventType: "credits.disbursed" });
console.log(prueba.data);$paso = $client->sandbox->simulate->creditDisbursement($appId);
$paso = $client->sandbox->simulate->creditDisbursement($appId, status: $paso['status']);
$fallo = $client->sandbox->simulate->creditDisbursement($appId, status: 'PROCESSING', amount: '22.22');
echo $paso['status'], ' ', $fallo['status'], ' ', $fallo['reason_code'] ?? '', PHP_EOL;
$prueba = $client->webhooks->endpoints->sendTest('whe_sbx_…', eventType: 'credits.disbursed');var paso = await client.Sandbox.Simulate.CreditDisbursementAsync(appId);
paso = await client.Sandbox.Simulate.CreditDisbursementAsync(appId, paso["status"]!.GetValue<string>());
var fallo = await client.Sandbox.Simulate.CreditDisbursementAsync(appId, "PROCESSING", "22.22");
Console.WriteLine($"{paso["status"]} {fallo["status"]} {fallo["reason_code"]}");
Transaction prueba = await client.Webhooks.Endpoints.SendTestAsync("whe_sbx_…", "credits.disbursed");paso, err := client.Sandbox.Simulate.CreditDisbursement(ctx, appID, &compense.SimulationState{})
if err != nil {
log.Fatal(err)
}
paso, err = client.Sandbox.Simulate.CreditDisbursement(ctx, appID,
&compense.SimulationState{Status: fmt.Sprint(paso["status"])})
if err != nil {
log.Fatal(err)
}
fallo, err := client.Sandbox.Simulate.CreditDisbursement(ctx, appID,
&compense.SimulationState{Status: "PROCESSING", Amount: "22.22"})
if err != nil {
log.Fatal(err)
}
fmt.Println(paso["status"], fallo["status"], fallo["reason_code"])
_, err = client.Webhooks.Endpoints.SendTest(ctx, "whe_sbx_…", &compense.SendTestParams{EventType: "credits.disbursed"})curl -X POST "$GW/sandbox/credits/$APP_ID/advance/" \
-H "X-API-Key: $COMPENSE_API_KEY" -H "Content-Type: application/json" \
-d '{"status": "PROCESSING", "amount": "22.22"}'
# {"object": "disbursement", "previous_status": "PROCESSING", "status": "FAILED", "terminal": true,
# "reason_code": "DISBURSEMENT_FAILED", ...}
curl -X POST "$GW/webhooks/endpoints/whe_sbx_…/test/" \
-H "X-API-Key: $COMPENSE_API_KEY" -H "Idempotency-Key: prueba-desembolso-1" \
-H "Content-Type: application/json" -d '{"event_type": "credits.disbursed"}'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-Keyderived fromexternal_referenceand the step. - You store
application_idand the threetransaction_idvalues as soon as you receive each response. - The handler verifies the signature over the raw body, ignores
is_test, deduplicates byX-Webhook-Idand responds in under 10 s. - You compare
resource.amountwith 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).