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 並跳過重複入帳。