UDI Lens

Verifying signatures

Every request carries three headers, following Standard Webhooks:

HeaderDescription
webhook-idEvent ID; unchanged on retries
webhook-timestampSend time (Unix seconds); updated on each retry
webhook-signatureOne or more v1,<base64> values separated by spaces

Algorithm

signed_content = webhook-id + "." + webhook-timestamp + "." + raw body
key            = base64_decode(API key without the "whsec_" prefix)
signature      = "v1," + base64(HMAC-SHA256(key, signed_content))

To verify:

  1. All three headers must be present
  2. webhook-timestamp must be within 5 minutes of now (replay protection)
  3. Compute the expected signature and compare it with any value in webhook-signature using a constant-time comparison
  4. Always use the raw body. Parsing and re-serializing JSON changes whitespace or key order and breaks the signature

The API key never appears in requests. Don't ask us to send it in an Authorization header; plain headers end up in proxy, WAF and monitoring logs.

Key rotation

After you rotate the key in the App or portal, webhook-signature carries both the old and new signatures for 24 hours. Switch your system to the new key within that window.

Official libraries

Standard Webhooks maintains libraries in many languages:

import { Webhook } from "standardwebhooks";
const wh = new Webhook(process.env.UDILENS_API_KEY); // whsec_...
const event = wh.verify(rawBody, headers); // throws when verification fails

Examples

Python (Flask)

import base64, hashlib, hmac, time

def verify(api_key: str, headers, raw_body: bytes) -> bool:
    msg_id, ts, sigs = headers.get("webhook-id"), headers.get("webhook-timestamp"), headers.get("webhook-signature")
    if not (msg_id and ts and sigs) or abs(time.time() - int(ts)) > 300:
        return False
    key = base64.b64decode(api_key.removeprefix("whsec_"))
    expected = "v1," + base64.b64encode(hmac.new(key, f"{msg_id}.{ts}.".encode() + raw_body, hashlib.sha256).digest()).decode()
    return any(hmac.compare_digest(s, expected) for s in sigs.split(" "))

C# (.NET 8)

static bool Verify(string apiKey, string id, string ts, string sigs, string rawBody)
{
    if (Math.Abs(DateTimeOffset.UtcNow.ToUnixTimeSeconds() - long.Parse(ts)) > 300) return false;
    using var hmac = new HMACSHA256(Convert.FromBase64String(apiKey["whsec_".Length..]));
    var expected = Encoding.UTF8.GetBytes("v1," + Convert.ToBase64String(
        hmac.ComputeHash(Encoding.UTF8.GetBytes($"{id}.{ts}.{rawBody}"))));
    return sigs.Split(' ').Any(s => CryptographicOperations.FixedTimeEquals(Encoding.UTF8.GetBytes(s), expected));
}

Java 17

static boolean verify(String apiKey, String id, String ts, String sigs, String body) throws Exception {
    if (Math.abs(System.currentTimeMillis() / 1000 - Long.parseLong(ts)) > 300) return false;
    Mac mac = Mac.getInstance("HmacSHA256");
    mac.init(new SecretKeySpec(Base64.getDecoder().decode(apiKey.substring(6)), "HmacSHA256"));
    byte[] expected = ("v1," + Base64.getEncoder().encodeToString(
        mac.doFinal((id + "." + ts + "." + body).getBytes(StandardCharsets.UTF_8)))).getBytes(StandardCharsets.UTF_8);
    for (String s : sigs.split(" ")) if (MessageDigest.isEqual(s.getBytes(StandardCharsets.UTF_8), expected)) return true;
    return false;
}

Test vector

Check your implementation with:

ItemValue
API keywhsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw
webhook-idmsg_p5jXN8AQM9LWM0D4loKWxJek
webhook-timestamp1614265330
body{"test": 2432232314}
Expected signaturev1,g0hM9SsE+OTPJTGt/tmIKtSyZlE3uFJELVlNIOLJ1OE=

(The timestamp is old; skip the time check while testing.) You can also paste values into Signature practice on the test receiver.