# Verify webhook signatures

> Verify the tt-signature HMAC-SHA256 header on TimeTracking webhooks, with a real test vector and copy-paste code for Node.js, Python, PHP, C#, Go and Ruby.

URL: https://timetracking.tech/developers/webhooks/verify-signatures
Last updated: 2026-10-24

Every delivery has this header:

```
tt-signature: t=<unix seconds>,v1=<hex HMAC-SHA256>
```

where

```
v1 = hex( HMAC_SHA256( key = webhook secret (the full "whsec_…" string, as UTF-8),
                       message = "<t>" + "." + <raw request body bytes> ) )
```

To verify a delivery:

1. Read the **raw body bytes** before any JSON parsing. Re-serialising the parsed JSON changes key order and whitespace, and the signature then fails.
2. Parse the header into `t` and `v1`.
3. Reject when `|now − t| > 300` seconds. This protects against replay of a captured request.
4. Compute the HMAC and compare it with `v1` in **constant time**.
5. Respond `401` on failure. Respond `2xx` quickly on success, then dedupe on the event `id`.

During [secret rotation](https://timetracking.tech/developers/webhooks/overview#test-inspect-replay-rotate-v11), accept a signature that matches **either** the old or the new secret.

## Test vector (real delivery)

Use this to unit-test your implementation. Pass "now" = `1792790010` or disable the tolerance check for the test.

| | |
|---|---|
| secret | `whsec_t8icP5U1Zxcb1gTcgfcYegTDy61UX-ra` (webhook since deleted) |
| header | `t=1792790000,v1=7e81359509a29cb02c86ac686c67bf4434826027d10933a89cee5dba3e60d62f` |
| raw body (exact bytes, no trailing newline) | see below |

```
{"id":"evt_9087e71890de14cd0197877a","data":{"rows":2,"runId":"239a1898-fab5-486c-a2be-92d939be90cc","endsOn":"2026-12-06","mapping":"Generiek CSV (per dag)","startsOn":"2026-11-30"},"type":"export.completed","created":"2026-10-23T19:08:42.950Z"}
```

The Node.js, Python and Ruby snippets below were run against this vector and return `true`. The PHP, C# and Go snippets use the same algorithm; run the vector in your own test suite.

## Node.js

```js
import { createHmac, timingSafeEqual } from "node:crypto";

/** rawBody: Buffer or string exactly as received. */
export function verifyTimeTrackingSignature(rawBody, header, secret, toleranceSec = 300, nowSec = Date.now() / 1000) {
  const parts = Object.fromEntries(String(header ?? "").split(",").map((p) => p.trim().split("=", 2)));
  const t = Number(parts.t);
  if (!parts.v1 || !Number.isInteger(t)) return false;
  if (Math.abs(nowSec - t) > toleranceSec) return false;
  const expected = Buffer.from(createHmac("sha256", secret).update(`${parts.t}.`).update(rawBody).digest("hex"));
  const given = Buffer.from(parts.v1);
  return expected.length === given.length && timingSafeEqual(expected, given);
}

// Express: keep the raw body
// app.post("/tt", express.raw({ type: "application/json" }), (req, res) => {
//   if (!verifyTimeTrackingSignature(req.body, req.get("tt-signature"), process.env.TT_WEBHOOK_SECRET)) return res.sendStatus(401);
//   const event = JSON.parse(req.body.toString("utf8")); res.sendStatus(200); /* … */
// });
// Next.js route handler: const raw = await request.text();
```

## Python

```python
import hashlib, hmac, time

def verify_timetracking_signature(raw_body: bytes, header: str, secret: str, tolerance: int = 300, now: float | None = None) -> bool:
    try:
        parts = dict(p.strip().split("=", 1) for p in (header or "").split(","))
        t, v1 = parts["t"], parts["v1"]
        ts = int(t)
    except (KeyError, ValueError):
        return False
    if abs((now if now is not None else time.time()) - ts) > tolerance:
        return False
    expected = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, v1)

# Flask:   verify_timetracking_signature(request.get_data(), request.headers.get("tt-signature"), SECRET)
# FastAPI: raw = await request.body()
# Django:  request.body
```

## PHP

```php
<?php
function verify_timetracking_signature(string $rawBody, ?string $header, string $secret, int $tolerance = 300, ?int $now = null): bool {
    $parts = [];
    foreach (explode(',', (string) $header) as $p) {
        $kv = explode('=', trim($p), 2);
        if (count($kv) === 2) { $parts[$kv[0]] = $kv[1]; }
    }
    if (!isset($parts['t'], $parts['v1']) || !ctype_digit($parts['t'])) { return false; }
    if (abs(($now ?? time()) - (int) $parts['t']) > $tolerance) { return false; }
    $expected = hash_hmac('sha256', $parts['t'] . '.' . $rawBody, $secret);
    return hash_equals($expected, $parts['v1']);
}

// $raw = file_get_contents('php://input');
// if (!verify_timetracking_signature($raw, $_SERVER['HTTP_TT_SIGNATURE'] ?? null, getenv('TT_WEBHOOK_SECRET'))) { http_response_code(401); exit; }
// Laravel: $request->getContent(), $request->header('tt-signature')
```

## C# (.NET 6+)

```csharp
using System;
using System.Linq;
using System.Security.Cryptography;
using System.Text;

public static class TimeTrackingWebhook
{
    public static bool Verify(byte[] rawBody, string? header, string secret, int toleranceSec = 300, long? nowUnix = null)
    {
        var parts = (header ?? "").Split(',')
            .Select(p => p.Trim().Split('=', 2))
            .Where(kv => kv.Length == 2)
            .ToDictionary(kv => kv[0], kv => kv[1]);
        if (!parts.TryGetValue("t", out var t) || !parts.TryGetValue("v1", out var v1) || !long.TryParse(t, out var ts)) return false;
        var now = nowUnix ?? DateTimeOffset.UtcNow.ToUnixTimeSeconds();
        if (Math.Abs(now - ts) > toleranceSec) return false;

        using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secret));
        var message = Encoding.UTF8.GetBytes(t + ".").Concat(rawBody).ToArray();
        var expected = Convert.ToHexString(hmac.ComputeHash(message)).ToLowerInvariant();
        return CryptographicOperations.FixedTimeEquals(Encoding.ASCII.GetBytes(expected), Encoding.ASCII.GetBytes(v1));
    }
}

// ASP.NET Core minimal API:
// app.MapPost("/tt", async (HttpRequest req) => {
//     using var ms = new MemoryStream(); await req.Body.CopyToAsync(ms);
//     if (!TimeTrackingWebhook.Verify(ms.ToArray(), req.Headers["tt-signature"], secret)) return Results.Unauthorized();
//     return Results.Ok();
// });
```

## Go

```go
package ttwebhook

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

// Verify checks a tt-signature header against the raw request body.
func Verify(rawBody []byte, header, secret string, tolerance time.Duration, now time.Time) bool {
	parts := map[string]string{}
	for _, p := range strings.Split(header, ",") {
		kv := strings.SplitN(strings.TrimSpace(p), "=", 2)
		if len(kv) == 2 {
			parts[kv[0]] = kv[1]
		}
	}
	t, v1 := parts["t"], parts["v1"]
	ts, err := strconv.ParseInt(t, 10, 64)
	if err != nil || v1 == "" {
		return false
	}
	if d := now.Sub(time.Unix(ts, 0)); d > tolerance || d < -tolerance {
		return false
	}
	mac := hmac.New(sha256.New, []byte(secret))
	mac.Write([]byte(t + "."))
	mac.Write(rawBody)
	expected := hex.EncodeToString(mac.Sum(nil))
	return hmac.Equal([]byte(expected), []byte(v1))
}

// net/http:
// body, _ := io.ReadAll(r.Body)
// if !ttwebhook.Verify(body, r.Header.Get("tt-signature"), secret, 5*time.Minute, time.Now()) { w.WriteHeader(401); return }
```

## Ruby

```ruby
require "openssl"

def secure_compare(a, b)
  return false unless a.bytesize == b.bytesize
  a.bytes.zip(b.bytes).reduce(0) { |acc, (x, y)| acc | (x ^ y) }.zero?
end

def verify_timetracking_signature(raw_body, header, secret, tolerance: 300, now: Time.now.to_i)
  parts = header.to_s.split(",").map { |p| p.strip.split("=", 2) }.to_h
  t, v1 = parts["t"], parts["v1"]
  return false unless t && t.match?(/\A\d+\z/) && v1
  return false if (now - t.to_i).abs > tolerance
  expected = OpenSSL::HMAC.hexdigest("SHA256", secret, "#{t}.#{raw_body}")
  secure_compare(expected, v1)
end

# Rails: verify_timetracking_signature(request.raw_post, request.headers["tt-signature"], ENV["TT_WEBHOOK_SECRET"])
# Sinatra: request.body.rewind; raw = request.body.read
```

## Common mistakes

| Symptom | Cause |
|---|---|
| Always invalid | Body parsed and re-serialised (e.g. `express.json()` before verifying, or `JSON.stringify(req.body)`) |
| Always invalid | Secret without the `whsec_` prefix, or the secret of another webhook |
| Invalid after a few minutes in tests | The timestamp tolerance; replayed captures are older than 300 s |
| Invalid with non-ASCII names (é, ł) | Body decoded with the wrong charset; hash the bytes |
| Intermittently invalid after rotating | Old secret removed before all queued deliveries were signed with the new one; accept both for a while |
| iPaaS step fails | The trigger gives you parsed JSON only. Use an option that exposes the raw body (see the [Zapier](https://timetracking.tech/developers/integrations/ipaas/zapier), [Make](https://timetracking.tech/developers/integrations/ipaas/make) and [n8n](https://timetracking.tech/developers/integrations/ipaas/n8n) guides) |
