Webhooks
Subscribe an endpoint
Self-service webhooks through the API: browse the catalog, register a public HTTPS URL, store the secret that is shown only once, and list, edit, pause and delete endpoints, also from the compense CLI.
On this page
An endpoint is a URL you own that receives events. You register it with your API key, choose which
events it subscribes to, and Compense returns, only once, the secret it will use to sign every
delivery. Everything you do here is tied to the organization that owns the key: an id from another
organization responds 404.
Scopes #
| Scope | Allows |
|---|---|
webhooks:read | Browse the catalog, list and view endpoints, list and view deliveries. |
webhooks:manage | Create, edit and delete endpoints, rotate the secret, send tests and retry deliveries. |
A key without the scope gets 403. Request the smallest scope you need: a system that only
receives events doesn’t need any; one that manages endpoints needs webhooks:manage.
Request signing
webhooks:manage is on the list of high-value operations. If your key has request signing enabled
(X-Compense-Signature), these writes must be signed as well. See
Authentication.
1. Browse the event catalog #
GET/v1/gateway/webhooks/event-catalog/
returns every subscribable event with its category, description,
version and a payload_example, plus the legacy aliases and the signature contract. It’s the
source of truth: a new event shows up there first. Filter with category (credits,
subsidies, monetary_allowance, affiliates, wallets, loans, health, admin, pqrs,
general).
import os
from compense_gateway import CompenseClient
client = CompenseClient(api_key=os.environ["COMPENSE_API_KEY"], environment="sandbox")
catalogo = client.webhooks.event_catalog(category="wallets")
for ev in catalogo["events"]:
print(ev["event"], ev["category"], ev["version"])
print(catalogo["aliases"]) # {"subsidy.application.received": "subsidies.application.received"}import { CompenseClient } from "@compense/sdk";
const client = new CompenseClient({ apiKey: process.env.COMPENSE_API_KEY!, environment: "sandbox" });
const catalogo = await client.webhooks.eventCatalog({ category: "wallets" });
for (const ev of catalogo.events as Array<Record<string, unknown>>) {
console.log(ev.event, ev.category, ev.version);
}<?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: 'wallets');
foreach ($catalogo['events'] as $ev) {
echo $ev['event'], ' ', $ev['category'], ' ', $ev['version'], PHP_EOL;
}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("wallets");
foreach (JsonNode? ev in catalogo["events"]!.AsArray())
{
Console.WriteLine($"{ev!["event"]} {ev["category"]} {ev["version"]}");
}package main
import (
"context"
"fmt"
"log"
"os"
compense "github.com/Appiasoft/compense-saas-sdk-go"
)
func main() {
ctx := context.Background()
client, err := compense.NewClient(os.Getenv("COMPENSE_API_KEY"),
compense.WithEnvironment(compense.Sandbox))
if err != nil {
log.Fatal(err)
}
catalogo, err := client.Webhooks.EventCatalog(ctx, "wallets") // "" = all categories
if err != nil {
log.Fatal(err)
}
for _, raw := range catalogo["events"].([]any) {
ev := raw.(map[string]any)
fmt.Println(ev["event"], ev["category"], ev["version"])
}
}curl "https://sandbox.dev.compense.co/gw/v1/gateway/webhooks/event-catalog/?category=wallets" \
-H "X-API-Key: $COMPENSE_API_KEY"Response (abridged):
{
"count": 14,
"categories": { "wallets": ["wallets.cashin.initiated", "wallets.cashin.confirmed", "…"] },
"events": [
{
"event": "wallets.topup.completed",
"label": "…",
"description": "…",
"category": "wallets",
"since": "2026-10-27",
"version": "1",
"payload_example": { "event": "wallets.topup.completed", "version": "1", "tenant_id": "…", "occurred_at": "…", "resource": { "…": "…" } }
}
],
"aliases": { "subsidy.application.received": "subsidies.application.received" },
"signature": { "algorithm": "HMAC-SHA256", "headers": { "X-Webhook-Signature": "sha256=<hex …>", "…": "…" } }
}The full table, by category, is in Event catalog.
2. Register the endpoint #
POST/v1/gateway/webhooks/endpoints/
registers a URL and the list of events you want to receive.
URL rules
- It must use HTTPS and point to a public host.
localhost,*.local,*.internal, private IPs, loopback and link-local (169.254.*) are rejected, as are URLs with embedded credentials (https://user:password@…).
events rules
- A non-empty list of catalog keys.
- Legacy aliases are normalized to their canonical key (
subsidy.application.receivedis stored assubsidies.application.received).
Limit: 20 endpoints per organization (422 WEBHOOK_ENDPOINT_LIMIT when you exceed it).
from compense_gateway import WebhookEndpoint
tx = client.webhooks.endpoints.create(
url="https://erp.nova.example/compense/webhooks",
events=["credits.decision", "wallets.topup.completed"],
label="ERP Nova",
description="Accounting reconciliation",
idempotency_key="nova-wh-erp-001",
)
endpoint = WebhookEndpoint.from_dict(tx.data)
print(endpoint.id, endpoint.status, endpoint.timeout_seconds, endpoint.max_retries)
secreto = tx.data["secret"] # ONLY returned here: store it now in your secrets managerimport { WebhookEndpoint } from "@compense/sdk";
const tx = await client.webhooks.endpoints.create({
url: "https://erp.nova.example/compense/webhooks",
events: ["credits.decision", "wallets.topup.completed"],
label: "ERP Nova",
description: "Accounting reconciliation",
idempotencyKey: "nova-wh-erp-001",
});
const endpoint = WebhookEndpoint.fromDict(tx.data);
console.log(endpoint.id, endpoint.status, endpoint.timeoutSeconds, endpoint.maxRetries);
const secreto = endpoint.secret; // ONLY returned here: store it now in your secrets manager
use Compense\Sdk\Model\WebhookEndpoint;
$tx = $client->webhooks->endpoints->create(
url: 'https://erp.nova.example/compense/webhooks',
events: ['credits.decision', 'wallets.topup.completed'],
label: 'ERP Nova',
description: 'Accounting reconciliation',
idempotencyKey: 'nova-wh-erp-001',
);
$endpoint = WebhookEndpoint::fromArray($tx->data);
echo $endpoint->id, ' ', $endpoint->status, PHP_EOL;
$secreto = $tx->data['secret']; // ONLY returned here: store it now in your secrets manager
using Compense.Sdk.Models;
Transaction tx = await client.Webhooks.Endpoints.CreateAsync(
"https://erp.nova.example/compense/webhooks",
new[] { "credits.decision", "wallets.topup.completed" },
label: "ERP Nova",
description: "Accounting reconciliation",
idempotencyKey: "nova-wh-erp-001");
WebhookEndpoint endpoint = WebhookEndpoint.FromJson(tx.Data);
Console.WriteLine($"{endpoint.Id} {endpoint.Status} {endpoint.TimeoutSeconds} {endpoint.MaxRetries}");
string secreto = endpoint.Secret!; // ONLY returned here: store it now in your secrets managertx, err := client.Webhooks.Endpoints.Create(ctx, &compense.WebhookEndpointCreateParams{
URL: "https://erp.nova.example/compense/webhooks",
Events: []string{"credits.decision", "wallets.topup.completed"},
Label: "ERP Nova",
Description: "Accounting reconciliation",
}, compense.WithIdempotencyKey("nova-wh-erp-001"))
if err != nil {
log.Fatal(err)
}
endpoint := compense.WebhookEndpointFromWire(tx.Data)
fmt.Println(endpoint.ID, endpoint.Status)
secreto := endpoint.Secret // ONLY returned here: store it now in your secrets manager
_ = secretocurl -X POST https://sandbox.dev.compense.co/gw/v1/gateway/webhooks/endpoints/ \
-H "X-API-Key: $COMPENSE_API_KEY" \
-H "Idempotency-Key: nova-wh-erp-001" \
-H "Content-Type: application/json" \
-d '{
"url": "https://erp.nova.example/compense/webhooks",
"events": ["credits.decision", "wallets.topup.completed"],
"label": "ERP Nova",
"description": "Accounting reconciliation",
"active": true
}'The response is the write envelope; the endpoint goes in data:
{
"status": "success",
"transaction_id": "a1b2c3d4-0000-4a1b-9c2d-000000000010",
"module": "webhooks",
"data": {
"id": "3f1a2b3c-0000-4a1b-9c2d-000000000001",
"url": "https://erp.nova.example/compense/webhooks",
"label": "ERP Nova",
"events": ["credits.decision", "wallets.topup.completed"],
"active": true,
"status": "active",
"timeout_seconds": 10,
"max_retries": 5,
"stats": { "deliveries_count_24h": 0, "success_rate_24h": 100.0, "last_delivery_at": null },
"secret": "whsec_..."
}
}The secret is shown only once
data.secret only comes in the create and rotate responses. The gateway doesn’t store
secrets: no read returns it, and if you repeat the creation with the same Idempotency-Key you get
the original response with "idempotent_replay": true but without secret. Store it in your
secrets manager (HashiCorp Vault, Azure Key Vault, Google Secret Manager…) before doing anything else.
If you lost it, rotate it.
3. List and view your endpoints #
GET/v1/gateway/webhooks/endpoints/
and
GET/v1/gateway/webhooks/endpoints/{endpoint_id}/
return your organization’s
endpoints without the secret, with their state and statistics for the last 24 hours.
for ep in client.webhooks.endpoints.list():
print(ep.id, ep.status, ep.url, ep.events)
ep = client.webhooks.endpoints.get("3f1a2b3c-0000-4a1b-9c2d-000000000001")
print(ep.raw["stats"]) # deliveries_count_24h, success_rate_24h, last_delivery_at…for (const ep of await client.webhooks.endpoints.list()) {
console.log(ep.id, ep.status, ep.url, ep.events);
}
const ep = await client.webhooks.endpoints.get("3f1a2b3c-0000-4a1b-9c2d-000000000001");
console.log(ep.raw.stats);foreach ($client->webhooks->endpoints->list() as $ep) {
echo $ep->id, ' ', $ep->status, ' ', $ep->url, ' ', implode(',', $ep->events), PHP_EOL;
}
$ep = $client->webhooks->endpoints->get('3f1a2b3c-0000-4a1b-9c2d-000000000001');
print_r($ep->raw['stats'] ?? []);foreach (WebhookEndpoint ep in await client.Webhooks.Endpoints.ListAsync())
{
Console.WriteLine($"{ep.Id} {ep.Status} {ep.Url} {string.Join(",", ep.Events)}");
}
WebhookEndpoint uno = await client.Webhooks.Endpoints.GetAsync("3f1a2b3c-0000-4a1b-9c2d-000000000001");
Console.WriteLine(uno.Raw["stats"]);endpoints, err := client.Webhooks.Endpoints.List(ctx)
if err != nil {
log.Fatal(err)
}
for _, ep := range endpoints {
fmt.Println(ep.ID, ep.Status, ep.URL, ep.Events)
}
uno, err := client.Webhooks.Endpoints.Get(ctx, "3f1a2b3c-0000-4a1b-9c2d-000000000001")
if err != nil {
log.Fatal(err)
}
fmt.Println(uno.Raw["stats"])curl https://sandbox.dev.compense.co/gw/v1/gateway/webhooks/endpoints/ \
-H "X-API-Key: $COMPENSE_API_KEY"
curl https://sandbox.dev.compense.co/gw/v1/gateway/webhooks/endpoints/$ENDPOINT_ID/ \
-H "X-API-Key: $COMPENSE_API_KEY"4. Edit or pause an endpoint #
PATCH/v1/gateway/webhooks/endpoints/{endpoint_id}/
is a partial update: it only accepts url, events, label,
description and active. Any other field, including secret, responds
422 WEBHOOK_INVALID_FIELD (you change the secret by rotating it).
To stop receiving events without losing the history, pause with active: false; to resume,
active: true.
tx = client.webhooks.endpoints.update(
"3f1a2b3c-0000-4a1b-9c2d-000000000001",
events=["credits.decision", "credits.disbursed"],
active=False, # pause
idempotency_key="nova-wh-erp-001-pausa",
)
print(tx.data["status"]) # pausedconst tx = await client.webhooks.endpoints.update("3f1a2b3c-0000-4a1b-9c2d-000000000001", {
events: ["credits.decision", "credits.disbursed"],
active: false, // pause
idempotencyKey: "nova-wh-erp-001-pausa",
});
console.log(tx.data.status); // paused
$tx = $client->webhooks->endpoints->update(
'3f1a2b3c-0000-4a1b-9c2d-000000000001',
events: ['credits.decision', 'credits.disbursed'],
active: false, // pause
idempotencyKey: 'nova-wh-erp-001-pausa',
);
echo $tx->data['status'], PHP_EOL; // paused
Transaction pausa = await client.Webhooks.Endpoints.UpdateAsync(
"3f1a2b3c-0000-4a1b-9c2d-000000000001",
events: new[] { "credits.decision", "credits.disbursed" },
active: false, // pause
idempotencyKey: "nova-wh-erp-001-pausa");
Console.WriteLine(pausa.Data["status"]); // pausedpausa, err := client.Webhooks.Endpoints.Update(ctx, "3f1a2b3c-0000-4a1b-9c2d-000000000001",
&compense.WebhookEndpointUpdateParams{
Events: []string{"credits.decision", "credits.disbursed"},
Active: compense.Bool(false), // pause
}, compense.WithIdempotencyKey("nova-wh-erp-001-pausa"))
if err != nil {
log.Fatal(err)
}
fmt.Println(pausa.Data["status"]) // pausedcurl -X PATCH https://sandbox.dev.compense.co/gw/v1/gateway/webhooks/endpoints/$ENDPOINT_ID/ \
-H "X-API-Key: $COMPENSE_API_KEY" \
-H "Idempotency-Key: nova-wh-erp-001-pausa" \
-H "Content-Type: application/json" \
-d '{"events": ["credits.decision", "credits.disbursed"], "active": false}'A disabled endpoint (disabled by Compense) can’t be reactivated with active: true: it responds
409 WEBHOOK_ENDPOINT_DISABLED. Create another endpoint or contact support.
5. Delete an endpoint #
DELETE/v1/gateway/webhooks/endpoints/{endpoint_id}/
deletes the endpoint and its entire delivery history. It can’t
be undone.
Deleting erases the history
If you only want to stop receiving events (for example, during a maintenance window), use
active: false. That way you keep the deliveries for auditing and retrying.
tx = client.webhooks.endpoints.delete("3f1a2b3c-0000-4a1b-9c2d-000000000001")
print(tx.status)const borrado = await client.webhooks.endpoints.delete("3f1a2b3c-0000-4a1b-9c2d-000000000001");
console.log(borrado.status);$tx = $client->webhooks->endpoints->delete('3f1a2b3c-0000-4a1b-9c2d-000000000001');
echo $tx->status, PHP_EOL;Transaction borrado = await client.Webhooks.Endpoints.DeleteAsync("3f1a2b3c-0000-4a1b-9c2d-000000000001");
Console.WriteLine(borrado.Status);borrado, err := client.Webhooks.Endpoints.Delete(ctx, "3f1a2b3c-0000-4a1b-9c2d-000000000001")
if err != nil {
log.Fatal(err)
}
fmt.Println(borrado.Status)curl -X DELETE https://sandbox.dev.compense.co/gw/v1/gateway/webhooks/endpoints/$ENDPOINT_ID/ \
-H "X-API-Key: $COMPENSE_API_KEY" \
-H "Idempotency-Key: nova-wh-erp-001-borrar"The SDKs generate an Idempotency-Key automatically on every write if you don’t provide one; with
cURL, send it yourself (see Idempotency).
Endpoint states #
| State | Who sets it | Receives events | What to do |
|---|---|---|---|
active | You (on creation or with active: true) | Yes | Nothing. |
paused | You (active: false) | No | Reactivate it with active: true whenever you want. |
failing | Compense: with 10 or more deliveries in 24 h, less than 50% were successful | Yes | Check your receiver and the delivery history. |
disabled | Compense (e.g. after 3 410 Gone responses) | No | It can’t be reactivated through the API: create another endpoint or contact support. |
Errors #
All errors use application/problem+json with trace_id (see Errors).
The engine’s code comes in detail.
| HTTP | Code (in detail) | When |
|---|---|---|
401 | — | X-API-Key is missing. |
403 | — | The key doesn’t have the scope, is expired or revoked, or the IP isn’t allowed. |
404 | WEBHOOK_ENDPOINT_NOT_FOUND | The id doesn’t exist or belongs to another organization. |
409 | WEBHOOK_ENDPOINT_DISABLED | You tried to reactivate an endpoint disabled by Compense. |
409 | — | Same Idempotency-Key while the original request is still in progress. |
422 | WEBHOOK_INVALID_URL | URL that isn’t HTTPS, a private or internal host, or with credentials. |
422 | WEBHOOK_INVALID_EVENTS | events empty or with keys outside the catalog. |
422 | WEBHOOK_INVALID_FIELD | Non-modifiable field (e.g. secret) or a non-boolean active. |
422 | WEBHOOK_ENDPOINT_LIMIT | Your organization already has 20 endpoints. |
429 | — | Per-minute limit or monthly key quota. |
From the terminal: the compense CLI #
The Python and JavaScript SDKs ship the compense CLI (in Python it’s installed with the
package; in Node, npx compense). It reads the key from $COMPENSE_API_KEY and points to the sandbox
by default. For this flow it has three commands:
export COMPENSE_API_KEY="cmp_..."
compense events --category wallets # catalog (filtered)
compense endpoint-create \
--url https://erp.nova.example/compense/webhooks \
--event credits.decision \
--event wallets.topup.completed \
--label "ERP Nova" # prints the endpoint with its secret, ONCE
compense endpoints # your endpoints, without the secretThe CLI has no commands for editing, pausing, deleting or rotating: do it with the SDK or with cURL. All commands in The compense CLI.