How-to guide

Verify a delivery on your receiver

When a queue signs its outbound deliveries with Queuey HMAC, every request to your endpoint carries the same X-Queuey-* signature headers. Verify them so you only act on requests that genuinely came from Queuey — and reject replays. A queue that receives Stripe webhooks can sign in Stripe's format instead; those deliveries are verified with Stripe's libraries.

The checks

  1. Read the signing headers; reject if any is missing.
  2. Reject stale timestamps (a few minutes of clock skew).
  3. Recompute the body hash from the raw bytes — never from a re-serialized JSON object — and reject it unless it equals X-Queuey-Content-SHA256.
  4. Rebuild the canonical string — the method, the path decoded (all but %2F), the normalized query, the timestamp, the nonce and the body hash, one per line — and HMAC-SHA256 it with your secret. The query is normalized as Queuey signs it: each key and value decoded (+ is a space), the pairs sorted by key and then by value, each re-encoded (RFC 3986), joined with &. So ?sort=name&page=2&tag=a+b&tag=%C3%A9 is signed as page=2&sort=name&tag=a%20b&tag=%C3%A9. For v2, add the event id and the idempotency key as lines seven and eight.
  5. Compare to the v2= entry of X-Queuey-Signatures in constant time — or, for a delivery from before v2 only, to X-Queuey-Signature.

Verify (TypeScript / Node)

verify.ts
import crypto from "crypto";

// %XX decoded as UTF-8; bytes that are not valid UTF-8 stay as they are,
// as .NET's Uri.UnescapeDataString and ASP.NET Core's path decoding leave them.
function decodePercent(text) {
  return text.replace(/(%[0-9A-Fa-f]{2})+/g, (run) => {
    try { return decodeURIComponent(run); } catch {}
    const bytes = run.match(/%[0-9A-Fa-f]{2}/g);
    let out = "";
    for (let i = 0; i < bytes.length; ) {
      let n = Math.min(4, bytes.length - i);
      for (; n > 0; n--) {
        try { out += decodeURIComponent(bytes.slice(i, i + n).join("")); break; } catch {}
      }
      if (n === 0) { out += bytes[i]; n = 1; }
      i += n;
    }
    return out;
  });
}

// The path as Queuey signs it: percent-decoded, as ASP.NET Core reads a
// request path, except %2F, which stays encoded.
// /hooks/caf%C3%A9/order%2F1/a%20b is signed as /hooks/café/order%2F1/a b.
function canonicalPath(rawPath) {
  return (rawPath || "/").split(/(%2[Ff])/)
    .map((part, i) => (i % 2 === 1 ? part : decodePercent(part)))
    .join("");
}

// The query as Queuey signs it: decode each key and value ("+" is a space),
// sort by key, then by value, re-encode (RFC 3986), join with "&".
function normalizeQuery(rawQuery) {
  if (!rawQuery || !rawQuery.trim()) return "";
  const raw = rawQuery.startsWith("?") ? rawQuery.slice(1) : rawQuery;
  if (!raw.trim()) return "";

  const decode = (s) => decodePercent(s.replace(/\+/g, " "));
  // encodeURIComponent leaves !'()* alone; RFC 3986 escapes them.
  const encode = (s) => encodeURIComponent(s)
    .replace(/[!'()*]/g, (c) => "%" + c.charCodeAt(0).toString(16).toUpperCase());

  const pairs = raw.split("&").filter((segment) => segment.length > 0).map((segment) => {
    const i = segment.indexOf("=");
    return i < 0 ? [decode(segment), ""] : [decode(segment.slice(0, i)), decode(segment.slice(i + 1))];
  });
  const ordinal = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
  pairs.sort((x, y) => ordinal(x[0], y[0]) || ordinal(x[1], y[1]));
  return pairs.map(([key, value]) => encode(key) + "=" + encode(value)).join("&");
}

// Express-style handler. IMPORTANT: verify against the RAW body bytes,
// before any JSON parsing/re-serialization.
export function verifyQueueyDelivery(req, secret, { acceptV1 = false } = {}) {
  const keyId = req.get("X-Queuey-Key-Id");
  const timestamp = req.get("X-Queuey-Timestamp");
  const nonce = req.get("X-Queuey-Nonce");
  const contentHash = req.get("X-Queuey-Content-SHA256");
  if (!keyId || !timestamp || !nonce || !contentHash) return false;

  // 1. Reject stale deliveries (±5 minutes).
  const skew = Math.abs(Math.floor(Date.now() / 1000) - Number(timestamp));
  if (!Number.isFinite(skew) || skew > 300) return false;

  // 2. Recompute the body hash from the raw bytes; it must match the header.
  const bodyHash = crypto.createHash("sha256")
    .update(req.rawBody)              // Buffer of the raw request body
    .digest("hex").toLowerCase();
  if (contentHash.trim().toLowerCase() !== bodyHash) return false;

  // 3. The six lines v1 signs, and v2 signs first.
  const lines = [
    req.method.trim().toUpperCase(),  // METHOD
    canonicalPath(req.path),          // PATH, decoded except %2F
    normalizeQuery(req.originalUrl.split("?")[1] ?? ""), // QUERY, normalized
    timestamp,                        // TIMESTAMP
    nonce,                            // NONCE
    bodyHash,                         // CONTENT_SHA256
  ];
  const hmac = (text) => crypto
    .createHmac("sha256", Buffer.from(secret, "utf8"))
    .update(Buffer.from(text, "utf8"))
    .digest("hex");
  const same = (provided, expected) => {
    const a = Buffer.from(provided.trim().toLowerCase(), "utf8");
    const b = Buffer.from(expected, "utf8");
    return a.length === b.length && crypto.timingSafeEqual(a, b);
  };

  // 4. v2, in X-Queuey-Signatures as v2=<hex>: the six lines, then the event id
  //    and the idempotency key, trimmed, empty when the delivery has none.
  const v2 = (req.get("X-Queuey-Signatures") ?? "").split(",")
    .map((part) => part.trim())
    .find((part) => part.startsWith("v2="))
    ?.slice(3);
  if (v2) {
    const eventId = (req.get("X-Queuey-Event-Id") ?? "").trim();
    const idempotencyKey = (req.get("Idempotency-Key") ?? "").trim();
    return same(v2, hmac([...lines, eventId, idempotencyKey].join("\n")));
  }

  // 5. Only a delivery from before v2: v1, in X-Queuey-Signature. It does not
  //    cover the event id or the idempotency key, so do not deduplicate on them.
  const v1 = req.get("X-Queuey-Signature");
  return acceptV1 && Boolean(v1) && same(v1, hmac(lines.join("\n")));
}

It verifies v2 and refuses a delivery that carries only v1. Pass { acceptV1: true } to take those too — deliveries made before v2 — and do not deduplicate on the event id or the idempotency key of a delivery that only v1 verified.

What the signature covers, and the event id

In .NET, QueueyDeliveryVerifier does all of this (see Queuey.Client). What it checks, and what any receiver should:

  • The signing headers must be there: X-Queuey-Key-Id, X-Queuey-Timestamp, X-Queuey-Nonce, X-Queuey-Content-SHA256, and the signature — X-Queuey-Signatures for v2, X-Queuey-Signature for v1.
  • The timestamp must be within five minutes of your clock.
  • The SHA-256 of the raw body must equal X-Queuey-Content-SHA256. The signature covers that header, not the bytes, so this check is what ties the signature to the body.
  • v1, in X-Queuey-Signature as hex: an HMAC-SHA256 with your secret over six lines, METHOD\nPATH\nQUERY\nTIMESTAMP\nNONCE\nCONTENT_SHA256. PATH is percent-decoded as ASP.NET Core reads a request path, except %2F, which stays encoded: /hooks/caf%C3%A9/order%2F1/a%20b is signed as /hooks/café/order%2F1/a b. QUERY is normalized: each key and value decoded, sorted, and re-encoded.
  • v2, in X-Queuey-Signatures as v2=<hex> (a comma-separated list, one entry per version): the same HMAC over eight lines, the six of v1 and then EVENT_ID (X-Queuey-Event-Id) and IDEMPOTENCY_KEY (Idempotency-Key), each trimmed, and empty when the delivery has none. A delivery carries both versions, over the same timestamp, nonce and body hash.
  • With a nonce guard (NonceAlreadySeen in .NET), a nonce seen before is rejected. Without one, the five-minute window is the only protection against the same delivery sent again.

Deduplicate on the event id or the Idempotency-Key only with v2. A retry or a redelivery of one event carries the same id, each time with a new nonce, so the id is what to deduplicate on. v2 covers both values: a delivery with another event id or key needs a new valid signature, which takes the secret. v1 covers neither, so with v1 alone whoever captured a delivery can send it again within the five minutes under another event id, and it verifies; a nonce guard narrows that, since the capture's nonce is then spent.

Verify the raw body
Frameworks that parse JSON before your handler runs will break verification — the bytes you hash must be exactly what Queuey sent. Capture the raw body (e.g. express.raw() or a rawBody hook) and hash that.

Deliveries signed in Stripe's format

A queue whose ingress verifies Stripe webhooks can use Stripe signature recalculation. Its deliveries carry Stripe-Signature instead of X-Queuey-Signature and the other X-Queuey-* signature headers, so the checks above do not apply to them. Verify them the way you verify a request from Stripe: with Stripe's library (constructEvent and its default tolerance), the raw body, and the secret the queue signs with: the same whsec_… secret its Stripe ingress verifies with.

t is the time of the delivery attempt, and every retry or replay is signed afresh, so deduplicate on the Stripe event's id. Queuey signs only the unchanged bytes of events that arrived through the queue's Stripe-verified ingress after recalculation started; what that means, and what it refuses to sign, is in Stripe webhooks, step 5.

Related