Skip to main content
Every Tallwatch webhook carries an X-Tallwatch-Signature header. Verify it against your signing secret before you process the body. Skip that, and anyone who learns your URL can forge alerts.

The scheme

The header follows Stripe’s format:
t is the Unix timestamp at signing time (also sent as X-Tallwatch-Timestamp). v1 is the signature. Tallwatch computes it as:
The signed string is the literal t=, the timestamp, a ., then the raw request body. To verify, parse t and v1 out of the header, recompute the HMAC over the same string, and compare it to v1 with a constant-time check.
Sign over the raw bytes of the body, not the parsed JSON. Parsing and re-serializing reorders fields and drops whitespace, which changes the HMAC even when nothing was tampered with. Read the raw body, verify, then parse.

Verification snippets

Common mistakes

The signed string is t=<ts>.<body>, not the body on its own. Prepend the literal t=, the timestamp from the header, and a . before the raw body, or the HMAC won’t match.
The header is t=...,v1=.... Compare your computed HMAC against the v1 value, not the entire header string.
Parsing JSON and re-serializing it changes whitespace and field order, so the HMAC breaks. Read the raw body before any body-parser runs. In Express, mount express.raw() ahead of express.json(). In Flask, call request.get_data() before request.get_json().
A plain equality check leaks timing, which lets a determined attacker recover the signature byte by byte. Use your language’s constant-time helper:
  • Node: crypto.timingSafeEqual
  • Go: hmac.Equal
  • Python: hmac.compare_digest
  • Ruby: Rack::Utils.secure_compare

Guard against replays

The timestamp is signed, so it can’t be tampered with after the fact. Reject any request whose t is more than a few minutes from your clock (300 seconds is a reasonable window). That way a captured request can’t be replayed against you later. The Node snippet above shows the check.

Rotate the secret

Change the signing secret any time from the channel form. The change takes effect on the next dispatch. To rotate without dropping alerts:
1

Generate a new secret

Run openssl rand -base64 32 or equivalent.
2

Accept both secrets in your receiver

Verify against the old and the new secret, accepting either, for the rollover window.
3

Update Tallwatch

Paste the new secret into the channel form and save. Every dispatch now uses it.
4

Drop the old secret

Once your logs show traffic verifying under the new secret, remove the old one.

Why HMAC and not JWT or mTLS

HMAC-SHA256 is supported everywhere with no dependencies, which suits a webhook receiver. JWT would embed the payload in the token and couple the body shape to the signing format, breaking templated bodies. mTLS is stronger but means a certificate per workspace and a renewal pipeline. We may offer JWT or mTLS for higher tiers later without dropping HMAC.