Skip to main content
Every delivery is signed so you can prove it came from Vocily and wasn’t replayed. Verify every request before acting on it.

The signature

Every delivery carries two headers:
The 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).
Always compare in constant time (hmac.compare_digest, crypto.timingSafeEqual, hash_equals) — never == on the hex string.

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 exact v1 they must produce. No live webhook needed. (Example secret; don’t use it in production.)
  • signed is the exact string that gets hashed: "<t>." + body.
  • v1 is the expected result: HMAC-SHA256(secret, signed) in hex.

Check your implementation

Feed the vector into the verify() 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.
If the first line passes, your HMAC logic is correct and you’re ready for live deliveries. If it fails, check the two most common bugs: re-serializing the JSON instead of using the raw bytes, and the exact "<t>." + body format of the signed string.

No-code hosts

Zapier, Make, n8n, and similar “catch hook” tools usually can’t compute HMAC, so they can’t verify the signature — the authenticity layer is decorative there. Fine for prototypes; for anything sensitive, point Vocily at your own server and verify.