message.comDevelopers

Signature verification Live

Every webhook we send and every webhook we receive is HMAC-signed. Verifying the signature is the difference between a hardened integration and a public write API. Two schemes are in use; both reduce to HMAC-SHA256 with a 5-minute replay window.

Skipping verification is a critical bug, not a minor convenience. An unsigned endpoint can be hit by anyone who knows the URL, and webhook URLs leak through CI logs, proxy traces, and old tickets. Verify on every request.

The two schemes

Scheme reference
// Two signing schemes are in use:
//
// 1. Plain HMAC-SHA256 of the raw body
//    Used by inbound email, inbound calls, and outgoing webhooks.
//    Header: X-Signature: sha256=<hex>
//
// 2. Svix scheme (HMAC-SHA256 over id.timestamp.body)
//    Used by outbound email webhook deliveries.
//    Headers: svix-id, svix-timestamp, svix-signature

Plain HMAC-SHA256

The simpler scheme: HMAC-SHA256 of the raw request body using the workspace webhook secret. The signature is sent in X-Signature: sha256=<hex>. Read the RFC 2104 HMAC specification for algorithm details.

Node.js
import { createHmac, timingSafeEqual } from "crypto";

export function verifyPlain(rawBody: Buffer, header: string, secret: string) {
  const [scheme, sig] = header.split("=");
  if (scheme !== "sha256") return false;
  const expected = createHmac("sha256", secret).update(rawBody).digest("hex");
  return timingSafeEqual(Buffer.from(sig), Buffer.from(expected));
}
Python
import hmac, hashlib

def verify_plain(raw_body: bytes, header: str, secret: str) -> bool:
    scheme, _, sig = header.partition("=")
    if scheme != "sha256":
        return False
    expected = hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(sig, expected)
PHP
<?php
function verify_plain(string $rawBody, string $header, string $secret): bool {
    [$scheme, $sig] = explode("=", $header, 2);
    if ($scheme !== "sha256") return false;
    $expected = hash_hmac("sha256", $rawBody, $secret);
    return hash_equals($expected, $sig);
}
Ruby
require "openssl"

def verify_plain(raw_body, header, secret)
  scheme, sig = header.split("=", 2)
  return false unless scheme == "sha256"
  expected = OpenSSL::HMAC.hexdigest("sha256", secret, raw_body)
  Rack::Utils.secure_compare(expected, sig)
end

Three rules apply regardless of language:

  1. Compute the HMAC over the raw request body bytes. JSON parsers reorder keys.
  2. Compare in constant time (timingSafeEqual, hmac.compare_digest, hash_equals, secure_compare). Plain === is vulnerable to timing attacks.
  3. Reject any request where the scheme prefix is missing or unexpected.

Svix scheme

Our outbound email webhook uses the Svix scheme. The signed payload is the dotted concatenation id.timestamp.body, the key is the base64 decoding of the secret (after stripping the whsec_ prefix), and the header carries one or more signatures space-separated.

Node.js
import { createHmac, timingSafeEqual } from "crypto";

export function verifySvix(
  rawBody: Buffer,
  id: string,
  timestamp: string,
  signatureHeader: string,
  secret: string  // 'whsec_...'
) {
  // Reject if timestamp is more than 5 minutes old (replay window).
  const now = Math.floor(Date.now() / 1000);
  if (Math.abs(now - Number(timestamp)) > 300) return false;

  // Strip 'whsec_' prefix, base64-decode to get the raw HMAC key.
  const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");

  const signed = `${id}.${timestamp}.${rawBody.toString("utf8")}`;
  const expected = createHmac("sha256", key).update(signed).digest("base64");

  // Header may contain multiple sigs (during rotation), space-separated, each 'v1,<sig>'.
  for (const part of signatureHeader.split(" ")) {
    const [version, sig] = part.split(",");
    if (version !== "v1") continue;
    if (timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) return true;
  }
  return false;
}
Python
import hmac, hashlib, base64, time

def verify_svix(raw_body: bytes, id: str, ts: str, sig_header: str, secret: str) -> bool:
    if abs(int(time.time()) - int(ts)) > 300:
        return False
    key = base64.b64decode(secret.removeprefix("whsec_"))
    signed = f"{id}.{ts}.{raw_body.decode()}"
    expected = base64.b64encode(hmac.new(key, signed.encode(), hashlib.sha256).digest()).decode()
    for part in sig_header.split(" "):
        version, _, sig = part.partition(",")
        if version == "v1" and hmac.compare_digest(sig, expected):
            return True
    return False

Svix's manual verification guide is the authoritative reference if you need additional language samples or edge-case handling.

Replay window

Every request carries a timestamp (the Svix scheme makes it explicit; the plain scheme relies on a X-Timestamp header for the same purpose). Reject any request whose timestamp is more than five minutes off from your server clock. The combination of signature plus replay window means an attacker who steals one captured request cannot replay it later.

Time skew is real. If your servers drift, you will get spurious replay rejections. Run NTP and align clocks within a few seconds.

Secret rotation

The signature header is a space-separated list of v1,<sig> entries. Accept any matching signature in the list. During a rotation, we sign with both the old and new secret simultaneously for 24 hours, so requests verify against either side without coordination.

When rotating secrets in your own code:

  1. Add the new secret as an environment variable alongside the old one.
  2. Deploy code that accepts both.
  3. Switch the live secret in our dashboard.
  4. Once 24 hours have passed, drop the old secret from your environment.

Capturing the raw body

Web frameworks often parse the JSON body before you can hash it. Configure your framework to expose the raw bytes:

  • Express: use express.raw({ type: 'application/json' }) on the webhook route only.
  • Fastify: add a content-type parser that buffers the raw body.
  • FastAPI: read the body with await request.body() before parsing JSON.
  • Rails: request.raw_post gives you the unparsed bytes.

Common pitfalls

  • Verifying the parsed body. JSON parsers reorder keys, normalise whitespace, and break signatures. Always verify the raw bytes.
  • Comparing with ===. Constant-time comparison only. Otherwise an attacker can brute-force a signature byte by byte using response timing.
  • Skipping the replay window. A captured webhook delivery is replayable forever without a timestamp check. Always enforce the 5-minute window.
  • Storing secrets in source control. Webhook secrets are bearer credentials. Use environment variables or a secret manager, never git.