Security

How a webhook delivery is signed, with verification code, what to do while Cockpit does not show the signing secret, and why deduplication is your replay control.

A webhook delivery arrives over the public internet, at a URL that can leak like any other. 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, so that you can recompute the signature and compare. Once you have the secret: verify first, then read the body. Until then, follow the rule below.

Until the signing secret is available

The platform generates a secret when you create an endpoint and signs every delivery with it, but Cockpit does not show that secret yet, and no public API returns it. So today you cannot verify a signature. Until you can:

  • Treat a delivery as a notification, not as data. Use the event id and topic to decide what to look at, then re-read the entity through the API with your own key and act on what the API returns. A forged delivery then costs you one extra read, not a wrong write.
  • Still deduplicate on X-Revenexx-Event-Id, as below.
  • Keep the verification code ready. The scheme below is what the platform signs with today, so a receiver built against it works as soon as you have the secret.

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 (the event's data object)
HeaderX-Revenexx-Signature: v0=<hex>
Secretthe per-endpoint signing secret: whsec_ followed by 64 hex characters. Use the whole string, whsec_ included, as the HMAC key. Not yet shown in Cockpit.

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, newest first, while a secret is being rotated (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-Event-Idthe event id — use it to deduplicate
X-Revenexx-Topicthe event topic
X-Revenexx-TimestampRFC 3339 instant the request was formatted, e.g. 2026-05-31T10:00:00Z
X-Revenexx-<key>one per key in the event's metadata

The body is the event's data object and does not repeat the event id, so X-Revenexx-Event-Id is the identifier to deduplicate on — deliveries are at least once.

Replay, and what the timestamp is worth

A delivery you have already accepted can be captured and sent again. Two controls address that, and they are not equal:

  • Deduplicate on X-Revenexx-Event-Id — required. It is the only per-event identifier a delivery carries, and it is stable across every retry. Refuse to act twice on the same id. Deliveries are at-least-once, so you need this regardless of replay.
  • Check the timestamp — optional. You may reject a delivery whose X-Revenexx-Timestamp is implausibly far from your own clock. Parse it as RFC 3339, and note it is set per delivery attempt, so a legitimate retry carries a fresh one.

Be clear about what the timestamp buys you: the signature covers only the body bytes, so no header is authenticated — anyone able to replay a body can also set the timestamp to the current time. A window raises the cost of a naive replay; deduplication is what actually protects you. If you enforce a window, keep your clock synchronised and the window generous, or you will reject genuine deliveries.

What the signature does guarantee is strong and worth stating: nobody without your endpoint's secret can produce a body your receiver will accept.

Rotating the signing secret

Cockpit cannot rotate a secret yet. What a rotation looks like on the wire, so your receiver is ready: for 24 hours after a new secret is issued, each delivery carries two signatures in X-Revenexx-Signature, the new one first and the previous one second. The verification code above already accepts the delivery if either matches, so no code change is needed to survive a rotation.

Good hygiene

  • HTTPS only. Register an https:// URL so the body and headers are encrypted in transit. Cockpit does not reject http://, so check it yourself.
  • Use a URL nobody can guess, with a long random path segment, and treat it like a credential. While signatures can't be checked, it is what keeps strangers from finding your receiver at all.
  • Keep the secret server-side once you have it. It belongs in your receiver's configuration, never in client code or a repo.
  • Reject unverified requests with 401 once you can verify. Until then, re-read before you act, as described above.
  • Deduplicate on X-Revenexx-Event-Id. At-least-once delivery means the same event will arrive twice sooner or later.

Where to go next

  • Delivery — retries, ordering, and the delivery attempts in Cockpit.
  • Webhooks overview — register an endpoint and choose topics.
  • Events — the envelope behind the data you're verifying.
  • Verify a delivery — the same checks from a reporting pipeline's point of view.
Was this page helpful?