Implementação
Exemplos Completos
import express from 'express';
interface PixWebhookPayload {
event: 'CashIn' | 'CashOut' | 'CashInReversal' | 'CashOutReversal';
status: 'PENDING' | 'CONFIRMED' | 'ERROR';
transactionType: 'PIX';
movementType: 'CREDIT' | 'DEBIT';
transactionId: string;
externalId: string | null;
endToEndId: string;
pixKey: string | null;
feeAmount: number;
originalAmount: number;
finalAmount: number;
processingDate: string;
errorCode: string | null;
errorMessage: string | null;
counterpart?: Counterpart;
parentTransaction?: ParentTransaction;
metadata: Record<string, unknown>;
}
interface Counterpart {
name: string;
document: string;
bank: {
bankISPB: string | null;
bankName: string | null;
bankCode: string | null;
accountBranch: string | null;
accountNumber: string | null;
};
}
interface ParentTransaction {
transactionId: string;
externalId: string;
endToEndId: string;
processingDate: string;
wasTotalRefunded: boolean;
remainingAmountForRefund: number;
metadata: Record<string, unknown>;
counterpart: Counterpart;
}
const app = express();
app.use(express.json());
// Middleware de autenticação Basic Auth
function validateBasicAuth(
req: express.Request,
res: express.Response,
next: express.NextFunction
) {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Basic ')) {
return res.status(401).json({ error: 'Unauthorized' });
}
const base64Credentials = authHeader.split(' ')[1];
const credentials = Buffer.from(base64Credentials, 'base64').toString('ascii');
const [username, password] = credentials.split(':');
if (
username !== process.env.WEBHOOK_USER ||
password !== process.env.WEBHOOK_PASS
) {
return res.status(401).json({ error: 'Invalid credentials' });
}
next();
}
// Set para controle de idempotência
const processedTransactions = new Set<string>();
app.post('/webhooks/pix', validateBasicAuth, async (req, res) => {
const payload: PixWebhookPayload = req.body;
// Responder rapidamente (webhook exige resposta em até 10s)
res.status(200).json({ acknowledged: true });
// Verificar idempotência
if (processedTransactions.has(payload.transactionId)) {
console.log(`Transação ${payload.transactionId} já processada`);
return;
}
// Marcar como processada
processedTransactions.add(payload.transactionId);
// Processar assincronamente
try {
switch (payload.event) {
case 'CashIn':
await handleCashIn(payload);
break;
case 'CashOut':
await handleCashOut(payload);
break;
case 'CashInReversal':
await handleCashInReversal(payload);
break;
case 'CashOutReversal':
await handleCashOutReversal(payload);
break;
}
} catch (error) {
console.error(`Erro ao processar ${payload.event}:`, error);
processedTransactions.delete(payload.transactionId);
}
});
async function handleCashIn(payload: PixWebhookPayload) {
console.log(`[CashIn] Recebido: R$ ${payload.finalAmount}`);
// await orderService.markAsPaid(payload.externalId);
}
async function handleCashOut(payload: PixWebhookPayload) {
console.log(`[CashOut] Enviado: R$ ${payload.originalAmount}`);
// await transferService.markAsCompleted(payload.transactionId);
}
async function handleCashInReversal(payload: PixWebhookPayload) {
console.log(`[CashInReversal] Estornado: R$ ${payload.originalAmount}`);
// await refundService.markAsCompleted(payload.transactionId);
}
async function handleCashOutReversal(payload: PixWebhookPayload) {
console.log(`[CashOutReversal] Devolvido: R$ ${payload.finalAmount}`);
// await balanceService.credit(payload.finalAmount);
}
app.listen(3000);from flask import Flask, request, jsonify
from functools import wraps
import base64
import os
from typing import Dict, Any, Optional
from dataclasses import dataclass
app = Flask(__name__)
processed_transactions: set = set()
@dataclass
class PixWebhookPayload:
event: str
status: str
transaction_id: str
external_id: Optional[str]
end_to_end_id: str
fee_amount: float
original_amount: float
final_amount: float
counterpart: Optional[Dict[str, Any]]
parent_transaction: Optional[Dict[str, Any]]
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> 'PixWebhookPayload':
return cls(
event=data.get('event'),
status=data.get('status'),
transaction_id=data.get('transactionId'),
external_id=data.get('externalId'),
end_to_end_id=data.get('endToEndId'),
fee_amount=data.get('feeAmount', 0),
original_amount=data.get('originalAmount', 0),
final_amount=data.get('finalAmount', 0),
counterpart=data.get('counterpart'),
parent_transaction=data.get('parentTransaction'),
)
def require_basic_auth(f):
@wraps(f)
def decorated(*args, **kwargs):
auth_header = request.headers.get('Authorization')
if not auth_header or not auth_header.startswith('Basic '):
return jsonify({'error': 'Unauthorized'}), 401
try:
credentials = base64.b64decode(
auth_header.split(' ')[1]
).decode('utf-8')
username, password = credentials.split(':')
if (
username != os.environ.get('WEBHOOK_USER') or
password != os.environ.get('WEBHOOK_PASS')
):
return jsonify({'error': 'Invalid credentials'}), 401
except Exception:
return jsonify({'error': 'Invalid auth header'}), 401
return f(*args, **kwargs)
return decorated
@app.route('/webhooks/pix', methods=['POST'])
@require_basic_auth
def handle_pix_webhook():
data = request.get_json()
payload = PixWebhookPayload.from_dict(data)
# Idempotência
if payload.transaction_id in processed_transactions:
return jsonify({'acknowledged': True}), 200
processed_transactions.add(payload.transaction_id)
# Processar
if payload.event == 'CashIn':
print(f"[CashIn] R$ {payload.final_amount:.2f}")
elif payload.event == 'CashOut':
print(f"[CashOut] R$ {payload.original_amount:.2f}")
elif payload.event == 'CashInReversal':
print(f"[CashInReversal] R$ {payload.original_amount:.2f}")
elif payload.event == 'CashOutReversal':
print(f"[CashOutReversal] R$ {payload.final_amount:.2f}")
return jsonify({'acknowledged': True}), 200
if __name__ == '__main__':
app.run(host='0.0.0.0', port=3000)<?php
$WEBHOOK_USER = getenv('WEBHOOK_USER') ?: 'brbtc';
$WEBHOOK_PASS = getenv('WEBHOOK_PASS') ?: 'secret';
$PROCESSED_FILE = '/tmp/processed_transactions.json';
function validateBasicAuth($user, $pass): bool {
$authHeader = $_SERVER['HTTP_AUTHORIZATION'] ?? '';
if (empty($authHeader) || !str_starts_with($authHeader, 'Basic ')) {
return false;
}
$credentials = base64_decode(substr($authHeader, 6));
list($u, $p) = explode(':', $credentials, 2);
return $u === $user && $p === $pass;
}
function isProcessed($txId): bool {
global $PROCESSED_FILE;
if (!file_exists($PROCESSED_FILE)) return false;
$processed = json_decode(file_get_contents($PROCESSED_FILE), true) ?? [];
return in_array($txId, $processed);
}
function markProcessed($txId): void {
global $PROCESSED_FILE;
$processed = file_exists($PROCESSED_FILE)
? json_decode(file_get_contents($PROCESSED_FILE), true) ?? []
: [];
$processed[] = $txId;
file_put_contents($PROCESSED_FILE, json_encode(array_slice($processed, -10000)));
}
// Validações
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(405);
exit;
}
if (!validateBasicAuth($WEBHOOK_USER, $WEBHOOK_PASS)) {
http_response_code(401);
echo json_encode(['error' => 'Unauthorized']);
exit;
}
$payload = json_decode(file_get_contents('php://input'), true);
// Responder rapidamente
http_response_code(200);
header('Content-Type: application/json');
echo json_encode(['acknowledged' => true]);
if (function_exists('fastcgi_finish_request')) {
fastcgi_finish_request();
}
// Idempotência
if (isProcessed($payload['transactionId'])) {
exit;
}
markProcessed($payload['transactionId']);
// Processar
switch ($payload['event']) {
case 'CashIn':
error_log("[CashIn] R$ " . $payload['finalAmount']);
break;
case 'CashOut':
error_log("[CashOut] R$ " . $payload['originalAmount']);
break;
case 'CashInReversal':
error_log("[CashInReversal] R$ " . $payload['originalAmount']);
break;
case 'CashOutReversal':
error_log("[CashOutReversal] R$ " . $payload['finalAmount']);
break;
}Assinatura HMAC (X-Avista-Signature)
Todo webhook enviado pela Brasil Bitcoin para uma conta que tenha rotacionado um secret inclui o header X-Avista-Signature com a assinatura HMAC-SHA256 do corpo da requisição. Validar essa assinatura é a forma mais robusta de garantir que o webhook é legítimo (não foi forjado por um terceiro que descobriu sua URL) e que o payload não foi alterado em trânsito.
O secret é gerado por conta — uma única chave assina todos os tipos de evento. Os valores possíveis do campo event no payload são: CashIn (PIX recebido), CashOut (PIX enviado), CashInReversal (estorno de recebimento), CashOutReversal (devolução de envio recebida) e os MEDs (MedCreated, MedAccepted, MedRejected, etc.). Não confunda com o tipo usado no cadastro de URLs (snake_case cash_in, cash_out, ...) — o campo event do payload é PascalCase.
Para clientes novos (apenas HMAC): rotacione o secret ANTES de subir seu validador em produção. Sua função deve rejeitar requisições sem o header X-Avista-Signature — não apenas quando a assinatura está incorreta. Contas que ainda não rotacionaram recebem webhooks sem o header (backward-compat para clientes legados em Basic Auth); se você está implementando do zero, esse caminho não deve passar pela sua validação.
Gerando e rotacionando a chave
A chave é gerada e rotacionada pelo Internet Banking — não há endpoint público de API para isso. Esse fluxo garante que apenas o operador da conta autenticado pelo painel tenha acesso ao plaintext.
Onde encontrar:
Internet Banking → Configurações → aba "Webhooks"
│
└─ 🔐 Chave de assinatura HMAC (card no topo)Estados do card
O card "Chave de assinatura HMAC" tem dois estados visuais que refletem o estado real da sua conta:
Aparece para contas que nunca rotacionaram uma chave. Os webhooks continuam sendo entregues normalmente, sem o header X-Avista-Signature (compatibilidade com clientes legados em Basic Auth).
🔐 Chave de assinatura HMAC
Permite que seu sistema valide criptograficamente que os webhooks
vieram da Brasil Bitcoin e não foram alterados. Opcional — sem ela, os
webhooks continuam sendo entregues normalmente.
┌──────────────────────────────────────────────────────┐
│ 🔒 Você ainda não tem uma chave configurada │
│ Webhooks são entregues sem assinatura. │
│ Gere uma chave para começar a validar a origem │
│ dos webhooks no seu sistema. │
│ │
│ [🔐 Gerar minha chave] │
└──────────────────────────────────────────────────────┘Aparece após você rotacionar a chave pela primeira vez. O painel nunca exibe o plaintext aqui — apenas o hint (últimos 4 caracteres) e a data da última rotação.
🔐 Chave de assinatura HMAC
Chave atual: •••••••• a14c78
Última rotação: 19/05/2026 14:32
[🔄 Rotacionar] [📖 Como validar no meu sistema ↗]Fluxo 1 — Primeira geração
No card vazio, clique no botão 🔐 Gerar minha chave.
Uma modal de confirmação aparece explicando o que acontece:
Você vai gerar sua primeira chave HMAC
A partir de agora os webhooks enviados pela Brasil Bitcoin virão assinados com o header
X-Avista-Signature.Ao gerar:
- A nova chave será exibida apenas uma vez — copie e guarde imediatamente em local seguro do seu sistema.
- Todos os webhooks enviados a partir de agora passam a usar a nova chave para assinar o header
X-Avista-Signature.[Cancelar] [Gerar chave]
Após confirmar, uma modal bloqueante mostra o plaintext da chave:
⚠️ Esta chave será exibida apenas uma vez
Copie e guarde em um local seguro do seu sistema (cofre, secret
manager, variável de ambiente). Não conseguimos recuperá-la depois
— você precisará gerar uma nova caso perca.
Chave gerada:
┌────────────────────────────────────────────────────┐
│ whsec_8f3c9a1b...a14c78 [📋] │
└────────────────────────────────────────────────────┘
Hint (final): a14c78 • Gerada em 19/05/2026 14:32
☐ Confirmo que copiei a chave e guardei em local seguro
[Concluir] (disabled)A modal não fecha por:
- Tecla
Esc - Clique no backdrop
- Botão de fechar (não existe)
Você deve:
- Clicar 📋 Copiar (botão fica verde com "Copiado" temporariamente)
- Marcar o checkbox "Confirmo que copiei..."
- Clicar Concluir (só habilita após o checkbox)
Cole o plaintext na variável de ambiente do servidor que recebe os webhooks:
export BRBTC_WEBHOOK_SECRET=whsec_8f3c9a1b...a14c78Idealmente, guarde em um secret manager (AWS Secrets, Azure Key Vault, HashiCorp Vault, Google Secret Manager, Doppler, etc.) — não em arquivo .env versionado.
O próximo webhook enviado pela Brasil Bitcoin para a sua URL já virá com o header X-Avista-Signature. Use um dos snippets de validação abaixo para verificar.
Fluxo 2 — Rotação de chave existente
Use a rotação quando:
- ✓ Suspeita de vazamento da chave atual
- ✓ Rotação programada de segurança (ex.: a cada 90 dias)
- ✓ Funcionário com acesso à chave deixou o time
No card "com chave ativa", clique em 🔄 Rotacionar.
Uma modal de confirmação diferente da primeira geração aparece — destacando a destrutividade:
⚠️ A chave atual (•••• a14c78) será substituída
Após a rotação, todos os webhooks enviados pela Brasil Bitcoin usarão a nova chave.
Ao rotacionar:
- A nova chave será exibida apenas uma vez — copie e guarde imediatamente em local seguro.
- Todos os webhooks enviados a partir de agora passam a usar a nova chave.
- ⚠️ Mantenha a chave anterior aceita no seu validador por ~30 minutos para absorver webhooks em trânsito (já enfileirados antes da rotação).
[Cancelar] [Sim, rotacionar]
Idealmente, seu validador deve aceitar ambas as chaves (antiga + nova) por ~30 minutos durante a transição. Isso evita rejeitar webhooks em flight (já enfileirados na Brasil Bitcoin antes da rotação).
Implementação sugerida no seu servidor:
const SECRETS = [
process.env.BRBTC_WEBHOOK_SECRET_CURRENT,
process.env.BRBTC_WEBHOOK_SECRET_PREVIOUS, // opcional, durante rotação
].filter(Boolean);
function isValid(rawBody, received) {
return SECRETS.some(secret => {
const expected = crypto.createHmac('sha256', secret).update(rawBody).digest('hex');
return crypto.timingSafeEqual(Buffer.from(expected, 'hex'), Buffer.from(received, 'hex'));
});
}Após ~30 minutos, remova BRBTC_WEBHOOK_SECRET_PREVIOUS do seu vault.
Mesmo fluxo da primeira geração: modal bloqueante com botão Copiar + checkbox de confirmação.
Janela de rotação: a rotação substitui o secret atomicamente — não há janela de coexistência entre o secret antigo e o novo no lado do Brasil Bitcoin. Webhooks em flight (já enfileirados, ainda não enviados) que tenham sido renderizados antes da rotação são entregues com a assinatura antiga; depois da rotação, todos passam a usar a chave nova. Por isso a recomendação de aceitar ambas as chaves por ~30 minutos no seu validador. Em release futuro vamos suportar current/previous nativamente no schema para eliminar essa janela operacional.
Perdi minha chave, e agora? Basta acessar o Internet Banking e gerar uma nova via Rotacionar. Não há recuperação do plaintext anterior — esse é exatamente o objetivo do modelo (zero-knowledge para o Brasil Bitcoin). Atualize a chave no seu servidor antes ou logo após a rotação para minimizar a janela de webhooks rejeitados.
Mecanismos de segurança da UI
A interface aplica algumas proteções para evitar exposição acidental do plaintext:
| Proteção | Como funciona |
|---|---|
| Plaintext mostrado uma única vez | Modal de revelação só aparece após geração/rotação ativa. Refresh da página nunca exibe o plaintext. |
| Sem cópia automática | A chave não é copiada para o clipboard automaticamente. Você precisa clicar 📋 Copiar ativamente. |
| Dismissal bloqueado | A modal de revelação não fecha por Esc, clique fora ou botão de fechar. O único caminho é o botão Concluir. |
| Checkbox obrigatório | O botão Concluir fica desabilitado até você marcar o checkbox "Confirmo que copiei a chave e guardei em local seguro". |
| Confirmação antes de rotacionar | A rotação (destrutiva) sempre passa por uma modal de confirmação que explica as consequências antes de gerar a nova chave. |
| Hint visível, plaintext nunca | Após fechar a modal de revelação, o painel mostra apenas •••••••• a14c78 para você identificar visualmente qual chave está ativa. |
Como validar
A assinatura cobre exatamente o corpo bruto da requisição (rawBody) em UTF-8, antes de qualquer JSON.parse. Se você reserializar o objeto recebido para calcular o HMAC, qualquer divergência (ordem de chaves, espaços, conversão de encoding) fará a assinatura não bater.
import crypto from 'crypto';
import express from 'express';
const app = express();
// Opção A: rota dedicada com matcher amplo — captura qualquer Content-Type
// (inclusive `application/json; charset=utf-8` que vem do Brasil Bitcoin).
app.use('/webhooks', express.raw({ type: '*/*' }));
function isValidSignature(rawBody: Buffer, received: string, secret: string): boolean {
const expected = crypto.createHmac('sha256', secret).update(rawBody).digest('hex');
const a = Buffer.from(expected, 'hex');
const b = Buffer.from(received, 'hex');
if (a.length !== b.length) return false;
return crypto.timingSafeEqual(a, b);
}
app.post('/webhooks/pix', (req, res) => {
const signature = req.header('X-Avista-Signature');
// CRÍTICO: rejeite requisições SEM o header, não só quando a assinatura está errada
if (!signature || !isValidSignature(req.body, signature, process.env.BRBTC_WEBHOOK_SECRET!)) {
return res.status(401).json({ error: 'invalid signature' });
}
const payload = JSON.parse(req.body.toString('utf8'));
// ...processar payload
res.status(200).json({ acknowledged: true });
});// Opção B: preserve o rawBody em paralelo ao parsing automático.
// Útil quando você já tem `express.json()` global e não pode trocar.
app.use(express.json({
verify: (req, _res, buf) => {
(req as any).rawBody = buf; // Buffer com o corpo bruto
},
}));
app.post('/webhooks/pix', (req, res) => {
const signature = req.header('X-Avista-Signature');
const rawBody = (req as any).rawBody as Buffer | undefined;
if (!signature || !rawBody || !isValidSignature(rawBody, signature, secret)) {
return res.status(401).json({ error: 'invalid signature' });
}
// req.body já vem parseado aqui
res.status(200).json({ acknowledged: true });
});import hmac, hashlib, os
from flask import Flask, request, abort
app = Flask(__name__)
SECRET = os.environ['BRBTC_WEBHOOK_SECRET']
@app.route('/webhooks/pix', methods=['POST'])
def webhook():
raw_body = request.get_data() # bytes UTF-8, antes de JSON parse
received = request.headers.get('X-Avista-Signature', '')
if not received:
abort(401)
expected = hmac.new(SECRET.encode('utf-8'), raw_body, hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected, received):
abort(401)
payload = request.get_json()
# ...processar payload
return {'acknowledged': True}, 200func validSignature(rawBody []byte, received, secret string) bool {
if received == "" {
return false
}
mac := hmac.New(sha256.New, []byte(secret))
mac.Write(rawBody)
expected := hex.EncodeToString(mac.Sum(nil))
return hmac.Equal([]byte(expected), []byte(received))
}
http.HandleFunc("/webhooks/pix", func(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
if !validSignature(body, r.Header.Get("X-Avista-Signature"), os.Getenv("BRBTC_WEBHOOK_SECRET")) {
http.Error(w, "invalid signature", http.StatusUnauthorized)
return
}
// json.Unmarshal(body, &payload) ...
w.WriteHeader(http.StatusOK)
})<?php
function validSignature(string $rawBody, string $received, string $secret): bool {
if ($received === '') return false;
$expected = hash_hmac('sha256', $rawBody, $secret);
return hash_equals($expected, $received); // timing-safe em PHP
}
$rawBody = file_get_contents('php://input');
$received = $_SERVER['HTTP_X_BRBTC_SIGNATURE'] ?? '';
if (!validSignature($rawBody, $received, getenv('BRBTC_WEBHOOK_SECRET'))) {
http_response_code(401);
echo json_encode(['error' => 'invalid signature']);
exit;
}
$payload = json_decode($rawBody, true);
// ...processar payload
http_response_code(200);
echo json_encode(['acknowledged' => true]);Use sempre comparação timing-safe (crypto.timingSafeEqual / hmac.compare_digest / hmac.Equal / hash_equals). Comparar com == abre brecha para ataques de timing.
Encoding: o corpo é UTF-8. Em linguagens que precisam de bytes explícitos (Java/Kotlin/PHP), garanta que está lendo o stream como bytes — não converta para String em encoding diferente antes de calcular o HMAC. Em Python, request.get_data() já retorna bytes; em Node, Buffer é o tipo correto.
A assinatura HMAC não previne replay. Ela prova autenticidade e integridade — não impede que um atacante que capture um webhook legítimo (proxy comprometido, log vazado) o reenvie. Sempre combine validação de assinatura com dedup por transactionId/medId no seu storage. Estamos avaliando incluir X-Avista-Timestamp coberto pelo HMAC + janela de aceitação em release futuro.
Compatibilidade com Basic Auth
Se você ainda valida webhooks via Basic Auth nos headers customizados, continue validando — o header X-Avista-Signature é adicional, não substitui. Recomendamos migrar para HMAC e remover o Basic Auth quando estiver pronto.
Idempotência
Webhooks podem ser enviados mais de uma vez (em caso de retentativas). Implemente tratamento de idempotência para evitar processamento duplicado.
Use o campo transactionId como chave única:
// Verificar se já processou
const isProcessed = await redis.get(`webhook:${payload.transactionId}`);
if (isProcessed) {
console.log('Webhook já processado, ignorando');
return;
}
// Marcar como processado ANTES de processar
await redis.set(`webhook:${payload.transactionId}`, '1', 'EX', 86400);
// Processar webhook
await processWebhook(payload);Boas Práticas
Retentativas
Se seu endpoint não responder com HTTP 200 em até 10 segundos:
| Tentativa | Intervalo | Tempo acumulado |
|---|---|---|
| 1ª | Imediato | 0 min |
| 2ª (1º retry) | 5 minutos | 5 min |
| 3ª (2º retry) | 5 minutos | 10 min |
| 4ª (3º retry) | 15 minutos | 25 min |
Após 4 tentativas sem sucesso (tempo total ~25 minutos), o webhook é movido para uma fila de falhas (DLQ). Implemente consulta periódica como fallback para garantir que nenhuma transação seja perdida.
A estratégia de retry diferencia erros temporários (network, timeout, 5xx) de erros permanentes (validação, formato inválido). Erros permanentes não são retentados.
Códigos de Resposta
Seu endpoint deve retornar um código HTTP apropriado:
| Código | Descrição | Ação do Sistema |
|---|---|---|
2xx | Sucesso (200, 201, 204, etc.) | ✅ Webhook confirmado, não será retentado |
3xx | Redirecionamento | ⚠️ Considerado falha, será retentado |
4xx | Erro do cliente | ⚠️ Considerado falha, será retentado |
5xx | Erro do servidor | ⚠️ Considerado falha, será retentado |
O sistema valida apenas o código HTTP. Qualquer resposta 2xx (200-299) é considerada sucesso, independente do conteúdo do body. Você pode retornar body vazio, "OK", ou qualquer JSON.