⌘K
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.
Last updated: · Markdown version
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:
- Read the raw body bytes before any JSON parsing. Re-serialising the parsed JSON changes key order and whitespace, and the signature then fails.
- Parse the header into
tandv1. - Reject when
|now − t| > 300seconds. This protects against replay of a captured request. - Compute the HMAC and compare it with
v1in constant time. - Respond
401on failure. Respond2xxquickly on success, then dedupe on the eventid.
During secret rotation, 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
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
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
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+)
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
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
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, Make and n8n guides) |