支付回调
支付成功后,网关向商户配置的回调地址发送 POST 请求。回调是入账的唯一依据。
用户可能付完款就关掉页面,所以页面跳转、前端轮询结果都不能用来入账。
回调地址在商户后台「接入配置 → 支付回调」里自己配。点「保存配置」时,网关会先向新地址发一条 webhook.ping 测试事件(信封和真实事件一样、签名规则一样,data 里只有商户号和一句说明),收到 2xx 才生效;平时想自检,点地址旁边的「测试地址」再发一条。所以回调程序遇到不认识的 event_type 直接返回 2xx 就行,不要报错。
对回调地址的几点要求:
- 公网能访问到就行,带端口也可以。建议用 HTTPS;填 http 也能保存,但回调内容会明文经过公网,风险自担
- 别按来源 IP 设白名单。网关跑在阿里云函数计算上,出口 IP 不固定,来源可不可信靠验签判断
- 别用 https 加 IP 配自签证书。网关会校验证书,过不了就直接投递失败;测试阶段用 http,或者用有正规证书的域名
按单指定回调地址
默认情况下回调地址只有一个,就是「接入配置」里填的那个,三种事件都往它发,下单时不用传、也不能传回调地址。
几个网站共用一个商户号、又想各收各的回调,就把回调方式切成「按单指定」,下单时带上 notify_url。
在「接入配置 → 支付回调」里选「按单指定」,把各站的地址逐条填进去,最多 20 条,然后点「保存配置」。保存时网关会给新加的地址各发一条测试回调,都返回 2xx 才生效;确认框里要勾一项:下单会带上这里登记的地址,漏传或对不上就拒单。之后下单传 notify_url,值要和登记的地址一字不差,大小写、末尾的斜杠都算。这张单的 order.paid、refund.succeeded、refund.failed 都发到这个地址。
几条规则记一下:
- 统一地址模式下传了
notify_url,报 42202
- 按单指定模式下
notify_url 必传:漏传报 40004,地址不在白名单报 42202。漏传在下单这一步就拦住了,不会发到别的地方去
- 切换只管之后的单,已经下的单和路上的回调还按原来的地址发
- 从白名单里删掉一个地址,发往它的回调就停了,记成「未投递」,不会转发到统一地址
- 顽兔运营这边只能看,不会替你切换,也不会替你改白名单
const order = await pay.create_order({
out_trade_no: 'B_20260917000042',
amount: '100.00',
subject: '积分充值100元',
pay_mode: 'qrcode',
notify_url: 'https://b.example.com/wantu/webhook', // 要和白名单里登记的地址一字不差
})
order = pay.create_order(
out_trade_no="B_20260917000042",
amount="100.00",
subject="积分充值100元",
pay_mode="qrcode",
notify_url="https://b.example.com/wantu/webhook", # 要和白名单里登记的地址一字不差
)
order, err := pay.CreateOrder(ctx, wantupay.CreateOrderParams{
OutTradeNo: "B_20260917000042",
Amount: "100.00",
Subject: "积分充值100元",
PayMode: "qrcode",
NotifyURL: "https://b.example.com/wantu/webhook", // 要和白名单里登记的地址一字不差
})
Map<String, Object> order = pay.createOrder(new WantuPay.CreateOrderParams()
.outTradeNo("B_20260917000042")
.amount("100.00")
.subject("积分充值100元")
.payMode("qrcode")
.notifyUrl("https://b.example.com/wantu/webhook")); // 要和白名单里登记的地址一字不差
接收回调
// 签名针对请求体原文,webhook 路由必须挂 express.raw()(不要全局挂 express.json())
app.post('/wantu/webhook', express.raw({ type: '*/*' }), async (req, res) => {
let event
try {
event = pay.verify_webhook(req.headers, req.body) // req.body 是原文 Buffer,直接传
} catch (err) {
return res.sendStatus(400) // 验签失败:不处理业务,网关会重试
}
if (event.event_type === 'order.paid') {
const data = event.data
// 入账三件套:查到本地订单 → 金额一致 → 事务内幂等入账
// UPDATE orders SET status='paid' WHERE out_trade_no=? AND status='created'
// 更新行数为 1 才执行加积分;为 0 说明是重复回调,直接返回 200
}
res.sendStatus(200)
})
from fastapi import Request, Response
from wantu_pay import WantuWebhookVerifyError
@app.post("/wantu/webhook")
async def wantu_webhook(request: Request):
raw_body = await request.body() # 必须是原文,不要先 request.json()
try:
event = pay.verify_webhook(request.headers, raw_body)
except WantuWebhookVerifyError:
return Response(status_code=400) # 验签失败:不处理业务,网关会重试
if event["event_type"] == "order.paid":
data = event["data"]
# 入账三件套:查到本地订单 → 金额一致 → 事务内幂等入账
# UPDATE orders SET status='paid' WHERE out_trade_no=%s AND status='created'
# 更新行数为 1 才执行加积分;为 0 说明是重复回调,直接返回 200
...
return {"ok": True}
func webhook(w http.ResponseWriter, r *http.Request) {
raw, err := io.ReadAll(r.Body) // 必须是请求体原文,验签要在任何 JSON 解析之前
if err != nil {
w.WriteHeader(http.StatusBadRequest)
return
}
event, err := pay.VerifyWebhook(r.Header, raw)
if err != nil {
w.WriteHeader(http.StatusBadRequest) // 验签失败:不处理业务,网关会重试
return
}
if event.EventType == wantupay.EventOrderPaid {
// 入账三件套:查到本地订单 → 金额一致 → 事务内幂等入账
// UPDATE orders SET status='paid' WHERE out_trade_no=$1 AND status='created'
// 更新行数为 1 才执行加积分;为 0 说明是重复回调,直接返回 200
}
w.WriteHeader(http.StatusOK)
}
@PostMapping("/wantu/webhook")
public ResponseEntity<String> webhook(@RequestHeader Map<String, String> headers,
@RequestBody byte[] rawBody) { // 必须是请求体原文字节
Map<String, Object> event;
try {
event = pay.verifyWebhook(headers, rawBody);
} catch (WantuPay.WebhookVerifyException e) {
return ResponseEntity.badRequest().build(); // 验签失败:不处理业务,网关会重试
}
if ("order.paid".equals(event.get("event_type"))) {
// 入账三件套:查到本地订单 → 金额一致 → 事务内幂等入账
// UPDATE orders SET status='paid' WHERE out_trade_no=? AND status='created'
// 更新行数为 1 才执行加积分;为 0 说明是重复回调,直接返回 200
}
return ResponseEntity.ok("ok");
}
各框架取原始请求体的方式:Express 用 express.raw()(并且不要全局挂 express.json(),
会提前吃掉原文),Koa 用 raw-body,FastAPI 用 await request.body(),
Next.js App Router 用 await request.text(),Nest 开启 rawBody: true,
Go 用 io.ReadAll(r.Body)(Gin 别用 ShouldBindJSON),
Spring 用 @RequestBody byte[](声明成 Map 或 DTO 就拿不到原文了)。
处理步骤
- 验签。
verify_webhook 抛错时返回 400,不处理业务
- 核对金额。事件里的
amount 必须与本地订单金额一致
- 幂等入账。以
out_trade_no 建唯一约束,重复回调直接返回 200
- 返回 200。耗时逻辑先落库再异步处理,不要让回调等待
事件类型
attach 是下单时传的附加数据,三种事件都原样带回:不去空格、不转码,中文和符号都行,传了空串就回空串;
下单没传就没有这个键。它不经过支付渠道,所以没有 subject 那些字符限制。
订单关闭不推送回调,需要时用查单接口获取。收到 refund.failed 表示这笔退款不会到账,
处理方式见查单与退款。
多个网站共用一个商户号
一个商户号给两个以上的网站(产品线)收款很常见。两个站用的是同一套后端,就不用配多个回调地址:
区分靠打标——下单时把站点写进单据,回调原样带回来。打标有两处:
out_trade_no 加站点前缀(A_、B_)。单号在商户号内必须唯一,两个站各自生成单号早晚会撞上 40901,
前缀是必须的;顺带三种回调事件都能靠它认出是哪个站的单
attach 写站点标识(如 site=B)。三种回调都原样带回,后台、财务看流水一眼能认;要同时放用户 ID 之类的
内容也行,总长不超过 128 字符即可
const localOrderNo = '20260911000042' // 站内自己的订单号
const order = await pay.create_order({
out_trade_no: 'B_' + localOrderNo, // 站点前缀:同一商户号下单号不能撞,回调也靠它认站
amount: '100.00',
subject: '积分充值100元',
pay_mode: 'qrcode',
attach: 'site=B', // 三种回调都原样带回,后台、财务看流水一眼能认
})
local_order_no = "20260911000042" # 站内自己的订单号
order = pay.create_order(
out_trade_no="B_" + local_order_no, # 站点前缀:同一商户号下单号不能撞,回调也靠它认站
amount="100.00",
subject="积分充值100元",
pay_mode="qrcode",
attach="site=B", # 三种回调都原样带回,后台、财务看流水一眼能认
)
localOrderNo := "20260911000042" // 站内自己的订单号
order, err := pay.CreateOrder(ctx, wantupay.CreateOrderParams{
OutTradeNo: "B_" + localOrderNo, // 站点前缀:同一商户号下单号不能撞,回调也靠它认站
Amount: "100.00",
Subject: "积分充值100元",
PayMode: "qrcode",
Attach: "site=B", // 三种回调都原样带回,后台、财务看流水一眼能认
})
String localOrderNo = "20260911000042"; // 站内自己的订单号
Map<String, Object> order = pay.createOrder(new WantuPay.CreateOrderParams()
.outTradeNo("B_" + localOrderNo) // 站点前缀:同一商户号下单号不能撞,回调也靠它认站
.amount("100.00")
.subject("积分充值100元")
.payMode("qrcode")
.attach("site=B")); // 三种回调都原样带回,后台、财务看流水一眼能认
两个站是同一套后端时,到这里就够了:回调按 out_trade_no 查到本地订单,站点自然就知道了。
两个站是两套独立部署、各有各的库,首选按单指定回调地址:每个站下单时传自己的
notify_url,回调各收各的。不想动回调方式的话,也可以让收回调的那个站(下面叫 A 站)当个管子:
不是自己的单就原样转给 B 站——请求体和三个签名头一个字节不动,B 站用同一个密钥自己验签,A 站不需要理解报文。
A 站把 B 站的应答状态透传回去:B 站没收好就回非 2xx,网关按正常节奏重试,B 站等于借用了网关的重试机制。
const SITE_B_WEBHOOK = 'https://b.example.com/wantu/webhook'
const SIGN_HEADERS = ['x-wantu-timestamp', 'x-wantu-nonce', 'x-wantu-signature', 'content-type']
app.post('/wantu/webhook', express.raw({ type: '*/*' }), async (req, res) => {
let event
try {
event = pay.verify_webhook(req.headers, req.body)
} catch (err) {
return res.sendStatus(400)
}
// B 站的单:请求体和签名头原样转过去,B 站自己验签;B 站没收好就回 502,网关会重试
if (String(event.data.out_trade_no ?? '').startsWith('B_')) {
const headers: Record<string, string> = {}
for (const name of SIGN_HEADERS) {
const value = req.headers[name]
if (typeof value === 'string') headers[name] = value
}
try {
const upstream = await fetch(SITE_B_WEBHOOK, {
method: 'POST', headers, body: req.body, signal: AbortSignal.timeout(5000),
})
return res.sendStatus(upstream.ok ? 200 : 502)
} catch {
return res.sendStatus(502)
}
}
// 下面是 A 站自己的入账逻辑,和「接收回调」一节一样
res.sendStatus(200)
})
import httpx # pip install httpx
from fastapi import Request, Response
from wantu_pay import WantuWebhookVerifyError
SITE_B_WEBHOOK = "https://b.example.com/wantu/webhook"
SIGN_HEADERS = ("X-Wantu-Timestamp", "X-Wantu-Nonce", "X-Wantu-Signature", "Content-Type")
@app.post("/wantu/webhook")
async def wantu_webhook(request: Request):
raw_body = await request.body()
try:
event = pay.verify_webhook(request.headers, raw_body)
except WantuWebhookVerifyError:
return Response(status_code=400)
# B 站的单:请求体和签名头原样转过去,B 站自己验签;B 站没收好就回 502,网关会重试
if str(event["data"].get("out_trade_no", "")).startswith("B_"):
headers = {h: request.headers[h] for h in SIGN_HEADERS if h in request.headers}
try:
async with httpx.AsyncClient(timeout=5) as client:
upstream = await client.post(SITE_B_WEBHOOK, content=raw_body, headers=headers)
except httpx.HTTPError:
return Response(status_code=502)
return Response(status_code=200 if upstream.is_success else 502)
# 下面是 A 站自己的入账逻辑,和「接收回调」一节一样
return {"ok": True}
const siteBWebhook = "https://b.example.com/wantu/webhook"
var signHeaders = []string{"X-Wantu-Timestamp", "X-Wantu-Nonce", "X-Wantu-Signature", "Content-Type"}
func webhook(w http.ResponseWriter, r *http.Request) {
raw, err := io.ReadAll(r.Body)
if err != nil {
w.WriteHeader(http.StatusBadRequest)
return
}
event, err := pay.VerifyWebhook(r.Header, raw)
if err != nil {
w.WriteHeader(http.StatusBadRequest)
return
}
// B 站的单:请求体和签名头原样转过去,B 站自己验签;B 站没收好就回 502,网关会重试
if strings.HasPrefix(event.Data.OutTradeNo, "B_") {
forward, err := http.NewRequestWithContext(r.Context(), http.MethodPost, siteBWebhook, bytes.NewReader(raw))
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
return
}
for _, name := range signHeaders {
if value := r.Header.Get(name); value != "" {
forward.Header.Set(name, value)
}
}
client := &http.Client{Timeout: 5 * time.Second}
resp, err := client.Do(forward)
if err != nil {
w.WriteHeader(http.StatusBadGateway)
return
}
resp.Body.Close()
if resp.StatusCode/100 != 2 {
w.WriteHeader(http.StatusBadGateway)
return
}
w.WriteHeader(http.StatusOK)
return
}
// 下面是 A 站自己的入账逻辑,和「接收回调」一节一样
w.WriteHeader(http.StatusOK)
}
import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
private static final String SITE_B_WEBHOOK = "https://b.example.com/wantu/webhook";
private static final String[] SIGN_HEADERS = {"X-Wantu-Timestamp", "X-Wantu-Nonce", "X-Wantu-Signature", "Content-Type"};
private static final HttpClient FORWARDER = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(3)).build();
@PostMapping("/wantu/webhook")
public ResponseEntity<String> webhook(@RequestHeader Map<String, String> headers,
@RequestBody byte[] rawBody) {
Map<String, Object> event;
try {
event = pay.verifyWebhook(headers, rawBody);
} catch (WantuPay.WebhookVerifyException e) {
return ResponseEntity.badRequest().build();
}
// B 站的单:请求体和签名头原样转过去,B 站自己验签;B 站没收好就回 502,网关会重试
@SuppressWarnings("unchecked")
Map<String, Object> data = (Map<String, Object>) event.get("data");
if (String.valueOf(data.get("out_trade_no")).startsWith("B_")) {
HttpRequest.Builder forward = HttpRequest.newBuilder(URI.create(SITE_B_WEBHOOK))
.timeout(Duration.ofSeconds(5))
.POST(HttpRequest.BodyPublishers.ofByteArray(rawBody));
for (String name : SIGN_HEADERS) {
if (headers.containsKey(name)) {
forward.header(name, headers.get(name));
}
}
try {
int status = FORWARDER.send(forward.build(), HttpResponse.BodyHandlers.discarding()).statusCode();
return status / 100 == 2 ? ResponseEntity.ok("ok") : ResponseEntity.status(502).build();
} catch (IOException | InterruptedException e) {
return ResponseEntity.status(502).build();
}
}
// 下面是 A 站自己的入账逻辑,和「接收回调」一节一样
return ResponseEntity.ok("ok");
}
几点说明:
- 转发要设超时(示例 5 秒):网关等单次投递最多 10 秒,别让 B 站慢把 A 站也拖超时
webhook.ping 测试事件的 data 里没有 out_trade_no,按上面的写法会走到 A 站自己的分支,返回 2xx 即可
- 站点再多,把「前缀 → 地址」做成一张表就行
- 两个站属于不同主体、钱要进不同的支付宝或银商账户,那是两个商户号,不是本节的场景
重试机制
回调返回非 2xx 或超时(支付成功那一刻的首投 3 秒,之后的重试 10 秒),网关按 1 分钟、5 分钟、10 分钟、30 分钟、1 小时、
3 小时、6 小时、12 小时的间隔重试,共 8 次。商户后台的测试回调也按 3 秒算,处理逻辑请先落库再异步做。
这意味着同一事件一定会重复送达,入账幂等不是可选项。全部重试失败的事件顽兔侧
会告警处理,商户也可以随时用查单接口核对状态。
验签规则
用 SDK 时不需要关心本节。自行验签的参考:
- 请求头:
X-Wantu-Timestamp(秒级时间戳)、X-Wantu-Nonce、X-Wantu-Signature
- 签名串三行拼接:
timestamp + "\n" + nonce + "\n" + 请求体原文
- 算法:HMAC-SHA256,输出 hex 小写
- 密钥:在商户后台「接入配置」启用了独立的
webhook_secret 就用它,否则用 api_secret;
两者都没有并存期,轮换 api_secret(未启用独立密钥时)或重新生成 webhook_secret 后,回调立即改用新密钥签名
- 时间戳与当前时间相差超过 300 秒应拒绝
- 网关投递不跟随跳转:地址返回 3xx 按失败重试,请直接配置最终地址