Webhook 处理
Webhook 是平台在订单进入终态时,发送到你服务器的实时通知。收款完成后,你用 webhook 里的金额给玩家上分。
配置回调 URL
在创建订单时用 returnUrl 指定:
{
"merchantId": "aef6025b-...",
"orderNo": "ORD-001",
"returnUrl": "https://your-game.com/api/webhook",
"playerPhone": "01711000000",
"playerRef": "player_1",
"signature": "<md5-sorted>"
}
回调格式
平台向你的 returnUrl 发送 POST 请求。
| 项 | 值 |
|---|---|
Content-Type | application/x-www-form-urlencoded(不是 JSON) |
| 签名 | 表单里的 signature 字段,采用与请求相同的 MD5 排序算法 |
| 你必须应答 | HTTP 200,且响应体为字面量 SUCCESS;否则平台重试 |
收款完成回调字段
| 字段 | 描述 |
|---|---|
merchantId | 你的商户 ID |
orderNo | 你提交的订单号 |
platformOrderNo | 平台订单 ID(去重键) |
amount | 实际到账金额,两位小数(用这个上分,不是创建订单时的金额) |
payType | 通道,如 BKASH / NAGAD / ROCKET |
payTime | 支付时间,Unix 秒 |
status | "1" = 已支付,"0" = 未支付 |
signature | MD5 排序签名 |
原始请求体示例(form-urlencoded):
merchantId=aef6025b-...&orderNo=ORD-001&platformOrderNo=0b8a4f32-...&amount=500.00&payType=BKASH&payTime=1714731342&status=1&signature=9f8c...
验证签名并处理
签名算法与请求签名完全相同:对除 signature 外的所有字段排序拼接、追加 &key=<secret>、再做 MD5。
Node.js
const crypto = require('crypto');
const express = require('express');
const app = express();
// 重要:webhook 是 form-urlencoded
app.use(express.urlencoded({ extended: false }));
function sign(params, apiSecret) {
const signStr = Object.keys(params)
.filter((k) => k !== 'signature' && params[k] != null)
.sort()
.map((k) => `${k}=${params[k]}`)
.join('&') + `&key=${apiSecret}`;
return crypto.createHash('md5').update(signStr).digest('hex').toLowerCase();
}
app.post('/api/webhook', (req, res) => {
const { signature, ...fields } = req.body;
if (sign(fields, process.env.API_SECRET) !== signature) {
return res.status(401).send('invalid signature');
}
if (req.body.status === '1') {
// 幂等:用 platformOrderNo 去重。用 amount 上分。
creditPlayer(req.body.orderNo, req.body.amount);
}
// 必须应答字面量 SUCCESS
res.send('SUCCESS');
});
Python
import hashlib, os
from flask import Flask, request
app = Flask(__name__)
def sign(params: dict, api_secret: str) -> str:
items = sorted(
(k, v) for k, v in params.items()
if k != 'signature' and v is not None
)
sign_str = '&'.join(f'{k}={v}' for k, v in items) + f'&key={api_secret}'
return hashlib.md5(sign_str.encode()).hexdigest().lower()
@app.route('/api/webhook', methods=['POST'])
def webhook():
fields = request.form.to_dict()
signature = fields.pop('signature', '')
if sign(fields, os.environ['API_SECRET']) != signature:
return 'invalid signature', 401
if request.form.get('status') == '1':
credit_player(request.form['orderNo'], request.form['amount'])
return 'SUCCESS', 200 # 必须是字面量 SUCCESS
响应要求
- 成功:HTTP 200 且响应体为字面量
SUCCESS。 - 任何其他响应(非 200、或 200 但响应体不是
SUCCESS)都会触发重试。
重试策略
投递失败时按以下间隔重试,最多 5 次:
| 第几次 | 距上次延迟 |
|---|---|
| 1 | 1 分钟 |
| 2 | 5 分钟 |
| 3 | 15 分钟 |
| 4 | 60 分钟 |
| 5 | 360 分钟(6 小时) |
5 次全部失败后停止。请实现幂等处理,并用 查询订单 兜底。
处理建议
- 幂等:同一订单的 webhook 可能被投递多次。用
platformOrderNo做去重键,重复投递直接应答SUCCESS不重复上分。 - 用 webhook 的
amount上分:创建订单时的金额只是会话上限,实际到账以 webhook 为准。 - 兜底:不要完全依赖 webhook;对未收到回调的订单周期性调用 查询订单。
测试 Webhook
请我们把你的商户标记为 demo 商户,创建订单时带 isTest: true,平台会注入合成 SMS 并向你的 returnUrl 触发真实格式的 webhook。本地可用 ngrok 暴露端点:
ngrok http 3000
# 用生成的 https URL 作为 returnUrl
安全检查清单
- 用
application/x-www-form-urlencoded解析请求体 - 对除
signature外的字段重算 MD5 排序签名并比对 - 仅在
status === '1'时上分,且用 webhook 的amount - 用
platformOrderNo去重 - 应答字面量
SUCCESS - 用 查询订单 周期兜底