> ## Documentation Index
> Fetch the complete documentation index at: https://docs.truscan.co/llms.txt
> Use this file to discover all available pages before exploring further.

# Verifying signatures

> Confirm a delivery came from Truscan before you act on it.

Your endpoint URL is not a secret. Anyone who discovers it can send it a
convincing looking payload, so verify the signature before you act on one.

## The header

```
Truscan-Signature: t=1758290591,v1=5a9f3c...
```

| Part | Meaning                       |
| ---- | ----------------------------- |
| `t`  | Unix timestamp of the attempt |
| `v1` | HMAC-SHA256, hex encoded      |

The signed message is the timestamp, a full stop, then the **raw request
body**:

```
<timestamp>.<raw body>
```

Sign that with your endpoint's `whsec_` secret and compare the result to `v1`.

<Warning>
  Sign the bytes exactly as received. Parsing JSON and re-serialising it
  changes key order and whitespace, which changes the signature. Capture the
  raw body before any middleware consumes it.
</Warning>

## Two checks, not one

**Compare in constant time.** A normal string comparison returns as soon as it
finds a mismatched byte, and that timing difference is enough to recover a
valid signature one byte at a time.

**Reject an old timestamp.** The timestamp is inside the signed message, so it
cannot be altered without breaking the signature. That is what makes a replay
detectable: a captured delivery keeps its original `t` forever. Five minutes is
a reasonable tolerance.

## Examples

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

  const TOLERANCE = 5 * 60; // seconds

  // `raw` must be the unparsed body. In Express:
  //   app.post("/hooks", express.raw({ type: "application/json" }), handler)
  export function verify(raw, header, secret) {
    const parts = Object.fromEntries(
      header.split(",").map((p) => p.split("=")),
    );
    if (!parts.t || !parts.v1) return false;

    const age = Math.abs(Math.floor(Date.now() / 1000) - Number(parts.t));
    if (age > TOLERANCE) return false;

    const expected = crypto
      .createHmac("sha256", secret)
      .update(`${parts.t}.${raw}`)
      .digest("hex");

    const a = Buffer.from(expected, "hex");
    const b = Buffer.from(parts.v1, "hex");
    return a.length === b.length && crypto.timingSafeEqual(a, b);
  }
  ```

  ```python Python theme={null}
  import hashlib, hmac, time

  TOLERANCE = 5 * 60  # seconds

  def verify(raw: bytes, header: str, secret: str) -> bool:
      parts = dict(p.split("=", 1) for p in header.split(","))
      if "t" not in parts or "v1" not in parts:
          return False

      if abs(int(time.time()) - int(parts["t"])) > TOLERANCE:
          return False

      expected = hmac.new(
          secret.encode(),
          f"{parts['t']}.".encode() + raw,
          hashlib.sha256,
      ).hexdigest()

      return hmac.compare_digest(expected, parts["v1"])
  ```

  ```go Go theme={null}
  package hooks

  import (
  	"crypto/hmac"
  	"crypto/sha256"
  	"encoding/hex"
  	"fmt"
  	"strconv"
  	"strings"
  	"time"
  )

  const tolerance = 5 * time.Minute

  func Verify(raw []byte, header, secret string) bool {
  	var ts, sig string
  	for _, part := range strings.Split(header, ",") {
  		k, v, ok := strings.Cut(part, "=")
  		if !ok {
  			continue
  		}
  		switch k {
  		case "t":
  			ts = v
  		case "v1":
  			sig = v
  		}
  	}
  	if ts == "" || sig == "" {
  		return false
  	}

  	secs, err := strconv.ParseInt(ts, 10, 64)
  	if err != nil {
  		return false
  	}
  	if age := time.Since(time.Unix(secs, 0)); age > tolerance || age < -tolerance {
  		return false
  	}

  	mac := hmac.New(sha256.New, []byte(secret))
  	fmt.Fprintf(mac, "%s.", ts)
  	mac.Write(raw)

  	want, err := hex.DecodeString(sig)
  	if err != nil {
  		return false
  	}
  	return hmac.Equal(mac.Sum(nil), want)
  }
  ```

  ```php PHP theme={null}
  <?php

  const TOLERANCE = 300; // seconds

  function verify(string $raw, string $header, string $secret): bool {
      $parts = [];
      foreach (explode(',', $header) as $piece) {
          [$k, $v] = array_pad(explode('=', $piece, 2), 2, null);
          $parts[$k] = $v;
      }
      if (empty($parts['t']) || empty($parts['v1'])) {
          return false;
      }

      if (abs(time() - (int) $parts['t']) > TOLERANCE) {
          return false;
      }

      $expected = hash_hmac('sha256', $parts['t'] . '.' . $raw, $secret);
      return hash_equals($expected, $parts['v1']);
  }
  ```
</CodeGroup>

## Rotating a secret

There is no rotation endpoint. Create a second endpoint with the same URL,
move your verification to accept either secret, then delete the original. That
keeps you receiving deliveries throughout.
