Webhooks
Probar tus webhooks
Envía eventos de prueba firmados a tu endpoint, dispáralos desde la consola con compense trigger, recibe entregas reales en localhost con compense listen, prueba tu receptor sin red y conoce qué simula el sandbox de webhooks.
En esta página
Antes de salir a producción quieres ver tu receptor aceptando una entrega firmada de verdad,
rechazando una alterada y respondiendo a tiempo. Esta página cubre las cuatro herramientas para
lograrlo: el evento de prueba del API, la CLI compense (trigger y listen), las pruebas unitarias
sin red y el sandbox de webhooks.
Evento de prueba por API #
POST/v1/gateway/webhooks/endpoints/{endpoint_id}/test/
hace que Compense envíe a uno de tus endpoints una entrega
firmada exactamente como una real:
- sin cuerpo, el tipo es
test.ping; - con
event_type, se envía elpayload_examplede ese evento del catálogo (por ejemplocredits.decision), para que pruebes el manejador de ese evento.
La prueba se distingue de una entrega real de dos formas: el payload trae "test": true y el
event_id (cabecera X-Webhook-Id) empieza por test-. Tu receptor debe responderla con 2xx
y no procesarla como real (ver Ignora los eventos de prueba).
El endpoint debe estar active; si no, la respuesta es 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", # omítelo para un test.ping
)
print(tx.data["delivery_id"], tx.data["event_id"], tx.data["status"])
# Sigue el resultado de la entrega:
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", // omítelo para un 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', // omítelo para un 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"); // sin el 2.º argumento: 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"])
}# Payload de ejemplo de un evento del catálogo
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"}'
# test.ping genérico: sin cuerpo
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"
}
}El cuerpo que recibe tu endpoint lleva el sobre normal más la marca de prueba:
{
"event": "credits.decision",
"version": "1",
"tenant_id": "nova",
"occurred_at": "2026-11-02T14:00:00Z",
"resource": { "…": "payload_example del catálogo" },
"test": true
}client.sandbox.trigger_webhook quedó deprecado (Python)
La versión 1.0 del SDK de Python tenía client.sandbox.trigger_webhook(...), que llamaba una ruta que
el gateway no publica. Hoy es un atajo sobre webhooks.endpoints.send_test y emite un
DeprecationWarning: usa send_test directamente.
Desde la consola: compense trigger #
La CLI compense (incluida en los SDK de Python y JavaScript) envuelve la prueba en
una orden. Sin --endpoint, elige un endpoint activo suscrito a ese evento; si no hay ninguno,
el primero activo:
export COMPENSE_API_KEY="cmp_..."
compense trigger credits.decision # elige el endpoint por ti
compense trigger wallets.topup.completed --endpoint 3f1a2b3c-0000-4a1b-9c2d-000000000001
compense deliveries --status failed # ¿alguna prueba falló?trigger imprime en stderr a qué endpoint envió la prueba y su delivery_id, y en stdout el data
de la respuesta como JSON. Si no tienes ningún endpoint activo, termina con código 2 y te pide crear
uno.
Recibir entregas reales en localhost: compense listen #
Un endpoint debe ser una URL HTTPS pública: Compense no puede llamar a http://localhost:8000.
Para desarrollar en tu máquina con entregas reales:
- Abre un túnel HTTPS hacia tu máquina con la herramienta que uses (ngrok, cloudflared o
similar) apuntando al puerto del listener (
8787por defecto). - Registra la URL pública del túnel como endpoint (con
compense endpoint-createo el SDK) y guarda el secreto en tu receptor local. - Corre
compense listen: recibe cada entrega en127.0.0.1:8787y la reenvía tal cual (cuerpo crudo y cabeceras de firma intactos) a tu receptor local, así que tu verificación HMAC funciona igual que en producción. Imprime las cabeceras de cada entrega y le devuelve a Compense el código que respondió tu receptor, para que los reintentos reflejen la realidad.
compense listen --forward-to http://localhost:8000/compense/webhooks --port 8787
# (con el SDK de JavaScript: 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 (ambiente dev)
participant T as Túnel HTTPS público
participant L as compense listen :8787
participant A as Tu receptor :8000
C->>T: POST firmado
T->>L: POST (mismos bytes)
L->>A: POST (cuerpo crudo + cabeceras de firma)
A-->>L: 200 / 401 / 500
L-->>T: el mismo código
T-->>C: el mismo códigolisten necesita entregas reales: no funciona con el sandbox
El sandbox no hace POST HTTP a tu URL (las pruebas y los replays responden
"simulated": true), así que con una llave de sandbox listen nunca recibirá nada. Para recibir
entregas firmadas de verdad usa una llave de producción en el ambiente dev
(--environment dev, ver Entornos). listen no usa la llave: solo
reenvía; la llave la usan endpoint-create y trigger.
Prueba tu receptor sin red #
Para pruebas unitarias o de integración no necesitas a Compense: firma tú mismo un cuerpo con un secreto de prueba y pásalo por la verificación. Los SDK exponen la misma función que usa el despachador para calcular la firma.
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=(",", ":"), # JSON compacto, como lo envía Compense
).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"
# Con 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("verificación: %v", err)
}
}# Contra tu receptor corriendo en local (el mismo secreto configurado en él):
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"Casos que vale la pena cubrir en tu suite:
- Entrega válida →
200y el efecto aplicado una vez. - La misma entrega dos veces (mismo
X-Webhook-Id) →200y el efecto aplicado una vez. - Un carácter del cuerpo cambiado, o firmada con otro secreto →
401. - Timestamp de hace 10 minutos →
401(ventana de 300 s). "test": true→200sin efecto.- Evento que no manejas →
200sin efecto. - Firmada con el secreto anterior durante una rotación →
200.
Diferencias del sandbox #
Con una llave de sandbox, todo el módulo de webhooks responde de forma determinista y sin tocar el motor real. Sirve para escribir y probar tu código de administración (crear, listar, editar, rotar, consultar entregas, reintentar), no para recibir entregas.
| Aspecto | Sandbox | Producción |
|---|---|---|
| Envío HTTP a tu URL | No hay: la prueba y el replay responden "simulated": true | POST real firmado |
| Catálogo | 9 eventos de muestra, con "sample": true y un payload_example genérico | Completo (52 eventos) |
Validación de events | Solo de formato (recurso.evento, minúsculas y _) | Contra el catálogo completo |
| Id del endpoint | whe_sbx_<hash>: la misma URL da siempre el mismo id. Cualquier whe_sbx_… existe; cualquier otro id responde 404 | UUID |
| Secreto | whsec_sbx_<hash>, fijo por endpoint (no es real: sirve para practicar la verificación). Rotar devuelve otro secreto fijo | Aleatorio, mostrado una vez |
| Lista de endpoints | Siempre un endpoint de muestra | Los tuyos |
| Evento de prueba | delivery_id whd_sbx_test_… y event_id test-sbx-… | Entrega real |
| Webhooks de otros módulos | No se emiten (una PQRS radicada en sandbox no genera pqrs.created) | Se emiten |
Y tres entregas fijas por organización, con fechas fijas para que los filtros sean deterministas:
| Id | Estado | Evento | Fecha | 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, crea whd_sbx_retry_… |
whd_sbx_aban_… | abandoned (HTTP 503) | credits.disbursed | 2026-10-03 | 201, crea whd_sbx_retry_… |
Con ellas puedes probar tu script de reprocesar las fallidas:
en el sandbox recolecta exactamente dos entregas (fail y aban) y la de éxito responde 409 si
intentas reintentarla. El resto de valores de prueba del sandbox está en
Datos de prueba.