Verifying webhook signatures
How to verify the X-Tokeflow-Signature header on incoming webhooks using HMAC-SHA256, with a complete Node/Express example.
Every webhook Tokeflow sends carries an X-Tokeflow-Signature header. Verifying it proves the request
came from Tokeflow and was not tampered with in transit. Always verify before acting on a webhook — an
unverified endpoint can be spoofed by anyone who learns its URL.
The signature header
X-Tokeflow-Signature: t=1737030609,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd| Part | Meaning |
|---|---|
t | The Unix timestamp (in seconds) when the signature was generated. |
v1 | The HMAC-SHA256 signature, hex-encoded. |
The signature is computed over the string {t}.{raw_request_body} — the timestamp, a literal ., then
the exact raw bytes of the request body — keyed by your endpoint's signing secret.
You obtain the signing secret (it looks like whsec_…) when you create the endpoint in the Tokeflow
Dashboard. Treat it like a password: store it as a secret, never commit it, and use it verbatim — including
the whsec_ prefix — as the HMAC key.
Verification steps
- Read the raw request body as bytes/string, before any JSON parsing.
- Read the
X-Tokeflow-Signatureheader and split it intotandv1. - Compute
HMAC_SHA256(secret,+ "${t}.${rawBody}" +)and hex-encode it. - Compare your value to
v1using a constant-time comparison. - Reject the request if the timestamp
tis outside your tolerance (e.g. older than 5 minutes) to prevent replay attacks.
Capture the raw body. The signature is computed over the exact bytes Tokeflow sent. If your framework parses JSON and re-serializes it, key order and whitespace can change and the signature will no longer match. Configure your route to expose the raw body (see the example below).
Node / Express example
import express from 'express';
import crypto from 'crypto';
const app = express();
const SIGNING_SECRET = process.env.TOKEFLOW_WEBHOOK_SECRET; // "whsec_..."
const TOLERANCE_SECONDS = 5 * 60;
// Use the raw body parser on the webhook route so the bytes are untouched.
app.post(
'/webhooks/tokeflow',
express.raw({ type: 'application/json' }),
(req, res) => {
const header = req.get('X-Tokeflow-Signature') || '';
const rawBody = req.body; // a Buffer, thanks to express.raw
if (!verify(rawBody, header, SIGNING_SECRET)) {
return res.status(400).send('Invalid signature');
}
const event = JSON.parse(rawBody.toString('utf8'));
// Acknowledge immediately, then process asynchronously.
res.status(200).send('ok');
handleEvent(event); // dedupe on event.id, reconcile via the API
},
);
function verify(rawBody, signatureHeader, secret) {
// Parse "t=...,v1=..."
const parts = Object.fromEntries(
signatureHeader.split(',').map((kv) => kv.split('=')),
);
const timestamp = Number(parts.t);
const provided = parts.v1;
if (!timestamp || !provided) return false;
// Reject stale timestamps (replay protection).
const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - timestamp) > TOLERANCE_SECONDS) return false;
// Recompute the HMAC over `${t}.${rawBody}`.
const signedPayload = `${timestamp}.${rawBody.toString('utf8')}`;
const expected = crypto
.createHmac('sha256', secret)
.update(signedPayload)
.digest('hex');
// Constant-time compare.
const a = Buffer.from(expected, 'hex');
const b = Buffer.from(provided, 'hex');
return a.length === b.length && crypto.timingSafeEqual(a, b);
}The signing scheme is compatible with the widely used t=…,v1=… HMAC-SHA256 pattern, so existing helper
libraries that verify that format will work — just pass your whsec_… secret as the key and verify against
the raw body.
After verifying
- Respond with a
2xxstatus quickly; do the heavy work asynchronously. - A non-
2xxresponse or a timeout causes Tokeflow to retry the delivery with backoff. - Deduplicate on the event
idand reconcile state via the API. See Webhook event types.