Webhooks
Test your webhooks
Send signed test events to your endpoint, fire them from the terminal with compense trigger, receive real deliveries on localhost with compense listen, test your receiver without a network and learn what the webhooks sandbox simulates.
On this page
Before going to production you want to see your receiver accept a genuinely signed delivery,
reject a tampered one and respond in time. This page covers the four tools to get there: the API test
event, the compense CLI (trigger and listen), unit tests without a network and the webhooks
sandbox.
Test event through the API #
POST/v1/gateway/webhooks/endpoints/{endpoint_id}/test/
makes Compense send one of your endpoints a delivery signed
exactly like a real one:
- with no body, the type is
test.ping; - with
event_type, it sends that catalog event’spayload_example(for examplecredits.decision), so you can test the handler for that event.
A test differs from a real delivery in two ways: the payload carries "test": true and the
event_id (X-Webhook-Id header) starts with test-. Your receiver should respond to it with
2xx and not process it as real (see Ignore test events).
The endpoint must be active; otherwise the response is 409 WEBHOOK_ENDPOINT_INACTIVE.
import os
from compense_gateway import CompenseClient
client = CompenseClient(api_key=os.environ["COMPENSE_API_KEY"], environment="sandbox")
tx = client.webhooks.endpoints.send_test(
"3f1a2b3c-0000-4a1b-9c2d-000000000001",
event_type="credits.decision", # omit it for a test.ping
)
print(tx.data["delivery_id"], tx.data["event_id"], tx.data["status"])
# Follow the delivery result:
entrega = client.webhooks.deliveries.get(tx.data["delivery_id"])
print(entrega["status"], entrega.get("response"))import { CompenseClient } from "@compense/sdk";
const client = new CompenseClient({ apiKey: process.env.COMPENSE_API_KEY!, environment: "sandbox" });
const tx = await client.webhooks.endpoints.sendTest("3f1a2b3c-0000-4a1b-9c2d-000000000001", {
eventType: "credits.decision", // omit it for a test.ping
});
console.log(tx.data.delivery_id, tx.data.event_id, tx.data.status);
const entrega = await client.webhooks.deliveries.get(String(tx.data.delivery_id));
console.log(entrega.status, entrega.response);<?php
require __DIR__ . '/vendor/autoload.php';
use Compense\Sdk\CompenseClient;
$client = new CompenseClient(apiKey: getenv('COMPENSE_API_KEY'), environment: 'sandbox');
$tx = $client->webhooks->endpoints->sendTest(
'3f1a2b3c-0000-4a1b-9c2d-000000000001',
eventType: 'credits.decision', // omit it for a test.ping
);
echo $tx->data['delivery_id'], ' ', $tx->data['event_id'], ' ', $tx->data['status'], PHP_EOL;
$entrega = $client->webhooks->deliveries->get($tx->data['delivery_id']);
echo $entrega['status'], PHP_EOL;using System.Text.Json.Nodes;
using Compense.Sdk;
using Compense.Sdk.Models;
using var client = new CompenseClient(
Environment.GetEnvironmentVariable("COMPENSE_API_KEY")!, CompenseEnvironment.Sandbox);
Transaction tx = await client.Webhooks.Endpoints.SendTestAsync(
"3f1a2b3c-0000-4a1b-9c2d-000000000001", "credits.decision"); // without the 2nd argument: test.ping
Console.WriteLine($"{tx.Data["delivery_id"]} {tx.Data["event_id"]} {tx.Data["status"]}");
JsonObject entrega = await client.Webhooks.Deliveries.GetAsync(tx.Data["delivery_id"]!.GetValue<string>());
Console.WriteLine(entrega["status"]);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)
}
tx, err := client.Webhooks.Endpoints.SendTest(ctx, "3f1a2b3c-0000-4a1b-9c2d-000000000001",
&compense.SendTestParams{EventType: "credits.decision"}) // nil: test.ping
if err != nil {
log.Fatal(err)
}
fmt.Println(tx.Data["delivery_id"], tx.Data["event_id"], tx.Data["status"])
entrega, err := client.Webhooks.Deliveries.Get(ctx, fmt.Sprint(tx.Data["delivery_id"]))
if err != nil {
log.Fatal(err)
}
fmt.Println(entrega["status"])
}# Example payload of a catalog event
curl -X POST https://sandbox.dev.compense.co/gw/v1/gateway/webhooks/endpoints/$ENDPOINT_ID/test/ \
-H "X-API-Key: $COMPENSE_API_KEY" \
-H "Idempotency-Key: prueba-credits-decision-001" \
-H "Content-Type: application/json" \
-d '{"event_type": "credits.decision"}'
# Generic test.ping: no body
curl -X POST https://sandbox.dev.compense.co/gw/v1/gateway/webhooks/endpoints/$ENDPOINT_ID/test/ \
-H "X-API-Key: $COMPENSE_API_KEY" \
-H "Idempotency-Key: prueba-ping-001"{
"status": "success",
"transaction_id": "a1b2c3d4-0000-4a1b-9c2d-000000000040",
"module": "webhooks",
"data": {
"delivery_id": "8b7c6d5e-0000-4a1b-9c2d-000000000401",
"event_type": "credits.decision",
"event_id": "test-…",
"status": "pending"
}
}The body your endpoint receives carries the normal envelope plus the test flag:
{
"event": "credits.decision",
"version": "1",
"tenant_id": "nova",
"occurred_at": "2026-11-02T14:00:00Z",
"resource": { "…": "payload_example from the catalog" },
"test": true
}client.sandbox.trigger_webhook is deprecated (Python)
Version 1.0 of the Python SDK had client.sandbox.trigger_webhook(...), which called a route the
gateway doesn’t publish. Today it’s a shortcut over webhooks.endpoints.send_test and emits a
DeprecationWarning: use send_test directly.
From the terminal: compense trigger #
The compense CLI (included in the Python and JavaScript SDKs) wraps the test in a
single command. Without --endpoint, it picks an active endpoint subscribed to that event; if
there is none, the first active one:
export COMPENSE_API_KEY="cmp_..."
compense trigger credits.decision # picks the endpoint for you
compense trigger wallets.topup.completed --endpoint 3f1a2b3c-0000-4a1b-9c2d-000000000001
compense deliveries --status failed # did any test fail?trigger prints to stderr which endpoint it sent the test to and its delivery_id, and to stdout the
response’s data as JSON. If you have no active endpoint, it exits with code 2 and asks you to
create one.
Receive real deliveries on localhost: compense listen #
An endpoint must be a public HTTPS URL: Compense can’t call http://localhost:8000. To develop on
your machine with real deliveries:
- Open an HTTPS tunnel to your machine with whatever tool you use (ngrok, cloudflared or
similar) pointing to the listener port (
8787by default). - Register the tunnel’s public URL as an endpoint (with
compense endpoint-createor the SDK) and store the secret in your local receiver. - Run
compense listen: it receives each delivery on127.0.0.1:8787and forwards it as is (raw body and signature headers intact) to your local receiver, so your HMAC verification works exactly as in production. It prints each delivery’s headers and returns to Compense the code your receiver responded with, so retries reflect reality.
compense listen --forward-to http://localhost:8000/compense/webhooks --port 8787
# (with the JavaScript SDK: npx compense listen --forward-to … --port 8787)→ POST / event=credits.decision
X-Webhook-Signature: sha256=…
X-Webhook-Timestamp: 1793628000
X-Webhook-Event: credits.decision
X-Webhook-Id: test-…
↳ forwarded to http://localhost:8000/compense/webhooks → 200sequenceDiagram
participant C as Compense (dev environment)
participant T as Public HTTPS tunnel
participant L as compense listen :8787
participant A as Your receiver :8000
C->>T: Signed POST
T->>L: POST (same bytes)
L->>A: POST (raw body + signature headers)
A-->>L: 200 / 401 / 500
L-->>T: the same code
T-->>C: the same codelisten needs real deliveries: it doesn't work with the sandbox
The sandbox doesn’t make HTTP POSTs to your URL (tests and replays respond "simulated": true),
so with a sandbox key listen will never receive anything. To receive genuinely signed deliveries,
use a production key in the dev environment (--environment dev, see
Environments). listen doesn’t use the key: it only forwards; the key is
used by endpoint-create and trigger.
Test your receiver without a network #
For unit or integration tests you don’t need Compense: sign a body yourself with a test secret and run it through verification. The SDKs expose the same function the dispatcher uses to compute the signature.
import json
import time
from compense_gateway.webhooks import expected_signature, verify_webhook
SECRETO = "whsec_prueba_local"
cuerpo = json.dumps(
{"event": "credits.decision", "version": "1", "tenant_id": "horizonte",
"occurred_at": "2026-10-27T15:20:00Z", "resource": {"decision": "approved"}},
separators=(",", ":"), # compact JSON, the way Compense sends it
).encode("utf-8")
ahora = int(time.time())
cabeceras = {
"X-Webhook-Signature": "sha256=" + expected_signature(SECRETO, cuerpo),
"X-Webhook-Event": "credits.decision",
"X-Webhook-Id": "evt-prueba-0001",
"X-Webhook-Timestamp": str(ahora),
}
event = verify_webhook(cuerpo, cabeceras, secret=SECRETO, now=ahora)
assert event.resource["decision"] == "approved"
# With Flask: app.test_client().post("/compense/webhooks", data=cuerpo, headers=cabeceras)import { expectedSignature, verifyWebhook } from "@compense/sdk/webhooks";
const SECRETO = "whsec_prueba_local";
const cuerpo = JSON.stringify({
event: "credits.decision", version: "1", tenant_id: "horizonte",
occurred_at: "2026-10-27T15:20:00Z", resource: { decision: "approved" },
});
const ahora = Math.floor(Date.now() / 1000);
const cabeceras = {
"X-Webhook-Signature": `sha256=${expectedSignature(SECRETO, cuerpo)}`,
"X-Webhook-Event": "credits.decision",
"X-Webhook-Id": "evt-prueba-0001",
"X-Webhook-Timestamp": String(ahora),
};
const event = verifyWebhook(cuerpo, cabeceras, SECRETO, { now: ahora });
console.assert(event.resource.decision === "approved");<?php
use Compense\Sdk\Webhook\Webhook;
$secreto = 'whsec_prueba_local';
$cuerpo = json_encode([
'event' => 'credits.decision', 'version' => '1', 'tenant_id' => 'horizonte',
'occurred_at' => '2026-10-27T15:20:00Z', 'resource' => ['decision' => 'approved'],
]);
$ahora = time();
$cabeceras = [
'X-Webhook-Signature' => 'sha256=' . Webhook::expectedSignature($secreto, $cuerpo),
'X-Webhook-Event' => 'credits.decision',
'X-Webhook-Id' => 'evt-prueba-0001',
'X-Webhook-Timestamp' => (string) $ahora,
];
$event = Webhook::verify($cuerpo, $cabeceras, $secreto, now: (float) $ahora);
assert($event->resource()['decision'] === 'approved');using System.Text;
using Compense.Sdk.Webhooks;
const string Secreto = "whsec_prueba_local";
byte[] cuerpo = Encoding.UTF8.GetBytes(
"{\"event\":\"credits.decision\",\"version\":\"1\",\"tenant_id\":\"horizonte\"," +
"\"occurred_at\":\"2026-10-27T15:20:00Z\",\"resource\":{\"decision\":\"approved\"}}");
long ahora = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
var cabeceras = new Dictionary<string, string>
{
["X-Webhook-Signature"] = "sha256=" + CompenseWebhooks.ExpectedSignature(Secreto, cuerpo),
["X-Webhook-Event"] = "credits.decision",
["X-Webhook-Id"] = "evt-prueba-0001",
["X-Webhook-Timestamp"] = ahora.ToString(),
};
WebhookEvent evt = CompenseWebhooks.Verify(cuerpo, cabeceras, Secreto,
now: DateTimeOffset.FromUnixTimeSeconds(ahora));
Console.WriteLine(evt.Resource["decision"]); // approvedpackage receptor
import (
"net/http"
"strconv"
"testing"
"time"
"github.com/Appiasoft/compense-saas-sdk-go/webhook"
)
func TestVerificaEntregaFirmada(t *testing.T) {
const secreto = "whsec_prueba_local"
cuerpo := []byte(`{"event":"credits.decision","version":"1","tenant_id":"horizonte",` +
`"occurred_at":"2026-10-27T15:20:00Z","resource":{"decision":"approved"}}`)
ahora := time.Now()
h := http.Header{}
h.Set("X-Webhook-Signature", "sha256="+webhook.ExpectedSignature(secreto, cuerpo))
h.Set("X-Webhook-Event", "credits.decision")
h.Set("X-Webhook-Id", "evt-prueba-0001")
h.Set("X-Webhook-Timestamp", strconv.FormatInt(ahora.Unix(), 10))
ev, err := webhook.Verify(cuerpo, h, []string{secreto},
webhook.WithNow(func() time.Time { return ahora }))
if err != nil || ev.Resource()["decision"] != "approved" {
t.Fatalf("verification: %v", err)
}
}# Against your receiver running locally (with the same secret configured in it):
SECRET="whsec_prueba_local"
BODY='{"event":"credits.decision","version":"1","tenant_id":"horizonte","resource":{"decision":"approved"}}'
SIG=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$SECRET" | sed 's/^.* //')
curl -i -X POST http://localhost:8000/compense/webhooks \
-H "Content-Type: application/json" \
-H "X-Webhook-Signature: sha256=$SIG" \
-H "X-Webhook-Event: credits.decision" \
-H "X-Webhook-Id: evt-prueba-0001" \
-H "X-Webhook-Timestamp: $(date +%s)" \
--data-binary "$BODY"Cases worth covering in your suite:
- Valid delivery →
200and the effect applied once. - The same delivery twice (same
X-Webhook-Id) →200and the effect applied once. - One character of the body changed, or signed with a different secret →
401. - Timestamp from 10 minutes ago →
401(300 s window). "test": true→200with no effect.- An event you don’t handle →
200with no effect. - Signed with the previous secret during a rotation →
200.
Sandbox differences #
With a sandbox key, the whole webhooks module responds deterministically and without touching the real engine. It’s meant for writing and testing your management code (create, list, edit, rotate, query deliveries, retry), not for receiving deliveries.
| Aspect | Sandbox | Production |
|---|---|---|
| HTTP delivery to your URL | None: the test and the replay respond "simulated": true | Real signed POST |
| Catalog | 9 sample events, with "sample": true and a generic payload_example | Full (52 events) |
events validation | Format only (resource.event, lowercase and _) | Against the full catalog |
| Endpoint id | whe_sbx_<hash>: the same URL always yields the same id. Any whe_sbx_… exists; any other id responds 404 | UUID |
| Secret | whsec_sbx_<hash>, fixed per endpoint (not real: it’s for practicing verification). Rotating returns another fixed secret | Random, shown once |
| Endpoint list | Always one sample endpoint | Yours |
| Test event | delivery_id whd_sbx_test_… and event_id test-sbx-… | Real delivery |
| Webhooks from other modules | Not emitted (a PQRS submitted in the sandbox doesn’t generate pqrs.created) | Emitted |
Plus three fixed deliveries per organization, with fixed dates so filters are deterministic:
| Id | Status | Event | Date | Replay |
|---|---|---|---|---|
whd_sbx_ok_… | success | credits.decision | 2026-10-01 | 409 WEBHOOK_DELIVERY_NOT_REPLAYABLE |
whd_sbx_fail_… | failed (HTTP 500) | wallets.cashin.confirmed | 2026-10-02 | 201, creates whd_sbx_retry_… |
whd_sbx_aban_… | abandoned (HTTP 503) | credits.disbursed | 2026-10-03 | 201, creates whd_sbx_retry_… |
With them you can test your reprocess every failed delivery
script: in the sandbox it collects exactly two deliveries (fail and aban), and the successful one
responds 409 if you try to retry it. The rest of the sandbox test values are in
Test data.