Skip to content

Verifying signatures

Stoneity signs webhooks with the Standard Webhooks scheme.

  1. Build the signed content: "{webhook-id}.{webhook-timestamp}.{raw body}".
  2. Compute HMAC-SHA256 with the secret's raw bytes: the secret is whsec_ followed by base64 — decode the part after whsec_.
  3. Base64-encode the digest and compare (constant-time) with the value after v1, in webhook-signature. The header may contain several space-separated signatures during secret rotation; any match is valid.
  4. 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.

Stoneity Public API v1