Brasil Bitcoindocs
Webhooks

Implementación

Ejemplos 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());

// Basic Auth authentication middleware
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 for idempotency control
const processedTransactions = new Set<string>();

app.post('/webhooks/pix', validateBasicAuth, async (req, res) => {
  const payload: PixWebhookPayload = req.body;

  // Respond quickly (webhook requires response within 10s)
  res.status(200).json({ acknowledged: true });

  // Check idempotency
  if (processedTransactions.has(payload.transactionId)) {
    console.log(`Transaction ${payload.transactionId} already processed`);
    return;
  }

  // Mark as processed
  processedTransactions.add(payload.transactionId);

  // Process asynchronously
  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(`Error processing ${payload.event}:`, error);
    processedTransactions.delete(payload.transactionId);
  }
});

async function handleCashIn(payload: PixWebhookPayload) {
  console.log(`[CashIn] Received: R$ ${payload.finalAmount}`);
}

async function handleCashOut(payload: PixWebhookPayload) {
  console.log(`[CashOut] Sent: R$ ${payload.originalAmount}`);
}

async function handleCashInReversal(payload: PixWebhookPayload) {
  console.log(`[CashInReversal] Refunded: R$ ${payload.originalAmount}`);
}

async function handleCashOutReversal(payload: PixWebhookPayload) {
  console.log(`[CashOutReversal] Returned: R$ ${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)
    if payload.transaction_id in processed_transactions:
        return jsonify({'acknowledged': True}), 200
    processed_transactions.add(payload.transaction_id)
    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)));
}

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);

http_response_code(200);
header('Content-Type: application/json');
echo json_encode(['acknowledged' => true]);

if (function_exists('fastcgi_finish_request')) {
    fastcgi_finish_request();
}

if (isProcessed($payload['transactionId'])) {
    exit;
}

markProcessed($payload['transactionId']);

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;
}

Firma HMAC (X-Avista-Signature)

Todo webhook enviado por Brasil Bitcoin a una cuenta que haya rotado un secret incluye el header X-Avista-Signature con la firma HMAC-SHA256 del cuerpo de la solicitud. Validar esa firma es la forma más robusta de garantizar que el webhook es legítimo (no fue forjado por un tercero que descubrió tu URL) y que el payload no fue alterado en tránsito.

El secret se genera por cuenta — una única clave firma todos los tipos de evento. Los valores posibles del campo event en el payload son: CashIn (PIX recibido), CashOut (PIX enviado), CashInReversal (reverso de un cobro), CashOutReversal (devolución de un envío) y los MEDs (MedCreated, MedAccepted, MedRejected, etc.). No confundas con el tipo usado en el registro de URLs (snake_case cash_in, cash_out, ...) — el campo event del payload es PascalCase.

Para clientes nuevos (solo HMAC): rota el secret ANTES de subir tu validador a producción. Tu función debe rechazar solicitudes sin el header X-Avista-Signature — no solo cuando la firma sea incorrecta. Las cuentas que aún no han rotado reciben webhooks sin el header (backward-compat para clientes legacy con Basic Auth); si lo implementas desde cero, ese camino no debe pasar por tu validación.

Generando y rotando la clave

La clave se genera y rota a través del Internet Banking — no hay endpoint público de API para esto. Este flujo garantiza que solo el operador de la cuenta autenticado por el panel tenga acceso al texto plano.

Dónde encontrarlo:

Internet Banking → Configuración → pestaña "Webhooks"

                                     └─ 🔐 Clave de Firma HMAC (tarjeta arriba)

Estados de la tarjeta

La tarjeta "Clave de Firma HMAC" tiene dos estados visuales que reflejan el estado real de tu cuenta:

Aparece para cuentas que nunca rotaron una clave. Los webhooks siguen siendo entregados normalmente, sin el header X-Avista-Signature (compatibilidad con clientes legacy en Basic Auth).

🔐 Clave de Firma HMAC

Permite que tu sistema valide criptográficamente que los webhooks
vinieron de Brasil Bitcoin y no fueron alterados. Opcional — sin ella, los
webhooks siguen siendo entregados normalmente.

┌──────────────────────────────────────────────────────┐
│ 🔒 Aún no tienes una clave configurada               │
│    Los webhooks se entregan sin firma.               │
│    Genera una clave para empezar a validar el        │
│    origen de los webhooks en tu sistema.             │
│                                                      │
│           [🔐 Generar mi clave]                       │
└──────────────────────────────────────────────────────┘

Aparece después de rotar la clave por primera vez. El panel nunca muestra el texto plano aquí — solo el hint (últimos 4 caracteres) y la fecha de la última rotación.

🔐 Clave de Firma HMAC

Clave actual:   •••••••• a14c78
Última rotación: 19/05/2026 14:32

[🔄 Rotar]  [📖 Cómo validar en mi sistema ↗]

Flujo 1 — Primera generación

En la tarjeta vacía, haz clic en el botón 🔐 Generar mi clave.

Un modal de confirmación aparece explicando lo que sucederá:

Vas a generar tu primera clave HMAC

A partir de ahora los webhooks enviados por Brasil Bitcoin vendrán firmados con el header X-Avista-Signature.

Al generar:

  • La nueva clave se mostrará una sola vez — copia y guarda inmediatamente en un lugar seguro.
  • Todos los webhooks enviados a partir de ahora usarán la nueva clave para firmar el header X-Avista-Signature.

[Cancelar] [Generar clave]

Después de confirmar, un modal bloqueante muestra el texto plano de la clave:

⚠️  Esta clave se mostrará solo una vez

Copia y guarda en un lugar seguro de tu sistema (vault, secret
manager, variable de entorno). No podemos recuperarla después
— necesitarás generar una nueva si la pierdes.

Clave generada:
┌────────────────────────────────────────────────────┐
│ whsec_8f3c9a1b...a14c78                       [📋] │
└────────────────────────────────────────────────────┘

Hint (final): a14c78 • Generada el 19/05/2026 14:32

☐ Confirmo que copié la clave y la guardé en lugar seguro

                          [Concluir] (deshabilitado)

El modal no se cierra por:

  • Tecla Esc
  • Clic en el backdrop
  • Botón de cerrar (no existe)

Debes:

  1. Hacer clic en 📋 Copiar (botón se vuelve verde mostrando "Copiado" brevemente)
  2. Marcar el checkbox "Confirmo que copié..."
  3. Hacer clic en Concluir (solo habilita después del checkbox)

Pega el texto plano en la variable de entorno del servidor que recibe webhooks:

export BRBTC_WEBHOOK_SECRET=whsec_8f3c9a1b...a14c78

Idealmente, guárdalo en un secret manager (AWS Secrets, Azure Key Vault, HashiCorp Vault, Google Secret Manager, Doppler, etc.) — no en un archivo .env versionado.

El próximo webhook que Brasil Bitcoin envíe a tu URL incluirá el header X-Avista-Signature. Usa uno de los snippets de validación abajo para verificar.

Flujo 2 — Rotando una clave existente

Rota cuando:

  • ✓ Sospechas que la clave actual fue filtrada
  • ✓ Rotación programada de seguridad (ej.: cada 90 días)
  • ✓ Un miembro del equipo con acceso a la clave dejó el equipo

En la tarjeta "clave activa", haz clic en 🔄 Rotar.

Un modal de confirmación diferente del primero aparece — destacando la destructividad:

⚠️ La clave actual (•••• a14c78) será reemplazada

Tras la rotación, todos los webhooks enviados por Brasil Bitcoin usarán la nueva clave.

Al rotar:

  • La nueva clave se mostrará solo una vez — copia y guarda inmediatamente.
  • Todos los webhooks enviados a partir de ahora usan la nueva clave.
  • ⚠️ Mantén la clave anterior aceptada en tu validador por ~30 minutos para absorber webhooks en vuelo (ya encolados antes de la rotación).

[Cancelar] [Sí, rotar]

Idealmente, tu validador debe aceptar ambas claves (antigua + nueva) por ~30 minutos durante la transición. Esto evita rechazar webhooks en vuelo.

Implementación sugerida en tu servidor:

const SECRETS = [
  process.env.BRBTC_WEBHOOK_SECRET_CURRENT,
  process.env.BRBTC_WEBHOOK_SECRET_PREVIOUS, // opcional, durante rotación
].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'));
  });
}

Tras ~30 minutos, remueve BRBTC_WEBHOOK_SECRET_PREVIOUS de tu vault.

Mismo flujo de la primera generación: modal bloqueante con botón Copiar + checkbox de confirmación.

Ventana de rotación: la rotación reemplaza el secret atómicamente — no hay ventana de coexistencia entre el secret antiguo y el nuevo del lado de Brasil Bitcoin. Los webhooks en vuelo (ya encolados, aún no enviados) renderizados antes de la rotación son entregados con la firma antigua; tras la rotación, todos pasan a usar la nueva clave. Por eso recomendamos aceptar ambas claves por ~30 minutos en tu validador. En una release futura soportaremos current/previous nativamente en el schema para eliminar esta ventana operacional.

Perdí mi clave, ¿y ahora? Simplemente entra al Internet Banking y genera una nueva vía Rotar. No hay recuperación del texto plano anterior — ese es exactamente el objetivo del modelo (zero-knowledge para Brasil Bitcoin). Actualiza la clave en tu servidor antes o justo después de la rotación para minimizar la ventana de webhooks rechazados.

Mecanismos de seguridad de la UI

La interfaz aplica varias protecciones para evitar la exposición accidental del texto plano:

ProtecciónCómo funciona
Texto plano mostrado una sola vezEl modal de revelación solo aparece tras una generación/rotación activa. Recargar la página nunca muestra el texto plano.
Sin copia automáticaLa clave no se copia automáticamente al portapapeles. Debes hacer clic activamente en 📋 Copiar.
Cierre bloqueadoEl modal de revelación no se cierra vía Esc, clic en backdrop o botón de cerrar. El único camino es el botón Concluir.
Checkbox obligatorioEl botón Concluir está deshabilitado hasta que marques el checkbox "Confirmo que copié la clave y la guardé en lugar seguro".
Confirmación antes de rotarLa rotación (destructiva) siempre pasa por un modal de confirmación que explica las consecuencias antes de generar la nueva clave.
Hint visible, texto plano nuncaTras cerrar el modal de revelación, el panel muestra solo •••••••• a14c78 para que puedas identificar visualmente cuál clave está activa.

Cómo validar

La firma cubre exactamente el cuerpo bruto de la solicitud (rawBody) en UTF-8, antes de cualquier JSON.parse. Si reserializas el objeto recibido para calcular el HMAC, cualquier divergencia (orden de claves, espacios, conversión de encoding) hará que la firma no coincida.

import crypto from 'crypto';
import express from 'express';

const app = express();

// Opción A: ruta dedicada con matcher amplio — captura cualquier Content-Type
// (incluyendo `application/json; charset=utf-8` que viene de 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: rechaza solicitudes SIN el header, no solo cuando la firma esté mal
  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'));
  // ...procesar payload
  res.status(200).json({ acknowledged: true });
});
// Opción B: preserva el rawBody en paralelo al parsing automático.
// Útil cuando ya tienes `express.json()` global y no puedes reemplazarlo.
app.use(express.json({
  verify: (req, _res, buf) => {
    (req as any).rawBody = buf; // Buffer con el cuerpo 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 ya viene parseado aquí
  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()
    # ...procesar payload
    return {'acknowledged': True}, 200
func 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 en 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);
// ...procesar payload
http_response_code(200);
echo json_encode(['acknowledged' => true]);

Usa siempre comparación timing-safe (crypto.timingSafeEqual / hmac.compare_digest / hmac.Equal / hash_equals). Comparar con == abre una brecha de timing-attack.

Encoding: el cuerpo es UTF-8. En lenguajes que requieren bytes explícitos (Java/Kotlin/PHP), asegúrate de leer el stream como bytes — no conviertas a String en encoding diferente antes de calcular el HMAC. En Python, request.get_data() ya retorna bytes; en Node, Buffer es el tipo correcto.

La firma HMAC no previene replay. Prueba autenticidad e integridad — no impide que un atacante que capture un webhook legítimo (proxy comprometido, log filtrado) lo reenvíe. Combina siempre validación de firma con dedup por transactionId/medId en tu storage. Estamos evaluando incluir X-Avista-Timestamp cubierto por el HMAC + ventana de aceptación en una release futura.

Compatibilidad con Basic Auth

Si aún validas webhooks via Basic Auth en headers customizados, sigue validando — el header X-Avista-Signature es adicional, no sustituye. Recomendamos migrar a HMAC y remover Basic Auth cuando esté listo.


Idempotencia

Los webhooks pueden enviarse más de una vez (en caso de reintentos). Implemente manejo de idempotencia para evitar procesamiento duplicado.

Use el campo transactionId como clave única:

const isProcessed = await redis.get(`webhook:${payload.transactionId}`);

if (isProcessed) {
  console.log('Webhook already processed, ignoring');
  return;
}

await redis.set(`webhook:${payload.transactionId}`, '1', 'EX', 86400);

await processWebhook(payload);


Mejores Prácticas


Reintentos

Si su endpoint no responde con HTTP 200 dentro de 10 segundos:

IntentoIntervaloTiempo acumulado
1roInmediato0 min
2do (1er reintento)5 minutos5 min
3ro (2do reintento)5 minutos10 min
4to (3er reintento)15 minutos25 min

Después de 4 intentos fallidos (tiempo total ~25 minutos), el webhook se mueve a una cola de mensajes muertos (DLQ). Implemente polling periódico como respaldo para asegurar que no se pierda ninguna transacción.


Códigos de Respuesta

Su endpoint debe retornar un código HTTP apropiado:

CódigoDescripciónAcción del Sistema
2xxÉxito (200, 201, 204, etc.)Webhook confirmado, no será reintentado
3xxRedirecciónConsiderado fallo, será reintentado
4xxError del clienteConsiderado fallo, será reintentado
5xxError del servidorConsiderado fallo, será reintentado

El sistema valida solo el código HTTP. Cualquier respuesta 2xx (200-299) se considera éxito, independientemente del contenido del cuerpo.

En esta página