跳到主要内容

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-Typeapplication/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" = 未支付
signatureMD5 排序签名

原始请求体示例(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 次:

第几次距上次延迟
11 分钟
25 分钟
315 分钟
460 分钟
5360 分钟(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
  • 查询订单 周期兜底

下一步