Verifying signatures
Every request carries three headers, following Standard Webhooks:
| Header | Description |
|---|---|
webhook-id | Event ID; unchanged on retries |
webhook-timestamp | Send time (Unix seconds); updated on each retry |
webhook-signature | One 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:
- All three headers must be present
webhook-timestampmust be within 5 minutes of now (replay protection)- Compute the expected signature and compare it with any value in
webhook-signatureusing a constant-time comparison - 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
Authorizationheader; 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:
| Item | Value |
|---|---|
| API key | whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw |
| webhook-id | msg_p5jXN8AQM9LWM0D4loKWxJek |
| webhook-timestamp | 1614265330 |
| body | {"test": 2432232314} |
| Expected signature | v1,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.