> ## 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.

# Verify the signature

> Every webhook is signed with HMAC-SHA256 over the timestamp and raw body. Verification snippets in Node, Go, Python, and Ruby.

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:

```text theme={null}
X-Tallwatch-Signature: t=1717337041,v1=3a8f...e2
```

`t` is the Unix timestamp at signing time (also sent as `X-Tallwatch-Timestamp`). `v1` is the signature. Tallwatch computes it as:

```text theme={null}
v1 = hex( HMAC_SHA256( signing_secret, "t=" + t + "." + raw_body ) )
```

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.

<Warning>
  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.
</Warning>

## Verification snippets

<CodeGroup>
  ```js Node.js (Express) theme={null}
  import crypto from "node:crypto";
  import express from "express";

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

  // Capture the raw body BEFORE express.json() parses it.
  app.use(
    "/webhooks/tallwatch",
    express.raw({ type: "application/json" }),
    (req, res) => {
      const header = req.header("X-Tallwatch-Signature") ?? "";
      const parts = Object.fromEntries(
        header.split(",").map((kv) => kv.split("=")),
      );
      const { t, v1 = "" } = parts;

      // Sign over the exact bytes: "t=<ts>." followed by the raw body.
      const signed = Buffer.concat([Buffer.from(`t=${t}.`), req.body]);
      const expected = crypto
        .createHmac("sha256", SECRET)
        .update(signed)
        .digest("hex");

      const a = Buffer.from(v1);
      const b = Buffer.from(expected);
      if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
        return res.status(401).send("invalid signature");
      }

      // Reject stale timestamps to blunt replay attacks.
      if (Math.abs(Date.now() / 1000 - Number(t)) > TOLERANCE_SEC) {
        return res.status(401).send("stale timestamp");
      }

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

  ```go Go (net/http) theme={null}
  package main

  import (
  	"crypto/hmac"
  	"crypto/sha256"
  	"encoding/hex"
  	"io"
  	"net/http"
  	"os"
  	"strings"
  )

  var secret = []byte(os.Getenv("TALLWATCH_WEBHOOK_SECRET"))

  func handleWebhook(w http.ResponseWriter, r *http.Request) {
  	body, err := io.ReadAll(r.Body)
  	if err != nil {
  		http.Error(w, "read failed", http.StatusBadRequest)
  		return
  	}

  	var t, v1 string
  	for _, part := range strings.Split(r.Header.Get("X-Tallwatch-Signature"), ",") {
  		kv := strings.SplitN(part, "=", 2)
  		if len(kv) != 2 {
  			continue
  		}
  		switch kv[0] {
  		case "t":
  			t = kv[1]
  		case "v1":
  			v1 = kv[1]
  		}
  	}

  	mac := hmac.New(sha256.New, secret)
  	mac.Write([]byte("t=" + t + "."))
  	mac.Write(body)
  	expected := hex.EncodeToString(mac.Sum(nil))

  	if !hmac.Equal([]byte(v1), []byte(expected)) {
  		http.Error(w, "invalid signature", http.StatusUnauthorized)
  		return
  	}

  	// ... process the body ...
  	w.WriteHeader(http.StatusNoContent)
  }

  func main() {
  	http.HandleFunc("/webhooks/tallwatch", handleWebhook)
  	http.ListenAndServe(":8080", nil)
  }
  ```

  ```python Python (Flask) theme={null}
  import hmac
  import hashlib
  import os

  from flask import Flask, request, abort

  app = Flask(__name__)
  SECRET = os.environ["TALLWATCH_WEBHOOK_SECRET"].encode()


  @app.route("/webhooks/tallwatch", methods=["POST"])
  def handle_webhook():
      raw_body = request.get_data()
      header = request.headers.get("X-Tallwatch-Signature", "")
      parts = dict(p.split("=", 1) for p in header.split(",") if "=" in p)
      t = parts.get("t", "")
      v1 = parts.get("v1", "")

      signed = b"t=" + t.encode() + b"." + raw_body
      expected = hmac.new(SECRET, signed, hashlib.sha256).hexdigest()

      if not hmac.compare_digest(v1, expected):
          abort(401, "invalid signature")

      payload = request.get_json(force=True)
      # ... process the payload ...
      return "", 204
  ```

  ```ruby Ruby (Sinatra) theme={null}
  require "sinatra"
  require "openssl"
  require "json"

  SECRET = ENV.fetch("TALLWATCH_WEBHOOK_SECRET")

  post "/webhooks/tallwatch" do
    body = request.body.read
    header = request.env["HTTP_X_TALLWATCH_SIGNATURE"].to_s
    parts = header.split(",").map { |kv| kv.split("=", 2) }.to_h
    t = parts["t"].to_s
    v1 = parts["v1"].to_s

    signed = "t=#{t}.#{body}"
    expected = OpenSSL::HMAC.hexdigest("SHA256", SECRET, signed)

    halt 401, "invalid signature" unless Rack::Utils.secure_compare(v1, expected)

    payload = JSON.parse(body)
    # ... process the payload ...
    status 204
  end
  ```
</CodeGroup>

## Common mistakes

<AccordionGroup>
  <Accordion title="Signing the body alone, without the `t=<ts>.` prefix">
    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.
  </Accordion>

  <Accordion title="Comparing the whole header instead of just `v1`">
    The header is `t=...,v1=...`. Compare your computed HMAC against the `v1` value, not the entire header string.
  </Accordion>

  <Accordion title="Verifying the parsed body, not the raw bytes">
    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()`.
  </Accordion>

  <Accordion title="Using `==` instead of a constant-time comparison">
    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`
  </Accordion>
</AccordionGroup>

## 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:

<Steps>
  <Step title="Generate a new secret">
    Run `openssl rand -base64 32` or equivalent.
  </Step>

  <Step title="Accept both secrets in your receiver">
    Verify against the old and the new secret, accepting either, for the rollover window.
  </Step>

  <Step title="Update Tallwatch">
    Paste the new secret into the channel form and save. Every dispatch now uses it.
  </Step>

  <Step title="Drop the old secret">
    Once your logs show traffic verifying under the new secret, remove the old one.
  </Step>
</Steps>

## 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.
