回调验签与幂等:一份可以直接抄的实现
支付回调是你系统里最容易被伪造、也最容易重复的入口。这篇给出 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 是事件发生那一刻的完整订单对象,和查询订单接口返回的一样。
签名是怎么算的
三步:
- 取请求头
X-Webhook-Timestamp,确认它和当前时间相差不超过 5 分钟(防重放); - 用你的 API Secret 计算
HMAC-SHA256(timestamp + "." + 原始请求体),转成小写十六进制,前面加上v2=; - 和请求头
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),不要换成 ==。
为什么会收到重复或乱序的回调
都是正常行为:
- 重试。 你没有在 5 秒内返回 HTTP 2xx(超时、返回 4xx / 5xx、返回 3xx 重定向都算失败),平台会按 15 秒、1 分钟、5 分钟、10 分钟、30 分钟、1 小时、2 小时、6 小时、12 小时重发,共约 22 小时。四种事件都会重试。
- 一个订单有多个事件。 用户分几次付款,每笔不足额的到账都会推一条
order.partially_paid,付足后再推order.paid。 - 乱序。 重试的请求体和第一次完全相同,可能晚于后面的事件才送到——比如
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 只在服务端,不进前端、不进日志
事件与字段的完整定义见 接入指南 · 回调事件。