Security
A webhook delivery arrives over the public internet at a URL anyone could discover. Before you act on one, you have to prove it actually came from revenexx and wasn't forged or tampered with. The platform signs every delivery with your endpoint's secret; you recompute that signature and compare. Verify first, then read the body.
The signature scheme
Every delivery carries an X-Revenexx-Signature header. The signature is an HMAC-SHA256 over the raw request body, hex-encoded, prefixed with v0=:
| Aspect | Value |
|---|---|
| Algorithm | HMAC-SHA256 |
| Encoding | hexadecimal |
| Signed content | the raw request body, exactly as received |
| Header | X-Revenexx-Signature: v0=<hex> |
| Secret | the per-endpoint signing secret (form whsec_…), issued when you register the endpoint |
The signature covers only the raw body bytes — nothing else is mixed in. So verification is: HMAC-SHA256 the bytes you received with your secret, hex-encode, and check it equals the hex after v0=.
The header can carry more than one comma-separated signature during a secret rotation (see below). Treat the delivery as valid if any of them matches.
Verify the raw body, not a re-encoded copy
The single most common mistake: parsing the JSON and then re-serializing it to verify. Re-encoding changes whitespace, key order, and number formatting, so the bytes differ and the HMAC won't match. Capture the raw request body before any JSON parsing and sign exactly those bytes.
Verifying a delivery
import hashlib
import hmac
def verify(raw_body: bytes, signature_header: str, secret: str) -> bool:
# signature_header is "v0=<hex>" or "v0=<hexA>,<hexB>" during rotation
presented = signature_header.removeprefix("v0=").split(",")
expected = hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
return any(hmac.compare_digest(p, expected) for p in presented)
import { createHmac, timingSafeEqual } from "node:crypto";
export function verify(rawBody, signatureHeader, secret) {
// rawBody must be the exact bytes received, not a re-serialized object
const presented = signatureHeader.replace(/^v0=/, "").split(",");
const expected = createHmac("sha256", secret).update(rawBody).digest("hex");
const expectedBuf = Buffer.from(expected);
return presented.some((p) => {
const pBuf = Buffer.from(p);
return pBuf.length === expectedBuf.length && timingSafeEqual(pBuf, expectedBuf);
});
}
<?php
function verify(string $rawBody, string $signatureHeader, string $secret): bool
{
// $rawBody must be the exact request body (e.g. file_get_contents('php://input'))
$presented = explode(',', preg_replace('/^v0=/', '', $signatureHeader));
$expected = hash_hmac('sha256', $rawBody, $secret);
foreach ($presented as $candidate) {
if (hash_equals($candidate, $expected)) {
return true;
}
}
return false;
}
Always compare with a constant-time function (hmac.compare_digest, timingSafeEqual, hash_equals) rather than ==, so an attacker can't learn the correct signature byte by byte from response timing.
Other headers on a delivery
The signature is the one you must check. These travel alongside it and are useful for routing and idempotency:
| Header | Value |
|---|---|
X-Revenexx-Signature | v0=<hex> — the HMAC to verify |
X-Revenexx-Id | the event id — use it to deduplicate |
X-Revenexx-Topic | the event topic, e.g. order.created |
X-Revenexx-Timestamp | unix time the request was formatted |
The event id is also in the body. Use it to drop duplicates, since deliveries are at least once.
Rotating the signing secret
Rotate the secret when it may have leaked, or on a routine schedule. Rotation is built to avoid downtime:
- Start a rotation from the customer console. The platform begins signing each delivery with both the new and the previous secret, sending both signatures in the comma-separated
X-Revenexx-Signatureheader. - Deploy the new secret to your receiver. Because your verification accepts any matching signature, both an updated and a not-yet-updated receiver keep working through the window.
- After the grace window, the platform stops signing with the old secret. Once you've confirmed the new secret works, the rotation is complete.
The verification code above already handles this: it splits on commas and accepts the delivery if any signature matches. No code change is needed to survive a rotation.
Good hygiene
- HTTPS only. Register
https://endpoints so the body and headers are encrypted in transit. - Keep the secret server-side. It belongs in your receiver's configuration, never in client code or a repo.
- Reject unverified requests with
401. Don't process, don't2xx— an unverifiable request is not a real delivery.
Where to go next
- Delivery — retries, ordering, and the delivery log.
- Webhooks overview — register an endpoint and choose topics.
- Events — the envelope you're verifying.