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
- TokenCashFlow computes
HMAC-SHA256(raw_body_bytes, webhook_secret) - The hex digest is prefixed with
sha256=and sent in theX-TCF-Signatureheader
X-TCF-Signature: sha256=a94a8fe5ccb19ba61c4c0873d391e987982fbbd3...Verification steps
- Read the raw request body bytes before any parsing
- Retrieve your webhook secret from your environment config
- Compute
HMAC-SHA256(body_bytes, secret_bytes) - Compare the computed hex digest to the value from the header (after stripping
sha256=) - Use a constant-time comparison to prevent timing attacks
- 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
- Return HTTP 400 immediately — do not process the event
- Log the raw body and headers for investigation
- Do not log the computed or received HMAC values (they are security-sensitive)
- 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:
- Generate a new secret
- Update your webhook config:
PUT /v1/webhookswith the newsecretvalue - 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
- 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.

