全部文章

回调验签与幂等:一份可以直接抄的实现

支付回调是你系统里最容易被伪造、也最容易重复的入口。这篇给出 CNIXPay 回调的签名算法、五种语言的验签代码,以及去重、乱序与兜底查询的完整做法。

  • 回调
  • 签名
  • 安全
  • 最佳实践

支付回调接口有两个天然的敌人:伪造(有人往你的 /notify 发一条假的 order.paid)和重复(同一个事件你收到了两次)。前者靠验签解决,后者靠幂等解决。两个都做对,回调才能作为发货依据。

回调长什么样

CNIXPay 的回调是一个 JSON 事件,签名放在请求头里:

POST /notify HTTP/1.1
Content-Type: application/json
X-Webhook-Id: evt_7f3c9a...
X-Webhook-Timestamp: 1760670205
X-Webhook-Signature: v2=5b1f0e...

{
  "id": "evt_7f3c9a...",
  "event": "order.paid",
  "createTime": 1760670200,
  "data": { "orderNo": "20261017105142a1b2c3d4e5f6", "status": "paid", "version": 3, "...": "..." }
}

data 是事件发生那一刻的完整订单对象,和查询订单接口返回的一样。

签名是怎么算的

三步:

  1. 取请求头 X-Webhook-Timestamp,确认它和当前时间相差不超过 5 分钟(防重放);
  2. 用你的 API Secret 计算 HMAC-SHA256(timestamp + "." + 原始请求体),转成小写十六进制,前面加上 v2=;
  3. 和请求头 X-Webhook-Signature 做常量时间比较。

签名覆盖的是原始请求体的字节,所以有一个最容易踩的坑:

  • 不要先解析 JSON 再序列化回去验签。 字段顺序、空格、数字格式只要有一点不同,签名就对不上。很多框架默认会把请求体解析成对象,验签前要拿到原始字节——Express 用 express.raw(),PHP 用 file_get_contents('php://input')。

好处也来自这里:验签不用关心有哪些字段,以后平台给订单对象加字段,你的验签代码一行都不用改。

五种语言的验签

Node.js

import crypto from 'node:crypto'

// rawBody 必须是原始请求体(Buffer)
export function verifyWebhook(rawBody, headers, apiSecret) {
  const timestamp = headers['x-webhook-timestamp']
  const signature = headers['x-webhook-signature'] ?? ''
  if (!/^\d+$/.test(timestamp ?? '') || Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false
  const expected =
    'v2=' + crypto.createHmac('sha256', apiSecret).update(`${timestamp}.`).update(rawBody).digest('hex')
  const a = Buffer.from(expected)
  const b = Buffer.from(signature)
  return a.length === b.length && crypto.timingSafeEqual(a, b)
}

// Express: app.post('/notify', express.raw({ type: 'application/json' }), handler)

Python

import hashlib
import hmac
import time


def verify_webhook(raw_body: bytes, headers, api_secret: str) -> bool:
    timestamp = headers.get("X-Webhook-Timestamp", "")
    signature = headers.get("X-Webhook-Signature", "")
    if not timestamp.isdigit() or abs(time.time() - int(timestamp)) > 300:
        return False
    expected = "v2=" + hmac.new(
        api_secret.encode(), timestamp.encode() + b"." + raw_body, hashlib.sha256
    ).hexdigest()
    return hmac.compare_digest(expected, signature)

Go

func VerifyWebhook(r *http.Request, rawBody []byte, apiSecret string) bool {
	timestamp := r.Header.Get("X-Webhook-Timestamp")
	sec, err := strconv.ParseInt(timestamp, 10, 64)
	if err != nil || time.Since(time.Unix(sec, 0)).Abs() > 5*time.Minute {
		return false
	}
	mac := hmac.New(sha256.New, []byte(apiSecret))
	mac.Write([]byte(timestamp + "."))
	mac.Write(rawBody)
	expected := "v2=" + hex.EncodeToString(mac.Sum(nil))
	return hmac.Equal([]byte(expected), []byte(r.Header.Get("X-Webhook-Signature")))
}

PHP

function verifyWebhook(string $rawBody, string $timestamp, string $signature, string $apiSecret): bool
{
    if (!ctype_digit($timestamp) || abs(time() - (int) $timestamp) > 300) {
        return false;
    }
    $expected = 'v2=' . hash_hmac('sha256', $timestamp . '.' . $rawBody, $apiSecret);
    return hash_equals($expected, $signature);
}

// $raw = file_get_contents('php://input');
// verifyWebhook($raw, $_SERVER['HTTP_X_WEBHOOK_TIMESTAMP'] ?? '', $_SERVER['HTTP_X_WEBHOOK_SIGNATURE'] ?? '', $secret);

Java

static boolean verifyWebhook(byte[] rawBody, String timestamp, String signature, String apiSecret)
        throws GeneralSecurityException {
    long ts;
    try {
        ts = Long.parseLong(timestamp);
    } catch (NumberFormatException e) {
        return false;
    }
    if (Math.abs(System.currentTimeMillis() / 1000 - ts) > 300) return false;

    Mac mac = Mac.getInstance("HmacSHA256");
    mac.init(new SecretKeySpec(apiSecret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
    mac.update((timestamp + ".").getBytes(StandardCharsets.UTF_8));
    String expected = "v2=" + HexFormat.of().formatHex(mac.doFinal(rawBody));
    return MessageDigest.isEqual(
        expected.getBytes(StandardCharsets.UTF_8), signature.getBytes(StandardCharsets.UTF_8));
}

五段代码都用了常量时间比较(timingSafeEqual / compare_digest / hmac.Equal / hash_equals / MessageDigest.isEqual),不要换成 ==。

为什么会收到重复或乱序的回调

都是正常行为:

  1. 重试。 你没有在 5 秒内返回 HTTP 2xx(超时、返回 4xx / 5xx、返回 3xx 重定向都算失败),平台会按 15 秒、1 分钟、5 分钟、10 分钟、30 分钟、1 小时、2 小时、6 小时、12 小时重发,共约 22 小时。四种事件都会重试。
  2. 一个订单有多个事件。 用户分几次付款,每笔不足额的到账都会推一条 order.partially_paid,付足后再推 order.paid。
  3. 乱序。 重试的请求体和第一次完全相同,可能晚于后面的事件才送到——比如 order.partially_paid 的第三次重试,在 order.paid 之后才到。

所以你的处理器必须能安全地接受同一个事件任意多次,并且不会被旧事件覆盖新状态。

幂等和乱序怎么处理

用两个字段就够:

  • X-Webhook-Id(同请求体里的 id):每个事件唯一,重试不变。按它去重。
  • data.version:订单版本号,订单每变化一次加 1。本地只保留 version 最大的那份订单,比它小的直接忽略。
CREATE TABLE webhook_events (
  id         VARCHAR(64) PRIMARY KEY,  -- X-Webhook-Id
  order_no   VARCHAR(64) NOT NULL,
  created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
app.post('/notify', express.raw({ type: 'application/json' }), async (req, res) => {
  if (!verifyWebhook(req.body, req.headers, API_SECRET)) return res.status(400).end()

  const event = JSON.parse(req.body)
  // INSERT 成功 = 第一次见到;主键冲突 = 已处理过,直接 2xx
  const isNew = await db.insertIgnore('webhook_events', { id: event.id, order_no: event.data.orderNo })
  if (isNew) await queue.push('order-changed', event.data)

  res.status(200).end()
})

// 队列消费者
async function onOrderChanged(order) {
  // 只在版本更新时写入:UPDATE ... WHERE order_no = ? AND version < ?
  const updated = await orders.saveIfNewer(order)
  if (updated && order.status === 'paid') await fulfillOnce(order.orderNo)
}

三个细节:

  • 先落库再返回 2xx,履约放队列。 回调处理器只做「验签 + 去重 + 入队」,几十毫秒内返回,避免超时触发重试。
  • 只在 status == "paid" 时发货,发货本身也要按订单号幂等(fulfillOnce)。underpaid 转人工,closed 作废。
  • 已作废的订单也可能收到 order.paid。 宽限期结束后才到的钱,平台核实后可能人工补单,订单会从 closed 变成 paid。按 version 处理就不会出错,不要因为本地已作废就丢弃。

收不到回调怎么办

回调是推送,推送就可能丢:你的服务宕机超过 22 小时、回调地址配错、验签代码有 bug 一直返回 4xx。所以要有一道主动查询兜底:

// 每隔几分钟:把本地还没出结果的订单批量查一次(每次最多 100 个)
const open = await orders.findOpen({ limit: 100 })
const res = await cnixpay.get('/orders', { merchantOrderNo: open.map((o) => o.merchantOrderNo) })
for (const order of res.data.items) await onOrderChanged(order)

查询返回的就是订单的权威状态,和回调里的 data 是同一个对象,可以直接复用同一套处理逻辑。用户在支付页点「我已付款」时,也可以单独查一次这笔订单,给出即时反馈。

检查清单

上线前把这几条过一遍:

  • 用原始请求体验签,校验时间戳在 5 分钟内,常量时间比较
  • 验签失败返回 4xx 并记日志
  • 按 X-Webhook-Id 去重,按 version 丢弃旧数据
  • 只在 status == "paid" 时发货,发货按订单号幂等
  • 处理器只记录 + 入队,5 秒内返回 2xx
  • 定时按 merchantOrderNo 批量查询未完结订单兜底
  • API Secret 只在服务端,不进前端、不进日志

事件与字段的完整定义见 接入指南 · 回调事件。