BlendFi
Webhooks

Verificação de assinatura

HMAC-SHA256 sobre timestamp + body bruto, comparação em tempo constante, janela de replay de 300 segundos. Código funcional em curl, Node e Python.

Toda entrega de webhook da BlendFi é assinada com HMAC-SHA256. Verificar essa assinatura é obrigatório: um receptor não verificado expõe uma superfície de execução remota de código pela rede pública. Esta página é a referência canônica: os headers que a BlendFi envia, o algoritmo, snippets funcionais em três linguagens e os quatro erros mais comuns em implementações.

Os headers

Toda entrega carrega:

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
HeaderFormatoPropósito
X-Blendfi-Event-Idevt_<id>Identificador estável para este evento. Use como sua chave de controle de duplicatas.
X-Blendfi-Event-Typeex.: conversion.completedHeader de conveniência para rotear antes de interpretar o body.
X-Blendfi-TimestampUnix em segundosQuando a BlendFi montou a entrega. Espelha o valor t= de dentro da assinatura e é usado para aplicação da janela de replay.
X-Blendfi-Signaturet=<unix_segundos>,v1=<hex>O timestamp de assinatura (t=) mais o HMAC-SHA256 de {t}.{raw_body} com chave igual ao segredo do endpoint, codificado em hexadecimal (v1=).

O timestamp assinado é carregado dentro do header X-Blendfi-Signature como o componente t=, e é isso que conecta a proteção de replay à assinatura: você não consegue verificar a assinatura sem também vinculá-la ao timestamp.

O algoritmo

Os cinco passos que todo verificador executa, exatamente nesta ordem:

  1. Leia os bytes brutos do body. Antes de qualquer interpretação do JSON, antes de qualquer re-serialização do middleware. Os bytes exatos que a BlendFi enviou.
  2. Separe o X-Blendfi-Signature nos componentes t= (timestamp) e v1= (assinatura em hexadecimal) e então reconstrua o payload assinado: {t}.{raw_body}.
  3. Aplique HMAC-SHA256 nessa string com o segredo em texto plano do endpoint. Pegue o resultado em hexadecimal.
  4. Compare em tempo constante o resultado com o valor v1=. Não use igualdade de string, ataques de timing recuperam segredos.
  5. Rejeite se agora - t > 300 segundos: proteção da janela de replay.

Tudo o mais (interpretar o body, controle de duplicatas, despacho) acontece depois que isso passar.

Código funcional

# Smoke-test contra um payload e segredo conhecidos.
# Útil para debug: produza a assinatura você mesmo e compare com o header.
TIMESTAMP=1714500000
BODY='{"id":"evt_01J","type":"conversion.completed","data":{}}'
SECRET='whsec_yoursecret'

# Reconstrói o payload assinado
SIGNED="${TIMESTAMP}.${BODY}"

# HMAC-SHA256, hexadecimal
echo -n "$SIGNED" | openssl dgst -sha256 -hmac "$SECRET" -hex | awk '{print $2}'
# → bate com o valor v1= de X-Blendfi-Signature
import crypto from "node:crypto";
import express from "express";

const app = express();

// Crítico: capture o body bruto, não um parseado nem re-serializado.
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");
    }

    // Separa "t=<unix>,v1=<hex>" nos seus componentes.
    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");
    }

    // Janela de replay
    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 em id (Redis com TTL ≥ janela de retentativa), pseudo:
    // if (await redis.exists(`webhook:${id}`)) return res.sendStatus(200);
    // await redis.set(`webhook:${id}`, "1", "EX", 600);

    // Ack rápido; faça o trabalho real de forma assíncrona.
    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()  # bytes brutos; NÃO 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

    # 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:
        return "malformed signature header", 401

    # Janela de replay
    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 em id (pseudo Redis):
    # if redis.exists(f"webhook:{id_}"): return "", 200
    # redis.setex(f"webhook:{id_}", 600, "1")

    # Ack rápido; enfileire o trabalho real.
    queue.put({"id": id_, "body": request.json})
    return "", 200

Quatro erros comuns na implementação

A grande maioria dos chamados de suporte sobre verificação de assinatura corresponde a um destes casos.

1. Body bruto re-serializado por middleware

A falha mais comum. express.json() no Express, interpretação automática do JSON no FastAPI, todos interpretam o body antes do seu handler ver e depois re-serializam para o código que vem depois. JSON re-serializado difere do original (whitespace, ordem das chaves, quebras de linha finais) → HMAC não bate.

Correção: capture o body bruto antes de qualquer middleware tocar. No Express, express.raw({ type: "application/json" }) para a rota de webhook. No FastAPI, await request.body().

2. Comparação string-equal em vez de tempo constante

== ou === em strings sai cedo assim que um byte difere. Um atacante medindo o tempo de resposta consegue recuperar a assinatura byte por byte. Sempre use crypto.timingSafeEqual (Node), hmac.compare_digest (Python) ou o equivalente da sua linguagem.

3. Clock skew excede 300 segundos

A janela de replay pega payloads antigos, mas também pega o seu próprio servidor se o relógio dele estiver com drift. Sintomas: falhas de assinatura agrupadas em torno de um único host; falhas intermitentes que "se corrigem sozinhas" minutos depois. Correção: sincronize NTP. A maioria dos provedores de cloud expõe um time-sync service local que faz isso automaticamente.

4. Segredo errado (test vs live)

Cada ambiente tem o próprio segredo. Uma chave sk_live_… cria endpoints de webhook com segredos de ambiente live; sk_test_… cria segredos de ambiente sandbox. Sintoma: toda assinatura falha depois de um deploy.

Dica de diagnóstico

Quando as assinaturas falham em CI mas não localmente (ou vice-versa), a primeira coisa a checar é qual segredo o verificador está lendo. Logue os primeiros 8 caracteres do segredo (nunca o segredo inteiro) e compare entre ambientes.

Leia em seguida

Nesta página