Skip to content

Request an integration

Tell us which system you want to connect. We review every request weekly and publish a step-by-step guide or build the connection.

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:

  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, 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)