Verifying signatures

Prove a delivery came from Nyumba Zetu, and that it is not a replay.

Every delivery is signed with HMAC‑SHA256 using the secret you received when you registered the endpoint. Verify every request before you act on it. An unverified endpoint will accept a body from anyone who learns its URL.

The headers

x-nz-webhook-timestampstringrequired

Unix time in seconds at the moment the body was signed. It is a separate header and it is covered by the signature — so it cannot be edited in transit, and you have something to measure the replay window against.

x-nz-webhook-signaturestringrequired

The signature, as v1=<hex>. v1 names the scheme; split on the first =, check the scheme is v1, and compare only the hex part. Treat an unknown scheme as a verification failure.

Deliveries also carry content-type: application/json, X-Webhook-Event (the event name) and X-Webhook-Execution-Id. These are convenience headers and are not part of the signature — never make a trust decision on them. The event name and id are inside the signed body as event and executionId.

The algorithm

signed_payload = "<x-nz-webhook-timestamp>" + "." + <raw request body>
expected       = "v1=" + lowercase_hex( HMAC_SHA256( secret, signed_payload ) )

Then, in order:

  1. Read the raw body

    The exact bytes of the request body, before any JSON parsing.

  2. Reject a timestamp outside ±300 seconds

    Compare x-nz-webhook-timestamp against your own clock. Reject if the absolute difference exceeds 300 seconds, in either direction.

  3. Recompute the MAC

    HMAC-SHA256 over "<timestamp>.<raw body>", using the timestamp from the header (not your own clock) and the secret as the key.

  4. Compare in constant time

    Use a constant-time equality function. Never ==, ===, strcmp or Buffer.equals on the two strings.

Three things that must not be skipped

Implementations

const crypto = require("crypto");

const TOLERANCE_SECONDS = 300;
const SCHEME = "v1";

/**
 * @param {Buffer|string} rawBody - the exact request body, unparsed
 * @param {object} headers - lowercase-keyed request headers
 * @param {string} secret - the signing secret, as issued
 */
function verifyWebhook(rawBody, headers, secret, nowSeconds = Math.floor(Date.now() / 1000)) {
  const timestampHeader = headers["x-nz-webhook-timestamp"];
  const signatureHeader = headers["x-nz-webhook-signature"];
  if (!secret || !timestampHeader || !signatureHeader) return false;

  // Integer seconds only.
  if (!/^\d+$/.test(String(timestampHeader))) return false;
  const timestamp = Number(timestampHeader);

  // Replay window, both directions.
  if (Math.abs(nowSeconds - timestamp) > TOLERANCE_SECONDS) return false;

  const [scheme, presented] = String(signatureHeader).split("=", 2);
  if (scheme !== SCHEME || !presented) return false;

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

  // Constant time. Lengths must match first: timingSafeEqual throws otherwise.
  const a = Buffer.from(presented, "utf8");
  const b = Buffer.from(expected, "utf8");
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

// Express: RAW body on this route, never express.json().
const express = require("express");
const app = express();

app.post("/nyumbazetu", express.raw({ type: "application/json" }), (req, res) => {
  if (!verifyWebhook(req.body, req.headers, process.env.NZ_WEBHOOK_SECRET)) {
    return res.status(400).send("invalid signature");
  }
  res.status(200).send("ok"); // acknowledge first
  const event = JSON.parse(req.body.toString("utf8"));
  enqueue(event); // then work, asynchronously
});

Failure returns false — it does not throw

Write your verifier so that every failure path returns a plain "no". A verifier that throws invites a catch that logs the error and falls through to processing the body anyway, which is the same as having no verification at all.

Reject with 400. A rejection is permanent — see Retries and failures — so the platform will not hammer you with retries of a body you cannot authenticate.

Testing your verifier

Fire a test event and watch it fail and then pass:

curl -s -X POST https://api.nyumbazetu.com/v3/webhooks/{uuid}/test \
  -H "Authorization: Bearer $NZ_TOKEN"

It goes through the real signing path, so it exercises your verifier exactly as a real delivery would. Useful checks to run against your own code:

  • A body you have re-serialised must fail.
  • A timestamp 400 seconds old must fail.
  • A timestamp 400 seconds in the future must fail.
  • A signature with the scheme changed to v2= must fail.
  • The untouched delivery must pass.

After rotating a secret

Rotation has no overlap window: the new secret is live immediately and the old one stops verifying. Make your verifier accept either of two secrets for the duration of a rotation — compute both expected signatures and constant-time compare against each — so that anything already in flight still verifies.