Skip to content

本章是 Payment Gateway 回调(也称为 webhook 或通知)的权威参考。回调是网关告知你的服务器各种状态变更的方式,这些变更不会出现在 POST /depositPOST /withdrawal 同步响应中:订单配对、完成、过期,或被上游驳回。如果你没有实作回调处理器,就必须轮询查询端点,才能得知每一笔订单的最终状态。

要整合回调,你必须:

  1. 在建立订单时注册每笔订单专属的 callback_url
  2. 在该 URL 实作一个接受 JSON 主体 POST 的 HTTPS 端点。
  3. 在信任主体之前,先验证每个请求的 X-Signature 标头。
  4. 回传 2xx 表示确认,或回传 5xx(或超时)以要求重试。
  5. 透过 (order_no, status) 让处理器具备幂等性。

签名协定与对内商户请求所用的一致。算法与参考实作请参考 Authentication & Signing

概念与术语

术语意义
回调Payment Gateway 发往商户控制 URL 的已签名 POST,携带 JSON 事件主体。仅限服务器对服务器。
callback_url商户提供的目标 URL,在调用 POST /depositPOST /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_matcheddeposit.matched充值
充值完全完成并入账至商户余额deposit_completeddeposit.completed充值
充值在付款配对前过期deposit_expireddeposit.expired充值
充值被运营人员取消deposit_cancelleddeposit.cancelled(status cancelled充值
提款由运营人员核准并排入上游处理withdrawal_approvedwithdrawal.approved提款
提款由上游渠道成功结算withdrawal_completedwithdrawal.completed提款
提款在派送前被运营人员驳回withdrawal_rejectedwithdrawal.rejected提款
提款在派送后于上游渠道失败withdrawal_failedwithdrawal.failed提款
租户拥有者邀请链接已发出tenant_owner_invite平台层事件,不绑订单n/a
为该商户开立计费对账单billing_statement_issued平台层事件,不绑订单n/a

商户 API 介面大多只在意充值与提款事件。tenant_owner_invitebilling_statement_issued 会发送到平台管理的地址,而非商户 callback_url

只有当订单具备非 null 的 callback_url 时才会触发回调。如果你建立充值或提款时没带 callback_url,就不会建立 NotificationLog 记录,你必须自行轮询状态。

回调请求形状

所有回调都是 HTTP POST 发往你的 callback_url,内容如下:

面向
MethodPOST
Content-Typeapplication/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

json
{
  "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"
}
字段型别说明
eventstringnotification_type 的点记法形式。要分支判断时用这个,而不是 message 文字。
order_nostring网关指派的订单号。稳定标识符。
merchant_order_nostring你在建立时提供的标识符。用于幂等查找。
amountstring请求金额,以十进位字符串表示。
actual_amountstring结算金额。对于浮动配对的银行转账,可能与 amount 不同。
currencystringISO 4217 币种代码(例如 THB)。
statusstring派送当下的订单状态。对 deposit.completed 而言为 "completed"
matched_atISO 8601 string | null网关配对订单的时间。
completed_atISO 8601 string | null订单结算完成的时间。

Body — deposit matched

形状与 deposit.completed 相同,但 status: "matched",且 completed_at 尚未设置。只有当订单透过会在 COMPLETED 之前回报 MATCHED 的渠道时才会发送(典型情况是渠道绑定账号订单)。

Body — deposit expired

json
{
  "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

json
{
  "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_codedeposit.cancelled 保留的兼容性别名。请优先使用与其他充值回调一致的标准 currency 字段。

Body — withdrawal events

提款回调遵循与充值相同的信封惯例。实际字段集会随 notification_type 而异;务必以 event 字段来分支,并把未知的额外字段视为向前兼容的新增。

每一个提款回调都可以仰赖的字段是:order_nomerchant_order_nostatusamountcurrency(或 currency_code),以及与事件对应的时间戳(approved_atcompleted_atrejected_at,或 failed_at)。事件专属字段记载于 Withdrawals 章节。

流程

下图描绘单一回调从派送到终态的轨迹。重试循环是固定间隔,不是指数退避。

两点实务说明:

  • Celery workerRetry sweep 是两个独立的任务。worker 透过 self.retry(countdown=30) 处理 inline 重试。扫描则是安全网,捡起 worker 漏掉的记录(程序重启、崩溃,或 SENDING 记录卡超过 5 分钟)。
  • 跨重试时 HTTP 主体与签名是字节相同的。每次派送时会重新计算 X-Timestamp 标头。

验证签名

使用与 Authentication 章节随附的相同参考样本。它们经过与后端 verify_md5_signature 实作的字节对字节同位测试。若 4 个样本中任一个分歧,CI 会失败。

bash
#!/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
python
#!/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
<?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);
typescript
#!/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);

以文字叙述,验证协定为:

  1. 把 JSON 主体解析为字符串 key 对字符串 value 的扁平映射。值为 null 的 key 跳过。
  2. 依字母顺序(ASCII 顺序)对 key 排序。
  3. 组出查询字符串 k1=v1&k2=v2&...
  4. 附加你的 API key(不加 & 分隔符)。
  5. 对 UTF-8 字节计算 MD5,输出小写十六进位。
  6. 使用常数时间比较(Python 的 hmac.compare_digest、PHP 的 hash_equals、Node.js 的 crypto.timingSafeEqual)与 X-Signature 比对。

若验证失败,立即回传 401,不要套用任何副作用。验证失败几乎一定代表:API key 错误、主体被代理修改,或是你排序了错误的 key 集合(例如,你把标头纳入了签名集合)。

示例

示例 1 — Python(FastAPI)完整回调处理器

python
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) 做幂等

最简单的去重表:

sql
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_idX-Timestamp

  • Payment Gateway 在重试之间重发同一笔 NotificationLog 记录;没有每次尝试专属、你可以仰赖的 event id。
  • X-Timestamp 在重试之间会变。以它做去重会让同一个事件套用 5 次。
  • 一笔订单可能经过数个状态(例如先 MATCHEDCOMPLETED);每一次转换都是合法且不同的回调,你确实要处理。(order_no, status) 同时允许 MATCHEDCOMPLETED,又能阻挡同一状态内的重复。

示例 3 — 5xx 响应触发重试

回传任何 5xx(或关闭连线,或耗时超过设置的超时),网关就会重试。派送时间表:

尝试时间偏移尝试后的 NotificationStatus
1T+0WAITING_RETRY
2T+30sWAITING_RETRY
3T+60sWAITING_RETRY
4T+90sWAITING_RETRY
5T+120sFAILED

你可以在两处观察这些尝试:

  • merchant-portal 的 Notifications 记录(运营人员视角的 NotificationLog)。
  • 你自己的请求日志。每次重试都会带来相同的主体,以及新的 X-Timestamp / X-Signature

从首次尝试到最终 FAILED 的墙钟时间大约是 2 分钟。如果你的端点短暂断线,全部 5 次尝试可能都撞上同一段中断,耗尽重试。

示例 4 — 透过 /query/* 拉取漏掉的回调

当重试额度耗尽时,网关就放弃了。要恢复,请在你这边执行一个调度对账器:

python
# 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 秒。把任何重活(结算商品、寄送邮件、写入慢速下游系统)移到你这边的背景队列。先以 2xx ack;之后再做事。
  • 在服务器端验证签名。 永远不要接受源自浏览器的回调。永远不要把 API key 嵌进客户端代码。签名 key 与你用于对外请求的相同。
  • 幂等必须以 (order_no, status) 为依据。 网关在全部 5 次重试之间重用同一笔 NotificationLog 记录;没有每次尝试专属的 event id。以 X-Timestamp 去重是错的,因为时间戳在重试之间会变。
  • 4xx 为终态——重试额度就此用完。 一旦你回 4xx,网关就标记为 FAILED 并停止。如果你不小心回了 4xx(例如验证器中的暂时 bug),你必须透过查询端点对账。没有「请求重试」的 API。
  • 乱序回调会发生。 在从中断恢复的过程中,deposit.matched 可能在 deposit.completed 之后抵达。你的幂等表能防止重复套用状态,但你的业务逻辑应该接受终态,无论中间状态是否送达。
  • 不要在验证前记录原始主体。 在检查 X-Signature 前把主体当作可信,是一个权限提升的攻击面。先验证,再记录。
  • eventnotification_type 做分支,绝不要用 message message 是人类可读标签;它不是契约的一部分,可能会变动。
  • data key 随事件类型而异。 deposit.completedmatched_atcompleted_atdeposit.expiredexpired_atdeposit.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-Timestamporder_nostatus、签名验证结果。 原始主体仅在验证后记录,并对 PII 做遮罩。
  • [ ] 下列情况触发告警NotificationLog.status = FAILED 比率超过门槛时,或对账将订单翻转到终态却没有成功回调时。
  • [ ] runbook 已就绪,回答:「一次中断让 100 个回调 FAIL——我们如何恢复?」答案就是对账器;验证它能用。

交叉参考

  • Authentication & Signing — 本章使用的签名算法与验证样本来源。
  • Errors — 对内请求的错误信封、错误码与重试指引。
  • API Reference — 完整端点参考,包含 /query/*
  • Sandbox — 如何在测试环境中(包含 mock channel)端对端驱动充值与提款状态。
  • Deposits — 充值状态机,以及触发每个充值回调的事件。
  • Withdrawals — 提款状态机,以及提款回调的事件专属字段。
  • Quickstart — 从首次请求到首次验证回调的 15 分钟路径。