Brasil Bitcoindocs
Webhooks

实现

完整示例

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

HMAC 签名 (X-Avista-Signature)

Brasil Bitcoin 发送给已轮换 secret 的账户的每个 webhook 都包含 X-Avista-Signature 头部,其中包含请求体的 HMAC-SHA256 签名。验证此签名是确保 webhook 合法(未被发现您 URL 的第三方伪造)且在传输过程中未被篡改的最强方式。

Secret 是按账户生成的 — 同一个密钥签署所有事件类型。Payload 中 event 字段的可能值为:CashIn(收到 PIX)、CashOut(发送 PIX)、CashInReversal(收款冲正)、CashOutReversal(发送返还)以及 MED(MedCreatedMedAcceptedMedRejected 等)。不要与注册 URL 时使用的类型混淆(snake_case cash_incash_out 等)— payload 的 event 字段是 PascalCase。

新客户(仅 HMAC):在将您的验证器部署到生产环境之前,先轮换 secret。您的函数必须拒绝没有 X-Avista-Signature 头部的请求— 不仅仅是签名错误的请求。尚未轮换的账户接收无头部的 webhook(向后兼容 Basic Auth 旧客户端);如果您从零开始实施,此路径不应通过您的验证。

生成和轮换密钥

密钥通过 Internet Banking 生成和轮换 — 没有用于此操作的公开 API 端点。此流程确保只有通过面板进行身份验证的账户操作员才能访问明文。

位置:

Internet Banking → 设置 → "Webhooks" 标签页

                                └─ 🔐 HMAC 签名密钥(顶部卡片)

卡片状态

"HMAC 签名密钥" 卡片有两种视觉状态,反映您账户的实际状态:

显示给从未轮换过密钥的账户。Webhook 继续正常传送,不附带 X-Avista-Signature 头部(对使用 Basic Auth 的旧客户端向后兼容)。

🔐 HMAC 签名密钥

允许您的系统加密验证 webhook 来自 Brasil Bitcoin 且未被修改。可选 — 没有
它,webhook 也会正常传送。

┌──────────────────────────────────────────────────────┐
│ 🔒 您还没有配置密钥                                  │
│    Webhook 在没有签名的情况下传送。                  │
│    生成密钥以开始在系统中验证 webhook 的来源。       │
│                                                      │
│           [🔐 生成我的密钥]                           │
└──────────────────────────────────────────────────────┘

在您首次轮换密钥后显示。面板从不在此处显示明文 — 只显示 hint(最后 4 个字符)和上次轮换日期。

🔐 HMAC 签名密钥

当前密钥:   •••••••• a14c78
上次轮换:   2026-05-19 14:32

[🔄 轮换]  [📖 如何在我的系统中验证 ↗]

流程 1 — 首次生成

在空卡片中,点击 🔐 生成我的密钥 按钮。

确认模态出现,解释将发生什么:

您即将生成您的第一个 HMAC 密钥

从现在起,Brasil Bitcoin 发送的 webhook 将使用 X-Avista-Signature 头部进行签名。

生成时:

  • 新密钥将仅显示一次 — 立即复制并存储到系统中的安全位置。
  • 从现在起发送的所有 webhook 都将使用新密钥来签名 X-Avista-Signature 头部。

[取消] [生成密钥]

确认后,阻塞式模态显示密钥明文:

⚠️  此密钥仅显示一次

复制并存储到系统的安全位置(vault、secret manager、环境变量)。
我们之后无法恢复它 — 如果丢失,您需要生成新的。

生成的密钥:
┌────────────────────────────────────────────────────┐
│ whsec_8f3c9a1b...a14c78                       [📋] │
└────────────────────────────────────────────────────┘

Hint(末尾): a14c78 • 生成于 2026-05-19 14:32

☐ 我确认已复制密钥并安全存储

                          [完成] (禁用)

模态不会通过以下方式关闭:

  • Esc
  • 点击背景
  • 关闭按钮(不存在)

必须:

  1. 点击 📋 复制(按钮变绿并短暂显示"已复制")
  2. 勾选"我确认已复制..."复选框
  3. 点击 完成(只有在勾选复选框后才启用)

将明文粘贴到接收 webhook 的服务器的环境变量中:

export BRBTC_WEBHOOK_SECRET=whsec_8f3c9a1b...a14c78

理想情况下,将其存储在 secret manager 中(AWS Secrets、Azure Key Vault、HashiCorp Vault、Google Secret Manager、Doppler 等)— 而不是版本化的 .env 文件中。

Brasil Bitcoin 发送到您 URL 的下一个 webhook 将包含 X-Avista-Signature 头部。使用下方验证片段之一进行验证。

流程 2 — 轮换现有密钥

何时轮换:

  • ✓ 您怀疑当前密钥泄露
  • ✓ 定期安全轮换(例如,每 90 天)
  • ✓ 拥有密钥访问权限的团队成员离开团队

在"密钥已激活"卡片中,点击 🔄 轮换

出现一个与首次生成不同的确认模态 — 突出显示破坏性:

⚠️ 当前密钥(•••• a14c78)将被替换

轮换后,Brasil Bitcoin 发送的所有 webhook 都将使用新密钥。

轮换时:

  • 新密钥将仅显示一次 — 立即复制并存储。
  • 从现在起发送的所有 webhook 都使用新密钥。
  • ⚠️ 在您的验证器中保留旧密钥约 30 分钟,以吸收飞行中的 webhook(在轮换前已排队)。

[取消] [是的,轮换]

理想情况下,在过渡期间,您的验证器应同时接受新旧两个密钥约 30 分钟。这避免拒绝飞行中的 webhook。

服务器上的建议实现:

const SECRETS = [
  process.env.BRBTC_WEBHOOK_SECRET_CURRENT,
  process.env.BRBTC_WEBHOOK_SECRET_PREVIOUS, // 可选,轮换期间
].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'));
  });
}

约 30 分钟后,从您的 vault 中移除 BRBTC_WEBHOOK_SECRET_PREVIOUS

与首次生成相同的流程:带有"复制"按钮和确认复选框的阻塞式模态。

轮换窗口:轮换以原子方式替换 secret — Brasil Bitcoin 端的新旧 secret 之间没有共存窗口。在轮换前已渲染的飞行中 webhook(已排队但未发送)将以旧签名传送;轮换后,所有 webhook 都改用新密钥。这就是为什么我们建议您的验证器在约 30 分钟内同时接受两个密钥。在未来版本中,我们将原生支持 schema 中的 current/previous 以消除此操作窗口。

密钥丢失了怎么办? 只需登录 Internet Banking 并通过 轮换 生成新密钥。无法恢复以前的明文 — 这正是该模型的目标(对 Brasil Bitcoin 零知识)。在轮换之前或之后立即更新服务器上的密钥,以最小化被拒绝的 webhook 窗口。

UI 安全机制

界面应用了多项保护措施,以防止意外泄露明文:

保护机制工作方式
明文仅显示一次揭示模态仅在生成/轮换后显示。刷新页面永远不会显示明文。
无自动复制密钥不会自动复制到剪贴板。您必须主动点击 📋 复制
阻止关闭揭示模态不会通过 Esc、点击背景或关闭按钮关闭。唯一路径是 完成 按钮。
强制复选框在您勾选"我确认已复制密钥并安全存储"复选框之前,完成 按钮被禁用。
轮换前确认轮换(破坏性)始终通过确认模态,在生成新密钥之前解释后果。
可见 hint,从不显示明文关闭揭示模态后,面板仅显示 •••••••• a14c78,以便您可视化识别哪个密钥处于活动状态。

如何验证

签名覆盖的正是 UTF-8 编码的原始请求体(rawBody),在任何 JSON.parse 之前。如果您将接收到的对象重新序列化以计算 HMAC,任何差异(键顺序、空格、编码转换)都会导致签名不匹配。

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

const app = express();

// 选项 A:专用路由 + 宽泛 matcher — 捕获任何 Content-Type
// (包括来自 Brasil Bitcoin 的 `application/json; charset=utf-8`)。
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');
  // 关键:拒绝没有 header 的请求,而不仅是签名错误的
  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'));
  // ...处理 payload
  res.status(200).json({ acknowledged: true });
});
// 选项 B:在自动解析的同时保留 rawBody。
// 当您已有全局 `express.json()` 且无法替换时有用。
app.use(express.json({
  verify: (req, _res, buf) => {
    (req as any).rawBody = buf; // 包含原始 body 的 Buffer
  },
}));

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 此处已被解析
  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()  # UTF-8 字节,JSON 解析前
    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()
    # ...处理 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);  // PHP 中的 timing-safe
}

$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);
// ...处理 payload
http_response_code(200);
echo json_encode(['acknowledged' => true]);

始终使用 timing-safe 比较(crypto.timingSafeEqual / hmac.compare_digest / hmac.Equal / hash_equals)。使用 == 比较会打开 timing-attack 漏洞。

编码:body 是 UTF-8。在需要显式字节的语言中(Java/Kotlin/PHP),请确保以字节方式读取流 — 不要在计算 HMAC 之前转换为其他编码的字符串。在 Python 中,request.get_data() 已返回 bytes;在 Node 中,Buffer 是正确的类型。

HMAC 签名不能防止 replay。它证明真实性和完整性 — 不能阻止捕获合法 webhook 的攻击者(被攻陷的代理、泄露的日志)重新发送它。始终将签名验证与存储中 transactionId/medId 的去重结合。我们正在评估在未来版本中包含 HMAC 覆盖的 X-Avista-Timestamp + 接受窗口。

与 Basic Auth 的兼容性

如果您仍通过自定义头部中的 Basic Auth 验证 webhook,请继续验证X-Avista-Signature 头部是附加的,不是替代。我们建议在准备好后迁移到 HMAC 并移除 Basic Auth。


幂等性

Webhooks 可能会发送多次(在重试的情况下)。实现幂等性处理以避免重复处理。

使用 transactionId 字段作为唯一键:

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


最佳实践


重试机制

如果您的端点未在 10 秒内响应 HTTP 200:

尝试次数间隔累计时间
第 1 次立即0 分钟
第 2 次(第 1 次重试)5 分钟5 分钟
第 3 次(第 2 次重试)5 分钟10 分钟
第 4 次(第 3 次重试)15 分钟25 分钟

在 4 次不成功的尝试后(总时间约 25 分钟),webhook 将被移至死信队列(DLQ)。请实现定期轮询作为后备方案,以确保不会遗漏任何交易。


响应码

您的端点应返回适当的 HTTP 状态码:

状态码描述系统行为
2xx成功 (200, 201, 204 等)Webhook 已确认,不会重试
3xx重定向视为失败,将重试
4xx客户端错误视为失败,将重试
5xx服务器错误视为失败,将重试

系统仅验证 HTTP 状态码。任何 2xx 响应(200-299)都被视为成功,无论响应体内容如何。

本页目录