异步回调通知 (Webhooks)
当订单状态发生变更(如买家完成付款、承兑商上传打款凭证、代付完成或订单取消)时,SureLink 网关会主动向商户在后台预先配置的 Webhook 回调地址 发送 HTTP POST 请求,以便商户系统实时更新本地订单状态并推进后续业务。
1. 回调机制与安全规范
1.1 网关外发请求头 (Headers)
网关发出的每个 Webhook POST 请求均包含与商户入站请求对称对齐的 HMAC 签名校验头:
| 请求头字段 | 说明 |
|---|---|
Content-Type | 固定为 application/json |
User-Agent | 固定为 otc-gateway-webhook/1.0 |
X-Api-Key | 接收该事件的商户 API Key |
X-Timestamp | 毫秒级 Unix 时间戳字符串 |
X-Nonce | 32 字符的十六进制随机数 |
X-Signature | 网关使用商户 API Secret 计算出的 HMAC-SHA256 签名小写 Hex |
1.2 签名校验公式
商户服务端在收到 Webhook 请求后,必须提取原始字节流(Raw Body)进行验签:
$$\text{Expected Signature} = \operatorname{HMAC-SHA256}_{\text{API Secret}}(\text{X-Timestamp} + \text{"."} + \text{X-Nonce} + \text{"."} + \text{RawRequestBody})$$
商户将计算结果与请求头中的 X-Signature 进行比对,必须完全一致方可信任本条报文。
2. 报文协议与事件类型
Webhook 请求体包含统一的事件外层包装:
json
{
"eventId": "c7a8b9d0-1234-5678-9abc-def012345678",
"occurredAt": 1715000000000,
"type": "order.completed",
"data": { ... }
}2.1 事件类型字典
事件类型 (type) | 业务触发时机 | 对应方向 |
|---|---|---|
order.completed | 法币充值订单交易成功,代币已记账 | 入金 (DEPOSIT) |
order.cancelled | 法币充值订单超时取消、用户自主取消或争议取消 | 入金 (DEPOSIT) |
payout.proof_submitted | 承兑商已汇出法币并上传银行打款凭证图片 | 代付 (WITHDRAW) |
payout.completed | 商户确认放行(或客服仲裁放行),代付最终完成 | 代付 (WITHDRAW) |
payout.disputed | 代付订单进入争议仲裁流程 | 代付 (WITHDRAW) |
payout.cancelled | 代付订单被取消,商户冻结资产已解冻退回 | 代付 (WITHDRAW) |
2.2 充值订单完成通知 (order.completed)
json
{
"eventId": "f47ac10b-58cc-4372-a567-0e02b2c3d479",
"occurredAt": 1715000000000,
"type": "order.completed",
"data": {
"orderId": "698c56fa-b0f1-460d-8302-39c09c916781",
"orderNo": "ORD20261011000001",
"merchantOrderNo": "MCH_PAYIN_20261011_001",
"status": "COMPLETED",
"direction": "DEPOSIT",
"assetSymbol": "USDT",
"assetAmount": "100.000000",
"fiatCurrency": "CNY",
"fiatAmount": "725.50",
"fxRate": "7.2550",
"paymentSubmittedAt": "2026-10-11T08:38:12.000Z",
"completedAt": "2026-10-11T08:40:05.000Z",
"chainTxKey": null,
"createdAt": "2026-10-11T08:35:00.000Z",
"updatedAt": "2026-10-11T08:40:05.000Z"
}
}2.3 代付凭证上传通知 (payout.proof_submitted)
承兑商已向收款人汇款并上传回单,商户收到后可在商户后台或自有网银核验到账情况:
json
{
"eventId": "8b9c0d1e-2345-6789-abcd-ef0123456789",
"occurredAt": 1715000100000,
"type": "payout.proof_submitted",
"data": {
"orderId": "3b2e5a7d-8f90-4c12-9e34-5a6b7c8d9e0f",
"orderNo": "WD20261011000088",
"merchantOrderNo": "MCH_PAYOUT_20261011_888",
"status": "PAYMENT_SUBMITTED",
"direction": "WITHDRAW",
"fiatCurrency": "CNY",
"lockedFiatAmount": "1000.00",
"merchantActionDueAt": "2026-10-11T11:00:00.000Z",
"paymentDueAt": "2026-10-11T09:30:00.000Z",
"firstProofSubmittedAt": "2026-10-11T09:15:30.000Z",
"completedAt": null,
"reason": null,
"createdAt": "2026-10-11T09:00:00.000Z",
"updatedAt": "2026-10-11T09:15:30.000Z"
}
}3. 商户端验签代码示例
typescript
import express from 'express';
import { createHmac, timingSafeEqual } from 'node:crypto';
const app = express();
// 必须获取原始 Raw Body 字节流进行验签
app.post('/api/webhook', express.raw({ type: 'application/json' }), (req, res) => {
const apiKey = req.headers['x-api-key'] as string;
const timestamp = req.headers['x-timestamp'] as string;
const nonce = req.headers['x-nonce'] as string;
const receivedSig = req.headers['x-signature'] as string;
const rawBody = req.body.toString('utf8');
const myApiSecret = process.env.SURELINK_API_SECRET!;
// 1. 拼接源串并计算预期签名
const source = `${timestamp}.${nonce}.${rawBody}`;
const expectedSig = createHmac('sha256', myApiSecret).update(source).digest('hex');
// 2. 常量时间安全比对 (防时序侧信道攻击)
const isMatch = receivedSig.length === expectedSig.length &&
timingSafeEqual(Buffer.from(receivedSig, 'hex'), Buffer.from(expectedSig, 'hex'));
if (!isMatch) {
console.error('Webhook 签名不匹配,拒绝处理');
return res.status(401).send('Invalid signature');
}
// 3. 校验通过,解析 JSON 并进行业务处理
const payload = JSON.parse(rawBody);
console.log(`收到事件: ${payload.type}, eventId: ${payload.eventId}`);
// 必须返回 2xx 告知网关投递成功
res.status(200).send('OK');
});python
from fastapi import FastAPI, Request, HTTPException
import hmac
import hashlib
import json
import os
app = FastAPI()
API_SECRET = os.getenv("SURELINK_API_SECRET", "your_secret")
@app.post("/api/webhook")
async def handle_webhook(request: Request):
api_key = request.headers.get("x-api-key")
timestamp = request.headers.get("x-timestamp")
nonce = request.headers.get("x-nonce")
received_sig = request.headers.get("x-signature")
raw_body = (await request.body()).decode("utf-8")
# 构造签名串
source = f"{timestamp}.{nonce}.{raw_body}"
expected_sig = hmac.new(
API_SECRET.encode("utf-8"),
source.encode("utf-8"),
hashlib.sha256
).hexdigest()
if not hmac.compare_digest(received_sig, expected_sig):
raise HTTPException(status_code=401, detail="Invalid signature")
event = json.loads(raw_body)
# 处理业务逻辑...
return {"status": "success"}4. 响应要求、重试与去重规则
- 响应要求:
- 商户接收端在处理成功后,必须返回 HTTP 状态码 2xx(如
200 OK)。 - 请求超时时间设定为 10 秒,超时将被判定为本次投递失败。
- 商户接收端在处理成功后,必须返回 HTTP 状态码 2xx(如
- 重试机制:
- 若商户接口返回
5xx、429 Too Many Requests或网络连接超时,网关将通过事务外发队列(Transactional Outbox)自动执行指数退避重试。 - 若商户接口返回
4xx客户端错误或3xx重定向响应,网关将视作终态失败(禁止重定向以防 DNS 变异)并转入死信队列(DLQ),不再自动重试。
- 若商户接口返回
- 幂等去重处理:
- 极端网络抖动可能导致同一事件重复投递。商户系统必须根据
eventId记录已消费凭证,若收到相同的eventId,直接返回200 OK并跳过重复入账。
- 极端网络抖动可能导致同一事件重复投递。商户系统必须根据
