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:

HeaderValue
X-Webhook-Signaturesha256=<hex>, where <hex> = HMAC-SHA256(secret, raw body) in lowercase hexadecimal
X-Webhook-EventEvent type (credits.decision, test.ping, …)
X-Webhook-IdThe delivery’s event_id: your deduplication key
X-Webhook-TimestampUnix seconds at send time
Content-Typeapplication/json
User-AgentCompense-Webhooks/1.0

The five rules for verifying correctly:

  1. 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.
  2. 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.
  3. Constant-time comparison (hmac.compare_digest, crypto.timingSafeEqual, hash_equals, CryptographicOperations.FixedTimeEquals, hmac.Equal). A regular string comparison leaks information through response timing.
  4. The timestamp isn’t signed. X-Webhook-Timestamp is 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 by X-Webhook-Id.
  5. Respond fast. 2xx in under 10 seconds, and process in the background. A 401 for an invalid signature is the right response; a repeated 410 Gone disables 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.

Webhook 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

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.

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()                 # 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.

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

Go
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 the event_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.

SDKWiden the windowDisable it (tolerates a missing header)
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()

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 2xx as 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 401 to an invalid signature and 2xx to events you don’t handle. Avoid 410 Gone unless you want Compense to disable the endpoint: after repeated 410 responses, 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.

Manual verification (standard library)
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 valida

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.

PythonJavaScript / TSPHP.NETGo
Functionverify_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 fromcompense_gateway.webhooks@compense/sdk/webhooks (or /webhooks/web)Compense\Sdk\WebhookCompense.Sdk.Webhooksgithub.com/Appiasoft/compense-saas-sdk-go/webhook
Base error (→ 401)WebhookVerificationErrorWebhookVerificationErrorWebhookVerificationExceptionWebhookVerificationExceptionwebhook.ErrVerification
Missing or mismatched signatureWebhookSignatureErrorWebhookSignatureErrorWebhookSignatureExceptionWebhookSignatureExceptionwebhook.ErrSignature
Missing, malformed or out-of-window timestampWebhookTimestampErrorWebhookTimestampErrorWebhookTimestampExceptionWebhookTimestampExceptionwebhook.ErrTimestamp
Authentic body that isn’t a JSON objectWebhookVerificationErrorWebhookVerificationErrorWebhookVerificationExceptionWebhookVerificationExceptionwebhook.ErrVerification
No non-empty secret (configuration error → 500)ValueErrorTypeError\InvalidArgumentExceptionArgumentExceptionwebhook.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 #

SymptomLikely causeSolution
Every delivery fails the signatureThe framework already parsed the body (express.json, Pydantic model, $request->json()) and you pass it the re-serialized objectPass 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 secretThe receiver only knows one secretVerify against [new, old] during the rollout (Rotation)
WebhookTimestampError on legitimate deliveriesServer clock skewSync NTP; as a last resort, widen tolerance
A proxy or WAF returns 403/413The edge blocks the POST or the sizeAllow User-Agent: Compense-Webhooks/1.0 on the receiver route
The secret “doesn’t work”You base64-decoded it or stripped the whsec_ prefixUse it as is, as UTF-8 text