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:

CabeceraValor
X-Webhook-Signaturesha256=<hex>, donde <hex> = HMAC-SHA256(secreto, cuerpo crudo) en hexadecimal minúscula
X-Webhook-EventTipo de evento (credits.decision, test.ping, …)
X-Webhook-Idevent_id de la entrega: tu clave de deduplicación
X-Webhook-TimestampSegundos Unix del envío
Content-Typeapplication/json
User-AgentCompense-Webhooks/1.0

Las cinco reglas para verificar bien:

  1. 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.
  2. 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.
  3. 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.
  4. El timestamp no está firmado. X-Webhook-Timestamp no 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 por X-Webhook-Id.
  5. Responde rápido. 2xx en menos de 10 segundos y procesa en segundo plano. Un 401 ante una firma inválida es la respuesta correcta; un 410 Gone repetido 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.

Receptor de webhooks
# 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

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.

Python
# 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.

TypeScript
// 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
// 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.

Go
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_id procesados 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_id nuevo, 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 el event_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.

SDKAmpliar la ventanaDesactivarla (tolera que falte la cabecera)
Pythonverify_webhook(..., tolerance=600)tolerance=None
JavaScript / TSverifyWebhook(body, headers, secretos, { tolerance: 600 }){ tolerance: null }
PHPWebhook::verify($body, $headers, $secretos, tolerance: 600)tolerance: null
.NETCompenseWebhooks.Verify(body, headers, secretos, toleranceSeconds: 600)toleranceSeconds: null
Gowebhook.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 2xx en cuanto verifiques y registres el evento; haz el trabajo real (contabilizar, notificar, llamar otros sistemas) en una cola o tarea en segundo plano.
  • Responde 401 a una firma inválida y 2xx a los eventos que no manejas. Evita 410 Gone salvo que quieras que Compense deshabilite el endpoint: tras respuestas 410 repetidas 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.

Verificación manual (biblioteca estándar)
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 valida

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.

PythonJavaScript / TSPHP.NETGo
Funciónverify_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 decompense_gateway.webhooks@compense/sdk/webhooks (o /webhooks/web)Compense\Sdk\WebhookCompense.Sdk.Webhooksgithub.com/Appiasoft/compense-saas-sdk-go/webhook
Error base (→ 401)WebhookVerificationErrorWebhookVerificationErrorWebhookVerificationExceptionWebhookVerificationExceptionwebhook.ErrVerification
Firma ausente o distintaWebhookSignatureErrorWebhookSignatureErrorWebhookSignatureExceptionWebhookSignatureExceptionwebhook.ErrSignature
Timestamp ausente, malformado o fuera de ventanaWebhookTimestampErrorWebhookTimestampErrorWebhookTimestampExceptionWebhookTimestampExceptionwebhook.ErrTimestamp
Cuerpo auténtico que no es un objeto JSONWebhookVerificationErrorWebhookVerificationErrorWebhookVerificationExceptionWebhookVerificationExceptionwebhook.ErrVerification
Ningún secreto no vacío (error de configuración → 500)ValueErrorTypeError\InvalidArgumentExceptionArgumentExceptionwebhook.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íntomaCausa probableSolución
Toda entrega falla la firmaEl framework ya parseó el cuerpo (express.json, modelo de Pydantic, $request->json()) y le pasas el objeto re-serializadoPasa 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ñesRe-serializaste el JSON (Compense escapa lo no ASCII como \uXXXX)Igual que arriba: nunca re-serialices
Falla tras rotar el secretoEl receptor solo conoce un secretoVerifica contra [nuevo, viejo] durante el despliegue (Rotación)
WebhookTimestampError en entregas legítimasReloj del servidor desfasadoSincroniza NTP; como último recurso amplía tolerance
Un proxy o WAF devuelve 403/413El borde bloquea el POST o el tamañoPermite 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