Signature algorithm
HMAC SHA-256 over a timestamped payload. Forward-compatible. Replay-protected.
The Tourical signature scheme is modelled on Stripe's. If you've verified a Stripe webhook before, this will look familiar.
The header
Tourical-Signature: t=1716724800,v1=8b2c9a8d6f12...Comma-separated <name>=<value> pairs. Order does not matter. Whitespace is trimmed.
| Name | Meaning |
|---|---|
t | Unix-second timestamp at the moment we computed the signature. |
v1 | Hex SHA-256 HMAC of the signed payload using the v1 algorithm. |
Future signature schemes will add v2=... and so on. Ignore unknown vN= names — that's how we ship algorithm changes without breaking your verifier.
The signed payload
<timestamp>.<raw-body>A literal string: the unix timestamp, a single dot, then the raw request body exactly as received. The HMAC is HMAC_SHA256(signingKey, signedPayload) rendered as a lowercase hex digest.
Critical gotchas:
- Do not re-stringify the JSON. Reading
req.bodyafter Express's JSON middleware loses whitespace; the signature will fail. Capture the raw body before parsing. - The signing key is the plaintext value we returned when you created the subscription. We store an encrypted copy on our side; you store the plaintext.
- Timestamp is in seconds, not milliseconds.
Replay protection
Reject any request where now - t exceeds your tolerance window. Recommended default: 300 seconds (5 minutes).
reject if abs(currentUnixSec - parseInt(t)) > 300This is the same window we ship as the default in our own code. Combined with the Tourical-Event-Id dedupe key, replays older than the window can't reach your handler.
Increase the window only if you know your endpoint sits behind a queue that may delay delivery by minutes — and document why. Decrease it freely if you don't need the slack.
Backwards compatibility
We will never rename an existing signature scheme. v1 is v1 for the life of the API.
When we introduce v2, the header will carry both: t=...,v1=...,v2=.... Verifiers that only know about v1 keep working. The day we deprecate v1, you'll get six months' notice via the changelog and a banner in the operator dashboard.
Why the timestamp matters
A signature without a timestamp is replayable forever — any captured webhook can be re-fired by anyone who gets it. Mixing the timestamp into the HMAC binds the signature to the moment of delivery; combined with a tolerance window, it gives you replay protection without needing your own nonce store.
Cross-reference
The reference implementation we use on our side lives in tourical-app/src/lib/webhooks/signature.ts. The verifier on the next page matches it byte-for-byte.

