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’s payload_example (for example credits.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"))
JSON
{
  "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:

JSON
{
  "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:

Shell
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:

  1. Open an HTTPS tunnel to your machine with whatever tool you use (ngrok, cloudflared or similar) pointing to the listener port (8787 by default).
  2. Register the tunnel’s public URL as an endpoint (with compense endpoint-create or the SDK) and store the secret in your local receiver.
  3. Run compense listen: it receives each delivery on 127.0.0.1:8787 and 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.
Shell
compense listen --forward-to http://localhost:8000/compense/webhooks --port 8787
# (with the JavaScript SDK: npx compense listen --forward-to … --port 8787)
Text
→ 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 → 200
sequenceDiagram
    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 code

listen 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.

Sign and verify a test delivery
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)

Cases worth covering in your suite:

  • Valid delivery → 200 and the effect applied once.
  • The same delivery twice (same X-Webhook-Id) → 200 and 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 → 200 with no effect.
  • An event you don’t handle → 200 with 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.

AspectSandboxProduction
HTTP delivery to your URLNone: the test and the replay respond "simulated": trueReal signed POST
Catalog9 sample events, with "sample": true and a generic payload_exampleFull (52 events)
events validationFormat only (resource.event, lowercase and _)Against the full catalog
Endpoint idwhe_sbx_<hash>: the same URL always yields the same id. Any whe_sbx_… exists; any other id responds 404UUID
Secretwhsec_sbx_<hash>, fixed per endpoint (not real: it’s for practicing verification). Rotating returns another fixed secretRandom, shown once
Endpoint listAlways one sample endpointYours
Test eventdelivery_id whd_sbx_test_… and event_id test-sbx-…Real delivery
Webhooks from other modulesNot 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:

IdStatusEventDateReplay
whd_sbx_ok_…successcredits.decision2026-10-01409 WEBHOOK_DELIVERY_NOT_REPLAYABLE
whd_sbx_fail_…failed (HTTP 500)wallets.cashin.confirmed2026-10-02201, creates whd_sbx_retry_…
whd_sbx_aban_…abandoned (HTTP 503)credits.disbursed2026-10-03201, 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.