Verify and route webhook events safely
Production-grade webhook handler in Node and Python. Signature verification, idempotent processing, ack-fast pattern, dead-letter queue.
A complete webhook handler that: verifies HMAC-SHA256 signatures in constant time, deduplicates on X-Blendfi-Event-Id, returns 2xx in under 500ms, dispatches to per-event handlers asynchronously, and recovers from outages via manual redelivery. ~30 minutes including testing. Prereqs: a sandbox key, a webhook endpoint registered, Redis (or any KV store) for dedup.
Prereqs
- Sandbox API key.
- Webhook endpoint registered with
POST /v1/webhook_endpoints, see Endpoint management. - Plaintext webhook secret stored in
BLENDFI_WEBHOOK_SECRET(returned once at endpoint creation; not retrievable later). - Redis or equivalent KV for the dedup key. In-memory
Mapworks for single-instance dev; production needs durable storage with TTL.
What good webhook handling looks like
Verify signature → dedup on X-Blendfi-Event-Id → 200 in under 500ms → enqueue for async processing → fan out to per-event handlers. Anything that risks blocking the BlendFi delivery (DB writes, external calls, slow business logic) goes after the ack.
1. Stand up the endpoint
The minimal handler that returns 200 immediately. We'll layer on signing and dispatch next.
import express from "express";
const app = express();
app.post(
"/blendfi-webhooks",
express.raw({ type: "application/json" }),
(req, res) => {
res.sendStatus(200);
},
);
app.listen(3000);from flask import Flask, request
app = Flask(__name__)
@app.post("/blendfi-webhooks")
def handle():
return "", 200
if __name__ == "__main__":
app.run(port=3000)Expose it via tunnel (cloudflared tunnel --url http://localhost:3000); register the tunneled URL as a sandbox webhook endpoint; fire a test_helper to confirm BlendFi reaches you.
2. Verify the signature
The four canonical mistakes (raw-body re-serialization, string-equal vs constant-time, clock skew, wrong secret) all originate here. Get this right once. The X-Blendfi-Signature header carries the signing timestamp (t=) and the HMAC (v1=); parse both out of that one header.
import crypto from "node:crypto";
const SECRET = process.env.BLENDFI_WEBHOOK_SECRET;
function verify(req) {
const sigHdr = req.header("x-blendfi-signature");
if (!sigHdr) throw new Error("missing webhook headers");
// Parse "t=<unix>,v1=<hex>" into its components.
const parts = Object.fromEntries(
sigHdr.split(",").map((p) => p.split("=", 2)),
);
const ts = parts.t;
const provided = parts.v1;
if (!ts || !provided) throw new Error("malformed signature header");
// Replay window, 300s per BlendFi convention
const skew = Math.abs(Date.now() / 1000 - Number(ts));
if (skew > 300) throw new Error("timestamp outside 300s window");
// Reconstruct signed payload from RAW body bytes
const signedPayload = `${ts}.${req.body.toString("utf8")}`;
const expected = crypto
.createHmac("sha256", SECRET)
.update(signedPayload)
.digest("hex");
const a = Buffer.from(provided);
const b = Buffer.from(expected);
if (!(a.length === b.length && crypto.timingSafeEqual(a, b))) {
throw new Error("signature mismatch");
}
}import hmac, hashlib, time, os
SECRET = os.environ["BLENDFI_WEBHOOK_SECRET"].encode()
def verify(request):
sig_hdr = request.headers.get("X-Blendfi-Signature", "")
if not sig_hdr:
raise ValueError("missing webhook headers")
# 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:
raise ValueError("malformed signature header")
# Replay window
if abs(time.time() - int(ts)) > 300:
raise ValueError("timestamp outside 300s window")
raw = request.get_data() # raw bytes; do not use request.json
signed_payload = f"{ts}.".encode() + raw
expected = hmac.new(SECRET, signed_payload, hashlib.sha256).hexdigest()
if not hmac.compare_digest(provided, expected):
raise ValueError("signature mismatch")Wire it into the handler, return 401 on any failure, fail closed.
3. Dedup on X-Blendfi-Event-Id
BlendFi may retry the same event after a transient failure. Same X-Blendfi-Event-Id means the same business event; processing it twice creates duplicates downstream. Dedup at the entry point.
import Redis from "ioredis";
const redis = new Redis(process.env.REDIS_URL);
async function alreadyProcessed(eventId) {
// SET NX with TTL = "claim if not already claimed"
const claimed = await redis.set(`webhook:${eventId}`, "1", "EX", 600, "NX");
return claimed === null; // null means key existed → already processed
}import redis as redislib
redis = redislib.Redis.from_url(os.environ["REDIS_URL"])
def already_processed(event_id: str) -> bool:
# SET NX with TTL = "claim if not already claimed"
claimed = redis.set(f"webhook:{event_id}", "1", ex=600, nx=True)
return claimed is NoneTTL must be ≥ the BlendFi retry window (~2 minutes is plenty; 600s = 10 minutes is safe).
4. Ack fast; route async
Return 200 to BlendFi the moment signature + dedup pass. Do business work in a background queue so a slow downstream doesn't block the next webhook.
import { Queue } from "bullmq";
const queue = new Queue("blendfi-webhooks", { connection: { url: process.env.REDIS_URL } });
app.post(
"/blendfi-webhooks",
express.raw({ type: "application/json" }),
async (req, res) => {
try {
verify(req);
} catch (e) {
return res.status(401).send(e.message);
}
const id = req.header("x-blendfi-event-id");
if (await alreadyProcessed(id)) {
return res.sendStatus(200); // dedup; no-op replay
}
res.sendStatus(200); // ack first
await queue.add("event", JSON.parse(req.body.toString("utf8")));
},
);from rq import Queue
from redis import Redis as RedisSync
queue = Queue("blendfi-webhooks", connection=RedisSync.from_url(os.environ["REDIS_URL"]))
@app.post("/blendfi-webhooks")
def handle():
try:
verify(request)
except ValueError as e:
return str(e), 401
event_id = request.headers["X-Blendfi-Event-Id"]
if already_processed(event_id):
return "", 200
queue.enqueue("worker.process_event", request.json)
return "", 2005. Per-event handlers in the worker
The async worker dispatches by event type:
import { Worker } from "bullmq";
const handlers = {
"conversion.created": (data) => trackPending(data.id),
"conversion.completed": (data) => markOrderDelivered(data.id),
"conversion.failed": (data) => alertOps(data.id, data.failure_reason),
"conversion.standby": (data) => triageStandby(data.id, data.standby_reason),
"conversion.abandoned": (data) => escalateToOps(data.id),
"user.kyc_approved": (data) => unlockUser(data.id),
"user.kyc_rejected": (data) => notifyUser(data.id, data.latest_submission.rejection_reason),
};
new Worker(
"blendfi-webhooks",
async (job) => {
const event = job.data;
const handler = handlers[event.type];
if (handler) await handler(event.data.object);
// Unknown types are silently OK, additive event additions in the future.
},
{ connection: { url: process.env.REDIS_URL } },
);def process_event(event: dict):
handlers = {
"conversion.created": lambda d: track_pending(d["id"]),
"conversion.completed": lambda d: mark_order_delivered(d["id"]),
"conversion.failed": lambda d: alert_ops(d["id"], d["failure_reason"]),
"conversion.standby": lambda d: triage_standby(d["id"], d["standby_reason"]),
"conversion.abandoned": lambda d: escalate_to_ops(d["id"]),
"user.kyc_approved": lambda d: unlock_user(d["id"]),
"user.kyc_rejected": lambda d: notify_user(
d["id"], d["latest_submission"]["rejection_reason"]
),
}
handler = handlers.get(event["type"])
if handler:
handler(event["data"]["object"])6. Test the whole chain
Drive every event from sandbox test_helpers:
CID=cnv_01J…
H="-H Authorization:Bearer\ $BLENDFI_KEY"
I="-H Idempotency-Key:$(uuidgen)"
curl -X POST $BLENDFI_BASE/v1/test_helpers/conversions/$CID/simulate-onramp-pix-paid $H $I
# Expect: signature verifies → dedup miss → ack 200 → worker fires markOrderDelivered
curl -X POST $BLENDFI_BASE/v1/test_helpers/conversions/$CID/simulate-onramp-pix-paid $H $I
# Expect: signature verifies → dedup hit → ack 200 → worker NOT fired (replay no-op)
curl -X POST $BLENDFI_BASE/v1/test_helpers/conversions/$CID/expire $H $I
# Expect: signature verifies → dedup miss → ack 200 → conversion ends in expiredInspect the deliveries dashboard:
curl "$BLENDFI_BASE/v1/webhook_endpoints/$WEBHOOK_ENDPOINT_ID/deliveries" \
-H "Authorization: Bearer $BLENDFI_KEY"Each delivery shows the response status your endpoint returned, latency, and any error message.
Common pitfalls
Don't return 4xx for duplicates
A 4xx tells BlendFi "delivery failed permanently"; it stops retrying. For a duplicate, return 200, the handler did its job (no-op replay).
Body parsing before signature verify
express.json() and similar middleware re-serialize the body before your handler sees it. Re-serialized JSON differs in whitespace and key order, so HMAC won't match. Always capture raw bytes first.
Synchronous DB writes inside the request handler
If a slow database call blocks past 5 seconds, BlendFi treats the delivery as a timeout and retries. You get the same event delivered again (your dedup catches it, but the retry is wasted). Always: ack first, work async.
🎉 You've done it
You have a production-grade webhook handler that survives retries, runs idempotently, and acks within 500ms.
Production checklist
- Signature verification with constant-time compare and the 300s replay window.
- Parse
X-Blendfi-Signatureinto itst=andv1=components before verifying. - Idempotent processing: dedup on
X-Blendfi-Event-Idwith TTL ≥ retry window. - Ack within 500ms: defer real work to a queue.
- Per-event handlers that can be added/removed without breaking the dispatcher.
- Unknown event types treated as no-op: BlendFi adds events additively.
- Dead-letter queue on the worker: failed handler executions don't lose the event.
- Alerting on signature failures: sustained failures mean a wrong secret or attack.
- Manual redelivery runbook: for failed deliveries,
POST /v1/webhook_deliveries/{id}/redeliver. - Secret rotation procedure: rotation is an immediate cutover (the old secret stops working at once), so deploy the new secret promptly and rehearse the swap.
Next steps
- Build a PIX-to-USDT onramp checkout, exercise your handler with onramp events
- Build a USDT-to-PIX payout, exercise it with offramp events
- Webhooks → Retries and replay, the recovery runbook for outages
