Skip to content

Signature Verification

Every webhook delivery includes an X-TCF-Signature header containing an HMAC-SHA256 signature of the raw request body. Always verify this signature before processing any event.

How signing works

  1. TokenCashFlow computes HMAC-SHA256(raw_body_bytes, webhook_secret)
  2. The hex digest is prefixed with sha256= and sent in the X-TCF-Signature header
X-TCF-Signature: sha256=a94a8fe5ccb19ba61c4c0873d391e987982fbbd3...

Verification steps

  1. Read the raw request body bytes before any parsing
  2. Retrieve your webhook secret from your environment config
  3. Compute HMAC-SHA256(body_bytes, secret_bytes)
  4. Compare the computed hex digest to the value from the header (after stripping sha256=)
  5. Use a constant-time comparison to prevent timing attacks
  6. If comparison fails: return HTTP 400 and do not process the event

Critical: Read the raw bytes, not the parsed JSON. Re-serialising a parsed object may produce different byte output and will cause signature verification to fail.

Code examples

Python

python
import hmac
import hashlib
from flask import Flask, request, abort

app = Flask(__name__)
WEBHOOK_SECRET = "your_webhook_secret_here"  # from env var in production

@app.route("/webhook", methods=["POST"])
def webhook_handler():
    sig_header = request.headers.get("X-TCF-Signature", "")
    body = request.get_data()  # raw bytes — do NOT use request.json here

    expected = hmac.new(
        WEBHOOK_SECRET.encode("utf-8"),
        body,
        hashlib.sha256
    ).hexdigest()
    received = sig_header.removeprefix("sha256=")

    if not hmac.compare_digest(expected, received):
        abort(400, "Invalid signature")

    payload = request.json
    handle_event(payload)
    return "", 200


def handle_event(payload):
    event = payload.get("event")
    if event == "payment.completed":
        print(f"Payment completed: {payload['data']['payment_id']}")

Node.js (Express)

javascript
const crypto = require('crypto');
const express = require('express');
const app = express();

const WEBHOOK_SECRET = process.env.WEBHOOK_SECRET;

// IMPORTANT: use express.raw() — not express.json() — for the webhook route
app.post('/webhook', express.raw({ type: 'application/json' }), (req, res) => {
  const sigHeader = req.headers['x-tcf-signature'] || '';
  const body = req.body; // Buffer of raw bytes

  const expected = crypto
    .createHmac('sha256', WEBHOOK_SECRET)
    .update(body)
    .digest('hex');
  const received = sigHeader.replace('sha256=', '');

  // Constant-time comparison
  if (!crypto.timingSafeEqual(
    Buffer.from(expected, 'hex'),
    Buffer.from(received, 'hex')
  )) {
    return res.status(400).send('Invalid signature');
  }

  const payload = JSON.parse(body);
  if (payload.event === 'payment.completed') {
    console.log('Payment completed:', payload.data.payment_id);
  }

  res.sendStatus(200);
});

PHP

php
<?php

define('WEBHOOK_SECRET', getenv('WEBHOOK_SECRET'));

function verify_webhook(): array {
    $sig_header = $_SERVER['HTTP_X_TCF_SIGNATURE'] ?? '';
    $body = file_get_contents('php://input'); // raw bytes

    $expected = hash_hmac('sha256', $body, WEBHOOK_SECRET);
    $received = str_replace('sha256=', '', $sig_header);

    // Constant-time comparison
    if (!hash_equals($expected, $received)) {
        http_response_code(400);
        exit('Invalid signature');
    }

    return json_decode($body, true);
}

$payload = verify_webhook();

if ($payload['event'] === 'payment.completed') {
    $payment_id = $payload['data']['payment_id'];
    $net_amount = $payload['data']['net_amount_usd'];
    // fulfil_order($payment_id, $net_amount);
}

http_response_code(200);

What to do when verification fails

  1. Return HTTP 400 immediately — do not process the event
  2. Log the raw body and headers for investigation
  3. Do not log the computed or received HMAC values (they are security-sensitive)
  4. Common causes of failure:
    • Using parsed JSON instead of raw bytes
    • Incorrect secret (check environment variable)
    • Body was modified by an intermediate proxy (e.g. body logging middleware) — ensure your framework reads raw bytes before any middleware that might alter the body

Secret rotation

To rotate your webhook secret:

  1. Generate a new secret
  2. Update your webhook config: PUT /v1/webhooks with the new secret value
  3. Deploy your updated handler (with the new secret) before or immediately after the API update — there is a brief window where the old secret may still be used for in-flight deliveries
  4. TokenCashFlow uses the new secret for all subsequent deliveries

Rotation does not affect pending retry deliveries — those were signed with the secret in use at the time of enqueuing.

TokenCashFlow Documentation