回调通知与验签
订单进入「已确认」后,平台会把结果 POST 到你配置的回调地址。
- 本单在创建订单时传了
notifyUrl,就用本单的;没传,用商户档案上的默认回调地址。 - 两者都没有时不生成回调记录,也不报错 —— 不是每个商家都要接回调,它可以主动查。
报文
http
POST https://your-shop.com/pay/notify
Content-Type: application/json; charset=utf-8
X-Pay-Timestamp: 1760000000
X-Pay-Signature: <小写十六进制签名>
X-Pay-Event: pay.confirmed
User-Agent: cginfo-pay/0.1json
{
"merchantOrderNo": "SHOP-2026-0001",
"orderNo": "PO20261011XXXXXXXXXXXX",
"merchantNo": "M00000XXXX",
"status": 3,
"statusText": "已确认",
"amount": "100500000",
"payAmount": "100500123",
"receivedAmount": "100500123",
"amountDisplay": "100.500123",
"chainId": 2,
"tokenId": 1,
"payAddress": "TXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX",
"txHash": "0x...链上交易哈希",
"subject": "订单 SHOP-2026-0001",
"confirmedAt": "2026-10-11T09:58:41Z"
}字段要点:
- 金额一律是最小单位整数的字符串(
amount/payAmount/receivedAmount), 另外给一个amountDisplay给人看。别自己按decimals乘一遍 —— 用浮点会差一个最小单位,而对账时两边都觉得自己没错。 status当前只会是 3(已确认),这是唯一会触发回调的状态。留这个字段是为了将来有别的通知类型时不用改协议。receivedAmount是链上实收,可能大于应付(买家多转了)。
验签
signature = HMAC-SHA256(key = apiKey, message = timestamp + body)timestamp 是 X-Pay-Timestamp 头的值,直接字符串拼接原始报文正文。
go
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"io"
"net/http"
"os"
)
func verifyCallback(r *http.Request) ([]byte, bool) {
raw, _ := io.ReadAll(r.Body) // 原始字节,先别解析
ts := r.Header.Get("X-Pay-Timestamp")
sig := r.Header.Get("X-Pay-Signature")
mac := hmac.New(sha256.New, []byte(os.Getenv("PAY_API_KEY")))
mac.Write([]byte(ts)) // 时间戳:字符串拼接,不是相加
mac.Write(raw) // 正文:原始字节,不是重新序列化后的
want := hex.EncodeToString(mac.Sum(nil))
return raw, hmac.Equal([]byte(want), []byte(sig)) // 常数时间比较
}三条容易踩的坑:
- 用原始报文体算签名。 解析成对象再重新序列化,字段顺序或空格一变就验不过。
- 用常数时间比较。 逐字节短路的
==可以被逐位猜出签名。 - 签名密钥是你的 API KEY,不是另发的一把。轮换 KEY 之后,这里也要同步换。
幂等:靠 merchantOrderNo
回调会被重试,所以你的处理必须幂等。用 merchantOrderNo 做去重键:
go
if alreadyProcessed(p.MerchantOrderNo) {
w.WriteHeader(http.StatusOK) // 已经处理过了,直接回 2xx 让平台停手
return
}一个常见的错误做法是"回调到了就发货",收到第二次回调就发第二次货。 正确的做法是:先判断这个订单是不是已经处理过,再决定要不要动作。
重试
- 你返回 2xx → 投递成功,平台不再重试。成功后订单进入终态「已完成」。
- 返回其它状态码、超时或不响应 → 平台记录为"失败待重试",按退避序列重试: 约
5 秒 → 30 秒 → 2 分钟 → 10 分钟 → 30 分钟 → 1 小时 → 2 小时 → 6 小时。 - 达到重试上限后标记为已放弃。这不是静默丢弃:记录仍可查,平台可以手工重发。
- 单次回调请求有超时限制(约 10 秒)。你的处理应当尽快返回, 把耗时动作(发货、发邮件)放到异步队列里,不要卡在回调请求里。
- 重试发送的是同一份报文(内容不变),所以你按内容做的幂等校验才有意义。
回调失败不会改变订单状态
回调没成功时,订单停在「已确认」——钱是收到了的,只是没通知成功。 所以"没收到回调"不等于"钱没到"。用查询订单核对即可。
为什么必须以查询接口为准
回调是尽力投递的一条通路,它天然会有这些情况:
- 延迟:退避重试意味着你收到的时间可能比确认时间晚。
- 重复:重试意味着同一个结果可能到达多次。
- 丢失:地址改了、防火墙拦了、发版期间不可达,重试到上限就放弃了。
所以正确的做法是双轨:
- 用回调做变更触发,收到就尽快处理。
- 在关键动作(发货、结算、对账)之前,用查询订单 拿一次真实状态作为最终依据。
回调地址的几条要求
- 必须是平台可访问的 http(s) 地址,不能是内网地址。
- 它由平台主动请求,所以要按"对外开放的服务"来防护,而不是按"内部接口"。
- 建议对同一订单的处理做成先落库再动作:先记下"这笔单已收到回调", 再执行发货等副作用,避免处理到一半崩溃后状态不明。