Webhooks
Verificar la firma
Cómo comprobar que una entrega viene de Compense: HMAC-SHA256 sobre el cuerpo crudo, receptores completos en Flask, FastAPI, Express, Laravel, ASP.NET Core y Go, deduplicación, eventos de prueba, ventana de timestamp y verificación manual sin SDK.
En esta página
Tu endpoint es una URL pública: cualquiera puede hacerle POST. La firma es lo que te garantiza
que el cuerpo lo envió Compense y que nadie lo alteró en el camino. Nunca proceses una entrega sin
verificarla primero. Esta página trae receptores completos en los cinco lenguajes de los SDK y la
verificación manual, por si no usas un SDK.
El contrato de firma #
Cada entrega es un POST con el cuerpo JSON y estas cabeceras:
| Cabecera | Valor |
|---|---|
X-Webhook-Signature | sha256=<hex>, donde <hex> = HMAC-SHA256(secreto, cuerpo crudo) en hexadecimal minúscula |
X-Webhook-Event | Tipo de evento (credits.decision, test.ping, …) |
X-Webhook-Id | event_id de la entrega: tu clave de deduplicación |
X-Webhook-Timestamp | Segundos Unix del envío |
Content-Type | application/json |
User-Agent | Compense-Webhooks/1.0 |
Las cinco reglas para verificar bien:
- Firma sobre los bytes crudos. Calcula el HMAC sobre los bytes exactos del cuerpo tal como
llegan. No lo parsees y lo vuelvas a serializar (
JSON.parse+JSON.stringify,json.loads+json.dumps,$request->json()…): Compense envía JSON compacto (sin espacios, con los caracteres no ASCII escapados como\uXXXX) y cualquier cambio rompe la firma. - Secreto como texto. Usa el secreto
whsec_...tal cual, como texto UTF-8, para la llave del HMAC. No lo decodifiques de base64 ni le quites el prefijo. - Comparación en tiempo constante (
hmac.compare_digest,crypto.timingSafeEqual,hash_equals,CryptographicOperations.FixedTimeEquals,hmac.Equal). Una comparación normal de cadenas filtra información por el tiempo de respuesta. - El timestamp no está firmado.
X-Webhook-Timestampno entra en la firma. Úsalo para rechazar entregas muy viejas (los SDK aceptan ±300 s por defecto), pero tu defensa real contra repeticiones es deduplicar porX-Webhook-Id. - Responde rápido.
2xxen menos de 10 segundos y procesa en segundo plano. Un401ante una firma inválida es la respuesta correcta; un410 Gonerepetido deshabilita tu endpoint.
Los SDK implementan exactamente estas reglas (y prueban contra los mismos vectores, byte a byte), así que la forma recomendada es usar su función de verificación.
Receptor completo con el SDK #
Cada receptor: lee el cuerpo crudo, verifica contra la lista de secretos vigentes (uno, o dos
durante una rotación), responde 401 si falla, ignora las pruebas, deduplica
por event_id, responde 200 y deja el trabajo real a una cola. La pestaña cURL simula una entrega
firmada contra tu receptor local.
# Flask — pip install compense-sdk-python flask
import os
import queue
import threading
from flask import Flask, request
from compense_gateway.webhooks import WebhookEvent, WebhookVerificationError, verify_webhook
app = Flask(__name__)
# El secreto vigente y, solo mientras rotas, el anterior.
SECRETOS = [s for s in (os.environ.get("COMPENSE_WEBHOOK_SECRET"),
os.environ.get("COMPENSE_WEBHOOK_SECRET_OLD")) if s]
procesados: set[str] = set() # en producción: tu BD o Redis (con TTL de días)
cola: "queue.Queue[WebhookEvent]" = queue.Queue()
def manejar(event: WebhookEvent) -> None:
print(event.event, event.event_id, event.resource) # tu lógica de negocio
def trabajador() -> None:
while True:
manejar(cola.get())
threading.Thread(target=trabajador, daemon=True).start()
@app.post("/compense/webhooks")
def recibir():
try:
# request.get_data(): los bytes crudos, ANTES de cualquier parseo
event = verify_webhook(request.get_data(), request.headers, secret=SECRETOS)
except WebhookVerificationError:
return "", 401 # firma o timestamp inválidos
if event.is_test: # send_test: confirma y no lo proceses
return "", 200
if event.event_id in procesados: # entrega repetida (at-least-once)
return "", 200
procesados.add(event.event_id)
cola.put(event) # el trabajo pesado, fuera del request
return "", 200// Express — npm i @compense/sdk express
import express from "express";
import { verifyWebhook, WebhookVerificationError, type WebhookEvent } from "@compense/sdk/webhooks";
const app = express();
// El secreto vigente y, solo mientras rotas, el anterior.
const secretos = [process.env.COMPENSE_WEBHOOK_SECRET, process.env.COMPENSE_WEBHOOK_SECRET_OLD].filter(
(s): s is string => Boolean(s),
);
const procesados = new Set<string>(); // en producción: tu BD o Redis (con TTL de días)
function manejar(event: WebhookEvent): void {
console.log(event.event, event.eventId, event.resource); // tu lógica de negocio
}
// express.raw en ESTA ruta: req.body llega como Buffer con los bytes crudos.
// No pongas express.json() antes de esta ruta.
app.post("/compense/webhooks", express.raw({ type: "*/*" }), (req, res) => {
let event: WebhookEvent;
try {
event = verifyWebhook(req.body, req.headers, secretos);
} catch (err) {
if (err instanceof WebhookVerificationError) return res.sendStatus(401);
throw err;
}
if (event.isTest) return res.sendStatus(200); // sendTest: no lo proceses
if (procesados.has(event.eventId)) return res.sendStatus(200); // repetido (at-least-once)
procesados.add(event.eventId);
setImmediate(() => manejar(event)); // en producción: encola (BullMQ, SQS…)
return res.sendStatus(200);
});
app.listen(8000, () => console.log("http://localhost:8000/compense/webhooks"));<?php
// Laravel — composer require compense/sdk
//
// routes/api.php (sin middleware CSRF):
// Route::post('/compense/webhooks', \App\Http\Controllers\CompenseWebhookController::class);
//
// config/services.php:
// 'compense' => [
// 'webhook_secrets' => array_values(array_filter([
// env('COMPENSE_WEBHOOK_SECRET'), env('COMPENSE_WEBHOOK_SECRET_OLD'),
// ])),
// ],
declare(strict_types=1);
namespace App\Http\Controllers;
use App\Jobs\HandleCompenseEvent;
use Compense\Sdk\Webhook\Webhook;
use Compense\Sdk\Webhook\WebhookVerificationException;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
final class CompenseWebhookController
{
public function __invoke(Request $request): JsonResponse
{
try {
$event = Webhook::verify(
$request->getContent(), // cuerpo CRUDO, no $request->all() ni json()
$request->headers->all(), // HeaderBag de Symfony
config('services.compense.webhook_secrets'),
);
} catch (WebhookVerificationException $e) {
Log::warning('Webhook de Compense rechazado', ['motivo' => $e->getMessage()]);
return response()->json(['error' => 'firma inválida'], 401);
}
if ($event->isTest()) { // sendTest: confirma y no lo proceses
return response()->json(['received' => true, 'test' => true]);
}
// At-least-once: Cache::add solo escribe si la clave no existe (7 días cubre los reintentos).
if (Cache::add('compense:webhook:' . $event->eventId, true, now()->addDays(7))) {
HandleCompenseEvent::dispatch($event->event(), $event->payload); // a la cola
}
return response()->json(['received' => true]);
}
}// ASP.NET Core minimal API (.NET 8) — dotnet add package Compense.Sdk
// Compense__WebhookSecrets__0=whsec_... (vigente)
// Compense__WebhookSecrets__1=whsec_... (anterior, solo mientras rotas)
using System.Collections.Concurrent;
using System.Threading.Channels;
using Compense.Sdk.Webhooks;
var builder = WebApplication.CreateBuilder(args);
string[] secretos = builder.Configuration.GetSection("Compense:WebhookSecrets").Get<string[]>()
?? Array.Empty<string>();
var procesados = new ConcurrentDictionary<string, bool>(); // en producción: tu BD
var cola = Channel.CreateUnbounded<WebhookEvent>();
var app = builder.Build();
// Consumidor en segundo plano: el trabajo pesado no bloquea la respuesta.
_ = Task.Run(async () =>
{
await foreach (WebhookEvent e in cola.Reader.ReadAllAsync())
{
app.Logger.LogInformation("{Event} {EventId} {Resource}", e.Event, e.EventId, e.Resource);
}
});
app.MapPost("/compense/webhooks", async (HttpRequest request) =>
{
// Los bytes crudos, exactamente como llegaron.
using var buffer = new MemoryStream();
await request.Body.CopyToAsync(buffer);
WebhookEvent evt;
try
{
evt = CompenseWebhooks.Verify(buffer.ToArray(), request.Headers, secretos);
}
catch (WebhookVerificationException ex)
{
app.Logger.LogWarning("Webhook rechazado: {Motivo}", ex.Message);
return Results.Unauthorized();
}
if (evt.IsTest) return Results.Ok(); // SendTestAsync: no lo proceses
if (!procesados.TryAdd(evt.EventId, true)) return Results.Ok(); // repetido (at-least-once)
await cola.Writer.WriteAsync(evt);
return Results.Ok();
});
app.Run();// net/http — go get github.com/Appiasoft/compense-saas-sdk-go
package main
import (
"context"
"log"
"net/http"
"os"
"sync"
"time"
"github.com/Appiasoft/compense-saas-sdk-go/webhook"
)
func main() {
// El secreto vigente y, solo mientras rotas, el anterior (los vacíos se ignoran).
secretos := []string{os.Getenv("COMPENSE_WEBHOOK_SECRET"), os.Getenv("COMPENSE_WEBHOOK_SECRET_OLD")}
var mu sync.Mutex
procesados := map[string]bool{} // en producción: tu BD o Redis
cola := make(chan *webhook.Event, 1000)
go func() { // consumidor en segundo plano
for ev := range cola {
log.Printf("%s %s %v", ev.Event(), ev.ID, ev.Resource())
}
}()
// webhook.Handler verifica (401 si falla), y responde 200 si fn devuelve nil
// o 500 si devuelve error (Compense reintentará la entrega).
h := webhook.Handler(secretos, func(ctx context.Context, ev *webhook.Event) error {
if ev.IsTest() { // SendTest: confirma y no lo proceses
return nil
}
mu.Lock()
defer mu.Unlock()
if procesados[ev.ID] { // repetido (at-least-once)
return nil
}
procesados[ev.ID] = true
cola <- ev
return nil
})
mux := http.NewServeMux()
mux.Handle("/compense/webhooks", h)
srv := &http.Server{Addr: ":8000", Handler: mux, ReadHeaderTimeout: 10 * time.Second}
log.Fatal(srv.ListenAndServe())
}# Simula una entrega firmada contra tu receptor local (útil en pruebas unitarias o de humo).
SECRET="whsec_..." # el mismo que configuraste en el receptor
BODY='{"event":"test.ping","version":"1","tenant_id":"horizonte","test":true}'
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: test.ping" \
-H "X-Webhook-Id: test-local-0001" \
-H "X-Webhook-Timestamp: $(date +%s)" \
--data-binary "$BODY"
# → 200. Cambia un carácter de $BODY sin recalcular la firma → 401.Python con FastAPI #
En FastAPI (y en cualquier app Starlette) lee el cuerpo con await request.body(): son los bytes
crudos. No declares el cuerpo como modelo de Pydantic ni uses await request.json() antes de
verificar.
# FastAPI — pip install compense-sdk-python fastapi uvicorn
import os
from fastapi import BackgroundTasks, FastAPI, Request, Response
from compense_gateway.webhooks import WebhookEvent, WebhookVerificationError, verify_webhook
app = FastAPI()
SECRETOS = [s for s in (os.environ.get("COMPENSE_WEBHOOK_SECRET"),
os.environ.get("COMPENSE_WEBHOOK_SECRET_OLD")) if s]
procesados: set[str] = set() # en producción: tu BD o Redis
def manejar(event: WebhookEvent) -> None:
print(event.event, event.event_id, event.tenant_id, event.occurred_at, event.resource)
@app.post("/compense/webhooks")
async def recibir(request: Request, tareas: BackgroundTasks) -> Response:
crudo = await request.body() # bytes crudos, NO request.json()
try:
event = verify_webhook(crudo, request.headers, secret=SECRETOS)
except WebhookVerificationError:
return Response(status_code=401)
if event.is_test or event.event_id in procesados:
return Response(status_code=200)
procesados.add(event.event_id)
tareas.add_task(manejar, event) # se ejecuta después de responder
return Response(status_code=200)JavaScript en el edge (Web Crypto) #
@compense/sdk/webhooks usa node:crypto y es síncrono. Para runtimes que solo tienen Web
Crypto (Cloudflare Workers, Vercel o Next.js edge, Deno) importa @compense/sdk/webhooks/web: las
mismas reglas y las mismas decisiones, pero verifyWebhook devuelve una promesa.
// app/api/compense/webhooks/route.ts (Next.js, runtime edge) o un Worker
import { verifyWebhook, WebhookVerificationError } from "@compense/sdk/webhooks/web";
export const runtime = "edge";
export async function POST(request: Request): Promise<Response> {
const crudo = new Uint8Array(await request.arrayBuffer()); // bytes crudos, NO request.json()
try {
const event = await verifyWebhook(crudo, request.headers, process.env.COMPENSE_WEBHOOK_SECRET!);
if (!event.isTest) {
// deduplica por event.eventId y encola event.event / event.resource
}
return new Response(null, { status: 200 });
} catch (err) {
if (err instanceof WebhookVerificationError) return new Response(null, { status: 401 });
throw err;
}
}PHP sin framework y PSR-7 #
Webhook::verify acepta las cabeceras en casi cualquier forma: getallheaders(), $_SERVER
(HTTP_X_WEBHOOK_ID), el HeaderBag de Symfony o un mensaje PSR-7. Si tu framework es PSR-7 (Slim,
Mezzio, Symfony con el puente PSR-7), Webhook::verifyRequest($request, $secretos) lee el cuerpo del
stream por ti.
<?php
// php -S 0.0.0.0:8000 receptor.php
declare(strict_types=1);
require __DIR__ . '/vendor/autoload.php';
use Compense\Sdk\Webhook\Webhook;
use Compense\Sdk\Webhook\WebhookVerificationException;
$crudo = (string) file_get_contents('php://input');
$cabeceras = function_exists('getallheaders') ? getallheaders() : $_SERVER;
$secretos = array_values(array_filter([
(string) getenv('COMPENSE_WEBHOOK_SECRET'),
(string) getenv('COMPENSE_WEBHOOK_SECRET_OLD'),
]));
try {
$event = Webhook::verify($crudo, $cabeceras, $secretos);
} catch (WebhookVerificationException $e) {
http_response_code(401);
exit;
}
if (!$event->isTest()) {
// deduplica por $event->eventId y encola $event->event() / $event->resource()
}
http_response_code(200);Go con webhook.Verify #
Si tu router no es net/http puro o quieres controlar las respuestas, usa webhook.VerifyRequest
(lee hasta webhook.MaxBodyBytes, 1 MiB, y te devuelve también el cuerpo crudo) o webhook.Verify
con los bytes que ya leíste.
func recibir(w http.ResponseWriter, r *http.Request) {
ev, _, err := webhook.VerifyRequest(r, secretos)
switch {
case errors.Is(err, webhook.ErrVerification): // firma o timestamp
http.Error(w, "firma inválida", http.StatusUnauthorized)
return
case err != nil: // cuerpo ilegible o sin secretos configurados (webhook.ErrNoSecret)
http.Error(w, "error", http.StatusInternalServerError)
return
}
if !ev.IsTest() {
// deduplica por ev.ID y encola ev.Event() / ev.Resource()
}
w.WriteHeader(http.StatusOK)
}Deduplica por event_id #
La entrega es at-least-once: el mismo evento puede llegar más de una vez (un reintento después de
un timeout, un 200 que se perdió en la red). Cada entrega trae su event_id en X-Webhook-Id, y
los reintentos automáticos reusan el mismo id.
- Guarda los
event_idprocesados en tu base de datos (una tabla con índice único) o en Redis con un TTL de varios días; los reintentos automáticos llegan hasta unas 2 horas y 40 minutos después del primer intento, y un reintento manual puede llegar cuando lo pidas. - Un reintento manual (replay) llega con un
event_idnuevo, con la forma<original>-retry-<hex>. Si tu receptor ya había aplicado el original, haz tu proceso idempotente también por la clave de negocio (transaction_id,receipt_id,radicado…), no solo por elevent_id. - Marca el evento como procesado en la misma transacción en que aplicas su efecto, o encólalo de forma durable: si solo lo guardas en memoria, un reinicio te hace perder la deduplicación.
Ignora los eventos de prueba #
Las pruebas (send_test, compense trigger) van firmadas exactamente como una
entrega real, pero se distinguen de dos formas: el cuerpo trae "test": true y el event_id empieza
por test-. Los SDK lo resumen en una sola propiedad: event.is_test (Python), event.isTest (JS),
$event->isTest() (PHP), evt.IsTest (.NET) y ev.IsTest() (Go). Respóndelas con 2xx y no las
proceses como reales.
La ventana del timestamp #
X-Webhook-Timestamp no forma parte de la firma, pero los SDK lo usan como una primera barrera
contra repeticiones: rechazan una entrega cuyo timestamp se aleje más de 300 segundos de tu reloj
(en el pasado o en el futuro). Si tu servidor tiene el reloj desfasado, verás errores de timestamp en
entregas legítimas: sincroniza NTP antes de ampliar la ventana.
| SDK | Ampliar la ventana | Desactivarla (tolera que falte la cabecera) |
|---|---|---|
| Python | verify_webhook(..., tolerance=600) | tolerance=None |
| JavaScript / TS | verifyWebhook(body, headers, secretos, { tolerance: 600 }) | { tolerance: null } |
| PHP | Webhook::verify($body, $headers, $secretos, tolerance: 600) | tolerance: null |
| .NET | CompenseWebhooks.Verify(body, headers, secretos, toleranceSeconds: 600) | toleranceSeconds: null |
| Go | webhook.Verify(raw, header, secretos, webhook.WithTolerance(10*time.Minute)) | webhook.WithoutTolerance() |
Para pruebas deterministas, todos aceptan fijar el “ahora”: now= (Python), { now } (JS),
now: (PHP y .NET) y webhook.WithNow(fn) (Go).
Responde rápido y procesa en segundo plano #
- Compense espera la respuesta 10 segundos por intento. Si tu receptor tarda más, cuenta como fallo y la entrega se reintenta, aunque al final la hayas procesado.
- Responde
2xxen cuanto verifiques y registres el evento; haz el trabajo real (contabilizar, notificar, llamar otros sistemas) en una cola o tarea en segundo plano. - Responde
401a una firma inválida y2xxa los eventos que no manejas. Evita410 Gonesalvo que quieras que Compense deshabilite el endpoint: tras respuestas410repetidas lo hace. - Si tu proceso falla de forma transitoria antes de registrar el evento, responde
5xx: Compense lo reintentará (Reintentos y entregas).
Verificación manual, sin SDK #
Si tu lenguaje no tiene SDK o no quieres la dependencia, la verificación son pocas líneas con la biblioteca estándar. Haz siempre las dos comprobaciones: timestamp dentro de la ventana y HMAC en tiempo constante contra cada secreto vigente.
import hashlib
import hmac
import time
def firma_valida(cuerpo: bytes, cabeceras, secretos: list[str], tolerancia: int = 300) -> bool:
ts = cabeceras.get("X-Webhook-Timestamp")
if ts is None or abs(time.time() - int(float(ts))) > tolerancia:
return False
firma = (cabeceras.get("X-Webhook-Signature") or "").strip()
if not firma.startswith("sha256="):
return False
recibida = firma[len("sha256="):]
valida = False
for secreto in secretos: # sin cortocircuito: mismo tiempo con cualquier secreto
esperada = hmac.new(secreto.encode("utf-8"), cuerpo, hashlib.sha256).hexdigest()
valida |= hmac.compare_digest(esperada, recibida)
return validaimport { createHmac, timingSafeEqual } from "node:crypto";
export function firmaValida(
cuerpo: Buffer,
cabeceras: Record<string, string | string[] | undefined>,
secretos: string[],
tolerancia = 300,
): boolean {
const ts = Number(cabeceras["x-webhook-timestamp"]);
if (!Number.isFinite(ts) || Math.abs(Date.now() / 1000 - ts) > tolerancia) return false;
const firma = String(cabeceras["x-webhook-signature"] ?? "").trim();
if (!firma.startsWith("sha256=")) return false;
const recibida = Buffer.from(firma.slice("sha256=".length), "utf8");
let valida = false;
for (const secreto of secretos) {
const esperada = Buffer.from(createHmac("sha256", secreto).update(cuerpo).digest("hex"), "utf8");
valida = (esperada.length === recibida.length && timingSafeEqual(esperada, recibida)) || valida;
}
return valida;
}<?php
/** @param string[] $secretos */
function firmaValida(string $cuerpo, ?string $firma, ?string $timestamp, array $secretos, int $tolerancia = 300): bool
{
if ($timestamp === null || !is_numeric($timestamp) || abs(time() - (int) $timestamp) > $tolerancia) {
return false;
}
$firma = trim((string) $firma);
if (!str_starts_with($firma, 'sha256=')) {
return false;
}
$recibida = substr($firma, strlen('sha256='));
$valida = false;
foreach ($secretos as $secreto) {
$valida = hash_equals(hash_hmac('sha256', $cuerpo, $secreto), $recibida) || $valida;
}
return $valida;
}
$ok = firmaValida(
(string) file_get_contents('php://input'),
$_SERVER['HTTP_X_WEBHOOK_SIGNATURE'] ?? null,
$_SERVER['HTTP_X_WEBHOOK_TIMESTAMP'] ?? null,
[(string) getenv('COMPENSE_WEBHOOK_SECRET')],
);using System.Security.Cryptography;
using System.Text;
static bool FirmaValida(byte[] cuerpo, string? firma, string? timestamp, IEnumerable<string> secretos, int tolerancia = 300)
{
if (!long.TryParse(timestamp, out long ts)
|| Math.Abs(DateTimeOffset.UtcNow.ToUnixTimeSeconds() - ts) > tolerancia)
{
return false;
}
firma = firma?.Trim();
if (firma is null || !firma.StartsWith("sha256=", StringComparison.Ordinal))
{
return false;
}
byte[] recibida = Encoding.ASCII.GetBytes(firma.Substring("sha256=".Length));
bool valida = false;
foreach (string secreto in secretos)
{
byte[] hmac = HMACSHA256.HashData(Encoding.UTF8.GetBytes(secreto), cuerpo);
byte[] esperada = Encoding.ASCII.GetBytes(Convert.ToHexString(hmac).ToLowerInvariant());
valida |= CryptographicOperations.FixedTimeEquals(esperada, recibida);
}
return valida;
}import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"math"
"net/http"
"strconv"
"strings"
"time"
)
func firmaValida(cuerpo []byte, h http.Header, secretos []string) bool {
ts, err := strconv.ParseInt(h.Get("X-Webhook-Timestamp"), 10, 64)
if err != nil || math.Abs(float64(time.Now().Unix()-ts)) > 300 {
return false
}
recibida, ok := strings.CutPrefix(strings.TrimSpace(h.Get("X-Webhook-Signature")), "sha256=")
if !ok {
return false
}
valida := false
for _, secreto := range secretos {
mac := hmac.New(sha256.New, []byte(secreto))
mac.Write(cuerpo)
esperada := hex.EncodeToString(mac.Sum(nil))
if hmac.Equal([]byte(esperada), []byte(recibida)) {
valida = true
}
}
return valida
}# Verifica a mano una entrega guardada: cuerpo.json (bytes exactos) y la cabecera recibida.
SECRET="whsec_..."
RECIBIDA="sha256=3b0c…" # valor de X-Webhook-Signature
ESPERADA="sha256=$(openssl dgst -sha256 -hmac "$SECRET" < cuerpo.json | sed 's/^.* //')"
[ "$ESPERADA" = "$RECIBIDA" ] && echo "firma válida" || echo "firma inválida"Para depurar, los SDK también exponen el cálculo suelto: expected_signature y parse_signature
(Python), expectedSignature y parseSignature (JS; en /webhooks/web expectedSignature es
asíncrona), Webhook::expectedSignature y Webhook::parseSignature (PHP),
CompenseWebhooks.ExpectedSignature y CompenseWebhooks.ParseSignature (.NET), y
webhook.ExpectedSignature y webhook.ParseSignature (Go).
Errores de verificación por SDK #
Todos verifican en el mismo orden: primero el timestamp, luego la firma (contra cada secreto,
sin cortocircuito) y solo entonces decodifican el JSON. Captura la clase base y responde 401.
| Python | JavaScript / TS | PHP | .NET | Go | |
|---|---|---|---|---|---|
| Función | verify_webhook(body, headers, secret=…) | verifyWebhook(body, headers, secret, options?) | Webhook::verify($body, $headers, $secret) | CompenseWebhooks.Verify(body, headers, secret) | webhook.Verify(raw, header, secrets, opts...) |
| Importar de | compense_gateway.webhooks | @compense/sdk/webhooks (o /webhooks/web) | Compense\Sdk\Webhook | Compense.Sdk.Webhooks | github.com/Appiasoft/compense-saas-sdk-go/webhook |
Error base (→ 401) | WebhookVerificationError | WebhookVerificationError | WebhookVerificationException | WebhookVerificationException | webhook.ErrVerification |
| Firma ausente o distinta | WebhookSignatureError | WebhookSignatureError | WebhookSignatureException | WebhookSignatureException | webhook.ErrSignature |
| Timestamp ausente, malformado o fuera de ventana | WebhookTimestampError | WebhookTimestampError | WebhookTimestampException | WebhookTimestampException | webhook.ErrTimestamp |
| Cuerpo auténtico que no es un objeto JSON | WebhookVerificationError | WebhookVerificationError | WebhookVerificationException | WebhookVerificationException | webhook.ErrVerification |
Ningún secreto no vacío (error de configuración → 500) | ValueError | TypeError | \InvalidArgumentException | ArgumentException | webhook.ErrNoSecret |
En Go los errores se comprueban con errors.Is(err, webhook.ErrSignature); webhook.Handler ya
traduce ErrVerification a 401 y ErrNoSecret a 500. En .NET, WebhookVerificationException
hereda de CompenseException.
Problemas frecuentes #
| Síntoma | Causa probable | Solución |
|---|---|---|
| Toda entrega falla la firma | El framework ya parseó el cuerpo (express.json, modelo de Pydantic, $request->json()) y le pasas el objeto re-serializado | Pasa los bytes crudos: express.raw, request.get_data() / await request.body(), $request->getContent(), request.Body copiado a un MemoryStream, io.ReadAll(r.Body) |
| Falla solo con tildes o eñes | Re-serializaste el JSON (Compense escapa lo no ASCII como \uXXXX) | Igual que arriba: nunca re-serialices |
| Falla tras rotar el secreto | El receptor solo conoce un secreto | Verifica contra [nuevo, viejo] durante el despliegue (Rotación) |
WebhookTimestampError en entregas legítimas | Reloj del servidor desfasado | Sincroniza NTP; como último recurso amplía tolerance |
Un proxy o WAF devuelve 403/413 | El borde bloquea el POST o el tamaño | Permite el User-Agent: Compense-Webhooks/1.0 en la ruta del receptor |
| El secreto “no sirve” | Lo decodificaste de base64 o le quitaste el prefijo whsec_ | Úsalo tal cual, como texto UTF-8 |