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:decide y credits:disburse (o una por función, mejor) y otra con webhooks:manage para 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.get

La 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 ERPDe dónde salePara qué
external_referenceTú (HORIZONTE-CRED-2026-000123)Llave de negocio.
application_iddata.application_id de la radicaciónLlave con la que llega el webhook.
tx_radicacion, tx_decision, tx_desembolsotransaction_id de cada sobreSoporte y transactions.get.
monto_aprobadoLo que enviaste en approved_amountComparar con resource.amount.
disbursement_iddata.disbursement_id del desembolsoComparar con resource.disbursement_id.
estadoTu máquina de estadosRADICADA → APROBADA → DESEMBOLSO_SOLICITADO → DESEMBOLSADA.
ultimo_eventoX-Webhook-IdDeduplicar 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 ya

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

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:

  1. Verifica la firma sobre el cuerpo crudo (nunca sobre un JSON re-serializado) y responde 401 si no es válida.
  2. Ignora las pruebas (is_test): respóndeles 200 sin tocar tu contabilidad.
  3. Deduplica por X-Webhook-Id: la entrega es al menos una vez.
  4. Concilia: busca la solicitud por resource.application_id, compara resource.amount con el monto aprobado y guarda disbursement_id y disbursed_at.
  5. Responde 2xx rápido (menos de 10 s). Si tu base de datos falla, responde 5xx y Compense reintentará (Reintentos de webhooks).
Handler 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 "", 200

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

Para ver qué entregas fallaron o quedaron abandonadas (y reenviarlas), usa el historial de entregas: Reintentos de webhooks.

5. Pruébalo en el sandbox #

  1. Radica con 1000000001 (elegible). Para los caminos tristes: 1000000004 se rechaza al radicar (OUTSTANDING_DEBT), 1000000003 queda IN_REVIEW y su desembolso responde 409, y un amount mayor a 50.000.000 se rechaza con AMOUNT_EXCEEDS_LIMIT. Ver Perfiles del sandbox.
  2. Recorre el ciclo del desembolso con el simulador: cada llamada avanza un paso (PENDING → PROCESSING → DISBURSED); con amount: "22.22" termina en FAILED y con "33.33" en RETURNED. Así pruebas cómo reacciona tu ERP a cada final.
  3. Dispara un evento de prueba de credits.disbursed contra tu endpoint con 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)

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-Key derivada de external_reference y del paso.
  • Guardas application_id y los tres transaction_id apenas recibes cada respuesta.
  • El handler verifica la firma sobre el cuerpo crudo, ignora is_test, deduplica por X-Webhook-Id y responde en menos de 10 s.
  • Comparas resource.amount con 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).