实现
完整示例
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(MedCreated、MedAccepted、MedRejected 等)。不要与注册 URL 时使用的类型混淆(snake_case cash_in、cash_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键- 点击背景
- 关闭按钮(不存在)
您必须:
- 点击 📋 复制(按钮变绿并短暂显示"已复制")
- 勾选"我确认已复制..."复选框
- 点击 完成(只有在勾选复选框后才启用)
将明文粘贴到接收 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}, 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); // 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)都被视为成功,无论响应体内容如何。