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

# Verify signatures

> Verify the X-Traceten-Signature header on every webhook delivery, with working Node, Python, and Go code and a conformance vector to test against.

## What this lets you do

Prove that a webhook request really came from Traceten and has not been tampered with or replayed, before your code acts on it. Anyone who discovers your endpoint URL can POST to it; the signature is what makes a delivery trustworthy.

## The scheme

Every delivery carries two headers:

| Header                           | Value                                                 |
| -------------------------------- | ----------------------------------------------------- |
| `X-Traceten-Signature`           | Lowercase hex HMAC-SHA256 digest (64 hex characters)  |
| `X-Traceten-Signature-Timestamp` | Unix time in **seconds** when the delivery was signed |

The signature is computed over the timestamp and the raw request body, joined by a single `.`:

```text theme={null}
signature = hex( hmac_sha256( secret, "{timestamp}.{raw_body}" ) )
```

* `secret` is the endpoint's signing secret, shown once at registration ([setup guide, step 3](/webhooks/setup)). Use it as a raw UTF-8 string; do not hex- or base64-decode it.
* `timestamp` is the exact string value of the `X-Traceten-Signature-Timestamp` header.
* `raw_body` is the exact bytes of the request body, untouched.

To verify: recompute the digest from the received timestamp and raw body, compare it to the header value in constant time, and reject the request if the timestamp is more than **300 seconds** from your current time. Signing the timestamp is what defeats replay: an attacker who captures a valid delivery cannot re-send it later, because the aged timestamp fails the freshness check and any altered timestamp breaks the digest.

<Note>
  If you use a Traceten [server SDK](/sdks/overview), prefer its built-in verifier over hand-rolling
  the scheme: `verifyWebhook()` in [Node](/sdks/node#verify-webhook-signatures) and
  [Python](/sdks/python#verify-webhook-signatures), and `VerifyWebhook` in
  [Go](/sdks/go#verify-webhook-signatures). Each does constant-time comparison and replay-window
  enforcement for you and returns a typed event. The snippets below are the reference
  implementation of the same scheme, for when you are not using an SDK.
</Note>

## Verify the raw body, not a re-serialized one

The single most common verification bug: a framework parses the JSON, your code re-serializes it, and the bytes no longer match what Traceten signed (key order, whitespace, and unicode escaping all differ). Always hash the raw bytes as received.

* **Express**: use `express.raw({ type: "application/json" })` on the webhook route, not `express.json()`.
* **Fastify**: use a content-type parser with `{ parseAs: "buffer" }` for the webhook route.
* **Flask**: use `request.get_data()`, not `request.get_json()`.
* **FastAPI / Starlette**: use `await request.body()`, not a Pydantic body parameter.
* **Go `net/http`**: read `r.Body` with `io.ReadAll` before any JSON decoding.

Parse the JSON only after the signature checks out.

## Node

```ts theme={null}
import { createHmac, timingSafeEqual } from "node:crypto";

export function verifyTracetenWebhook(
  secret: string,
  timestamp: string,
  rawBody: string,
  signature: string,
  toleranceSeconds = 300,
  nowSeconds = Date.now() / 1000,
): boolean {
  const ts = Number(timestamp);
  if (!Number.isFinite(ts)) return false;
  if (Math.abs(nowSeconds - ts) > toleranceSeconds) return false;

  const expected = createHmac("sha256", secret)
    .update(`${timestamp}.${rawBody}`, "utf8")
    .digest("hex");

  const expectedBuf = Buffer.from(expected, "utf8");
  const providedBuf = Buffer.from(signature, "utf8");
  if (expectedBuf.length !== providedBuf.length) return false;
  return timingSafeEqual(expectedBuf, providedBuf);
}
```

Wired into an Express route (note `express.raw`):

```ts theme={null}
import express from "express";
import { verifyTracetenWebhook } from "./verify-traceten-webhook";

const app = express();
const secret = process.env.TRACETEN_WEBHOOK_SECRET!;

app.post("/traceten-webhooks", express.raw({ type: "application/json" }), (req, res) => {
  const ok = verifyTracetenWebhook(
    secret,
    req.header("X-Traceten-Signature-Timestamp") ?? "",
    req.body.toString("utf8"),
    req.header("X-Traceten-Signature") ?? "",
  );
  if (!ok) {
    res.status(401).end();
    return;
  }

  const event = JSON.parse(req.body.toString("utf8"));
  // Acknowledge first; do heavy work on a queue.
  res.status(200).end();
});

app.listen(3000);
```

## Python

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


def verify_traceten_webhook(
    secret: str,
    timestamp: str,
    raw_body: bytes,
    signature: str,
    tolerance_seconds: int = 300,
    now_seconds: float | None = None,
) -> bool:
    try:
        ts = float(timestamp)
    except ValueError:
        return False
    now = time.time() if now_seconds is None else now_seconds
    if abs(now - ts) > tolerance_seconds:
        return False

    message = timestamp.encode("utf-8") + b"." + raw_body
    expected = hmac.new(secret.encode("utf-8"), message, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, signature)
```

Wired into a Flask route (note `request.get_data()`):

```python theme={null}
import os

from flask import Flask, request

from verify_traceten_webhook import verify_traceten_webhook

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


@app.post("/traceten-webhooks")
def traceten_webhooks():
    ok = verify_traceten_webhook(
        SECRET,
        request.headers.get("X-Traceten-Signature-Timestamp", ""),
        request.get_data(),
        request.headers.get("X-Traceten-Signature", ""),
    )
    if not ok:
        return "", 401

    event = request.get_json()
    # Acknowledge first; do heavy work on a queue.
    return "", 200
```

## Go

```go theme={null}
package main

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

// VerifyTracetenWebhook reports whether a delivery's signature is valid and
// its timestamp is within toleranceSeconds of now.
func VerifyTracetenWebhook(secret, timestamp string, rawBody []byte, signature string, toleranceSeconds float64) bool {
	ts, err := strconv.ParseFloat(timestamp, 64)
	if err != nil {
		return false
	}
	now := float64(time.Now().Unix())
	if math.Abs(now-ts) > toleranceSeconds {
		return false
	}

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

	return hmac.Equal([]byte(expected), []byte(signature))
}
```

Wired into a `net/http` handler (note `io.ReadAll` before any decoding):

```go theme={null}
package main

import (
	"encoding/json"
	"io"
	"log"
	"net/http"
	"os"
)

func main() {
	secret := os.Getenv("TRACETEN_WEBHOOK_SECRET")

	http.HandleFunc("/traceten-webhooks", func(w http.ResponseWriter, r *http.Request) {
		rawBody, err := io.ReadAll(r.Body)
		if err != nil {
			w.WriteHeader(http.StatusBadRequest)
			return
		}

		ok := VerifyTracetenWebhook(
			secret,
			r.Header.Get("X-Traceten-Signature-Timestamp"),
			rawBody,
			r.Header.Get("X-Traceten-Signature"),
			300,
		)
		if !ok {
			w.WriteHeader(http.StatusUnauthorized)
			return
		}

		var event map[string]any
		if err := json.Unmarshal(rawBody, &event); err != nil {
			w.WriteHeader(http.StatusBadRequest)
			return
		}
		// Acknowledge first; do heavy work on a queue.
		w.WriteHeader(http.StatusOK)
	})

	log.Fatal(http.ListenAndServe(":3000", nil))
}
```

## Conformance vector

Traceten publishes one fixed (secret, timestamp, body) → signature vector. Run your implementation against it before going live: if your code reproduces this signature, it is byte-identical to Traceten's signer.

| Input              | Value                                                               |
| ------------------ | ------------------------------------------------------------------- |
| Secret             | `whsec_traceten_known_answer_vector_do_not_use`                     |
| Timestamp          | `1720000000`                                                        |
| Raw body           | `{"id":"whd_kav","type":"webhook.ping","api_version":"2026-07-01"}` |
| Expected signature | `4763385ac0d2697688039023f942d29e302a415779cd9f31d6db478fc33cd195`  |

The vector's timestamp is intentionally old, so pass its own value as "now" (or an infinite tolerance) when testing:

```ts theme={null}
import assert from "node:assert";

assert.equal(
  verifyTracetenWebhook(
    "whsec_traceten_known_answer_vector_do_not_use",
    "1720000000",
    '{"id":"whd_kav","type":"webhook.ping","api_version":"2026-07-01"}',
    "4763385ac0d2697688039023f942d29e302a415779cd9f31d6db478fc33cd195",
    300,
    1720000000,
  ),
  true,
);
```

```python theme={null}
assert verify_traceten_webhook(
    "whsec_traceten_known_answer_vector_do_not_use",
    "1720000000",
    b'{"id":"whd_kav","type":"webhook.ping","api_version":"2026-07-01"}',
    "4763385ac0d2697688039023f942d29e302a415779cd9f31d6db478fc33cd195",
    now_seconds=1720000000,
)
```

For Go, override the clock by comparing against the vector's timestamp directly, or temporarily inject `now`; the digest itself must come out to the expected value:

```go theme={null}
mac := hmac.New(sha256.New, []byte("whsec_traceten_known_answer_vector_do_not_use"))
mac.Write([]byte("1720000000."))
mac.Write([]byte(`{"id":"whd_kav","type":"webhook.ping","api_version":"2026-07-01"}`))
got := hex.EncodeToString(mac.Sum(nil))
// got == "4763385ac0d2697688039023f942d29e302a415779cd9f31d6db478fc33cd195"
```

## Troubleshooting

**Every signature fails.** You are almost certainly hashing a re-serialized body. Log the exact bytes you hash and compare them, character by character, with the delivery log's payload in **Settings → Webhooks**. Also confirm you prepend the timestamp and the `.` separator.

**The conformance vector passes but real deliveries fail.** Your secret is wrong or stale. Secrets are per-endpoint, and [rotating](/webhooks/setup#set-up-the-endpoint) invalidates the old one immediately.

**Fails only sometimes.** Check server clock skew. The timestamp check compares against your clock; more than 300 seconds of skew rejects genuine deliveries. Sync with NTP rather than widening the tolerance.

**Signature header is missing.** The request did not come from Traceten's delivery path. Reject it.

## Next

* [Event reference](/webhooks/events)
* [Delivery semantics](/webhooks/delivery)
