Skip to content

回调通知与验签 ​

订单进入「已确认」后,平台会把结果 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.1
json
{
  "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 秒)。你的处理应当尽快返回, 把耗时动作(发货、发邮件)放到异步队列里,不要卡在回调请求里。
  • 重试发送的是同一份报文(内容不变),所以你按内容做的幂等校验才有意义。

回调失败不会改变订单状态

回调没成功时,订单停在「已确认」——钱是收到了的,只是没通知成功。 所以"没收到回调"不等于"钱没到"。用查询订单核对即可。

为什么必须以查询接口为准 ​

回调是尽力投递的一条通路,它天然会有这些情况:

  • 延迟:退避重试意味着你收到的时间可能比确认时间晚。
  • 重复:重试意味着同一个结果可能到达多次。
  • 丢失:地址改了、防火墙拦了、发版期间不可达,重试到上限就放弃了。

所以正确的做法是双轨:

  1. 用回调做变更触发,收到就尽快处理。
  2. 在关键动作(发货、结算、对账)之前,用查询订单 拿一次真实状态作为最终依据。

回调地址的几条要求 ​

  • 必须是平台可访问的 http(s) 地址,不能是内网地址。
  • 它由平台主动请求,所以要按"对外开放的服务"来防护,而不是按"内部接口"。
  • 建议对同一订单的处理做成先落库再动作:先记下"这笔单已收到回调", 再执行发货等副作用,避免处理到一半崩溃后状态不明。

相关 ​

面向全球中小商家的 Web3 支付基础设施