Webhooks
Verify the signature
How to confirm a delivery comes from Compense: HMAC-SHA256 over the raw body, complete receivers in Flask, FastAPI, Express, Laravel, ASP.NET Core and Go, deduplication, test events, the timestamp window and manual verification without an SDK.
On this page
Your endpoint is a public URL: anyone can POST to it. The signature is what guarantees that the
body was sent by Compense and that nobody tampered with it along the way. Never process a delivery
without verifying it first. This page includes complete receivers in the five SDK languages, plus
manual verification in case you don’t use an SDK.
The signature contract #
Every delivery is a POST with the JSON body and these headers:
| Header | Value |
|---|---|
X-Webhook-Signature | sha256=<hex>, where <hex> = HMAC-SHA256(secret, raw body) in lowercase hexadecimal |
X-Webhook-Event | Event type (credits.decision, test.ping, …) |
X-Webhook-Id | The delivery’s event_id: your deduplication key |
X-Webhook-Timestamp | Unix seconds at send time |
Content-Type | application/json |
User-Agent | Compense-Webhooks/1.0 |
The five rules for verifying correctly:
- Sign over the raw bytes. Compute the HMAC over the exact bytes of the body as they arrive.
Don’t parse it and serialize it again (
JSON.parse+JSON.stringify,json.loads+json.dumps,$request->json()…): Compense sends compact JSON (no whitespace, with non-ASCII characters escaped as\uXXXX) and any change breaks the signature. - Secret as text. Use the
whsec_...secret as is, as UTF-8 text, for the HMAC key. Don’t base64-decode it or strip the prefix. - Constant-time comparison (
hmac.compare_digest,crypto.timingSafeEqual,hash_equals,CryptographicOperations.FixedTimeEquals,hmac.Equal). A regular string comparison leaks information through response timing. - The timestamp isn’t signed.
X-Webhook-Timestampis not part of the signature. Use it to reject very old deliveries (the SDKs accept ±300 s by default), but your real defense against replays is deduplicating byX-Webhook-Id. - Respond fast.
2xxin under 10 seconds, and process in the background. A401for an invalid signature is the right response; a repeated410 Gonedisables your endpoint.
The SDKs implement exactly these rules (and test against the same vectors, byte for byte), so the recommended approach is to use their verification function.
Complete receiver with the SDK #
Each receiver: reads the raw body, verifies against the list of current secrets (one, or two
during a rotation), responds 401 if it fails, ignores tests, deduplicates by
event_id, responds 200 and hands the real work to a queue. The cURL tab simulates a signed
delivery against your local receiver.
# 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__)
# The current secret and, only while you rotate, the previous one.
SECRETOS = [s for s in (os.environ.get("COMPENSE_WEBHOOK_SECRET"),
os.environ.get("COMPENSE_WEBHOOK_SECRET_OLD")) if s]
procesados: set[str] = set() # in production: your DB or Redis (with a TTL of days)
cola: "queue.Queue[WebhookEvent]" = queue.Queue()
def manejar(event: WebhookEvent) -> None:
print(event.event, event.event_id, event.resource) # your business logic
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(): the raw bytes, BEFORE any parsing
event = verify_webhook(request.get_data(), request.headers, secret=SECRETOS)
except WebhookVerificationError:
return "", 401 # invalid signature or timestamp
if event.is_test: # send_test: acknowledge it and don't process it
return "", 200
if event.event_id in procesados: # duplicate delivery (at-least-once)
return "", 200
procesados.add(event.event_id)
cola.put(event) # the heavy work, outside the 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();
// The current secret and, only while you rotate, the previous one.
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>(); // in production: your DB or Redis (with a TTL of days)
function manejar(event: WebhookEvent): void {
console.log(event.event, event.eventId, event.resource); // your business logic
}
// express.raw on THIS route: req.body arrives as a Buffer with the raw bytes.
// Don't put express.json() before this route.
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: don't process it
if (procesados.has(event.eventId)) return res.sendStatus(200); // duplicate (at-least-once)
procesados.add(event.eventId);
setImmediate(() => manejar(event)); // in production: enqueue (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 (without CSRF middleware):
// 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(), // RAW body, not $request->all() or json()
$request->headers->all(), // Symfony HeaderBag
config('services.compense.webhook_secrets'),
);
} catch (WebhookVerificationException $e) {
Log::warning('Compense webhook rejected', ['motivo' => $e->getMessage()]);
return response()->json(['error' => 'invalid signature'], 401);
}
if ($event->isTest()) { // sendTest: acknowledge it and don't process it
return response()->json(['received' => true, 'test' => true]);
}
// At-least-once: Cache::add only writes if the key doesn't exist (7 days covers the retries).
if (Cache::add('compense:webhook:' . $event->eventId, true, now()->addDays(7))) {
HandleCompenseEvent::dispatch($event->event(), $event->payload); // to the queue
}
return response()->json(['received' => true]);
}
}// ASP.NET Core minimal API (.NET 8) — dotnet add package Compense.Sdk
// Compense__WebhookSecrets__0=whsec_... (current)
// Compense__WebhookSecrets__1=whsec_... (previous, only while you rotate)
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>(); // in production: your DB
var cola = Channel.CreateUnbounded<WebhookEvent>();
var app = builder.Build();
// Background consumer: the heavy work doesn't block the response.
_ = 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) =>
{
// The raw bytes, exactly as they arrived.
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 rejected: {Motivo}", ex.Message);
return Results.Unauthorized();
}
if (evt.IsTest) return Results.Ok(); // SendTestAsync: don't process it
if (!procesados.TryAdd(evt.EventId, true)) return Results.Ok(); // duplicate (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() {
// The current secret and, only while you rotate, the previous one (empty ones are ignored).
secretos := []string{os.Getenv("COMPENSE_WEBHOOK_SECRET"), os.Getenv("COMPENSE_WEBHOOK_SECRET_OLD")}
var mu sync.Mutex
procesados := map[string]bool{} // in production: your DB or Redis
cola := make(chan *webhook.Event, 1000)
go func() { // background consumer
for ev := range cola {
log.Printf("%s %s %v", ev.Event(), ev.ID, ev.Resource())
}
}()
// webhook.Handler verifies (401 if it fails), and responds 200 if fn returns nil
// or 500 if it returns an error (Compense will retry the delivery).
h := webhook.Handler(secretos, func(ctx context.Context, ev *webhook.Event) error {
if ev.IsTest() { // SendTest: acknowledge it and don't process it
return nil
}
mu.Lock()
defer mu.Unlock()
if procesados[ev.ID] { // duplicate (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())
}# Simulate a signed delivery against your local receiver (useful in unit or smoke tests).
SECRET="whsec_..." # the same one you configured in the receiver
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. Change one character of $BODY without recomputing the signature → 401.Python with FastAPI #
In FastAPI (and in any Starlette app) read the body with await request.body(): those are the raw
bytes. Don’t declare the body as a Pydantic model or use await request.json() before verifying.
# 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() # in production: your DB or 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() # raw bytes, NOT 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) # runs after responding
return Response(status_code=200)JavaScript on the edge (Web Crypto) #
@compense/sdk/webhooks uses node:crypto and is synchronous. For runtimes that only have Web
Crypto (Cloudflare Workers, Vercel or Next.js edge, Deno) import @compense/sdk/webhooks/web: the
same rules and the same decisions, but verifyWebhook returns a promise.
// app/api/compense/webhooks/route.ts (Next.js, edge runtime) or a 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()); // raw bytes, NOT request.json()
try {
const event = await verifyWebhook(crudo, request.headers, process.env.COMPENSE_WEBHOOK_SECRET!);
if (!event.isTest) {
// deduplicate by event.eventId and enqueue 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 without a framework and PSR-7 #
Webhook::verify accepts the headers in almost any shape: getallheaders(), $_SERVER
(HTTP_X_WEBHOOK_ID), Symfony’s HeaderBag or a PSR-7 message. If your framework is PSR-7 (Slim,
Mezzio, Symfony with the PSR-7 bridge), Webhook::verifyRequest($request, $secretos) reads the body
from the stream for you.
<?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()) {
// deduplicate by $event->eventId and enqueue $event->event() / $event->resource()
}
http_response_code(200);Go with webhook.Verify #
If your router isn’t plain net/http or you want to control the responses, use
webhook.VerifyRequest (it reads up to webhook.MaxBodyBytes, 1 MiB, and also returns the raw body)
or webhook.Verify with the bytes you already read.
func recibir(w http.ResponseWriter, r *http.Request) {
ev, _, err := webhook.VerifyRequest(r, secretos)
switch {
case errors.Is(err, webhook.ErrVerification): // signature or timestamp
http.Error(w, "invalid signature", http.StatusUnauthorized)
return
case err != nil: // unreadable body or no secrets configured (webhook.ErrNoSecret)
http.Error(w, "error", http.StatusInternalServerError)
return
}
if !ev.IsTest() {
// deduplicate by ev.ID and enqueue ev.Event() / ev.Resource()
}
w.WriteHeader(http.StatusOK)
}Deduplicate by event_id #
Delivery is at-least-once: the same event can arrive more than once (a retry after a timeout, a
200 that got lost on the network). Each delivery carries its event_id in X-Webhook-Id, and
automatic retries reuse the same id.
- Store the processed
event_ids in your database (a table with a unique index) or in Redis with a TTL of several days; automatic retries arrive up to about 2 hours and 40 minutes after the first attempt, and a manual retry can arrive whenever you request it. - A manual retry (replay) arrives with a new
event_id, in the form<original>-retry-<hex>. If your receiver had already applied the original, make your processing idempotent by the business key as well (transaction_id,receipt_id,radicado…), not just by theevent_id. - Mark the event as processed in the same transaction in which you apply its effect, or enqueue it durably: if you only keep it in memory, a restart makes you lose deduplication.
Ignore test events #
Tests (send_test, compense trigger) are signed exactly like a real delivery,
but they differ in two ways: the body carries "test": true and the event_id starts with test-.
The SDKs sum it up in a single property: event.is_test (Python), event.isTest (JS),
$event->isTest() (PHP), evt.IsTest (.NET) and ev.IsTest() (Go). Respond to them with 2xx
and don’t process them as real.
The timestamp window #
X-Webhook-Timestamp isn’t part of the signature, but the SDKs use it as a first barrier against
replays: they reject a delivery whose timestamp is more than 300 seconds away from your clock (in
the past or in the future). If your server’s clock is skewed, you’ll see timestamp errors on
legitimate deliveries: sync NTP before widening the window.
| SDK | Widen the window | Disable it (tolerates a missing header) |
|---|---|---|
| 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() |
For deterministic tests, all of them let you pin “now”: now= (Python), { now } (JS),
now: (PHP and .NET) and webhook.WithNow(fn) (Go).
Respond fast and process in the background #
- Compense waits 10 seconds for the response on each attempt. If your receiver takes longer, it counts as a failure and the delivery is retried, even if you did end up processing it.
- Respond
2xxas soon as you verify and record the event; do the real work (posting to accounting, sending notifications, calling other systems) in a queue or background task. - Respond
401to an invalid signature and2xxto events you don’t handle. Avoid410 Goneunless you want Compense to disable the endpoint: after repeated410responses, it does. - If your processing fails transiently before recording the event, respond
5xx: Compense will retry it (Retries and deliveries).
Manual verification, without an SDK #
If your language has no SDK or you don’t want the dependency, verification takes a few lines with the standard library. Always do both checks: timestamp within the window and a constant-time HMAC against each current secret.
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: # no short-circuit: same time with any secret
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
}# Manually verify a saved delivery: cuerpo.json (exact bytes) and the received header.
SECRET="whsec_..."
RECIBIDA="sha256=3b0c…" # value of X-Webhook-Signature
ESPERADA="sha256=$(openssl dgst -sha256 -hmac "$SECRET" < cuerpo.json | sed 's/^.* //')"
[ "$ESPERADA" = "$RECIBIDA" ] && echo "valid signature" || echo "invalid signature"For debugging, the SDKs also expose the standalone computation: expected_signature and
parse_signature (Python), expectedSignature and parseSignature (JS; in /webhooks/web
expectedSignature is asynchronous), Webhook::expectedSignature and Webhook::parseSignature
(PHP), CompenseWebhooks.ExpectedSignature and CompenseWebhooks.ParseSignature (.NET), and
webhook.ExpectedSignature and webhook.ParseSignature (Go).
Verification errors by SDK #
All of them verify in the same order: first the timestamp, then the signature (against each
secret, without short-circuiting) and only then do they decode the JSON. Catch the base class and
respond 401.
| Python | JavaScript / TS | PHP | .NET | Go | |
|---|---|---|---|---|---|
| Function | 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...) |
| Import from | compense_gateway.webhooks | @compense/sdk/webhooks (or /webhooks/web) | Compense\Sdk\Webhook | Compense.Sdk.Webhooks | github.com/Appiasoft/compense-saas-sdk-go/webhook |
Base error (→ 401) | WebhookVerificationError | WebhookVerificationError | WebhookVerificationException | WebhookVerificationException | webhook.ErrVerification |
| Missing or mismatched signature | WebhookSignatureError | WebhookSignatureError | WebhookSignatureException | WebhookSignatureException | webhook.ErrSignature |
| Missing, malformed or out-of-window timestamp | WebhookTimestampError | WebhookTimestampError | WebhookTimestampException | WebhookTimestampException | webhook.ErrTimestamp |
| Authentic body that isn’t a JSON object | WebhookVerificationError | WebhookVerificationError | WebhookVerificationException | WebhookVerificationException | webhook.ErrVerification |
No non-empty secret (configuration error → 500) | ValueError | TypeError | \InvalidArgumentException | ArgumentException | webhook.ErrNoSecret |
In Go, errors are checked with errors.Is(err, webhook.ErrSignature); webhook.Handler already
translates ErrVerification to 401 and ErrNoSecret to 500. In .NET,
WebhookVerificationException inherits from CompenseException.
Common problems #
| Symptom | Likely cause | Solution |
|---|---|---|
| Every delivery fails the signature | The framework already parsed the body (express.json, Pydantic model, $request->json()) and you pass it the re-serialized object | Pass the raw bytes: express.raw, request.get_data() / await request.body(), $request->getContent(), request.Body copied to a MemoryStream, io.ReadAll(r.Body) |
| Fails only with accents or ñ | You re-serialized the JSON (Compense escapes non-ASCII as \uXXXX) | Same as above: never re-serialize |
| Fails after rotating the secret | The receiver only knows one secret | Verify against [new, old] during the rollout (Rotation) |
WebhookTimestampError on legitimate deliveries | Server clock skew | Sync NTP; as a last resort, widen tolerance |
A proxy or WAF returns 403/413 | The edge blocks the POST or the size | Allow User-Agent: Compense-Webhooks/1.0 on the receiver route |
| The secret “doesn’t work” | You base64-decoded it or stripped the whsec_ prefix | Use it as is, as UTF-8 text |