Skip to content

异步回调通知 (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-Nonce32 字符的十六进制随机数
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. 响应要求、重试与去重规则 ​

  1. 响应要求:
    • 商户接收端在处理成功后,必须返回 HTTP 状态码 2xx(如 200 OK)。
    • 请求超时时间设定为 10 秒,超时将被判定为本次投递失败。
  2. 重试机制:
    • 若商户接口返回 5xx、429 Too Many Requests 或网络连接超时,网关将通过事务外发队列(Transactional Outbox)自动执行指数退避重试。
    • 若商户接口返回 4xx 客户端错误或 3xx 重定向响应,网关将视作终态失败(禁止重定向以防 DNS 变异)并转入死信队列(DLQ),不再自动重试。
  3. 幂等去重处理:
    • 极端网络抖动可能导致同一事件重复投递。商户系统必须根据 eventId 记录已消费凭证,若收到相同的 eventId,直接返回 200 OK 并跳过重复入账。