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
PartMeaning
tThe Unix timestamp (in seconds) when the signature was generated.
v1The 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

  1. Read the raw request body as bytes/string, before any JSON parsing.
  2. Read the X-Tokeflow-Signature header and split it into t and v1.
  3. Compute HMAC_SHA256(secret, + "${t}.${rawBody}" + ) and hex-encode it.
  4. Compare your value to v1 using a constant-time comparison.
  5. Reject the request if the timestamp t is 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 2xx status quickly; do the heavy work asynchronously.
  • A non-2xx response or a timeout causes Tokeflow to retry the delivery with backoff.
  • Deduplicate on the event id and reconcile state via the API. See Webhook event types.

On this page