How to verify the Topiic-Signature header on inbound webhooks - algorithm, samples, gotchas.
Every webhook Topiic sends carries a Topiic-Signature header. Verifying it confirms two things:
- The request really came from Topiic (the signer holds your API key’s signing secret).
- The body wasn’t tampered with in transit.
You must verify before doing anything with the payload. An unverified request might be an attacker trying to spoof a checkout.completed event for a customer they don’t own.
Header format
Section titled “Header format”Topiic-Signature: t=1769472312,v1=8f2a1c4b9d6e3a7f0c5b8a1d2e4f6c9b7a3d5e8f0c1a4b7d9e2c6a8b3d5f7e9cTwo comma-separated key/value pairs:
| Key | Meaning |
|---|---|
t | Unix timestamp (seconds) at the moment the request was signed. |
v1 | Lowercase hex of HMAC-SHA256 over <t>.<body>, keyed by your signing secret. |
The v1 prefix is a scheme version — future versions might add v2, etc. Verifiers should pick the highest scheme they recognise and ignore the others.
Algorithm
Section titled “Algorithm”- Parse
tandv1from the header. - Reconstruct the signed payload as the exact string
<t>.<body>— where<body>is the raw request body bytes as received, not a re-serialised version of the parsed JSON. - Compute
HMAC-SHA256(signing_secret, payload)and hex-encode. - Compare to
v1using a constant-time comparison. - (Optional but recommended) Reject if
now - t > 5 minutesto defeat replay of an intercepted request.
Reference verifiers
Section titled “Reference verifiers”import crypto from 'node:crypto';import express from 'express';
const app = express();const secret = process.env.TOPIIC_SIGNING_SECRET;
// Capture the raw body so HMAC verification matches the exact bytes Topiic signed.app.post('/webhooks/topiic', express.raw({ type: 'application/json' }), (req, res) => { const header = req.header('Topiic-Signature'); if (!verify(secret, req.body, header)) { return res.status(401).send('bad signature'); }
const event = JSON.parse(req.body.toString('utf8')); // Idempotency: dedupe on event.id before applying side-effects. // …handle event… res.status(200).end(); });
function verify(secret, rawBody, header) { if (!header) return false; const parts = Object.fromEntries( header.split(',').map((kv) => kv.split('=').map((s) => s.trim())) ); const t = Number(parts.t); const v1 = parts.v1; if (!t || !v1) return false; if (Math.abs(Date.now() / 1000 - t) > 300) return false; // 5-min freshness window
const expected = crypto .createHmac('sha256', secret) .update(`${t}.${rawBody.toString('utf8')}`) .digest('hex');
const a = Buffer.from(expected, 'hex'); const b = Buffer.from(v1, 'hex'); return a.length === b.length && crypto.timingSafeEqual(a, b);}import hmac, hashlib, os, timefrom fastapi import FastAPI, Header, HTTPException, Request
app = FastAPI()SECRET = os.environ['TOPIIC_SIGNING_SECRET']
@app.post('/webhooks/topiic')async def handle(request: Request, topiic_signature: str = Header(None)): raw = await request.body() # read once, before any parsing if not verify(SECRET, raw, topiic_signature): raise HTTPException(401, 'bad signature')
import json event = json.loads(raw) # Dedupe on event['id'] before applying side-effects. # …handle event… return {}
def verify(secret: str, raw: bytes, header: str | None) -> bool: if not header: return False parts = dict(kv.split('=', 1) for kv in header.split(',')) t, v1 = parts.get('t'), parts.get('v1') if not t or not v1: return False if abs(time.time() - int(t)) > 300: return False
expected = hmac.new( secret.encode(), f'{t}.{raw.decode()}'.encode(), hashlib.sha256, ).hexdigest() return hmac.compare_digest(expected, v1)<?php$secret = getenv('TOPIIC_SIGNING_SECRET');
$raw = file_get_contents('php://input');$header = $_SERVER['HTTP_TOPIIC_SIGNATURE'] ?? '';
if (!verify($secret, $raw, $header)) { http_response_code(401); exit('bad signature');}
$event = json_decode($raw, true);// Dedupe on $event['id'] before applying side-effects.// …handle event…http_response_code(200);
function verify(string $secret, string $raw, string $header): bool { $parts = []; foreach (explode(',', $header) as $kv) { [$k, $v] = array_pad(explode('=', $kv, 2), 2, ''); $parts[trim($k)] = trim($v); } $t = $parts['t'] ?? null; $v1 = $parts['v1'] ?? null; if (!$t || !$v1) return false; if (abs(time() - (int)$t) > 300) return false;
$expected = hash_hmac('sha256', "{$t}.{$raw}", $secret); return hash_equals($expected, $v1);}require 'sinatra'require 'openssl'
SECRET = ENV['TOPIIC_SIGNING_SECRET']
post '/webhooks/topiic' do raw = request.body.read halt 401 unless verify(SECRET, raw, request.env['HTTP_TOPIIC_SIGNATURE'])
event = JSON.parse(raw) # Dedupe on event['id'] before applying side-effects. # ...handle event... 200end
def verify(secret, raw, header) return false unless header parts = header.split(',').map { |kv| kv.split('=', 2) }.to_h t, v1 = parts['t'], parts['v1'] return false unless t && v1 return false if (Time.now.to_i - t.to_i).abs > 300
expected = OpenSSL::HMAC.hexdigest('sha256', secret, "#{t}.#{raw}") Rack::Utils.secure_compare(expected, v1)endCommon failures
Section titled “Common failures”| Symptom | Cause |
|---|---|
| Every request fails verification | Wrong secret. Confirm the tss_… value matches the one shown when you minted the key. |
| Some requests verify, others don’t | Body is being re-serialised somewhere in your middleware stack. Capture the raw bytes. |
| Verification works locally, fails in prod | Reverse proxy is rewriting the body (e.g. nginx + gzip + buffering). Pass the request through untouched, or compute the signature before any middleware. |
| Verification fails ~5% randomly | Clock skew on your server (t freshness check). Check NTP is healthy. Topiic uses UTC. |
| Header arrives lowercase / underscored | Different frameworks normalise differently — always look up case-insensitively (Topiic-Signature ≡ topiic-signature ≡ HTTP_TOPIIC_SIGNATURE). |
Testing your verifier
Section titled “Testing your verifier”Use the replay endpoint or button in the portal to send a recorded event again. Your test environment receives the same headers + body Topiic produced originally — perfect for asserting your verifier handles real-world payloads.
For unit tests, sign a fixture payload with the same algorithm and assert your verifier accepts it:
const body = JSON.stringify({ id: 'test', type: 'checkout.completed', /* … */ });const t = Math.floor(Date.now() / 1000);const v1 = crypto.createHmac('sha256', secret).update(`${t}.${body}`).digest('hex');const header = `t=${t},v1=${v1}`;assert(verify(secret, Buffer.from(body), header));