Receta
Desembolsar un crédito y conciliar
Caso completo de Caja Horizonte: radicar, decidir y desembolsar una libranza desde el ERP, recibir credits.disbursed por webhook y conciliar contra el ERP por external_reference, application_id y transaction_id. Con el handler en los cinco lenguajes y cómo probarlo en el sandbox.
En esta página
Caja Horizonte origina libranzas desde su ERP. El analista aprueba en el ERP, tesorería ordena
el desembolso y contabilidad necesita saber, sin consultar a nadie, cuándo salió la plata,
cuánto y contra qué solicitud. Esta receta arma ese circuito de punta a punta con el gateway:
radicar → decidir → desembolsar → recibir credits.disbursed → conciliar.
Necesitas:
- Una llave con
credits:submit,credits:read,credits:decideycredits:disburse(o una por función, mejor) y otra conwebhooks:managepara suscribir el endpoint. - Un endpoint HTTPS público en tu ERP para recibir webhooks.
- Los SDK instalados (Instalación) y conocer el módulo Créditos.
El circuito #
sequenceDiagram
autonumber
participant ERP as ERP Caja Horizonte
participant G as Gateway Compense
participant M as Motor de créditos
participant H as Handler de webhooks (ERP)
ERP->>G: submit_application (external_reference, Idempotency-Key)
G-->>ERP: transaction_id + data.application_id
Note over ERP: guarda external_reference ↔ application_id
ERP->>G: decide_application APPROVE (approved_amount)
G-->>ERP: data.status = APPROVED
ERP->>G: disburse_application (Idempotency-Key estable)
G->>M: desembolsar
G-->>ERP: transaction_id + data.disbursement_id
M--)H: credits.disbursed {application_id, disbursement_id, amount}
H->>H: verifica firma, deduplica por X-Webhook-Id
H->>ERP: busca por application_id, compara monto, marca DESEMBOLSADA
Note over ERP,G: job diario: lo que no llegó por webhook se concilia con transactions.getLa tabla de conciliación #
El webhook credits.disbursed trae application_id, disbursement_id, amount, currency,
method, disbursed_at y status, pero no tu external_reference ni el transaction_id del
gateway. Por eso el paso más importante es guardar, en el momento de radicar, la relación entre tus
ids y los de Compense:
| Columna en tu ERP | De dónde sale | Para qué |
|---|---|---|
external_reference | Tú (HORIZONTE-CRED-2026-000123) | Llave de negocio. |
application_id | data.application_id de la radicación | Llave con la que llega el webhook. |
tx_radicacion, tx_decision, tx_desembolso | transaction_id de cada sobre | Soporte y transactions.get. |
monto_aprobado | Lo que enviaste en approved_amount | Comparar con resource.amount. |
disbursement_id | data.disbursement_id del desembolso | Comparar con resource.disbursement_id. |
estado | Tu máquina de estados | RADICADA → APROBADA → DESEMBOLSO_SOLICITADO → DESEMBOLSADA. |
ultimo_evento | X-Webhook-Id | Deduplicar entregas repetidas. |
Las Idempotency-Key se derivan de external_reference (…-radicacion, …-decision,
…-desembolso), así que un reintento del ERP nunca radica ni desembolsa dos veces, aunque el
proceso se caiga a mitad de camino.
1. Suscribe el endpoint #
Una sola vez por entorno. El secreto (whsec_…) se muestra solo en esta respuesta: guárdalo en
tu gestor de secretos. Detalle en Suscripción.
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"]) # guarda el secreto yaconst 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. Origina: radicar, decidir y desembolsar #
En la vida real cada paso ocurre en un momento distinto (la radicación en el canal, la decisión en
el back-office, el desembolso en el cierre de tesorería). Aquí van seguidos para ver el hilo
completo; lo importante es qué se guarda después de cada respuesta. erp representa tu capa de
datos.
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="Capacidad de pago verificada", 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: "Capacidad de pago verificada", 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: 'Capacidad de pago verificada', 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: "Capacidad de pago verificada", 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: "Capacidad de pago verificada",
}, 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": "Capacidad de pago verificada"}'
curl -s -X POST "$GW/credits/applications/$APP_ID/disburse/" \
-H "X-API-Key: $COMPENSE_API_KEY" -H "Idempotency-Key: $REF-desembolso"Si cualquiera de las tres llamadas falla por red o por un 502/504, repite la misma llamada con
la misma llave: o se ejecuta por primera vez o recibes la respuesta original con
idempotent_replay: true. Un 422 en el desembolso significa que la solicitud no está aprobada, y un
409 con title: Request rejected by engine, que ya se está desembolsando o ya se desembolsó: en
ninguno de los dos casos reintentes; revisa el detalle.
3. Recibe credits.disbursed y concilia #
El handler hace cinco cosas, en este orden:
- Verifica la firma sobre el cuerpo crudo (nunca sobre un JSON re-serializado) y responde
401si no es válida. - Ignora las pruebas (
is_test): respóndeles200sin tocar tu contabilidad. - Deduplica por
X-Webhook-Id: la entrega es al menos una vez. - Concilia: busca la solicitud por
resource.application_id, compararesource.amountcon el monto aprobado y guardadisbursement_idydisbursed_at. - Responde
2xxrápido (menos de 10 s). Si tu base de datos falla, responde5xxy Compense reintentará (Reintentos de webhooks).
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"]] # [nuevo, viejo] mientras rotas
@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) # no la originó el 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!]; // [nuevo, viejo] mientras rotas
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'), // cuerpo CRUDO
getallheaders(),
[getenv('COMPENSE_WEBHOOK_SECRET')], // [nuevo, viejo] mientras rotas
);
} 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); // cuerpo CRUDO
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")} // [nuevo, viejo] mientras rotas
// Handler verifica la firma (401 si falla) y responde 500 si la función devuelve error,
// para que Compense reintente la entrega.
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)
}# Firma tú mismo una entrega y envíala a tu handler local: HMAC-SHA256 del cuerpo
# crudo con el secreto del endpoint (en el sandbox, el whsec_sbx_… que te devolvió create).
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"El mismo event_id puede llegar más de una vez (reintentos, replays manuales con id
<original>-retry-<hex>): por eso registrar_evento va al final y evento_aplicado al principio.
Compara montos como decimales, nunca como floats (Montos).
4. El job de conciliación diaria #
Los webhooks son la vía principal, pero un endpoint caído o pausado puede dejar huecos. Un job
diario revisa las solicitudes que siguen en DESEMBOLSO_SOLICITADO después de, digamos, una hora, y
las contrasta con dos fuentes: el registro de la llamada de desembolso (transactions.get con
tx_desembolso) y el estado de la solicitud en el motor (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"Desembolso fallido: {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, `Desembolso fallido: ${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'], 'Desembolso fallido: ' . ($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, $"Desembolso fallido: {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("Desembolso fallido: ", tx.Raw["error"]))
case detalle["status"] == "DISBURSED" || detalle["status"] == "ACTIVE":
erp.MarcarDesembolsada(ctx, sol.ExternalReference, sol.DisbursementID, "conciliacion-"+tx.TransactionID)
}
}# Para una solicitud pendiente: el registro de la llamada y el estado en el motor.
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}'Para ver qué entregas fallaron o quedaron abandonadas (y reenviarlas), usa el historial de entregas: Reintentos de webhooks.
5. Pruébalo en el sandbox #
- Radica con
1000000001(elegible). Para los caminos tristes:1000000004se rechaza al radicar (OUTSTANDING_DEBT),1000000003quedaIN_REVIEWy su desembolso responde409, y unamountmayor a 50.000.000 se rechaza conAMOUNT_EXCEEDS_LIMIT. Ver Perfiles del sandbox. - Recorre el ciclo del desembolso con el simulador: cada llamada avanza un paso
(
PENDING → PROCESSING → DISBURSED); conamount: "22.22"termina enFAILEDy con"33.33"enRETURNED. Así pruebas cómo reacciona tu ERP a cada final. - Dispara un evento de prueba de
credits.disbursedcontra tu endpoint consend_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"}'El sandbox no hace POST a tu URL
Con una llave de sandbox, send_test y los replays responden "simulated": true pero no
envían una petición HTTP real a tu endpoint. Para probar el handler de punta a punta: firma tú mismo
una entrega con el secreto whsec_sbx_… (pestaña cURL del handler) o pide una llave del entorno
dev, que sí entrega webhooks firmados. Más en Probar webhooks y en la
CLI del sandbox, cuyo comando compense listen reenvía las entregas a tu máquina.
Lista de chequeo antes de producción #
- Cada escritura usa una
Idempotency-Keyderivada deexternal_referencey del paso. - Guardas
application_idy los trestransaction_idapenas recibes cada respuesta. - El handler verifica la firma sobre el cuerpo crudo, ignora
is_test, deduplica porX-Webhook-Idy responde en menos de 10 s. - Comparas
resource.amountcon el monto aprobado como decimal. - Mientras rotas el secreto, el handler acepta
[nuevo, viejo](Rotación). - El job diario concilia lo que no llegó por webhook y alerta los
FAILED. - Si tu llave tiene firma de alto valor activa, las llamadas de decisión y desembolso llevan
X-Compense-Signature(los SDK no la implementan; ver Créditos).