Postel
Concepts

Signing and verification

What verify() actually checks, the v1 (HMAC) and v1a (Ed25519) signature schemes, and why constant-time comparison matters.

A signed webhook is just an HTTP request with three extra headers. The receiver's job is to reject every request that doesn't match a small, well-defined set of conditions — and to do so in a way that doesn't leak timing information.

The three headers

Every Standard Webhooks request carries:

HeaderPurpose
webhook-idStable, unique message identifier. Used for idempotent receive (the dedup helper indexes on this) and for kid lookup in JWKS mode.
webhook-timestampUnix epoch seconds, as a string. The receiver rejects timestamps outside a configurable window (default ±5 minutes).
webhook-signatureOne or more space-separated tokens of the form <version>,<base64-signature>. The receiver accepts a request if any signature in the header verifies against any of its configured secrets.

The signed-content string is constructed from these primitives:

${webhook-id}.${webhook-timestamp}.${body}

Where ${body} is the raw request body — byte-identical to what the producer sent. This is why raw-bytes preservation matters; if your framework parses and re-serializes JSON between receipt and verification, the signature will not match.

What verify checks, in order

const { event, matchedSecretIndex } = await verify(body, headers, secretOrKeyset);

Under the hood:

  1. Header presence and shape. All three headers must exist and parse. The signature header must contain at least one token of the form <version>,<base64>. Any failure throws MalformedHeader.
  2. Timestamp window. Reject if |now - webhook-timestamp| > toleranceSeconds (default 300). Throws TimestampTooOld.
  3. Key lookup (JWKS mode only). If a Keyset is provided, extract the kid from the message id and look up the corresponding public key. Throws UnknownKeyId if no match.
  4. Signature comparison. For each (secret, signature-token) pair, compute the expected signature and compare in constant time to the one in the header. Any one match succeeds; if none match, throws SignatureInvalid.
  5. Body parse. The verified raw bytes are parsed as JSON; the event shape is returned. (Parse failures throw SignatureInvalid — a parse error after a successful signature check almost always indicates a body that was tampered with downstream.)

On success the return value names which secret matched (matchedSecretIndex), so callers can detect when a sender is still using a deprecated key.

The signature schemes

Postel implements two schemes. Both are encoded inline in the webhook-signature header, distinguished by their version prefix.

v1 — HMAC-SHA256 (Standard Webhooks default)

webhook-signature: v1,base64(HMAC-SHA256(signed-content, secret))

Symmetric: the producer and the receiver share the same secret. Cheap to compute (~µs per verification). Default for Standard Webhooks-compliant producers.

The secret format is the Standard Webhooks convention: whsec_<base64> — a 32-byte random key, base64-encoded. The whsec_ prefix is a hint to humans and to leak-scanners; the verifier strips it before computing the HMAC.

v1a — Ed25519 (Postel extension)

webhook-signature: v1a,base64(Ed25519(signed-content, private-key))

Asymmetric: the producer signs with a private key, the receiver verifies with the corresponding public key. Slightly slower (~tens of µs) but unlocks public-key distribution — receivers can verify without ever holding signing material. Pairs naturally with JWKS.

v1a is a Postel-specific extension on top of Standard Webhooks. It is byte-compatible with the v1 envelope: a request can carry both v1,... and v1a,... tokens in the same header, and the receiver accepts on first match.

Multi-secret verification

verify accepts a single secret or an array. Arrays are tried in order:

const { event, matchedSecretIndex } = await verify(
  body,
  headers,
  [process.env.WEBHOOK_SECRET_CURRENT, process.env.WEBHOOK_SECRET_PREVIOUS],
);

if (matchedSecretIndex === 1) {
  log.warn("webhook signed with deprecated secret", { id: event.type });
}

This is the rotation-overlap window — see Key rotation for the full pattern.

Constant-time comparison

The signature check uses Web Crypto's crypto.subtle.timingSafeEqual (or an equivalent constant-time XOR-and-OR implementation when the platform doesn't ship it). Naive === comparison leaks information about which byte differs — an attacker observing response timing can mount a byte-by-byte chosen-plaintext attack on the signature.

The compliance suite includes a vector for this: two equal-length signatures that differ at byte 0 vs. byte 31 are required to verify in indistinguishable time.

Structured errors

verify never returns a boolean. Failures are typed:

import {
  SignatureInvalid,
  TimestampTooOld,
  MalformedHeader,
  UnknownKeyId,
  RawBytesMismatchDetected,
} from "@postel/edge";

try {
  const { event } = await verify(body, headers, secret);
} catch (err) {
  if (err instanceof SignatureInvalid) {
    // signature didn't match any configured secret
  } else if (err instanceof TimestampTooOld) {
    // outside the configured window — likely a replay or clock skew
  } else if (err instanceof MalformedHeader) {
    // a required header is missing or malformed
  } else if (err instanceof UnknownKeyId) {
    // JWKS mode: the kid in the request is not in the keyset
  } else if (err instanceof RawBytesMismatchDetected) {
    // framework adapter detected that the body was mutated before verify
  }
}

Each error names the failing step. The error message is safe to log — it never contains the secret or the signature bytes.

What's next

  • Key rotation — the multi-secret overlap window and JWKS.
  • Idempotency — at-least-once delivery and the dedup helper.
  • Raw bytes — why framework integration matters.

On this page