本章是 Payment Gateway 回調(也稱為 webhook 或通知)的權威參考。回調是閘道告知你的伺服器各種狀態變更的方式,這些變更不會出現在 POST /deposit 或 POST /withdrawal 同步回應中:訂單配對、完成、過期,或被上游駁回。如果你沒有實作回調處理器,就必須輪詢查詢端點,才能得知每一筆訂單的最終狀態。
要整合回調,你必須:
- 在建立訂單時註冊每筆訂單專屬的
callback_url。 - 在該 URL 實作一個接受 JSON 主體
POST的 HTTPS 端點。 - 在信任主體之前,先驗證每個請求的
X-Signature標頭。 - 回傳
2xx表示確認,或回傳5xx(或逾時)以要求重試。 - 透過
(order_no, status)讓處理器具備冪等性。
簽章協定與對內商戶請求所用的一致。演算法與參考實作請參考 Authentication & Signing。
概念與術語
| 術語 | 意義 |
|---|---|
| 回調 | Payment Gateway 發往商戶控制 URL 的已簽章 POST,攜帶 JSON 事件主體。僅限伺服器對伺服器。 |
callback_url | 商戶提供的目標 URL,在呼叫 POST /deposit 或 POST /withdrawal 時按訂單設定。正式環境必須為 HTTPS。 |
| NotificationLog | 閘道側的一筆紀錄,追蹤一個回調派送嘗試序列。儲存 URL、請求主體、回應狀態、回應主體、嘗試次數,以及目前的 NotificationStatus。 |
NotificationType | 決定發送 payload 形狀的事件識別碼。請見下方事件表。 |
| Ack | 商戶端點回傳的 2xx 回應。會把 NotificationLog 標記為 SUCCESS,並停止後續派送。 |
| 重試佇列 | Celery 任務 send_webhook_notification 加上週期性的 retry_failed_notifications 掃描。兩者一起實作 重試語意 所述的固定間隔重試策略。 |
| 冪等 | 你的處理器可能不只一次收到同一個回調。在套用副作用之前,先以 (order_no, status) 去重。 |
| 對帳 | 當所有派送嘗試都失敗時,你的本地狀態會與閘道分歧。你要透過輪詢 /query/* 端點來恢復。 |
何時觸發回調
只要訂單或平台事件達到下列狀態,就會把回調入列。實際的 notification_type 值是穩定的,來自後端的 NotificationType enum。
| 觸發條件 | notification_type | 攜帶的 event 欄位 | 訂單類型 |
|---|---|---|---|
| 充值與銀行交易配對(渠道綁定帳號訂單) | deposit_matched | deposit.matched | 充值 |
| 充值完全完成並入帳至商戶餘額 | deposit_completed | deposit.completed | 充值 |
| 充值在付款配對前過期 | deposit_expired | deposit.expired | 充值 |
| 充值被操作員取消 | deposit_cancelled | deposit.cancelled(status cancelled) | 充值 |
| 提款由操作員核准並排入上游處理 | withdrawal_approved | withdrawal.approved | 提款 |
| 提款由上游渠道成功結算 | withdrawal_completed | withdrawal.completed | 提款 |
| 提款在派送前被操作員駁回 | withdrawal_rejected | withdrawal.rejected | 提款 |
| 提款在派送後於上游渠道失敗 | withdrawal_failed | withdrawal.failed | 提款 |
| 租戶擁有者邀請連結已發出 | tenant_owner_invite | 平台層事件,不綁訂單 | n/a |
| 為該商戶開立計費對帳單 | billing_statement_issued | 平台層事件,不綁訂單 | n/a |
商戶 API 介面大多只在意充值與提款事件。tenant_owner_invite 與 billing_statement_issued 會發送到平台管理的位址,而非商戶 callback_url。
只有當訂單具備非 null 的 callback_url 時才會觸發回調。如果你建立充值或提款時沒帶 callback_url,就不會建立 NotificationLog 紀錄,你必須自行輪詢狀態。
回調請求形狀
所有回調都是 HTTP POST 發往你的 callback_url,內容如下:
| 面向 | 值 |
|---|---|
| Method | POST |
Content-Type | application/json |
X-API-Key | 你的商戶 API key。與發給你的對內呼叫所用相同。 |
X-Timestamp | 派送時的 Unix epoch 秒數。 |
X-Signature | 對排序後的 JSON 主體參數加上你的 API key 取 MD5 後的小寫十六進位。 |
| Body | 一個 JSON 物件,欄位依 notification_type 而定。請見下方範例。 |
| 連線逾時 | 預設 10 秒(Merchant.config.callback_timeout_seconds)。慢速處理器會視為失敗並觸發重試。 |
| TLS | 閘道會驗證商戶 TLS 憑證。正式環境拒絕自簽憑證。 |
標頭
標頭的認證語意與對內請求相同,方向反過來:你(商戶)現在是接收端,但用來簽章的 API key 仍然是你的。
POST /your/callback/path HTTP/1.1
Host: callbacks.your-domain.tld
Content-Type: application/json
X-API-Key: mk_live_abc123...
X-Timestamp: 1747756800
X-Signature: 7f3c2b1a0d9e8f4c6a5b...簽章是對主體欄位(也就是閘道要發給你的同一份 JSON)計算的:依 key 排序、以 k=v&... 串接,再接上 API key。沒有另一組要簽的標頭——閘道要你信任的每一個值都在主體裡。請見下方驗證簽章。
Body — deposit completed
{
"event": "deposit.completed",
"order_no": "D202605200000001",
"merchant_order_no": "MO-20260520-0001",
"amount": "1000.00",
"actual_amount": "1000.00",
"currency": "THB",
"status": "completed",
"matched_at": "2026-05-20T08:14:32.123456+00:00",
"completed_at": "2026-05-20T08:14:33.456789+00:00"
}| 欄位 | 型別 | 說明 |
|---|---|---|
event | string | notification_type 的點記法形式。要分支判斷時用這個,而不是 message 文字。 |
order_no | string | 閘道指派的訂單號。穩定識別碼。 |
merchant_order_no | string | 你在建立時提供的識別碼。用於冪等查找。 |
amount | string | 請求金額,以十進位字串表示。 |
actual_amount | string | 結算金額。對於浮動配對的銀行轉帳,可能與 amount 不同。 |
currency | string | ISO 4217 幣別代碼(例如 THB)。 |
status | string | 派送當下的訂單狀態。對 deposit.completed 而言為 "completed"。 |
matched_at | ISO 8601 string | null | 閘道配對訂單的時間。 |
completed_at | ISO 8601 string | null | 訂單結算完成的時間。 |
Body — deposit matched
形狀與 deposit.completed 相同,但 status: "matched",且 completed_at 尚未設定。只有當訂單透過會在 COMPLETED 之前回報 MATCHED 的渠道時才會發送(典型情況是渠道綁定帳號訂單)。
Body — deposit expired
{
"event": "deposit.expired",
"order_no": "D202605200000002",
"merchant_order_no": "MO-20260520-0002",
"amount": "1000.00",
"actual_amount": "0.0000",
"currency": "THB",
"status": "expired",
"expired_at": "2026-05-20T08:30:00.000000+00:00"
}actual_amount 一律會以字串帶出。充值過期時沒有實際到帳金額,因此值為 "0.0000"。
Body — deposit cancelled
{
"event": "deposit.cancelled",
"order_no": "D202605200000003",
"merchant_order_no": "MO-20260520-0003",
"status": "cancelled",
"amount": "1000.00",
"actual_amount": "0.0000",
"currency": "THB",
"currency_code": "THB",
"cancelled_at": "2026-05-20T09:00:00.000000+00:00"
}actual_amount 一律會以字串帶出。充值取消時沒有實際到帳金額,因此值為 "0.0000"。
注意:currency_code 是 deposit.cancelled 保留的相容性別名。請優先使用與其他充值回調一致的標準 currency 欄位。
Body — withdrawal events
提款回調遵循與充值相同的信封慣例。實際欄位集會隨 notification_type 而異;務必以 event 欄位來分支,並把未知的額外欄位視為向前相容的新增。
每一個提款回調都可以仰賴的欄位是:order_no、merchant_order_no、status、amount、currency(或 currency_code),以及與事件對應的時間戳記(approved_at、completed_at、rejected_at,或 failed_at)。事件專屬欄位記載於 Withdrawals 章節。
流程
下圖描繪單一回調從派送到終態的軌跡。重試迴圈是固定間隔,不是指數退避。
兩點實務說明:
Celery worker與Retry sweep是兩個獨立的任務。worker 透過self.retry(countdown=30)處理 inline 重試。掃描則是安全網,撿起 worker 漏掉的紀錄(程序重啟、崩潰,或SENDING紀錄卡超過 5 分鐘)。- 跨重試時 HTTP 主體與簽章是位元組相同的。每次派送時會重新計算
X-Timestamp標頭。
驗證簽章
使用與 Authentication 章節隨附的相同參考樣本。它們經過與後端 verify_md5_signature 實作的位元組對位元組同位測試。若 4 個樣本中任一個分歧,CI 會失敗。
#!/usr/bin/env bash
# Verify MD5 signature for Payment Gateway merchant callbacks (bash reference).
#
# Usage:
# ./verify.sh --api-key <key> --params 'k1=v1&k2=v2&...' --signature <sig>
#
# Output:
# stdout: "valid" or "invalid"
# exit code: 0 if valid, 1 if invalid
set -euo pipefail
API_KEY=""
PARAMS=""
SIGNATURE=""
while [[ $# -gt 0 ]]; do
case "$1" in
--api-key) API_KEY="$2"; shift 2 ;;
--params) PARAMS="$2"; shift 2 ;;
--signature) SIGNATURE="$2"; shift 2 ;;
*) echo "Unknown flag: $1" >&2; exit 2 ;;
esac
done
# Recompute the expected signature using the same algorithm as sign.sh
SORTED=$(printf '%s' "$PARAMS" | tr '&' '\n' | LC_ALL=C sort | grep -v '^$' | tr '\n' '&' | sed 's/&$//')
SIGN_STRING="${SORTED}${API_KEY}"
if command -v md5sum >/dev/null 2>&1; then
EXPECTED=$(printf '%s' "$SIGN_STRING" | md5sum | cut -d' ' -f1)
else
EXPECTED=$(printf '%s' "$SIGN_STRING" | md5)
fi
# Lowercase normalisation for case-insensitive comparison
EXPECTED_LC=$(printf '%s' "$EXPECTED" | tr '[:upper:]' '[:lower:]')
PROVIDED_LC=$(printf '%s' "$SIGNATURE" | tr '[:upper:]' '[:lower:]')
if [[ "$EXPECTED_LC" == "$PROVIDED_LC" ]]; then
echo "valid"
exit 0
else
echo "invalid"
exit 1
fi#!/usr/bin/env python3
"""Reference implementation: verify MD5 signature for Payment Gateway merchant callbacks.
Usage:
python verify.py --api-key <key> --params 'k1=v1&k2=v2&...' --signature <sig>
Output:
stdout: "valid" or "invalid"
exit code: 0 if valid, 1 if invalid
Algorithm:
1. Recompute the expected signature using the same algorithm as sign.py.
2. Compare against the provided signature using a constant-time comparison.
"""
import argparse
import hashlib
import hmac
import sys
from urllib.parse import parse_qsl
def create_signature(params: dict[str, str], api_key: str) -> str:
sorted_items = sorted(params.items())
query_string = "&".join(f"{k}={v}" for k, v in sorted_items if v is not None)
return hashlib.md5((query_string + api_key).encode("utf-8")).hexdigest()
def verify(params: dict[str, str], api_key: str, signature: str) -> bool:
expected = create_signature(params, api_key)
return hmac.compare_digest(expected.lower(), signature.lower())
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--api-key", required=True)
parser.add_argument("--params", required=True)
parser.add_argument("--signature", required=True)
args = parser.parse_args()
params = dict(parse_qsl(args.params, keep_blank_values=True))
if verify(params, args.api_key, args.signature):
print("valid")
return 0
print("invalid")
return 1
if __name__ == "__main__":
sys.exit(main())<?php
/**
* Verify MD5 signature for Payment Gateway merchant callbacks (PHP reference).
*
* Usage:
* php verify.php --api-key=<key> --params='k=v&...' --signature=<sig>
*
* Output:
* stdout: "valid" or "invalid"
* exit code: 0 if valid, 1 if invalid
*
* Note: getopt() requires `--flag=value` form (no space between flag and value).
*/
$opts = getopt('', ['api-key:', 'params:', 'signature:']);
if (!isset($opts['api-key'], $opts['params'], $opts['signature'])) {
fwrite(STDERR, "Usage: php verify.php --api-key=<key> --params='k=v&...' --signature=<sig>\n");
exit(2);
}
parse_str($opts['params'], $params);
ksort($params, SORT_STRING);
$parts = [];
foreach ($params as $k => $v) {
if ($v === null) continue;
$parts[] = "$k=$v";
}
$expected = md5(implode('&', $parts) . $opts['api-key']);
if (hash_equals(strtolower($expected), strtolower($opts['signature']))) {
echo "valid";
exit(0);
}
echo "invalid";
exit(1);#!/usr/bin/env node
/**
* Verify MD5 signature for Payment Gateway merchant callbacks (Node.js / TypeScript reference).
*
* Usage:
* tsx verify.ts --api-key <key> --params 'k=v&...' --signature <sig>
*
* Output:
* stdout: "valid" or "invalid"
* exit code: 0 if valid, 1 if invalid
*/
import { createHash, timingSafeEqual } from "crypto";
function parseArgs(argv: string[]): { apiKey: string; params: string; signature: string } {
let apiKey = "", params = "", signature = "";
for (let i = 2; i < argv.length; i++) {
if (argv[i] === "--api-key") apiKey = argv[++i];
else if (argv[i] === "--params") params = argv[++i];
else if (argv[i] === "--signature") signature = argv[++i];
}
if (!apiKey || !params || !signature) {
process.stderr.write("Usage: verify.ts --api-key <key> --params 'k=v&...' --signature <sig>\n");
process.exit(2);
}
return { apiKey, params, signature };
}
function expectedSignature(paramsQuery: string, apiKey: string): string {
const parsed = new Map<string, string>();
for (const pair of paramsQuery.split("&")) {
if (!pair) continue;
const eq = pair.indexOf("=");
const k = eq >= 0 ? pair.slice(0, eq) : pair;
const v = eq >= 0 ? pair.slice(eq + 1) : "";
parsed.set(k, v);
}
const sorted = [...parsed.entries()].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
const query = sorted.map(([k, v]) => `${k}=${v}`).join("&");
return createHash("md5").update(query + apiKey, "utf8").digest("hex");
}
function constantTimeEquals(a: string, b: string): boolean {
const aBuf = Buffer.from(a.toLowerCase(), "utf8");
const bBuf = Buffer.from(b.toLowerCase(), "utf8");
if (aBuf.length !== bBuf.length) return false;
return timingSafeEqual(aBuf, bBuf);
}
const { apiKey, params, signature } = parseArgs(process.argv);
const expected = expectedSignature(params, apiKey);
if (constantTimeEquals(expected, signature)) {
process.stdout.write("valid");
process.exit(0);
}
process.stdout.write("invalid");
process.exit(1);以文字敘述,驗證協定為:
- 把 JSON 主體解析為字串 key 對字串 value 的扁平映射。值為
null的 key 跳過。 - 依字母順序(ASCII 順序)對 key 排序。
- 組出查詢字串
k1=v1&k2=v2&...。 - 附加你的 API key(不加
&分隔符)。 - 對 UTF-8 位元組計算 MD5,輸出小寫十六進位。
- 使用常數時間比較(Python 的
hmac.compare_digest、PHP 的hash_equals、Node.js 的crypto.timingSafeEqual)與X-Signature比對。
若驗證失敗,立即回傳 401,不要套用任何副作用。驗證失敗幾乎一定代表:API key 錯誤、主體被代理修改,或是你排序了錯誤的 key 集合(例如,你把標頭納入了簽章集合)。
範例
範例 1 — Python(FastAPI)完整回調處理器
from datetime import UTC, datetime
import hashlib
import hmac
import json
from fastapi import FastAPI, HTTPException, Request, Response
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from .db import async_session
from .models import ProcessedCallback # (order_no, status) primary key
API_KEY = "mk_live_abc123..." # never hardcode in prod; read from secrets manager
TIMESTAMP_TOLERANCE_SECONDS = 300 # ±5 minutes
app = FastAPI()
def expected_signature(params: dict, api_key: str) -> str:
items = sorted((k, v) for k, v in params.items() if v is not None)
qs = "&".join(f"{k}={v}" for k, v in items)
return hashlib.md5((qs + api_key).encode("utf-8")).hexdigest()
@app.post("/callbacks/payment-gateway")
async def receive_callback(request: Request) -> Response:
# 1. Read raw body; parse JSON once for both signing and routing.
raw = await request.body()
try:
body = json.loads(raw)
except json.JSONDecodeError:
raise HTTPException(status_code=400, detail="invalid json")
# 2. Verify signature.
signature = request.headers.get("x-signature", "")
expected = expected_signature(body, API_KEY)
if not hmac.compare_digest(expected, signature.lower()):
raise HTTPException(status_code=401, detail="bad signature")
# 3. Verify timestamp window.
try:
ts = int(request.headers.get("x-timestamp", "0"))
except ValueError:
raise HTTPException(status_code=401, detail="bad timestamp")
now = int(datetime.now(UTC).timestamp())
if abs(now - ts) > TIMESTAMP_TOLERANCE_SECONDS:
raise HTTPException(status_code=401, detail="timestamp out of window")
# 4. Idempotency check.
order_no = body["order_no"]
status = body["status"]
async with async_session() as db: # type: AsyncSession
existing = await db.execute(
select(ProcessedCallback)
.where(ProcessedCallback.order_no == order_no)
.where(ProcessedCallback.status == status)
)
if existing.scalar_one_or_none() is not None:
return Response(status_code=200) # already processed; ack
# 5. Apply side effects (mark order paid, release goods, etc.).
await apply_order_state(db, body)
# 6. Record the (order_no, status) pair so a retry would be a no-op.
db.add(ProcessedCallback(order_no=order_no, status=status))
await db.commit()
return Response(status_code=200)
async def apply_order_state(db: AsyncSession, body: dict) -> None:
# Business logic specific to your application.
...這大致是最小的正確處理器。先驗證、去重,然後提交。
範例 2 — 以 (order_no, status) 做冪等
最簡單的去重表:
CREATE TABLE processed_callbacks (
order_no varchar(64) NOT NULL,
status varchar(32) NOT NULL,
received_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (order_no, status)
);每次成功套用回調時都插入。重複插入會在主鍵上失敗,你的處理器就會短路回傳 200。
為什麼是 (order_no, status),而不是 notification_id 或 X-Timestamp?
- Payment Gateway 在重試之間重發同一筆
NotificationLog紀錄;沒有每次嘗試專屬、你可以仰賴的 event id。 X-Timestamp在重試之間會變。以它做去重會讓同一個事件套用 5 次。- 一筆訂單可能經過數個狀態(例如先
MATCHED後COMPLETED);每一次轉換都是合法且不同的回調,你確實要處理。(order_no, status)同時允許MATCHED與COMPLETED,又能阻擋同一狀態內的重複。
範例 3 — 5xx 回應觸發重試
回傳任何 5xx(或關閉連線,或耗時超過設定的逾時),閘道就會重試。派送時間表:
| 嘗試 | 時間偏移 | 嘗試後的 NotificationStatus |
|---|---|---|
| 1 | T+0 | WAITING_RETRY |
| 2 | T+30s | WAITING_RETRY |
| 3 | T+60s | WAITING_RETRY |
| 4 | T+90s | WAITING_RETRY |
| 5 | T+120s | FAILED |
你可以在兩處觀察這些嘗試:
- merchant-portal 的 Notifications 紀錄(操作員視角的
NotificationLog)。 - 你自己的請求日誌。每次重試都會帶來相同的主體,以及新的
X-Timestamp/X-Signature。
從首次嘗試到最終 FAILED 的牆鐘時間大約是 2 分鐘。如果你的端點短暫斷線,全部 5 次嘗試可能都撞上同一段中斷,耗盡重試。
範例 4 — 透過 /query/* 拉取漏掉的回調
當重試額度耗盡時,閘道就放棄了。要恢復,請在你這邊執行一個排程對帳器:
# Pseudocode: every 5 minutes, reconcile orders that should have been finalized.
async def reconcile() -> None:
# Find local orders we created in the last 24h that are still pending
# without a corresponding completed callback in processed_callbacks.
stale = await find_stale_pending_orders(older_than_seconds=600)
for order in stale:
# Use merchant_order_no, the identifier you control.
remote = await gateway.query_deposit(merchant_order_no=order.merchant_order_no)
if remote["status"] in ("completed", "expired", "cancelled"):
await apply_terminal_state(order, remote)GET /api/v1/merchant/query/deposit/{order_no}(或 GET /api/v1/merchant/query/deposit?merchant_order_no=...)與對應的提款端點 /api/v1/merchant/query/withdrawal/{order_no}、/api/v1/merchant/query/withdrawal?merchant_order_no=... 記載於 充值 與 提款。當訂單在你的紀錄中可能尚未有閘道 order_no 時,務必以 merchant_order_no(你的)查詢。
安全的預設排程是:每 5 分鐘對帳一次,對象為超過 10 分鐘且尚未到達終態的訂單。這同時涵蓋耗盡的重試與罕見的回調遺失。
重試語意
實作於 backend/app/tasks/notification.py 的精確策略:
max_attempts:5。第 5 次嘗試仍未成功後,NotificationStatus會被設為FAILED,不再有後續派送。RETRY_DELAYS:[30, 30, 30, 30, 30]——嘗試之間固定 30 秒間隔。這不是指數退避。default_retry_delay:30 秒,當 Celery 重新排程任務時套用。- 單次嘗試逾時:10 秒(
Merchant.config.callback_timeout_seconds,可按商戶設定)。慢速回應會被視為失敗。 - 4xx 行為:你的處理器回傳
4xx即為終態失敗。閘道不會重試。意圖是:如果商戶在拒絕回調的形狀,以同一形狀重試也毫無意義。 - 5xx / 逾時 / 網路錯誤行為:視為暫時性失敗。
NotificationLog.status變為WAITING_RETRY,並設定next_attempt_at = now + 30s。Celery worker 透過self.retry(countdown=30)重新排程。 - 卡在 SENDING 的恢復:次要週期任務(
retry_failed_notifications)每分鐘掃描一次,搶救那些超過 5 分鐘未變更狀態的SENDING紀錄。它們會被重置為WAITING_RETRY並重新派送。 - 總派送窗口:從首次嘗試到最終
FAILED判定大約 2 分鐘(首次嘗試加上 4 個 30 秒間隔)。
同一訂單不會自動建立兩筆 NotificationLog 紀錄。透過 merchant portal 由操作員發起的重試會把 attempt_count 重置為 0 並重新啟用同一筆紀錄。它們不會建立第二筆紀錄。
陷阱
- 回調 URL 在正式環境必須是 HTTPS。 閘道要求有效的 TLS 憑證。自簽憑證會被拒絕(
webhook_ssl_verify = True預設為開)。請使用來自任何公開 CA 的真實憑證。 - 慢速回應視為失敗。 預設單次嘗試逾時為 10 秒。把任何重活(結算商品、寄送郵件、寫入慢速下游系統)移到你這邊的背景佇列。先以
2xxack;之後再做事。 - 在伺服器端驗證簽章。 永遠不要接受源自瀏覽器的回調。永遠不要把 API key 嵌進客戶端程式碼。簽章 key 與你用於對外請求的相同。
- 冪等必須以
(order_no, status)為依據。 閘道在全部 5 次重試之間重用同一筆NotificationLog紀錄;沒有每次嘗試專屬的 event id。以X-Timestamp去重是錯的,因為時間戳記在重試之間會變。 4xx為終態——重試額度就此用完。 一旦你回4xx,閘道就標記為FAILED並停止。如果你不小心回了4xx(例如驗證器中的暫時 bug),你必須透過查詢端點對帳。沒有「請求重試」的 API。- 亂序回調會發生。 在從中斷恢復的過程中,
deposit.matched可能在deposit.completed之後抵達。你的冪等表能防止重複套用狀態,但你的業務邏輯應該接受終態,無論中間狀態是否送達。 - 不要在驗證前記錄原始主體。 在檢查
X-Signature前把主體當作可信,是一個權限提升的攻擊面。先驗證,再記錄。 - 以
event或notification_type做分支,絕不要用message。message是人類可讀標籤;它不是契約的一部分,可能會變動。 datakey 隨事件類型而異。deposit.completed帶matched_at與completed_at;deposit.expired帶expired_at;deposit.cancelled使用currency_code,其他則使用currency。要寬容地解析。- 事件之間存在欄位名稱漂移。 有些 payload 使用
currency(ISO 代碼),而deposit.cancelled基於歷史原因使用currency_code。把兩種 key 視為等價。 - 不要把 30 秒重試間隔硬寫進你的監控。 此間隔是後端常數。如果未來版本更動了,硬寫它的監控會誤報警。需要實際排定時間時,請讀
NotificationLog.next_attempt_at。 - 重試耗盡時,你的狀態會分歧。 該事件不會再有後續回調抵達。你必須輪詢
/query/*來收斂。上線前先把輪詢器建好。
關於你可能回給閘道的完整錯誤碼清單(在主體中,而非以 HTTP 語意),請見 Errors。
上線檢查清單
在把正式流量導向你的回調處理器之前,逐項確認:
- [ ] 回調 URL 為 HTTPS 且憑證鏈有效。沒有自簽憑證。
- [ ] URL 可從閘道 egress IP 到達(測試環境 IP 見 Sandbox;正式 IP 請洽平台操作員)。
- [ ] 在預期並發下,中位回應時間低於 5 秒。 硬性逾時為 10 秒;留餘裕。
- [ ] 每個請求都會跑簽章驗證。 沒有「內部回調」的例外。
- [ ] 以
(order_no, status)為 key 的冪等表 已就位並已建立索引。 - [ ] 對帳器以排程運行(建議:每 5 分鐘對超過 10 分鐘的訂單運行一次),呼叫充值與提款的查詢端點。
- [ ] 日誌包含
X-Timestamp、order_no、status、簽章驗證結果。 原始主體僅在驗證後記錄,並對 PII 做遮罩。 - [ ] 下列情況觸發告警:
NotificationLog.status = FAILED比率超過門檻時,或對帳將訂單翻轉到終態卻沒有成功回調時。 - [ ] runbook 已就緒,回答:「一次中斷讓 100 個回調 FAIL——我們如何恢復?」答案就是對帳器;驗證它能用。
交叉參考
- Authentication & Signing — 本章使用的簽章演算法與驗證樣本來源。
- Errors — 對內請求的錯誤信封、錯誤碼與重試指引。
- API Reference — 完整端點參考,包含
/query/*。 - Sandbox — 如何在測試環境中(包含 mock channel)端對端驅動充值與提款狀態。
- Deposits — 充值狀態機,以及觸發每個充值回調的事件。
- Withdrawals — 提款狀態機,以及提款回調的事件專屬欄位。
- Quickstart — 從首次請求到首次驗證回調的 15 分鐘路徑。