Verifying webhook signatures
Check that a webhook was sent by Unimaze and was not changed, with code in C#, Java, Python and TypeScript.
Every webhook carries a UMZ-Webhook-Signature header, created with your webhook's signing secret:
UMZ-Webhook-Signature: t=1790762400,v0=1b69720d9b66a6118096ed70b96311e9e2829c1608ae52dfd633b5b5b68864ff
tis the time the request was signed (Unix seconds, UTC).v0is the lowercase hex HMAC-SHA256 of{t}.{raw request body}, using the signing secret as the key.- While a secret is being rotated, the header contains two
v0values (new secret first). Accept the request if any of them matches.
How to verify
- Read the raw request body as bytes, before any JSON parsing. Re-serializing the JSON changes the bytes and breaks the signature.
- Split the header on
,and collecttand allv0values. - Reject the request if
tis more than 5 minutes from your current time. This protects against replayed requests. - Compute HMAC-SHA256 over
t+.+ raw body, with the full signing secret (including thewhsec_prefix) as a UTF-8 key. - Compare your result to each
v0value using a constant-time comparison. - If nothing matches, respond
401and don't process the event.
Code samples
using System.Security.Cryptography;
using System.Text;
public static class WebhookSignature
{
public static bool Verify(byte[] rawBody, string signatureHeader, string secret,
TimeSpan? tolerance = null, DateTimeOffset? now = null)
{
string? timestamp = null;
var signatures = new List<string>();
foreach (var part in signatureHeader.Split(','))
{
var kv = part.Trim().Split('=', 2);
if (kv.Length != 2) continue;
if (kv[0] == "t") timestamp = kv[1];
else if (kv[0] == "v0") signatures.Add(kv[1]);
}
if (timestamp is null || signatures.Count == 0 || !long.TryParse(timestamp, out var t)) return false;
var age = (now ?? DateTimeOffset.UtcNow).ToUnixTimeSeconds() - t;
if (Math.Abs(age) > (tolerance ?? TimeSpan.FromMinutes(5)).TotalSeconds) return false; // possible replay
var signedPayload = Encoding.UTF8.GetBytes(timestamp + ".").Concat(rawBody).ToArray();
var expected = HMACSHA256.HashData(Encoding.UTF8.GetBytes(secret), signedPayload);
foreach (var s in signatures)
{
byte[] candidate;
try { candidate = Convert.FromHexString(s); } catch (FormatException) { continue; }
if (CryptographicOperations.FixedTimeEquals(expected, candidate)) return true;
}
return false;
}
}In ASP.NET Core, read the raw body before model binding:
app.MapPost("/hooks/unimaze", async (HttpRequest request) =>
{
using var ms = new MemoryStream();
await request.Body.CopyToAsync(ms);
var body = ms.ToArray();
if (!WebhookSignature.Verify(body, request.Headers["UMZ-Webhook-Signature"].ToString(), secret))
return Results.Unauthorized();
await queue.EnqueueAsync(body); // process in the background
return Results.NoContent();
});import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.ArrayList;
import java.util.HexFormat;
import java.util.List;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
public final class WebhookSignature {
public static boolean verify(byte[] rawBody, String signatureHeader, String secret,
long toleranceSeconds, long nowEpochSeconds) throws Exception {
String timestamp = null;
List<String> signatures = new ArrayList<>();
for (String part : signatureHeader.split(",")) {
String[] kv = part.trim().split("=", 2);
if (kv.length != 2) continue;
if (kv[0].equals("t")) timestamp = kv[1];
else if (kv[0].equals("v0")) signatures.add(kv[1]);
}
if (timestamp == null || signatures.isEmpty()) return false;
if (Math.abs(nowEpochSeconds - Long.parseLong(timestamp)) > toleranceSeconds) return false; // possible replay
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
mac.update((timestamp + ".").getBytes(StandardCharsets.UTF_8));
byte[] expected = mac.doFinal(rawBody);
for (String s : signatures) {
byte[] candidate;
try { candidate = HexFormat.of().parseHex(s); } catch (IllegalArgumentException e) { continue; }
if (MessageDigest.isEqual(expected, candidate)) return true;
}
return false;
}
}Call it with verify(body, header, secret, 300, Instant.now().getEpochSecond()). Requires Java 17 or later.
import hashlib
import hmac
import time
def verify_signature(raw_body: bytes, signature_header: str, secret: str,
tolerance_seconds: int = 300, now: int | None = None) -> bool:
timestamp, signatures = None, []
for part in signature_header.split(","):
key, _, value = part.strip().partition("=")
if key == "t":
timestamp = value
elif key == "v0":
signatures.append(value)
if timestamp is None or not signatures:
return False
now = int(time.time()) if now is None else now
if abs(now - int(timestamp)) > tolerance_seconds:
return False # possible replay
signed_payload = timestamp.encode() + b"." + raw_body
expected = hmac.new(secret.encode(), signed_payload, hashlib.sha256).hexdigest()
return any(hmac.compare_digest(expected, s) for s in signatures)In Flask, use request.get_data() for the raw body. In FastAPI, use await request.body().
import { createHmac, timingSafeEqual } from "node:crypto";
export function verifySignature(
rawBody: Buffer,
signatureHeader: string,
secret: string,
toleranceSeconds = 300,
now = Math.floor(Date.now() / 1000),
): boolean {
let timestamp: string | undefined;
const signatures: string[] = [];
for (const part of signatureHeader.split(",")) {
const [key, value] = part.trim().split("=", 2);
if (key === "t") timestamp = value;
else if (key === "v0") signatures.push(value);
}
if (!timestamp || signatures.length === 0) return false;
if (Math.abs(now - Number(timestamp)) > toleranceSeconds) return false; // possible replay
const expected = createHmac("sha256", secret)
.update(Buffer.concat([Buffer.from(`${timestamp}.`), rawBody]))
.digest();
return signatures.some((s) => {
const candidate = Buffer.from(s, "hex");
return candidate.length === expected.length && timingSafeEqual(candidate, expected);
});
}In Express, use express.raw({ type: "application/json" }) on the webhook route so req.body is the raw Buffer.
Test vector
Use these values in a unit test for your verification code. With the clock set to t, the result must be valid. Changing a single byte of the body, or moving the clock more than 5 minutes, must make it invalid.
| Input | Value |
|---|---|
| Signing secret | whsec_dGVzdC1zZWNyZXQtZG8tbm90LXVzZS1pbi1wcm9kdWN0aW9u |
t | 1790762400 (2026-09-30 10:00:00 UTC) |
Expected v0 | 1b69720d9b66a6118096ed70b96311e9e2829c1608ae52dfd633b5b5b68864ff |
Raw body (a single line, no trailing newline):
{"eventId":"3f2b6c1e-8d4a-4e9b-9a51-0c7d2e6f4a10","action":"importStatusChanged","transfer":"inbound","occurredAt":"2026-09-30T10:00:00.000Z","test":true,"party":"0196:6511230889","messageId":"6cfa5c24-25c5-4eb4-bed5-3ed6cc6c25c5","data":{"formerValue":"Pending","newValue":"Imported"}}Troubleshooting
| Symptom | Likely cause |
|---|---|
| Test vector passes, real requests fail | Your framework parsed and re-serialized the JSON. Verify against the raw body. |
| Fails only after a secret rotation | You're checking only the first v0 value, or you still use the old secret after the 24-hour overlap. |
| Fails intermittently | The server clock is off. Sync with NTP, or check the tolerance. |
| Everything fails | Wrong secret. The key is the full secret string including whsec_, UTF-8 encoded (not base64-decoded). |
Use Send test event in the management UI (MUI) to send a signed request to your endpoint and see the response.
Updated 5 days ago
