SALAF EMSDevelopers
salafems.com

Verifying signatures

The signing scheme, code in three languages, and test vectors.

Anyone can POST JSON at your endpoint. The signature is what tells you a delivery is ours — verify it before you read the body, not after.

The scheme

HMAC-SHA256 over "{timestamp}.{rawBody}", keyed with your endpoint's signing secret, hex-encoded, and sent in one header:

POST /webhooks/salaf HTTP/1.1
Content-Type: application/json
X-Salaf-Signature: t=1754899200,v1=dd3c17ef08611a833fd18718fb90d48d278db0f7f1ced1c1812aad359a718de8
X-Salaf-Event: order.created
X-Salaf-Event-Id: evt_01J4X8G9ABCDEFGHJKMNPQRSTV
PartMeaning
tThe Unix timestamp, in seconds, at which the request was signed.
v1A hex HMAC-SHA256 digest. v1 names the scheme, not the payload version — a future v2= would mean a different algorithm, and receivers that only understand v1 would keep working.

There may be two v1= values. That is a secret rotation in progress: the old secret keeps co-signing for 24 hours so you can deploy the new one at your own pace. Accept the delivery if either matches — see Rotating a secret.

Three details carry the whole security argument, and skipping any one of them produces code that looks like it verifies and does not:

  • The timestamp is inside the signed string. Signing the body alone would let anyone who captured one delivery replay it forever with a fresh t=, because the header would still verify. Because t is signed, changing it invalidates the signature — which is what makes your freshness check meaningful rather than decorative.
  • The raw bytes are signed, not a parsed object. See the raw-body trap.
  • The comparison must be constant-time. a === b on a digest leaks, through timing, how many leading characters an attacker guessed right. That turns forging a 64-character hex string from impossible into a few thousand requests.

Verifying

import crypto from "node:crypto";

const TOLERANCE_SECONDS = 300; // 5 minutes

/**
 * @param rawBody the EXACT bytes we sent, as a string or Buffer — never a
 *                re-serialised object.
 */
export function verifySalafSignature(rawBody, header, secret) {
  if (!header) return false;

  let timestamp = null;
  const signatures = [];
  for (const part of header.split(",")) {
    const [key, value] = part.split("=");
    if (key?.trim() === "t") timestamp = Number(value?.trim());
    if (key?.trim() === "v1") signatures.push(value?.trim());
  }
  if (!Number.isFinite(timestamp) || signatures.length === 0) return false;

  // Reject anything captured and replayed later. The timestamp is INSIDE the
  // signed string, so an attacker cannot simply put a fresh one in the header.
  const now = Math.floor(Date.now() / 1000);
  if (Math.abs(now - timestamp) > TOLERANCE_SECONDS) return false;

  const expected = crypto
    .createHmac("sha256", secret)
    .update(`${timestamp}.${rawBody}`, "utf8")
    .digest("hex");

  // During a secret rotation there are TWO v1= values; either may match.
  return signatures.some((candidate) => timingSafeEqual(expected, candidate));
}

// Never use === on a signature: the early exit leaks, through timing, how
// many leading characters an attacker guessed right.
function timingSafeEqual(a, b) {
  if (a.length !== b.length) return false;
  return crypto.timingSafeEqual(Buffer.from(a, "utf8"), Buffer.from(b, "utf8"));
}

Then reject the delivery if verification fails. A 400 is the right answer: we will retry it, but no number of retries will make an unsigned request valid, and the failure is visible in the merchant's delivery log where they can act on it.

The five-minute window

Reject any delivery whose t is more than 300 seconds from your own clock, in either direction. We do not enforce this — you do, and the code above shows where.

That window is a compromise between two real problems: a captured request should not be replayable tomorrow, and two machines nobody synchronised will disagree by a few seconds. Five minutes is comfortably more than clock skew and comfortably less than useful to an attacker.

The raw-body trap

This is the single most common way a working integration breaks intermittently, so it is worth stating bluntly: verify against the exact bytes you received.

Re-serialising JSON produces different bytes on different platforms — key order, unicode escaping, whitespace, number formatting. A receiver that verifies against JSON.stringify(req.body) will match on most payloads and fail on the ones containing a Bengali product name or a decimal that round-trips differently. It looks like an intermittent network problem and it is not.

Every popular framework parses the body for you by default, so getting the raw bytes takes one deliberate line:

import express from "express";

const app = express();

// Option A — keep the parsed body AND capture the raw bytes beside it.
app.use(
  express.json({
    verify: (req, _res, buffer) => {
      req.rawBody = buffer;
    },
  }),
);

// Option B — for the webhook route only, do not parse at all.
app.post(
  "/webhooks/salaf",
  express.raw({ type: "application/json" }),
  (req, res) => {
    // req.body is a Buffer here — exactly the bytes we signed.
    const ok = verifySalafSignature(
      req.body,
      req.get("X-Salaf-Signature"),
      process.env.SALAF_WEBHOOK_SECRET,
    );
    if (!ok) return res.sendStatus(400);

    const event = JSON.parse(req.body.toString("utf8"));
    res.sendStatus(200);
    void handleLater(event);
  },
);

// What NOT to do: JSON.stringify(req.body). Re-serialising changes key
// order, unicode escaping and whitespace, so the digest will differ from
// ours on some payloads and match on others — the worst kind of bug.

Verify your implementation

These vectors are pinned by a test in our own codebase, so they cannot drift away from what the server does. Run your verification code against them before you point it at a live endpoint — if it disagrees here, it will disagree in production, and you will be debugging it against real orders.

Secretwhsec_TestSecretForDocumentationVectorsOnly1
Timestamp1754899200
Raw body{"id":"evt_01J4X8G9ABCDEFGHJKMNPQRSTV","type":"salaf.ping"}
Signed string1754899200.{"id":"evt_01J4X8G9ABCDEFGHJKMNPQRSTV","type":"salaf.ping"}
Expected v1dd3c17ef08611a833fd18718fb90d48d278db0f7f1ced1c1812aad359a718de8
Full headert=1754899200,v1=dd3c17ef08611a833fd18718fb90d48d278db0f7f1ced1c1812aad359a718de8
import crypto from "node:crypto";

const secret = "whsec_TestSecretForDocumentationVectorsOnly1";
const timestamp = 1754899200;
const body = '{"id":"evt_01J4X8G9ABCDEFGHJKMNPQRSTV","type":"salaf.ping"}';

const signature = crypto
  .createHmac("sha256", secret)
  .update(`${timestamp}.${body}`, "utf8")
  .digest("hex");

console.log(signature);
// dd3c17ef08611a833fd18718fb90d48d278db0f7f1ced1c1812aad359a718de8

console.assert(
  signature === "dd3c17ef08611a833fd18718fb90d48d278db0f7f1ced1c1812aad359a718de8",
  "Your signing does not match Salaf's. Check that you sign " +
    "`timestamp + '.' + rawBody` and hex-encode the digest.",
);

If your digest matches, your signing is correct. The remaining things to check in your own code are the ones the vector cannot exercise: that you compare in constant time, that you enforce the timestamp window, and that you accept either v1 value when two are present.

Where the secret lives

  • Shown once, when the endpoint is created, and again only when you rotate it. It starts with whsec_.
  • Store it like a password — an environment variable or a secret manager, never in source. The whsec_ prefix exists so secret scanners can catch it if it does end up in a commit.
  • One secret per endpoint. If you receive events at two URLs, they have two different secrets, and verifying with the wrong one fails exactly as if the request were forged.
  • We cannot show it to you again. It is encrypted rather than hashed (signing needs the plaintext back), but there is no screen and no support process that prints it. If you lose it, rotate.