Skip to main content
Always verify the signature before trusting a webhook — it proves the delivery is genuinely from Numero and wasn’t tampered with or replayed.

The signature header

Each delivery carries:
X-Numero-Signature: t=1717412400123,v1=<base64 HMAC-SHA256>
  • t — the timestamp the signature was generated.
  • v1 — the signature.
The signed string is "{t}.{rawRequestBody}" — the t value, a literal dot, then the exact raw bytes of the request body.

How to verify

  1. Parse t and v1 from the X-Numero-Signature header.
  2. Build the signed string: `${t}.${rawBody}` (use the raw body, before JSON parsing).
  3. Compute HMAC-SHA256 of that string with your subscription’s signing secret, Base64-encode it.
  4. Compare to v1 using a constant-time comparison.
  5. Reject deliveries whose t is older than your tolerance (e.g. 5 minutes) to prevent replays.
Legacy: a bare-body X-Webhook-Signature (HMAC-SHA256 of just the body, Base64) is sent in parallel until 2026-06-26 for back-compat. Migrate to X-Numero-Signature.

Code examples

Node.js

const crypto = require("crypto");

function verifyNumeroSignature(rawBody, header, secret, toleranceMs = 5 * 60 * 1000) {
  const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));
  const t = Number(parts.t);
  if (!t || Math.abs(Date.now() - t) > toleranceMs) return false; // replay protection

  const expected = crypto.createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("base64");
  const a = Buffer.from(expected);
  const b = Buffer.from(parts.v1 || "");
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

// Express — note express.raw so req.body is the RAW bytes
app.post("/webhook", express.raw({ type: "application/json" }), (req, res) => {
  const ok = verifyNumeroSignature(req.body.toString(), req.get("X-Numero-Signature"), process.env.WEBHOOK_SECRET);
  if (!ok) return res.status(401).send("Invalid signature");

  const event = JSON.parse(req.body);
  console.log("Received:", event.event);
  res.status(200).send("OK");
});

Python

import hmac, hashlib, base64, time

def verify_numero_signature(raw_body: str, header: str, secret: str, tolerance_ms: int = 5 * 60 * 1000) -> bool:
    parts = dict(p.split("=", 1) for p in header.split(","))
    t = int(parts.get("t", 0))
    if not t or abs(int(time.time() * 1000) - t) > tolerance_ms:
        return False  # replay protection

    expected = base64.b64encode(
        hmac.new(secret.encode(), f"{t}.{raw_body}".encode(), hashlib.sha256).digest()
    ).decode()
    return hmac.compare_digest(expected, parts.get("v1", ""))

C#

using System.Security.Cryptography;
using System.Text;

public static bool VerifyNumeroSignature(string rawBody, string header, string secret, long toleranceMs = 5 * 60 * 1000)
{
    var parts = header.Split(',').Select(p => p.Split('=', 2)).ToDictionary(p => p[0], p => p[1]);
    if (!long.TryParse(parts.GetValueOrDefault("t"), out var t)) return false;
    var now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
    if (Math.Abs(now - t) > toleranceMs) return false; // replay protection

    using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secret));
    var expected = Convert.ToBase64String(hmac.ComputeHash(Encoding.UTF8.GetBytes($"{t}.{rawBody}")));
    return CryptographicOperations.FixedTimeEquals(
        Encoding.UTF8.GetBytes(expected),
        Encoding.UTF8.GetBytes(parts.GetValueOrDefault("v1", "")));
}

Important

  • Verify against the raw request body — before parsing it to JSON.
  • Always use a constant-time comparison (timingSafeEqual / compare_digest / FixedTimeEquals).
  • Enforce the timestamp tolerance so an intercepted delivery can’t be replayed later.
  • Use the envelope id to dedupe — the same event may be delivered more than once.
  • Never skip verification in production.