Verifying webhook signatures
Every webhook carries an X-Spay-Signature header:
X-Spay-Signature: t=<unix-seconds>,v1=<hex-hmac-sha256>To verify a webhook:
- Read the raw request body as bytes, before any JSON parsing.
- Split the header on
,and readtandv1. Reject the webhook if the header is missing,tisn’t a whole number, orv1isn’t 64 lowercase hex characters. - Reject it if
tis more than 5 minutes from your clock. Every delivery and retry is signed when it’s sent, so a genuine webhook is always inside that window, and a captured one can’t be replayed later. - Compute the HMAC-SHA256 of
t,., and the raw body, keyed with the UTF-8 bytes of your webhook signing secret. - Compare it with
v1in constant time. - Answer a rejected webhook with a non-2xx status. Deduplicate accepted ones by
eventId: a retry resends the sameeventIdand body with a newt.
import { createHmac, timingSafeEqual } from 'node:crypto';
const TOLERANCE_SECONDS = 300;
export function verifySpaySignature(header, rawBody, secret) { if (typeof header !== 'string') return false; const { t, v1 } = Object.fromEntries(header.split(',').map((p) => p.split('=', 2))); if (!/^[0-9]{1,18}$/.test(t ?? '') || !/^[0-9a-f]{64}$/.test(v1 ?? '')) return false; if (Math.abs(Date.now() / 1000 - Number(t)) > TOLERANCE_SECONDS) return false; const expected = createHmac('sha256', secret) .update(`${t}.`) .update(rawBody) .digest(); return timingSafeEqual(expected, Buffer.from(v1, 'hex'));}import hashlibimport hmacimport reimport time
TOLERANCE_SECONDS = 300
def verify_spay_signature(header: str | None, raw_body: bytes, secret: str) -> bool: if not header: return False parts = dict(p.split("=", 1) for p in header.split(",") if "=" in p) t, v1 = parts.get("t", ""), parts.get("v1", "") if not re.fullmatch(r"[0-9]{1,18}", t) or not re.fullmatch(r"[0-9a-f]{64}", v1): return False if abs(time.time() - int(t)) > TOLERANCE_SECONDS: return False expected = hmac.new(secret.encode(), t.encode() + b"." + raw_body, hashlib.sha256).hexdigest() return hmac.compare_digest(expected, v1)import ( "crypto/hmac" "crypto/sha256" "encoding/hex" "regexp" "strconv" "strings" "time")
const toleranceSeconds = 300
var ( timestampPattern = regexp.MustCompile(`^[0-9]{1,18}$`) signaturePattern = regexp.MustCompile(`^[0-9a-f]{64}$`))
func VerifySpaySignature(header string, rawBody []byte, secret string) bool { var t, v1 string for _, part := range strings.Split(header, ",") { k, v, _ := strings.Cut(part, "=") switch k { case "t": t = v case "v1": v1 = v } } if !timestampPattern.MatchString(t) || !signaturePattern.MatchString(v1) { return false } ts, err := strconv.ParseInt(t, 10, 64) if err != nil { return false } if age := time.Now().Unix() - ts; age > toleranceSeconds || age < -toleranceSeconds { return false } signature, _ := hex.DecodeString(v1) mac := hmac.New(sha256.New, []byte(secret)) mac.Write([]byte(t + ".")) mac.Write(rawBody) return hmac.Equal(mac.Sum(nil), signature)}import java.nio.charset.StandardCharsets;import java.security.GeneralSecurityException;import java.security.MessageDigest;import java.time.Instant;import java.util.HexFormat;import javax.crypto.Mac;import javax.crypto.spec.SecretKeySpec;
static final long TOLERANCE_SECONDS = 300;
static boolean verifySpaySignature(String header, byte[] rawBody, String secret) throws GeneralSecurityException { if (header == null) return false; String t = null, v1 = null; for (String part : header.split(",")) { String[] kv = part.split("=", 2); if (kv.length != 2) continue; if (kv[0].equals("t")) t = kv[1]; else if (kv[0].equals("v1")) v1 = kv[1]; } if (t == null || v1 == null || !t.matches("[0-9]{1,18}") || !v1.matches("[0-9a-f]{64}")) return false; if (Math.abs(Instant.now().getEpochSecond() - Long.parseLong(t)) > TOLERANCE_SECONDS) return false; Mac mac = Mac.getInstance("HmacSHA256"); mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256")); mac.update((t + ".").getBytes(StandardCharsets.UTF_8)); return MessageDigest.isEqual(mac.doFinal(rawBody), HexFormat.of().parseHex(v1));}To check your code, sign a body yourself with openssl and compare:
{ printf '%s.' "$T"; cat body.json; } | openssl dgst -sha256 -hmac "$SPAY_WEBHOOK_SECRET" | sed 's/^.*= //'