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
  • t is the time the request was signed (Unix seconds, UTC).
  • v0 is 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 v0 values (new secret first). Accept the request if any of them matches.

How to verify

  1. Read the raw request body as bytes, before any JSON parsing. Re-serializing the JSON changes the bytes and breaks the signature.
  2. Split the header on , and collect t and all v0 values.
  3. Reject the request if t is more than 5 minutes from your current time. This protects against replayed requests.
  4. Compute HMAC-SHA256 over t + . + raw body, with the full signing secret (including the whsec_ prefix) as a UTF-8 key.
  5. Compare your result to each v0 value using a constant-time comparison.
  6. If nothing matches, respond 401 and 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();
});

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.

InputValue
Signing secretwhsec_dGVzdC1zZWNyZXQtZG8tbm90LXVzZS1pbi1wcm9kdWN0aW9u
t1790762400 (2026-09-30 10:00:00 UTC)
Expected v01b69720d9b66a6118096ed70b96311e9e2829c1608ae52dfd633b5b5b68864ff

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

SymptomLikely cause
Test vector passes, real requests failYour framework parsed and re-serialized the JSON. Verify against the raw body.
Fails only after a secret rotationYou're checking only the first v0 value, or you still use the old secret after the 24-hour overlap.
Fails intermittentlyThe server clock is off. Sync with NTP, or check the tolerance.
Everything failsWrong 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.


Did this page help you?