The signature
Every delivery carries two headers:Vocily-Signature header is a comma-separated list of key=value parts:
Where
secret is the signing secret you set on the endpoint and rawBody is the exact bytes we
sent (see the warning below).
v1 can appear more than once. During a secret rotation we send one v1 per active secret
(old + new), e.g. t=…,v1=<old>,v1=<new>. Your verifier should accept if any v1 matches, so
both secrets work until the rotation grace window closes — zero dropped webhooks. The v1 prefix also
lets us add a future scheme (v2=…) later without breaking existing receivers.How to verify
1
Read the RAW body
Use the exact received bytes. Do not re-serialize parsed JSON — key order, whitespace, and
escaping change the bytes and the signature will never match.
2
Parse the header
Split
Vocily-Signature into t and one or more v1 values.3
Reject stale timestamps
If
|now − t| > 300000 ms (5 minutes), drop it — this blocks replay of a captured delivery.4
Recompute and compare
Compute
HMAC-SHA256(secret, "<t>." + rawBody) and constant-time compare against each v1.
Accept if any matches (during a secret rotation we send two v1 values so old and new both verify).Examples
Frameworks that give you parsed JSON by default (e.g. Express) need the raw body. In Express, use
express.raw({ type: "application/json" }) on the webhook route and verify before parsing.Test vector
Before you go live, confirm your code is correct against this known-answer test — fixed inputs with the exactv1 they must produce. No live webhook needed. (Example secret; don’t use it in
production.)
signedis the exact string that gets hashed:"<t>." + body.v1is the expected result:HMAC-SHA256(secret, signed)in hex.
Check your implementation
Feed the vector into theverify() from above: build the header as t=<t>,v1=<v1>, pass the raw
body, and set now = t (so the fixed old timestamp doesn’t trip the 5-minute freshness check). It
must return true for the real body and false for a tampered one.
"<t>." + body format of the signed string.