Webhooks
Event catalog
The 52 events you can subscribe to, by category; the common envelope (event, version, tenant_id, occurred_at, resource), the classic events without an envelope, amounts as strings, the legacy alias and payload examples.
On this page
Each event has a key (credits.decision, wallets.topup.completed, pqrs.responded…) that you
use when subscribing an endpoint and that arrives in the X-Webhook-Event header and in the body’s
event field. This page is a snapshot of the catalog as of November 1, 2026 (52 events). The
source of truth is the live catalog,
GET/v1/gateway/webhooks/event-catalog/
: if a new event shows up,
you’ll see it there first, along with its payload_example.
The event envelope #
Namespaced events (every category except general) arrive with the same envelope:
{
"event": "wallets.topup.completed",
"version": "1",
"tenant_id": "nova",
"occurred_at": "2026-10-27T15:00:00Z",
"resource": { "…": "event-specific fields" }
}event: the catalog key. This is what you should route on.version: major version of theresourceschema, as a string. Adding an optional field does not change it, so your code must ignore fields it doesn’t recognize. Only a breaking change bumps the version (see Deprecations).tenant_id: your organization’s code.occurred_at: when the event happened, in UTC. Use it for ordering: deliveries do not necessarily arrive in order.resource: the event data. Its exact shape is in each event’spayload_example.
Amounts as strings #
All amounts travel as decimal strings ("amount": "20000.00"), never as floating-point numbers.
Convert them with a decimal type (Decimal in Python, decimal in C#, big.Rat or
shopspring/decimal in Go, bcmath in PHP), not with float. More in Amounts.
Privacy #
Payloads are designed to travel over the internet:
- Wallet: they never include the PAN, CVV or the full ID document number; phone numbers are masked
(
phone_number_masked) and cards only carry the last 4 digits. - Health:
resourcenever carries the patient’s full ID document number or name. - PQRS: it doesn’t carry the response text or the ID document; fetch the details through the API.
- Administrative: no diagnoses or bank details.
Classic events (general category) #
The classic credit lifecycle events (application.*, credit.*, compliance.* and
installment.due_soon) predate the envelope: they don’t carry resource and their fields come at
the top level of the body. With the SDKs, read them from the full body:
| SDK | Namespaced envelope | Classic event |
|---|---|---|
| Python | event.resource["decision"] | event.payload["score"] |
| JavaScript / TS | event.resource.decision | event.payload.score |
| PHP | $event->resource()['decision'] | $event->payload['score'] |
| .NET | evt.Resource["decision"] | evt.Payload["score"] |
| Go | ev.Resource()["decision"] | ev.Payload["score"] |
(score is just illustrative: check the payload_example of each classic event in the catalog.)
Legacy alias #
subsidy.application.received is the old name of subsidies.application.received. The gateway
still accepts it when subscribing and normalizes it to the canonical key, which is the one you’ll
see on your endpoints and in deliveries. The SDKs ship the alias map and a normalization function:
canonical_event_type (Python), canonicalEventType (JS), Webhook::canonicalEventType (PHP),
CompenseWebhooks.CanonicalEventType (.NET) and webhook.CanonicalEventType (Go).
The test event #
test.ping isn’t in the catalog (you can’t subscribe to it): it’s the type your endpoints receive
when you request a test without event_type. Tests carry "test": true and an
event_id that starts with test-.
Full catalog #
Credits #
| Event | Since | When it's emitted |
|---|---|---|
credits.application.received | 2026-06-01 | A credit application was submitted through the gateway and accepted for processing. |
credits.decision | 2026-06-01 | An advisor or the engine decided the application (decision, approved or rejected). |
credits.disbursed | 2026-06-01 | The approved credit’s funds were disbursed. |
Subsidies #
| Event | Since | When it's emitted |
|---|---|---|
subsidies.application.received | 2026-06-01 | A subsidy application was submitted (legacy alias subsidy.application.received). |
subsidies.decision | 2026-06-01 | An advisor or the rules engine decided the subsidy application. |
subsidies.disbursed | 2026-06-01 | An approved subsidy was paid out. |
Monetary allowance #
| Event | Since | When it's emitted |
|---|---|---|
monetary_allowance.generated | 2026-06-01 | An affiliate’s monetary allowance was generated for a period. |
monetary_allowance.disbursed | 2026-06-01 | A generated monetary allowance was paid out. |
Affiliates #
| Event | Since | When it's emitted |
|---|---|---|
affiliates.synced | 2026-06-01 | An affiliate sync finished (summary of affiliates created, updated and withdrawn). |
Wallet #
| Event | Since | When it's emitted |
|---|---|---|
wallets.cashin.initiated | 2026-07-21 | A cash-in intent was recorded; no money has moved yet. |
wallets.cashin.confirmed | 2026-07-21 | The source confirmed the cash-in and e-money was issued to the wallet. |
wallets.transfer.created | 2026-07-21 | A transfer (P2P, Bre-B or between pockets) was created and posted to the ledger. |
wallets.transfer.settled | 2026-07-21 | The transfer was settled. |
wallets.transfer.failed | 2026-07-21 | The rail rejected the transfer; no money moved. |
wallets.disbursement.credited | 2026-07-21 | A subsidy or credit was paid directly into the wallet. |
wallets.withdrawal.authorized | 2026-07-21 | An ATM withdrawal was authorized (single-use token and funds on hold). |
wallets.bill_payment.completed | 2026-07-21 | The holder paid a utility bill and the biller accepted it. |
wallets.qr_payment.completed | 2026-07-21 | The holder paid an interoperable QR code (EMVCo). |
wallets.transaction.reversed | 2026-07-21 | A transaction was reversed (refund). |
wallets.dispute.opened | 2026-07-21 | The holder opened a dispute on a transaction. |
wallets.dispute.resolved | 2026-07-21 | An operator resolved the dispute (in the holder’s favor, with a reversal, or rejected). |
wallets.card.issued | 2026-07-21 | A card was issued (only the last 4 digits are sent). |
wallets.topup.completed | 2026-10-27 | A mobile top-up was posted (carrier, masked phone number, amount). |
Loan portfolio (payments and arrears) #
| Event | Since | When it's emitted |
|---|---|---|
payment.received | 2026-10-27 | A loan payment (installment, extra payment or full payoff) was recorded with its channel and reference. |
payment.applied | 2026-10-27 | The payment was allocated by priority order (principal, interest, late interest, insurance, taxes). |
installment.overdue | 2026-10-27 | An installment became overdue (emitted once per installment, with days_overdue). |
loan.paid_off | 2026-10-27 | The loan balance reached zero (paid in full). |
Health services #
| Event | Since | When it's emitted |
|---|---|---|
appointment.booked | 2026-10-27 | An appointment was booked through the gateway. |
appointment.cancelled | 2026-10-27 | An appointment was cancelled and its slot was released. |
appointment.rescheduled | 2026-10-27 | An appointment was moved to another slot (previous carries the previous date and time). |
appointment.status_changed | 2026-10-27 | Attendance was recorded (completed or no_show). |
health_billing.payment.registered | 2026-10-27 | A payer (EPS) payment was applied to a health invoice. |
Administrative #
| Event | Since | When it's emitted |
|---|---|---|
admin.hr.novelty.received | 2026-10-27 | An HR adjustment was recorded. |
admin.payroll.novelty.received | 2026-10-27 | A payroll adjustment was uploaded. |
admin.accounting.entry.drafted | 2026-10-27 | A draft journal entry was created. |
admin.treasury.document.registered | 2026-10-27 | A draft collection, outgoing payment or billing statement was recorded (kind). |
admin.treasury.statement.imported | 2026-10-27 | A bank statement was uploaded. |
PQRS #
| Event | Since | When it's emitted |
|---|---|---|
pqrs.created | 2026-10-27 | A PQRS was filed. |
pqrs.status_changed | 2026-10-27 | A PQRS changed status (including moving to resolved through a final response). |
pqrs.responded | 2026-10-27 | The entity recorded an official response. |
Classic credit lifecycle (general category, no namespaced envelope) #
| Event | Since | When 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. |
Events shared between loans and the classic lifecycle
payment.received, payment.applied and installment.overdue appear in the loans category
(loan portfolio, since October 27, 2026) and share their names with the classic credit lifecycle.
Subscribe to the key once; the catalog’s payload_example tells you its shape.
Read the live payload_example #
Before writing the handler for an event, ask the catalog for its example: it’s exactly the shape that will reach your receiver.
import json
import os
from compense_gateway import CompenseClient
client = CompenseClient(api_key=os.environ["COMPENSE_API_KEY"], environment="sandbox")
catalogo = client.webhooks.event_catalog(category="loans")
ejemplo = next(e for e in catalogo["events"] if e["event"] == "payment.received")
print(json.dumps(ejemplo["payload_example"], indent=2, ensure_ascii=False))import { CompenseClient } from "@compense/sdk";
const client = new CompenseClient({ apiKey: process.env.COMPENSE_API_KEY!, environment: "sandbox" });
const catalogo = await client.webhooks.eventCatalog({ category: "loans" });
const ejemplo = (catalogo.events as Array<Record<string, unknown>>).find((e) => e.event === "payment.received");
console.log(JSON.stringify(ejemplo?.payload_example, null, 2));<?php
require __DIR__ . '/vendor/autoload.php';
use Compense\Sdk\CompenseClient;
$client = new CompenseClient(apiKey: getenv('COMPENSE_API_KEY'), environment: 'sandbox');
$catalogo = $client->webhooks->eventCatalog(category: 'loans');
foreach ($catalogo['events'] as $e) {
if ($e['event'] === 'payment.received') {
echo json_encode($e['payload_example'], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE), PHP_EOL;
}
}using System.Linq;
using System.Text.Json.Nodes;
using Compense.Sdk;
using var client = new CompenseClient(
Environment.GetEnvironmentVariable("COMPENSE_API_KEY")!, CompenseEnvironment.Sandbox);
JsonObject catalogo = await client.Webhooks.EventCatalogAsync("loans");
JsonNode? ejemplo = catalogo["events"]!.AsArray()
.FirstOrDefault(e => e!["event"]?.GetValue<string>() == "payment.received");
Console.WriteLine(ejemplo?["payload_example"]?.ToJsonString());catalogo, err := client.Webhooks.EventCatalog(ctx, "loans")
if err != nil {
log.Fatal(err)
}
for _, raw := range catalogo["events"].([]any) {
ev := raw.(map[string]any)
if ev["event"] == "payment.received" {
out, _ := json.MarshalIndent(ev["payload_example"], "", " ")
fmt.Println(string(out))
}
}curl -s "https://sandbox.dev.compense.co/gw/v1/gateway/webhooks/event-catalog/?category=loans" \
-H "X-API-Key: $COMPENSE_API_KEY" \
| jq '.events[] | select(.event == "payment.received") | .payload_example'In the sandbox the catalog is a sample
With a sandbox key, the catalog returns 9 representative events flagged with "sample": true and
a generic payload_example. To see the full catalog and the real examples, use a production key in
the dev environment. See Test your webhooks.
Payload examples #
wallets.topup.completed (wallet) #
Emitted when a mobile top-up is posted, in the same transaction as the movement.
{
"event": "wallets.topup.completed",
"version": "1",
"tenant_id": "nova",
"occurred_at": "2026-10-27T15:00:00Z",
"resource": {
"transaction_id": "c3d4e5f6-0000-4a1b-9c2d-000000000123",
"wallet_id": "7a8b9c0d-0000-4a1b-9c2d-000000000045",
"carrier": "CLARO",
"phone_number_masked": "******4567",
"amount": "20000.00",
"currency": "COP",
"status": "completed"
}
}The other wallet events document these fields in resource:
| Event | resource fields |
|---|---|
wallets.transfer.created | transaction_id, source_wallet_id, recipient_wallet_id (null if it goes to another bank), amount, currency, rail_reference, is_internal, status; Bre-B (Colombia’s instant payments) adds rail: "breb" |
wallets.transfer.failed | transaction_id, source_wallet_id, amount, currency, rail_reference, reason, status |
wallets.bill_payment.completed | transaction_id, wallet_id, service_id, amount, currency, provider, provider_reference, status |
payment.received (loans) #
Emitted when a payment on a credit is recorded (installment, partial prepayment or payoff). Its
event_id is deterministic per receipt: retrying your request never produces a second webhook for the
same payment.
{
"event": "payment.received",
"version": "1",
"tenant_id": "horizonte",
"occurred_at": "2026-10-28T14:05:00Z",
"resource": {
"credit_id": "5d6e7f80-0000-4a1b-9c2d-000000000777",
"loan_number": "CRD-00123",
"payment_id": "9e0f1a2b-0000-4a1b-9c2d-000000000901",
"receipt_id": "7c8d9e0f-0000-4a1b-9c2d-000000000902",
"affiliate_id": "1b2c3d4e-0000-4a1b-9c2d-000000000310",
"kind": "installment",
"channel": "ACH_LIBRANZA",
"reference": "HORIZONTE-REC-2026-000901",
"amount": "456789.00",
"value_date": "2026-10-28",
"received_at": "2026-10-28T14:05:00Z"
}
}| Loans event | resource fields |
|---|---|
payment.applied | applied_to_capital, applied_to_interest, applied_to_penalties, applied_to_insurance, applied_to_taxes, installments[], outstanding_principal_after, loan_status_after |
loan.paid_off | credit_id, loan_number, affiliate_id, last_payment_id, channel, paid_off_at |
installment.overdue | credit_id, loan_number, affiliate_id, installment_number, amount, due_date, days_overdue (once per installment) |
pqrs.responded (PQRS) #
Emitted when the entity records an official response, whether it comes from the app, the dashboard or the API.
{
"event": "pqrs.responded",
"version": "1",
"tenant_id": "horizonte",
"occurred_at": "2026-10-29T16:20:00Z",
"resource": {
"pqrs_id": "4f0c1d2e-0000-4a1b-9c2d-000000000123",
"radicado": "PQRS-2026-000123",
"response_id": "9a1e2b3c-0000-4a1b-9c2d-000000000456",
"is_final": true,
"status": "resolved",
"external_reference": "HORIZONTE-CRM-88231",
"source": "gateway",
"actor": "integration:<key> agent:ana.p"
}
}pqrs.created also carries issue_type, service_area, status, priority, origin_channel,
affiliate_id and deadline; pqrs.status_changed carries from_status, to_status and priority.
Each module’s guide documents its events: wallet, loans, health, administrative and PQRS.
Route by event type #
A single endpoint can receive many events: route on event and let the ones you don’t handle pass
through (with a 2xx), so a new event doesn’t trigger retries. The SDKs ship the catalog snapshot for
quick checks (EVENT_TYPES in Python, JS and PHP, CompenseWebhooks.EventTypes in .NET and
webhook.EventTypes() / webhook.IsKnownEventType() in Go), but they never reject an event they
don’t recognize.
from compense_gateway.webhooks import WebhookEvent
def manejar(event: WebhookEvent) -> None:
if event.event == "wallets.topup.completed":
registrar_recarga(event.resource["transaction_id"], event.resource["amount"])
elif event.event == "payment.received":
conciliar_pago(event.resource["receipt_id"], event.resource["amount"])
elif event.event == "pqrs.responded":
actualizar_caso(event.resource["radicado"], event.resource["status"])
# any other event: ignore it and still respond 2xximport type { WebhookEvent } from "@compense/sdk/webhooks";
function manejar(event: WebhookEvent): void {
switch (event.event) {
case "wallets.topup.completed":
registrarRecarga(String(event.resource.transaction_id), String(event.resource.amount));
break;
case "payment.received":
conciliarPago(String(event.resource.receipt_id), String(event.resource.amount));
break;
case "pqrs.responded":
actualizarCaso(String(event.resource.radicado), String(event.resource.status));
break;
default:
break; // any other event: ignore it and still respond 2xx
}
}use Compense\Sdk\Webhook\WebhookEvent;
function manejar(WebhookEvent $event): void
{
$r = $event->resource();
match ($event->event()) {
'wallets.topup.completed' => registrarRecarga($r['transaction_id'], $r['amount']),
'payment.received' => conciliarPago($r['receipt_id'], $r['amount']),
'pqrs.responded' => actualizarCaso($r['radicado'], $r['status']),
default => null, // any other event: ignore it and still respond 2xx
};
}using Compense.Sdk.Webhooks;
static void Manejar(WebhookEvent evt)
{
switch (evt.Event)
{
case "wallets.topup.completed":
RegistrarRecarga(evt.Resource["transaction_id"]!.GetValue<string>(), evt.Resource["amount"]!.GetValue<string>());
break;
case "payment.received":
ConciliarPago(evt.Resource["receipt_id"]!.GetValue<string>(), evt.Resource["amount"]!.GetValue<string>());
break;
case "pqrs.responded":
ActualizarCaso(evt.Resource["radicado"]!.GetValue<string>(), evt.Resource["status"]!.GetValue<string>());
break;
default:
break; // any other event: ignore it and still respond 2xx
}
}func manejar(ev *webhook.Event) {
r := ev.Resource()
switch ev.Event() {
case "wallets.topup.completed":
registrarRecarga(fmt.Sprint(r["transaction_id"]), fmt.Sprint(r["amount"]))
case "payment.received":
conciliarPago(fmt.Sprint(r["receipt_id"]), fmt.Sprint(r["amount"]))
case "pqrs.responded":
actualizarCaso(fmt.Sprint(r["radicado"]), fmt.Sprint(r["status"]))
default:
// any other event: ignore it and still respond 2xx
}
}# With cURL there's no receiver: see the type of each recent delivery in the history.
curl -s "https://sandbox.dev.compense.co/gw/v1/gateway/webhooks/deliveries/?page_size=20" \
-H "X-API-Key: $COMPENSE_API_KEY" | jq -r '.results[] | "\(.event_type)\t\(.status)"'The complete receiver (signature verification, deduplication and fast response) is in Verify the signature.