Skip to main content

Verifying a webhook signature

The exact headers, the signing scheme and four rules to follow, with working Python and Node you can paste into your receiver today.

Written for whoever runs IT11 min readUpdated

Every webhook Qorionix sends is signed. This article is the contract: what we send, and exactly how to check it. If you follow it, you can prove that a request came from Qorionix, that nobody altered the body in transit, and that it is not a replay of an older delivery. It is written to be copied from — the Python and Node below are complete and correct as they stand.

A signature here is a short value computed from the message and a secret only the two of us know. Anyone can compute it if they hold the secret, and nobody can forge it if they do not, so a matching signature is proof of origin. It is not encryption: the body itself is readable, which is why the connection must also be HTTPS.

What arrives

A delivery. Every header appears twice, under two names, with identical values
POST /your/endpoint HTTP/1.1
Content-Type: application/json
X-Qorionix-Signature: t=1756652400,v1=8f3c1d...e9
X-Qorionix-Timestamp: 1756652400
X-Qorionix-Delivery:  0e965d4c-d3f6-4180-9f75-cbedb05a56db
X-Qorionix-Event:     alert.triggered
X-ZentryQ-Signature:  t=1756652400,v1=8f3c1d...e9   (deprecated, identical)
X-ZentryQ-Timestamp:  1756652400                    (deprecated, identical)
X-ZentryQ-Delivery:   0e965d4c-d3f6-4180-9f75-cbedb05a56db
X-ZentryQ-Event:      alert.triggered

{"id":"0e965d4c-…","type":"alert.triggered","organization_id":"8c1f…",
 "source":"siem","created_at":"2026-09-02T10:20:00Z","data":{…}}
The headers on a delivery, and what to do with each. Each is sent under both names.
HeaderMeaning
X-Qorionix-SignatureThe signature. t is the Unix timestamp the request was signed at; v1 is the hex HMAC.
X-Qorionix-TimestampA copy of t for convenience. Do not verify against this one — always use the t inside the signature header, because that is the value covered by the MAC.
X-Qorionix-DeliveryUnique delivery ID. Also your idempotency key: retries reuse it.
X-Qorionix-EventEvent type, for example alert.triggered.
X-Qorionix-RetryThe literal string true. Only present on a redelivery made by the background sweep, and absent on the first attempt and on the two in-process retries that follow it. Its absence therefore does not prove a delivery is the first one.

The distinction in the second row is the one people get wrong. X-Qorionix-Timestamp is outside the signature, so an attacker can change it freely. The t inside X-Qorionix-Signature cannot be changed without breaking v1. Verify against the one that is protected.

The envelope is stable and is versioned by type rather than by a version field: switch on type and read data. id, organization_id and created_at are always present, so you can deduplicate, route by tenant and check staleness without knowing the event. data deliberately carries identifiers, enumerations, counts and a deep link — never raw log events, never a command's arguments, never Sentinel-Q's reasoning text.

The X-ZentryQ-* headers, and how to migrate without an outage

These headers were named X-ZentryQ-* before the product was renamed to Qorionix. Every delivery now carries both spellings, with identical values, written by the same call — not copied afterwards, so they cannot drift apart. Nothing you have already built needs to change today.

The old name and the new one. Both are sent on every delivery, always with the same value.
DeprecatedCurrent
X-ZentryQ-SignatureX-Qorionix-Signature
X-ZentryQ-TimestampX-Qorionix-Timestamp
X-ZentryQ-DeliveryX-Qorionix-Delivery
X-ZentryQ-EventX-Qorionix-Event
X-ZentryQ-RetryX-Qorionix-Retry
The whole migration, in one line per language
Python  sig = request.headers.get("X-Qorionix-Signature") \
              or request.headers.get("X-ZentryQ-Signature", "")

Node    const sig = req.get("X-Qorionix-Signature")
                 || req.get("X-ZentryQ-Signature") || "";

Go      sig := webhook.SignaturePair.Get(r.Header)   // does both for you

We will not remove the deprecated headers on a date. They come out only after a full major release has shipped carrying both, and we will say so in the release notes well in advance. If you are reading this because a verifier that used to work started failing, the deprecated headers are not the cause — they are still being sent. Check the freshness window and that you are hashing the raw body bytes.

The scheme

The whole thing
signed_payload = "<t>" + "." + <raw request body bytes>
v1             = hex( HMAC_SHA256( signing_secret, signed_payload ) )

That is all of it. Three properties follow from those two lines.

  • Authentication. Only someone holding your signing secret can produce a valid v1, so a valid signature proves the request came from Qorionix.
  • Integrity. The body is inside the MAC, so a single altered byte invalidates the signature.
  • Replay resistance. The timestamp is inside the MAC too. An attacker who captures a delivery cannot make it look fresh, because changing t breaks v1. Enforce a freshness window and old captures stop working.

The header may carry more than one signature — t=…,v1=…,v1=… — during a secret rotation, and it may gain a v2= element in future. Accept the delivery if any signature you understand matches, and ignore versions you do not know. A verifier that rejects an unrecognised version will break on the day we add one, which is not a day you want to be paged about.

The four rules

Four rules. The first two are where implementations usually go wrong, and both of them fail silently in testing — a receiver with either mistake works perfectly against your own test requests and is insecure in production.

  1. MAC the raw bytes, before parsing

    Do not parse the JSON and re-serialise it. {"a":1} and { "a": 1 } are the same object and different bytes, and only the bytes we sent will verify. In most frameworks this means reaching for the raw body explicitly, because the default is to hand you a parsed object and throw the original away.

  2. Compare in constant time

    A byte-by-byte == that returns early leaks the expected MAC to an attacker who can measure your response time. Use hmac.compare_digest, crypto.timingSafeEqual, hmac.Equal, or your language's equivalent.

  3. Reject stale timestamps

    We recommend a 5-minute window, which is what our own reference implementation uses. Check the absolute difference, so a timestamp far in the future is rejected as well as one far in the past — otherwise a clock-skewed forgery sails through.

  4. Reject anything that fails, before doing any work

    Return 401 and stop. Do not log the body of a request you could not authenticate as if it were real; an attacker who can write arbitrary content into your own log is halfway to somewhere unpleasant.

Python

verify() — complete
import hashlib, hmac, time

TOLERANCE = 300  # seconds

def verify(secret: str, raw_body: bytes, header: str) -> bool:
    parts = dict(p.split('=', 1) for p in header.split(',') if '=' in p)
    try:
        ts = int(parts['t'])
    except (KeyError, ValueError):
        return False

    if abs(time.time() - ts) > TOLERANCE:
        return False  # stale or implausibly future-dated: treat as a replay

    signed = f'{ts}'.encode() + b'.' + raw_body
    expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, parts.get('v1', ''))

In Flask, using the raw body rather than the parsed one:

Flask receiver — note the header fallback on the middle line
@app.post('/qorionix')
def qorionix_webhook():
    sig = (request.headers.get('X-Qorionix-Signature')
           or request.headers.get('X-ZentryQ-Signature', ''))
    if not verify(SECRET, request.get_data(), sig):
        return '', 401
    event = request.get_json()   # only after verification
    ...
    return '', 200

Node.js

verify() and an Express receiver — complete
const crypto = require('crypto');
const TOLERANCE = 300;

function verify(secret, rawBody, header) {
  const parts = Object.fromEntries(
    header.split(',').map((p) => p.split('=')).filter((p) => p.length === 2),
  );
  const ts = Number(parts.t);
  if (!Number.isFinite(ts)) return false;
  if (Math.abs(Date.now() / 1000 - ts) > TOLERANCE) return false;

  const expected = crypto
    .createHmac('sha256', secret)
    .update(`${ts}.`)
    .update(rawBody)
    .digest();
  const got = Buffer.from(parts.v1 ?? '', 'hex');
  return expected.length === got.length && crypto.timingSafeEqual(expected, got);
}

// Express: capture the raw body, do not let a JSON parser consume it first.
app.post('/qorionix', express.raw({ type: 'application/json' }), (req, res) => {
  const sig = req.get('X-Qorionix-Signature') || req.get('X-ZentryQ-Signature') || '';
  if (!verify(SECRET, req.body, sig)) {
    return res.sendStatus(401);
  }
  const event = JSON.parse(req.body.toString('utf8'));
  res.sendStatus(200);
});

The express.raw middleware is doing the load-bearing work in that snippet. The common mistake is to mount express.json() globally and then reach for req.body, at which point the original bytes are gone and the signature can never match. The length check before timingSafeEqual is also required rather than defensive: that function throws when the two buffers differ in length.

Go

If you are writing in Go, the platform's own implementation is the reference — the same function our tests verify deliveries with. It defaults to a five-minute tolerance when you pass zero, and to the current time when you pass a zero time. SignaturePair.Get reads the current header name and falls back to the deprecated one, so a single call covers both spellings and there is nothing to migrate later.

Using the reference implementation
err := webhook.VerifySignature(
    secret,
    rawBody,
    webhook.SignaturePair.Get(r.Header),   // new name, falling back to the old
    time.Now(),
    5*time.Minute,
)

It returns one of four sentinel errors, and the text tells you which rule failed: webhook: subscription has no signing secret, webhook: signature header is malformed, webhook: signature does not match, and webhook: signature timestamp is outside the freshness window. Several are wrapped with the detail you actually need:

The wrapped forms, verbatim
webhook: signature timestamp is outside the freshness window: 7m12s off
webhook: signature header is malformed: empty
webhook: signature header is malformed: no t= element
webhook: signature header is malformed: no v1= element
webhook: signature header is malformed: timestamp "abc" is not an integer
webhook: signature header is malformed: v1 value is not hex

One ordering detail worth copying into your own verifier: freshness is checked before the MAC. A flood of replayed captures then costs a header parse each rather than an HMAC each, which is the cheap half of a denial-of-service you would otherwise be paying for.

Your signing secret

  • Generated by us, from 256 bits of cryptographically secure randomness, and prefixed whsec_. You cannot supply your own; a value sent in the create request is discarded before the record is written.
  • Shown exactly once, in the response to creating the webhook or rotating its secret. Store it in your secret manager in that same moment.
  • Never readable back. There is no API that returns it. Reads return only a hint of the form whsec_…a1b2 — the prefix and the last four characters — which is enough to confirm that the value in your secret manager is the current one and not enough to be useful to anyone else.
  • Stored with envelope encryption: a per-secret data key, itself wrapped by the master key for your cell, the same scheme that protects integration credentials. A deployment with no key wrapper configured refuses to store a secret at all rather than falling back to plaintext.

When it does not match

Almost every failure is one of six things, in roughly this order of likelihood.

  1. The body was parsed and re-serialised before it reached the MAC. Log the raw bytes and their length; if the length differs from Content-Length, this is your problem.
  2. The secret is the wrong one — a stale value from before a rotation, or a copy that lost a character. Compare its last four characters against the hint shown on the endpoint.
  3. The timestamp check used the wrong header, or checked only one direction. It must be the t inside the signature header, and an absolute difference.
  4. The framework is comparing strings rather than bytes, and one side has different capitalisation. Our hex is lowercase; normalise before comparing, or decode both to bytes.
  5. Clock skew. Five minutes is generous, but a container with an unsynchronised clock can exceed it. Check your host's time before assuming the signature is at fault.
  6. You are reading a header that is empty rather than absent, which is a different failure and reads the same. If your framework returns "" for a missing header, headers.get("X-Qorionix-Signature") or headers.get("X-ZentryQ-Signature") still works, but a strict if new_name in headers check does not fall back. This is the one new failure mode the rename introduced, and it only bites a receiver written to read exactly one name.

What to do next

  • Outbound webhooks — registering an endpoint, the event types, retries, and the current state of the publishing side.
  • Configuring a connector — how credentials are protected across the rest of the platform.
  • How to get help — if a signature still will not verify, send us the delivery id and we will look at what we sent.

Was this article wrong?

If a procedure here does not match what you see, or a limit we described has changed, tell us and we will fix the page. Email us about this article, or see how to get help if you need an answer rather than a correction.

Everything in integrations and notifications