Signature verification
HMAC-SHA256 over timestamp + raw body, constant-time compare, 300-second replay window. Working code in curl, Node, and Python.
Every webhook delivery from BlendFi is signed with HMAC-SHA256. Verifying that signature is mandatory: an unverified receiver exposes a remote-code-execution surface to the public internet. This page is the canonical reference: the headers BlendFi sends, the algorithm, working snippets in three languages, and the four most common implementation mistakes.
The headers
Every delivery carries:
X-Blendfi-Event-Id: evt_01J…
X-Blendfi-Event-Type: conversion.completed
X-Blendfi-Timestamp: 1714500000
X-Blendfi-Signature: t=1714500000,v1=<hex-of-hmac-sha256>
Content-Type: application/json| Header | Format | Purpose |
|---|---|---|
X-Blendfi-Event-Id | evt_<id> | Stable identifier for this event. Use as your dedup key. |
X-Blendfi-Event-Type | e.g. conversion.completed | Convenience header so you can route before parsing the body. |
X-Blendfi-Timestamp | Unix seconds | When BlendFi composed the delivery. Mirrors the t= value inside the signature, and is used for replay-window enforcement. |
X-Blendfi-Signature | t=<unix_seconds>,v1=<hex> | The signing timestamp (t=) plus the HMAC-SHA256 of {t}.{raw_body} keyed with the endpoint's secret, hex-encoded (v1=). |
The signed timestamp is carried inside the X-Blendfi-Signature header as the t= component, which is what wires replay protection into the signature: you cannot verify the signature without also binding it to the timestamp.
The algorithm
The five steps every verifier does, in this exact order:
- Read the raw body bytes. Before any JSON parsing, before any middleware re-serialization. The exact bytes BlendFi sent.
- Parse
X-Blendfi-Signatureinto itst=(timestamp) andv1=(hex signature) components, then reconstruct the signed payload:{t}.{raw_body}. - HMAC-SHA256 that string with the endpoint's plaintext secret. Take the result as hexadecimal.
- Constant-time compare the result against the
v1=value. Not string equality, timing attacks recover secrets. - Reject if
now - t > 300 seconds: replay-window protection.
Everything else (parsing the body, dedup, dispatch) happens after this passes.
Working code
# Smoke-test against a known good payload+secret.
# Useful for debugging: produce the signature yourself and compare with the header.
TIMESTAMP=1714500000
BODY='{"id":"evt_01J","type":"conversion.completed","data":{}}'
SECRET='whsec_yoursecret'
# Reconstruct signed payload
SIGNED="${TIMESTAMP}.${BODY}"
# HMAC-SHA256, hexadecimal
echo -n "$SIGNED" | openssl dgst -sha256 -hmac "$SECRET" -hex | awk '{print $2}'
# → matches the v1= value of X-Blendfi-Signatureimport crypto from "node:crypto";
import express from "express";
const app = express();
// Critical: capture the RAW body, not a parsed/re-serialized one.
app.post(
"/blendfi-webhooks",
express.raw({ type: "application/json" }),
(req, res) => {
const rawBody = req.body.toString("utf8");
const id = req.header("x-blendfi-event-id");
const signatureHeader = req.header("x-blendfi-signature");
if (!id || !signatureHeader) {
return res.status(401).send("missing webhook headers");
}
// Parse "t=<unix>,v1=<hex>" into its components.
const parts = Object.fromEntries(
signatureHeader.split(",").map((p) => p.split("=", 2)),
);
const timestamp = parts.t;
const provided = parts.v1;
if (!timestamp || !provided) {
return res.status(401).send("malformed signature header");
}
// Replay window
const skew = Math.abs(Date.now() / 1000 - Number(timestamp));
if (skew > 300) {
return res.status(401).send("timestamp outside 300s window");
}
const signedPayload = `${timestamp}.${rawBody}`;
const expected = crypto
.createHmac("sha256", process.env.BLENDFI_WEBHOOK_SECRET)
.update(signedPayload)
.digest("hex");
const a = Buffer.from(provided);
const b = Buffer.from(expected);
const match =
a.length === b.length && crypto.timingSafeEqual(a, b);
if (!match) {
return res.status(401).send("signature mismatch");
}
// Dedup on id (Redis with TTL ≥ retry window), pseudo:
// if (await redis.exists(`webhook:${id}`)) return res.sendStatus(200);
// await redis.set(`webhook:${id}`, "1", "EX", 600);
// Ack fast; do real work async.
res.sendStatus(200);
queue.enqueue({ id, body: JSON.parse(rawBody) });
},
);import hmac, hashlib, time, os
from flask import Flask, request
app = Flask(__name__)
SECRET = os.environ["BLENDFI_WEBHOOK_SECRET"].encode()
@app.post("/blendfi-webhooks")
def handle():
raw = request.get_data() # raw bytes; do NOT use request.json
id_ = request.headers.get("X-Blendfi-Event-Id")
sig_hdr = request.headers.get("X-Blendfi-Signature", "")
if not id_ or not sig_hdr:
return "missing webhook headers", 401
# Parse "t=<unix>,v1=<hex>" into its components.
parts = dict(p.split("=", 1) for p in sig_hdr.split(",") if "=" in p)
ts = parts.get("t")
provided = parts.get("v1")
if not ts or not provided:
return "malformed signature header", 401
# Replay window
if abs(time.time() - int(ts)) > 300:
return "timestamp outside 300s window", 401
signed_payload = f"{ts}.".encode() + raw
expected = hmac.new(SECRET, signed_payload, hashlib.sha256).hexdigest()
if not hmac.compare_digest(provided, expected):
return "signature mismatch", 401
# Dedup on id (Redis pseudo):
# if redis.exists(f"webhook:{id_}"): return "", 200
# redis.setex(f"webhook:{id_}", 600, "1")
# Ack fast; queue real work.
queue.put({"id": id_, "body": request.json})
return "", 200Four common implementation mistakes
The vast majority of signature-verification support tickets match one of these cases.
1. Raw body re-serialized by middleware
The most common failure. Express's express.json(), FastAPI's automatic JSON parsing, they all parse the body before your handler sees it, then re-serialize it for downstream code. Re-serialized JSON differs from the original (whitespace, key order, trailing newlines) → HMAC won't match.
Fix: capture the raw body before any middleware touches it. In Express, express.raw({ type: "application/json" }) for the webhook route. In FastAPI, await request.body().
2. String-equal instead of constant-time compare
Standard == or === on strings exits early as soon as a byte differs. An attacker measuring response time can recover the signature byte-by-byte. Always use crypto.timingSafeEqual (Node) or hmac.compare_digest (Python) or your language's equivalent.
3. Clock skew exceeds 300 seconds
The replay window catches old payloads, but it also catches your own server if its clock has drifted. Symptoms: signature failures clustered around a single host, intermittent failures that "fix themselves" minutes later. Fix: sync NTP. Most cloud providers expose a local time-sync service that handles this automatically.
4. Wrong secret (test vs live)
Each environment has its own secret. A sk_live_… key creates webhook endpoints with live-environment secrets; sk_test_… creates sandbox-environment secrets. Symptom: every signature fails after a deploy.
Diagnostic recipe
When signatures fail in CI but not locally (or vice versa), the first thing to check is which secret the verifier is reading from. Echo the first 8 characters of the secret to your logs (never the full thing) and compare across environments.
Read next
- Retries and replay, what happens when verification fails (BlendFi retries)
- Endpoint management, how to rotate the secret without dropping deliveries
- Sandbox testing, drive a delivery against a local listener and verify end-to-end
