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-timestampstringrequiredUnix 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-signaturestringrequiredThe 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:
Read the raw body
The exact bytes of the request body, before any JSON parsing.
Reject a timestamp outside ±300 seconds
Compare
x-nz-webhook-timestampagainst your own clock. Reject if the absolute difference exceeds 300 seconds, in either direction.Recompute the MAC
HMAC-SHA256over"<timestamp>.<raw body>", using the timestamp from the header (not your own clock) and the secret as the key.Compare in constant time
Use a constant-time equality function. Never
==,===,strcmporBuffer.equalson 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
});
import hashlib
import hmac
import time
TOLERANCE_SECONDS = 300
SCHEME = "v1"
def verify_webhook(raw_body: bytes, headers, secret: str, now_seconds: int | None = None) -> bool:
"""raw_body is the exact request body, unparsed. headers is case-insensitive."""
timestamp_header = headers.get("x-nz-webhook-timestamp")
signature_header = headers.get("x-nz-webhook-signature")
if not secret or not timestamp_header or not signature_header:
return False
if not str(timestamp_header).isdigit():
return False
timestamp = int(timestamp_header)
now = int(time.time()) if now_seconds is None else now_seconds
# Replay window, both directions.
if abs(now - timestamp) > TOLERANCE_SECONDS:
return False
scheme, _, presented = str(signature_header).partition("=")
if scheme != SCHEME or not presented:
return False
signed_payload = f"{timestamp}.".encode("utf-8") + raw_body
expected = hmac.new(
secret.encode("utf-8"), signed_payload, hashlib.sha256
).hexdigest()
# Constant time.
return hmac.compare_digest(presented, expected)
# Flask: get_data(), never get_json(), before verification.
from flask import Flask, request
app = Flask(__name__)
@app.post("/nyumbazetu")
def nyumbazetu_webhook():
if not verify_webhook(request.get_data(), request.headers, SECRET):
return "invalid signature", 400
enqueue(request.get_json()) # acknowledge fast, work asynchronously
return "ok", 200
<?php
const NZ_TOLERANCE_SECONDS = 300;
const NZ_SCHEME = 'v1';
/**
* @param string $rawBody The exact request body, unparsed.
* @param string|null $timestampHeader x-nz-webhook-timestamp
* @param string|null $signatureHeader x-nz-webhook-signature
*/
function nz_verify_webhook(
string $rawBody,
?string $timestampHeader,
?string $signatureHeader,
string $secret,
?int $nowSeconds = null
): bool {
if ($secret === '' || $timestampHeader === null || $signatureHeader === null) {
return false;
}
if (preg_match('/^\d+$/', $timestampHeader) !== 1) {
return false;
}
$timestamp = (int) $timestampHeader;
$now = $nowSeconds ?? time();
// Replay window, both directions.
if (abs($now - $timestamp) > NZ_TOLERANCE_SECONDS) {
return false;
}
$parts = explode('=', $signatureHeader, 2);
if (count($parts) !== 2 || $parts[0] !== NZ_SCHEME || $parts[1] === '') {
return false;
}
$presented = $parts[1];
$expected = hash_hmac('sha256', $timestamp . '.' . $rawBody, $secret);
// Constant time.
return hash_equals($expected, $presented);
}
// Plain PHP: php://input is the raw body. Do not json_decode before verifying.
$rawBody = file_get_contents('php://input');
$ok = nz_verify_webhook(
$rawBody,
$_SERVER['HTTP_X_NZ_WEBHOOK_TIMESTAMP'] ?? null,
$_SERVER['HTTP_X_NZ_WEBHOOK_SIGNATURE'] ?? null,
getenv('NZ_WEBHOOK_SECRET') ?: ''
);
if (!$ok) {
http_response_code(400);
exit('invalid signature');
}
http_response_code(200);
echo 'ok'; // acknowledge first
fastcgi_finish_request();
nz_enqueue(json_decode($rawBody, true)); // then work
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.