Skip to main content

Documentation Index

Fetch the complete documentation index at: https://docs.tallwatch.com/llms.txt

Use this file to discover all available pages before exploring further.

Every Tallwatch webhook POST includes an X-Tallwatch-Signature header. Your receiver MUST verify this header against your signing secret before processing the body. Without verification, anyone who guesses your URL can forge alerts.

The contract

For every outbound request, Tallwatch computes:
signature = "sha256=" + hex( HMAC_SHA256(signing_secret, raw_request_body) )
And sends that string as the X-Tallwatch-Signature header. To verify, your receiver re-computes the same HMAC over the raw body it received and compares it to the header value using a constant-time comparison. If they match, the request is genuine.
Verify against the raw bytes of the request body, not the parsed JSON. JSON re-serialisation drops whitespace and reorders fields, which makes the HMAC mismatch even when nothing was tampered with. Read the raw body first, verify, then parse.

Verification snippets

import crypto from "node:crypto";
import express from "express";

const app = express();
const SECRET = process.env.TALLWATCH_WEBHOOK_SECRET;

// Capture the raw body BEFORE express.json() parses it.
app.use(
  "/webhooks/tallwatch",
  express.raw({ type: "application/json" }),
  (req, res) => {
    const signatureHeader = req.header("X-Tallwatch-Signature") ?? "";
    const expected =
      "sha256=" +
      crypto.createHmac("sha256", SECRET).update(req.body).digest("hex");

    // Constant-time comparison. Bail before parsing on mismatch.
    if (
      signatureHeader.length !== expected.length ||
      !crypto.timingSafeEqual(
        Buffer.from(signatureHeader),
        Buffer.from(expected),
      )
    ) {
      return res.status(401).send("invalid signature");
    }

    const payload = JSON.parse(req.body.toString("utf8"));
    // ... process the payload ...
    res.status(204).end();
  },
);

Common verification mistakes

The signature is computed over the exact bytes Tallwatch sent. The moment you parse JSON and re-serialise it (JSON.parse then JSON.stringify), whitespace and field order change. The HMAC will not match.Fix: read the raw body BEFORE any body-parser middleware runs. In Express, mount express.raw({ type: "application/json" }) ahead of express.json(). In Flask, call request.get_data() before request.get_json().
A normal string equality check leaks timing information — a sophisticated attacker can guess your secret one byte at a time by measuring how long the comparison takes. Use the constant-time helper from your language’s crypto library:
  • Node: crypto.timingSafeEqual
  • Go: hmac.Equal
  • Python: hmac.compare_digest
  • Ruby: Rack::Utils.secure_compare or OpenSSL.secure_compare
The header value starts with the literal sha256= — six characters before the hex digest. If you compare just the hex part of the header against just the hex part of the expected value, you’re fine. If you compare the full header against just the hex, you fail. Most snippets above include the prefix on both sides.
Tallwatch only sends JSON, so the body is always UTF-8 text. But your HMAC must run over the raw bytes received, not over a re-encoded string. In strongly-typed languages this usually means Buffer/[]byte/bytes not string. In Python, request.get_data() returns bytes, which is what you want.

Rotating the secret

The signing secret can be changed at any time from the channel form in Tallwatch. The change takes effect on the next dispatch. To rotate without downtime:
1

Generate a new secret

Use openssl rand -base64 32 or equivalent.
2

Add the new secret to your receiver

Update your receiver’s config to accept signatures from EITHER the old or the new secret during the rollover window. Verify with both and accept either.
3

Update Tallwatch

Paste the new secret into the channel form and save. From this moment, all dispatches use the new secret.
4

Remove the old secret from your receiver

Once you’ve confirmed dispatches are coming in signed with the new secret (check your receiver logs), remove the old one from your config.

Why HMAC-SHA256 and not JWT or mTLS

  • HMAC-SHA256 is simple, fast, and supported by every language without dependencies. Verifying a JWT requires JWS libraries; mutual TLS requires cert provisioning on both sides.
  • JWT would put the payload inside the token, which couples the body shape to the signing format and breaks the body-template feature.
  • mTLS is more secure but operationally heavier — you’d need a cert per workspace and a renewal pipeline.
For a webhook receiver, signed HMAC is the right tool. We may add JWT or mTLS for higher-tier customers later, but won’t deprecate HMAC.