Skip to content

Verifying signatures

Anyone who knows your address can send it a request. A delivery is from Quatlas only when its signature checks out with your subscription’s signing secret.

  1. Read Quatlas-Timestamp (Unix seconds) and Quatlas-Signature.
  2. Refuse the delivery if the timestamp is more than 5 minutes from your clock. This stops an old delivery being replayed.
  3. Compute the HMAC-SHA256 of <timestamp>.<raw body>: the timestamp header’s value, a dot, then the body’s bytes exactly as you received them. The key is your signing secret (whsec_...) as UTF-8 bytes. Write the result as lowercase hex.
  4. Quatlas-Signature holds one or more comma-separated values like v1=<hex>. Accept the delivery when any v1= value equals yours, compared in constant time. There are two values while a secret rotation is in its grace period, one per valid secret.
  5. Dedupe on Quatlas-Event-Id: the same event can arrive more than once.

Each function below takes the raw body, the two headers and your secret, and returns whether the delivery is genuine. They are tested against deliveries signed exactly the way the platform signs them, including a rotation’s two signatures and the 5-minute window.

verify.mjs
import crypto from "node:crypto";
const TOLERANCE_SECONDS = 300; // refuse deliveries signed more than 5 minutes from your clock
/**
* Verifies a Quatlas webhook delivery.
* rawBody: the request body as a Buffer, exactly as received (not re-serialised JSON).
* headers: the request headers, lower-cased names (as Node gives them).
* secret: the subscription's signing secret (whsec_...).
*/
export function verifyQuatlasSignature(rawBody, headers, secret) {
const timestamp = headers["quatlas-timestamp"];
const signature = headers["quatlas-signature"];
if (typeof timestamp !== "string" || typeof signature !== "string" || !/^\d+$/.test(timestamp)) return false;
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > TOLERANCE_SECONDS) return false;
const expected = Buffer.from(
crypto.createHmac("sha256", secret).update(`${timestamp}.`).update(rawBody).digest("hex"),
);
// One v1= value per valid secret (two during a secret rotation): accept when any one matches.
return signature.split(",").some((part) => {
const value = part.trim();
if (!value.startsWith("v1=")) return false;
const candidate = Buffer.from(value.slice(3));
return candidate.length === expected.length && crypto.timingSafeEqual(candidate, expected);
});
}

With Express, take the raw body for this route only:

import express from "express";
import { verifyQuatlasSignature } from "./verify.mjs";
const app = express();
app.post("/quatlas/webhooks", express.raw({ type: "application/json" }), (req, res) => {
if (!verifyQuatlasSignature(req.body, req.headers, process.env.QUATLAS_WEBHOOK_SECRET)) {
return res.sendStatus(400);
}
const event = JSON.parse(req.body.toString("utf8"));
// Skip event.id if you have processed it already; queue the work, then answer.
res.sendStatus(204);
});

With the secret whsec_test, the timestamp 1791963721 and the body {"id":"evt"}, the string to sign is:

1791963721.{"id":"evt"}

and the header is:

Quatlas-Signature: v1=878fa619bb541a51b45e860b0d129b198853c2768f6a15d2d020a9f1e5b2b459

Reproduce it with OpenSSL to check your own code. (A full check would still refuse this fixed example by its timestamp, unless your clock is within 5 minutes of it.)

Terminal window
printf '%s' '1791963721.{"id":"evt"}' | openssl dgst -sha256 -hmac 'whsec_test'
# SHA2-256(stdin)= 878fa619bb541a51b45e860b0d129b198853c2768f6a15d2d020a9f1e5b2b459

Answer with a 4xx and do not process the delivery. Quatlas treats any answer other than 2xx as a failure and retries it on its schedule, so a receiver that keeps refusing genuine deliveries (for example, after a secret was rotated and not deployed) will see its subscription switched off after 20 failed attempts in a row. See Webhooks.

Check, in order: you sign the raw bytes; you use the current secret (or the previous one, during a rotation); your clock is right (use NTP); you compare lowercase hex.