Verifying signatures
Stoneity signs webhooks with the Standard Webhooks scheme.
- Build the signed content:
"{webhook-id}.{webhook-timestamp}.{raw body}". - Compute
HMAC-SHA256with the secret's raw bytes: the secret iswhsec_followed by base64 — decode the part afterwhsec_. - Base64-encode the digest and compare (constant-time) with the value after
v1,inwebhook-signature. The header may contain several space-separated signatures during secret rotation; any match is valid. - Reject timestamps older than 5 minutes to prevent replay.
js
import crypto from 'node:crypto';
export function verify(secret, headers, rawBody) {
const id = headers['webhook-id'];
const ts = headers['webhook-timestamp'];
const signatures = (headers['webhook-signature'] || '').split(' ');
if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false;
const key = Buffer.from(secret.replace(/^whsec_/, ''), 'base64');
const expected = 'v1,' + crypto.createHmac('sha256', key).update(`${id}.${ts}.${rawBody}`).digest('base64');
return signatures.some((s) => s.length === expected.length && crypto.timingSafeEqual(Buffer.from(s), Buffer.from(expected)));
}csharp
using System.Security.Cryptography;
using System.Text;
static bool Verify(string secret, string id, string ts, string body, string signatureHeader)
{
if (Math.Abs(DateTimeOffset.UtcNow.ToUnixTimeSeconds() - long.Parse(ts)) > 300) return false;
var key = Convert.FromBase64String(secret.Replace("whsec_", ""));
using var hmac = new HMACSHA256(key);
var expected = "v1," + Convert.ToBase64String(hmac.ComputeHash(Encoding.UTF8.GetBytes($"{id}.{ts}.{body}")));
return signatureHeader.Split(' ').Any(s =>
s.Length == expected.Length && CryptographicOperations.FixedTimeEquals(Encoding.UTF8.GetBytes(s), Encoding.UTF8.GetBytes(expected)));
}python
import base64, hmac, hashlib, time
def verify(secret: str, headers: dict, raw_body: bytes) -> bool:
msg_id, ts = headers["webhook-id"], headers["webhook-timestamp"]
if abs(time.time() - int(ts)) > 300:
return False
key = base64.b64decode(secret.removeprefix("whsec_"))
digest = hmac.new(key, f"{msg_id}.{ts}.".encode() + raw_body, hashlib.sha256).digest()
expected = "v1," + base64.b64encode(digest).decode()
return any(hmac.compare_digest(s, expected) for s in headers["webhook-signature"].split(" "))Always verify against the raw request body, before any JSON parsing or re-serialisation.