docs
Webhooks

实现

完整示例

以下示例接收交易事件、验证 X-Avista-Signature 签名、快速返回 2xx 并避免重复处理。密钥生成和签名细节请参见 HMAC 签名。

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

interface Counterpart {
  name: string | null;
  document: string | null;
  bank: {
    bankISPB: string | null;
    bankName: string | null;
    bankCode: string | null;
    accountBranch: string | null;
    accountNumber: string | null;
  };
}

interface ParentTransaction {
  transactionId: string;
  externalId: string | null;
  endToEndId: string | null;
  processingDate: string;
  wasTotalRefunded: boolean;
  remainingAmountForRefund: number;
  providerId: string | null;
  metadata: Record<string, unknown>;
  counterpart: Counterpart;
}

interface PixWebhookPayload {
  event: 'CashIn' | 'CashOut' | 'CashInReversal' | 'CashOutReversal';
  status: 'PENDING' | 'CONFIRMED' | 'ERROR';
  transactionType: 'PIX';
  movementType: 'CREDIT' | 'DEBIT';
  transactionId: string;
  externalId: string | null;
  endToEndId: string | null;
  pixKey: string | null;
  feeAmount: number;
  originalAmount: number;
  finalAmount: number;
  mismatchAmount: number | null;
  processingDate: string;
  errorCode: string | null;
  errorMessage: string | null;
  providerId: string | null;
  metadata: Record<string, unknown>;
  counterpart?: Counterpart;
  parentTransaction?: ParentTransaction;
}

const app = express();
const SECRET = process.env.FYPAY_WEBHOOK_SECRET!;

function isValidSignature(rawBody: Buffer, received: string | undefined): boolean {
  if (!received) return false;
  const expected = crypto.createHmac('sha256', SECRET).update(rawBody).digest('hex');
  const a = Buffer.from(expected, 'hex');
  const b = Buffer.from(received, 'hex');
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

const processed = new Set<string>();

app.post('/webhooks/pix', express.raw({ type: '*/*' }), (req, res) => {
  if (!isValidSignature(req.body, req.header('X-Avista-Signature'))) {
    return res.status(401).json({ error: 'invalid signature' });
  }

  const payload: PixWebhookPayload = JSON.parse(req.body.toString('utf8'));

  res.status(200).json({ acknowledged: true });

  const key = `${payload.event}:${payload.transactionId}:${payload.status}`;
  if (processed.has(key)) return;
  processed.add(key);

  handle(payload).catch((error) => {
    console.error(`处理 ${payload.event} 时出错:`, error);
    processed.delete(key);
  });
});

async function handle(payload: PixWebhookPayload) {
  switch (payload.event) {
    case 'CashIn':
      console.log(`[CashIn] ${payload.status} R$ ${payload.finalAmount}`);
      break;
    case 'CashOut':
      console.log(`[CashOut] ${payload.status} R$ ${payload.originalAmount}`);
      break;
    case 'CashInReversal':
      console.log(`[CashInReversal] R$ ${payload.originalAmount}`);
      break;
    case 'CashOutReversal':
      console.log(`[CashOutReversal] R$ ${payload.finalAmount}`);
      break;
  }
}

app.listen(3000);
import hashlib
import hmac
import json
import os

from flask import Flask, request, jsonify

app = Flask(__name__)
SECRET = os.environ['FYPAY_WEBHOOK_SECRET'].encode('utf-8')
processed: set = set()


def is_valid_signature(raw_body: bytes, received: str) -> bool:
    if not received:
        return False
    expected = hmac.new(SECRET, raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, received)


@app.route('/webhooks/pix', methods=['POST'])
def handle_pix_webhook():
    raw_body = request.get_data()
    if not is_valid_signature(raw_body, request.headers.get('X-Avista-Signature', '')):
        return jsonify({'error': 'invalid signature'}), 401

    payload = json.loads(raw_body)

    key = f"{payload['event']}:{payload['transactionId']}:{payload['status']}"
    if key in processed:
        return jsonify({'acknowledged': True}), 200
    processed.add(key)

    event = payload['event']
    if event == 'CashIn':
        print(f"[CashIn] {payload['status']} R$ {payload['finalAmount']}")
    elif event == 'CashOut':
        print(f"[CashOut] {payload['status']} R$ {payload['originalAmount']}")
    elif event == 'CashInReversal':
        print(f"[CashInReversal] R$ {payload['originalAmount']}")
    elif event == 'CashOutReversal':
        print(f"[CashOutReversal] R$ {payload['finalAmount']}")

    return jsonify({'acknowledged': True}), 200


if __name__ == '__main__':
    app.run(host='0.0.0.0', port=3000)
<?php

$SECRET = getenv('FYPAY_WEBHOOK_SECRET');
$PROCESSED_FILE = '/tmp/processed_webhooks.json';

function isProcessed(string $key): bool {
    global $PROCESSED_FILE;
    if (!file_exists($PROCESSED_FILE)) return false;
    $processed = json_decode(file_get_contents($PROCESSED_FILE), true) ?? [];
    return in_array($key, $processed, true);
}

function markProcessed(string $key): void {
    global $PROCESSED_FILE;
    $processed = file_exists($PROCESSED_FILE)
        ? json_decode(file_get_contents($PROCESSED_FILE), true) ?? []
        : [];
    $processed[] = $key;
    file_put_contents($PROCESSED_FILE, json_encode(array_slice($processed, -10000)));
}

if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
    http_response_code(405);
    exit;
}

$rawBody = file_get_contents('php://input');
$received = $_SERVER['HTTP_X_AVISTA_SIGNATURE'] ?? '';
$expected = hash_hmac('sha256', $rawBody, $SECRET);

if ($received === '' || !hash_equals($expected, $received)) {
    http_response_code(401);
    echo json_encode(['error' => 'invalid signature']);
    exit;
}

$payload = json_decode($rawBody, true);

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

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

$key = $payload['event'] . ':' . $payload['transactionId'] . ':' . $payload['status'];
if (isProcessed($key)) {
    exit;
}
markProcessed($key);

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)

在面板中生成签名密钥后,发送到您账户的每个 webhook 都会包含 X-Avista-Signature header,其值为使用该密钥计算的原始请求体的 HMAC-SHA256(十六进制)。验证签名可以确保 webhook 来自 FyPay(而不是发现了您 URL 的第三方),并且 payload 在传输过程中未被篡改。

该 header 的名称确实就是 X-Avista-Signature。这是目前发送的技术名称;请在验证程序中原样使用(PHP 中为 $_SERVER['HTTP_X_AVISTA_SIGNATURE'])。

密钥是按账户的:同一个密钥为所有已注册 URL 上的所有事件(交易和 MED)签名。手动重发同样使用账户的当前密钥签名。

在生成密钥之前,webhook 投递时不带 X-Avista-Signature header。如果您是从零开始集成,请在验证程序上线前生成密钥,并拒绝不带该 header 的请求,而不仅仅是签名错误的请求。

生成和轮换密钥

密钥只能通过面板(https://banking.fypay.me)生成和轮换;没有对应的 API 端点。

进入 设置 → Webhooks(Configurações → Webhooks),找到 HMAC 签名密钥(Chave de assinatura HMAC)卡片。

点击 轮换(Rotacionar)并确认您的密码。同一操作既用于生成第一个密钥,也用于替换现有密钥。

密钥只显示一次,显示窗口只有在您勾选已复制的确认后才能关闭。之后面板只显示密钥的结尾(最后 4 个字符)和最近一次轮换的日期。FyPay 无法找回密钥:如果丢失,请生成新的密钥。

将密钥保存在 secret manager 或环境变量中(切勿放在受版本控制的文件中):

export FYPAY_WEBHOOK_SECRET=<面板中生成的密钥>

从此时起,后续的 webhook 都会带有 X-Avista-Signature header。

轮换时不拒绝传输中的 webhook

轮换会立即替换密钥:此后发送的 webhook 使用新密钥。已在队列中的 webhook 可能仍使用旧密钥签名。因此在切换期间,请在约 30 分钟内同时接受两个密钥:

const SECRETS = [
  process.env.FYPAY_WEBHOOK_SECRET_CURRENT,
  process.env.FYPAY_WEBHOOK_SECRET_PREVIOUS,
].filter(Boolean) as string[];

function isValid(rawBody: Buffer, received: string): boolean {
  const b = Buffer.from(received, 'hex');
  return SECRETS.some((secret) => {
    const a = Buffer.from(
      crypto.createHmac('sha256', secret).update(rawBody).digest('hex'),
      'hex',
    );
    return a.length === b.length && crypto.timingSafeEqual(a, b);
  });
}

此期间过后,移除 FYPAY_WEBHOOK_SECRET_PREVIOUS。在怀疑密钥泄露或进行定期轮换(例如每 90 天)时执行轮换。

如何验证

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

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

const app = express();

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');
  if (!signature || !isValidSignature(req.body, signature, process.env.FYPAY_WEBHOOK_SECRET!)) {
    return res.status(401).json({ error: 'invalid signature' });
  }

  const payload = JSON.parse(req.body.toString('utf8'));
  res.status(200).json({ acknowledged: true });
});
app.use(express.json({
  verify: (req, _res, buf) => {
    (req as any).rawBody = buf;
  },
}));

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' });
  }
  res.status(200).json({ acknowledged: true });
});
import hmac, hashlib, os
from flask import Flask, request, abort

app = Flask(__name__)
SECRET = os.environ['FYPAY_WEBHOOK_SECRET']

@app.route('/webhooks/pix', methods=['POST'])
def webhook():
    raw_body = request.get_data()
    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()
    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("FYPAY_WEBHOOK_SECRET")) {
        http.Error(w, "invalid signature", http.StatusUnauthorized)
        return
    }
    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);
}

$rawBody = file_get_contents('php://input');
$received = $_SERVER['HTTP_X_AVISTA_SIGNATURE'] ?? '';
if (!validSignature($rawBody, $received, getenv('FYPAY_WEBHOOK_SECRET'))) {
    http_response_code(401);
    echo json_encode(['error' => 'invalid signature']);
    exit;
}

$payload = json_decode($rawBody, true);
http_response_code(200);
echo json_encode(['acknowledged' => true]);

请始终使用时间安全的比较(crypto.timingSafeEqual / hmac.compare_digest / hmac.Equal / hash_equals)。使用 == 比较会带来时序攻击风险。

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

HMAC 签名无法防止重放。它证明真实性和完整性,但无法阻止截获合法 webhook 的人(被攻破的代理、泄露的日志)重新发送它。请将签名验证与幂等性和 IP 白名单结合使用。

自定义 header

URL 上注册的自定义 header(最多 5 个)会随每个 webhook 与签名一起发送。如果您已经在验证自己的 header(例如 Authorization: Basic ...),可以继续验证:X-Avista-Signature 是附加的,不会替代这些 header。


幂等性

Webhook 可能被投递多次(重试和手动重发)。请实现幂等处理以避免重复处理。

对于交易事件,请使用 event + transactionId + status 的组合作为键。这样,同一交易状态变化时会重新处理,而同一事件的重复投递会被忽略。对于 MED 事件,请使用 medId + event。

const key = `webhook:${payload.event}:${payload.transactionId}:${payload.status}`;

const isNew = await redis.set(key, '1', 'EX', 86400, 'NX');

if (!isNew) {
  console.log('Webhook 已处理,忽略');
  return;
}

await processWebhook(payload);


最佳实践


重试

如果您的端点未在 10 秒内返回 2xx(或连接失败),投递将被重试:

尝试间隔累计时间
第 1 次立即0 分钟
第 2 次(第 1 次重试)30 秒30 秒
第 3 次(第 2 次重试)1 分钟1 分 30 秒
第 4 次(第 3 次重试)3 分钟4 分 30 秒
第 5 次(第 4 次重试)5 分钟9 分 30 秒
第 6 次(第 5 次重试)15 分钟24 分 30 秒

6 次尝试均失败后(约 25 分钟),自动投递结束。请使用手动重发或通过 API 查询交易,以免遗漏任何事件。

当事件注册了多个 URL 时,重试只针对失败的 URL。已成功接收的 URL 不会再次收到同一事件。


响应代码

您的端点应返回合适的 HTTP 代码:

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

系统只评估 HTTP 代码。任何 2xx(200-299)响应都视为成功,与响应体无关。您可以返回空响应体、"OK" 或任意 JSON。


后续步骤

本页目录