Verifique e roteie eventos de webhook com segurança
Handler de webhook em nível de produção em Node e Python. Verificação de assinatura, processamento idempotente, padrão de resposta rápida, fila de mensagens com falha (DLQ).
Um handler de webhook completo que verifica assinaturas HMAC-SHA256 em tempo constante, descarta duplicatas pelo X-Blendfi-Event-Id, retorna 2xx em menos de 500ms, despacha para handlers por evento de forma assíncrona e recupera de falhas via reentrega manual. Cerca de 30 minutos, incluindo os testes. Pré-requisitos: chave de sandbox, endpoint de webhook registrado e Redis (ou qualquer KV) para o controle de duplicatas.
Pré-requisitos
- Chave de API do sandbox.
- Endpoint de webhook registrado com
POST /v1/webhook_endpoints, veja Gerenciamento de endpoints. - Segredo de webhook em texto plano guardado em
BLENDFI_WEBHOOK_SECRET(retornado uma única vez na criação do endpoint; não recuperável depois). - Redis ou KV equivalente para a chave de controle de duplicatas. Um
Mapem memória funciona para dev de instância única; produção precisa de storage durável com TTL.
Como é um bom tratamento de webhooks
Verifique a assinatura → controle de duplicatas pelo X-Blendfi-Event-Id → 200 em menos de 500ms → enfileire para processamento assíncrono → distribua para handlers por evento. Qualquer coisa que arrisque bloquear a entrega da BlendFi (escritas no banco, chamadas externas, lógica de negócio lenta) fica depois do ack.
1. Suba o endpoint
O handler mínimo que responde 200 imediatamente. Vamos adicionar assinatura e despacho em seguida.
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)Exponha via túnel (cloudflared tunnel --url http://localhost:3000); registre a URL do túnel como um endpoint de webhook do sandbox; dispare um test_helper para confirmar que a BlendFi chega até você.
2. Verifique a assinatura
Os quatro erros canônicos (re-serialização do body bruto, string-equal vs tempo constante, clock skew, segredo errado) nascem todos aqui. Acerte isto uma vez. O header X-Blendfi-Signature carrega o timestamp de assinatura (t=) e o HMAC (v1=); extraia os dois desse único 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");
// Separa "t=<unix>,v1=<hex>" nos seus componentes.
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");
// Janela de replay, 300s por convenção da BlendFi
const skew = Math.abs(Date.now() / 1000 - Number(ts));
if (skew > 300) throw new Error("timestamp outside 300s window");
// Reconstrói o payload assinado a partir dos bytes BRUTOS do body
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")
# Separa "t=<unix>,v1=<hex>" nos seus componentes.
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")
# Janela de replay
if abs(time.time() - int(ts)) > 300:
raise ValueError("timestamp outside 300s window")
raw = request.get_data() # bytes brutos; não 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")Conecte ao handler, retorne 401 em qualquer falha, falhe fechado.
3. Controle de duplicatas pelo X-Blendfi-Event-Id
A BlendFi pode reentregar o mesmo evento após uma falha transitória. O mesmo X-Blendfi-Event-Id significa o mesmo evento de negócio; processá-lo duas vezes cria duplicatas no que vem depois. Faça o controle de duplicatas no ponto de entrada.
import Redis from "ioredis";
const redis = new Redis(process.env.REDIS_URL);
async function alreadyProcessed(eventId) {
// SET NX com TTL = "reivindica se ainda não reivindicado"
const claimed = await redis.set(`webhook:${eventId}`, "1", "EX", 600, "NX");
return claimed === null; // null significa que a chave existia → já processado
}import redis as redislib
redis = redislib.Redis.from_url(os.environ["REDIS_URL"])
def already_processed(event_id: str) -> bool:
# SET NX com TTL = "reivindica se ainda não reivindicado"
claimed = redis.set(f"webhook:{event_id}", "1", ex=600, nx=True)
return claimed is NoneO TTL deve ser ≥ a janela de retentativa da BlendFi (cerca de 2 minutos já basta; 600s = 10 minutos é seguro).
4. Ack rápido; roteie de forma assíncrona
Responda 200 para a BlendFi no instante em que assinatura + controle de duplicatas passarem. Faça o trabalho de negócio numa fila em segundo plano para que um downstream lento não bloqueie o próximo 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); // duplicata; replay no-op
}
res.sendStatus(200); // ack primeiro
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. Handlers por evento no worker
O worker assíncrono despacha por tipo de evento:
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);
// Tipos desconhecidos são silenciosamente OK, adições de eventos no futuro.
},
{ 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. Teste a cadeia inteira
Dispare cada evento pelos test_helpers do sandbox:
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
# Esperado: assinatura confere → controle de duplicatas não encontra → ack 200 → worker dispara markOrderDelivered
curl -X POST $BLENDFI_BASE/v1/test_helpers/conversions/$CID/simulate-onramp-pix-paid $H $I
# Esperado: assinatura confere → controle de duplicatas encontra → ack 200 → worker NÃO dispara (replay no-op)
curl -X POST $BLENDFI_BASE/v1/test_helpers/conversions/$CID/expire $H $I
# Esperado: assinatura confere → controle de duplicatas não encontra → ack 200 → conversão termina em expiredInspecione o painel de entregas:
curl "$BLENDFI_BASE/v1/webhook_endpoints/$WEBHOOK_ENDPOINT_ID/deliveries" \
-H "Authorization: Bearer $BLENDFI_KEY"Cada entrega mostra o status de resposta que o seu endpoint retornou, a latência e qualquer mensagem de erro.
Erros comuns
Não retorne 4xx para duplicatas
Um 4xx diz à BlendFi "entrega falhou permanentemente"; ela para de retentar. Para uma duplicata, retorne 200, o handler fez o trabalho dele (replay no-op).
Parsing do body antes de verificar a assinatura
express.json() e middlewares parecidos re-serializam o body antes do seu handler ver. JSON re-serializado difere em whitespace e ordem das chaves, então o HMAC não bate. Sempre capture os bytes brutos primeiro.
Escritas síncronas no banco dentro do handler da requisição
Se uma chamada lenta ao banco bloquear por mais de 5 segundos, a BlendFi trata a entrega como timeout e retenta. Você recebe o mesmo evento de novo (seu controle de duplicatas pega, mas a retentativa é desperdiçada). Sempre: ack primeiro, trabalho assíncrono.
🎉 Você conseguiu
Você tem um handler de webhook em nível de produção que sobrevive a retentativas, roda de forma idempotente e responde o ack em menos de 500ms.
Checklist de produção
- Verificação de assinatura com comparação em tempo constante e a janela de replay de 300s.
- Separe o
X-Blendfi-Signaturenos componentest=ev1=antes de verificar. - Processamento idempotente: controle de duplicatas pelo
X-Blendfi-Event-Idcom TTL ≥ janela de retentativa. - Ack em menos de 500ms: adie o trabalho real para uma fila.
- Handlers por evento que podem ser adicionados/removidos sem quebrar o despachante.
- Tipos de evento desconhecidos tratados como no-op: a BlendFi adiciona eventos de forma incremental.
- Fila de mensagens mortas no worker: execuções de handler que falham não perdem o evento.
- Alertas em falhas de assinatura: falhas sustentadas significam segredo errado ou ataque.
- Runbook de reentrega manual: para entregas falhas,
POST /v1/webhook_deliveries/{id}/redeliver. - Procedimento de rotação de segredo: a rotação é uma transição imediata (o segredo antigo para de funcionar na hora), então implante o segredo novo o quanto antes e ensaie a troca.
Próximos passos
- Implemente um onramp Pix → USDT, exercite seu handler com eventos de onramp
- Implemente um payout USDT → Pix, exercite com eventos de offramp
- Webhooks → Retentativas e replay, o runbook de recuperação para quedas
Implemente um offramp USDT → Pix
Caminho de ponta a ponta para uma integração offramp. Da cotação ao webhook de conclusão, com tratamento de standby.
Idempotência
Torne seus POSTs e PATCHes seguros para retentativas. Sem usuários duplicados, sem cobrança em dobro, mesmo quando a rede cai no meio da chamada.
