異步回調通知 (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並跳過重複入帳。
- 極端網路抖動可能導致同一事件重複投遞。商戶系統必須根據
