Security

Verify every webhook's signature before you trust it, with a code sample, and handle secret rotation.

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=:

AspectValue
AlgorithmHMAC-SHA256
Encodinghexadecimal
Signed contentthe raw request body, exactly as received
HeaderX-Revenexx-Signature: v0=<hex>
Secretthe 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

Python
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)
Node
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
<?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:

HeaderValue
X-Revenexx-Signaturev0=<hex> — the HMAC to verify
X-Revenexx-Idthe event id — use it to deduplicate
X-Revenexx-Topicthe event topic, e.g. order.created
X-Revenexx-Timestampunix 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:

  1. 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-Signature header.
  2. 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.
  3. 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't 2xx — 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.
Was this page helpful?