BlendFi

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 Map em 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 None

O 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 "", 200

5. 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 expired

Inspecione 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-Signature nos componentes t= e v1= antes de verificar.
  • Processamento idempotente: controle de duplicatas pelo X-Blendfi-Event-Id com 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

Nesta página