ZH

加密货币支付 Webhook:校验签名,处理重试和重复投递

SnapEX Pay Webhook

Webhook 是您的商店、计费面板或机器人得知客户已付款的方式。每当发生账单、付款或提现事件时,SnapEX Pay 都会向您的 URL 发送 HTTP POST。处理得当,它是您最快、最可靠的信号;处理草率,它就成了把从未付款的订单标记为已支付的漏洞。本指南介绍其中的关键部分。

设置

在商户后台进入 Invoices → Webhook 卡片,输入您的 URL 并点击 Save。每个账户一个 URL,接收所有类型的事件。首次保存时,后台只显示一次签名密钥 whsec_…:请只保存在您的服务器上。

URL 必须是带公网主机的绝对 https:// 地址:不能包含 login:password,不能是 localhost,主机也不能解析到私有或保留 IP。不会跟随重定向,因此请提供最终 URL。

收到的内容

每个请求带有四个请求头:

请求头 含义
Webhook-Id 唯一的事件 ID。重试或手动重新发送时不会改变。
Webhook-Timestamp 本次尝试发送时的 Unix 时间(秒)。每次重试都是新值。
Webhook-Signature v1= 加上 {Webhook-Timestamp}.{raw body} 的小写十六进制 HMAC-SHA256。
Content-Type 始终为 application/json。

请求体包含 id(与 Webhook-Id 相同)、type、created_at 和 data。对于 invoice.* 事件,data 是与 GET /invoices/{id} 结构相同的账单,包括 environment、amount_usd、status 和您的 external_id。

账单事件:

事件 何时发送
invoice.created 账单已创建,尚未选择支付方式。
invoice.awaiting 已选择支付方式并分配了地址。
invoice.underpaid 部分金额已到账;同一地址等待剩余金额。
invoice.paid 全额已支付并入账。这是发货的唯一信号。
invoice.partially_paid 只支付了一部分,补款时间已结束,已收到的部分记入您的余额。
invoice.expired 账单时限结束且未付款。
invoice.extra_payment 账单付清后又到达一笔转账;单独入账。

校验签名

  1. 在任何 JSON 解析之前,以字节形式读取原始请求体。重新序列化的 JSON 不会匹配。
  2. 以完整密钥(包含 whsec_ 前缀)为键,对 {Webhook-Timestamp}.{raw body} 计算 HMAC-SHA256,并进行十六进制编码。
  3. 使用常量时间函数,将 v1= + 十六进制值与 Webhook-Signature 请求头进行比较。
  4. 拒绝与您的时钟相差超过 5 分钟的 Webhook-Timestamp。重试带有新的时间戳,因此不会因此失败。

PHP:

$secret = getenv('SNAPEX_WEBHOOK_SECRET');          // whsec_… from the cabinet
$body   = file_get_contents('php://input');           // raw body, before json_decode
$ts     = $_SERVER['HTTP_WEBHOOK_TIMESTAMP'] ?? '';
$sig    = $_SERVER['HTTP_WEBHOOK_SIGNATURE'] ?? '';

$expected = 'v1=' . hash_hmac('sha256', $ts . '.' . $body, $secret);
if (!hash_equals($expected, $sig) || abs(time() - (int) $ts) > 300) {
    http_response_code(400);
    exit;
}

$event = json_decode($body, true);
// skip if $event['id'] was already processed; do the heavy work in a queue
http_response_code(200);

Node.js(Express):

const crypto = require('crypto');

app.post('/hooks/snapex', express.raw({ type: 'application/json' }), (req, res) => {
  const ts = req.get('Webhook-Timestamp') || '';
  const sig = Buffer.from(req.get('Webhook-Signature') || '');
  const expected = Buffer.from('v1=' + crypto
    .createHmac('sha256', process.env.SNAPEX_WEBHOOK_SECRET)
    .update(ts + '.' + req.body)
    .digest('hex'));

  const fresh = Math.abs(Date.now() / 1000 - Number(ts)) <= 300;
  if (!fresh || sig.length !== expected.length || !crypto.timingSafeEqual(sig, expected)) {
    return res.sendStatus(400);
  }

  const event = JSON.parse(req.body);
  // skip if event.id was already processed; queue the heavy work
  res.sendStatus(200);
});

重试、重复投递与顺序

  • 任何 2xx 都算成功。 其他状态码、超时或网络错误都算失败。连接超时 5 秒,整个请求超时 15 秒,因此请快速响应,并异步执行耗时工作。
  • 重试。 每个事件最多尝试 5 次:首次发送,之后每次失败后约 10 秒、1 分钟、5 分钟和 15 分钟重试。第五次失败后停止自动投递;后台会列出最近的投递,并提供 Retry 按钮,以相同的 Webhook-Id 重新发送事件。
  • 重复投递。 投递方式为至少一次(at-least-once):同一事件可能到达不止一次。保存已处理的 Webhook-Id,忽略重复的。
  • 顺序。 事件可能乱序到达:一个被重试的旧事件可能在较新的事件之后到达。不要依赖顺序;将事件中的状态与您已有的状态进行比较。
  • 如需可再次确认。 收到 Webhook 后,您可以通过 API 重新读取对象:GET /invoices/{id}。

不用真实资金测试

创建一张测试账单("environment": "test"),在支付页面付款,观察带有 data.environment = "test" 的 Webhook 到达。详情:如何不用真实资金测试加密货币支付。

适用于 WHMCS、BILLmanager、WooCommerce 及其他平台的现成模块已经会校验签名,且绝不会重复入账;参见 pay.snapex.pro。完整参考:SnapEX Pay API 文档。

准备好接收加密货币了吗?

创建商户账户并获取 API 密钥。每笔支付 1%,无月费。