Skip to content

Verifying webhook signatures

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:

  1. The request really came from Topiic (the signer holds your API key’s signing secret).
  2. 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.

Topiic-Signature: t=1769472312,v1=8f2a1c4b9d6e3a7f0c5b8a1d2e4f6c9b7a3d5e8f0c1a4b7d9e2c6a8b3d5f7e9c

Two comma-separated key/value pairs:

KeyMeaning
tUnix timestamp (seconds) at the moment the request was signed.
v1Lowercase 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.

  1. Parse t and v1 from the header.
  2. 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.
  3. Compute HMAC-SHA256(signing_secret, payload) and hex-encode.
  4. Compare to v1 using a constant-time comparison.
  5. (Optional but recommended) Reject if now - t > 5 minutes to defeat replay of an intercepted request.
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);
}
SymptomCause
Every request fails verificationWrong secret. Confirm the tss_… value matches the one shown when you minted the key.
Some requests verify, others don’tBody is being re-serialised somewhere in your middleware stack. Capture the raw bytes.
Verification works locally, fails in prodReverse 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% randomlyClock skew on your server (t freshness check). Check NTP is healthy. Topiic uses UTC.
Header arrives lowercase / underscoredDifferent frameworks normalise differently — always look up case-insensitively (Topiic-Signaturetopiic-signatureHTTP_TOPIIC_SIGNATURE).

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