# 小小宝 API v1 接入文档

更新日期：2026-10-21。本文说明商户如何通过自己的服务端调用小小宝，充值金额、创建开通草稿、确认订单并查询结果。

**Base URL：`https://xiaoxiaobao.online/api/v1`**

新客户先注册并验证邮箱，再在账号中心自行创建 API Key。仅未撤销、未过期且具备所需权限的有效 Key 可调用接口，并须满足所配置的 IP 和用量策略。普通成功收取 0.80 元，人机验证最终成功收取 1.00 元，明确失败收取 0 元。金额订单确认前需有至少 1.00 元，确认后先冻结 100 分；结果未知继续冻结。旧点数卡密已停用，不可消费或转入；历史记录保留。本文中的身份、Token、卡资料、订单号和时间均为示例，不可作为实际开通资料。

## 金额计费

普通成功收取 0.80 元，人机验证最终成功收取 1.00 元，明确失败收取 0 元。新订单确认前需有至少 1.00 元，确认后先冻结 100 分；结果或分类未知时保留冻结，继续查询原单。技术验证通过不等于最终开通成功。

本次升级不自动更换仍有效的 API Key；已撤销或过期的 Key 不能使用。新充值、卡密和订单均以人民币金额记账，接口使用整数分，80 表示 0.80 元，100 表示 1.00 元；不接受浮点金额。支付卡实际订阅扣款另计。

`GET /account` 的 `wallet` 是当前余额；`GET /plans` 的 `pricing` 返回 `billing_mode:"money_only"`、`version:"cny-tiered-v1"`、`currency:"CNY"`、`unit:"fen"`、`standard_fen:80`、`verification_fen:100`、`max_fen:100`。本单版本和报价创建后不可变，确认前读取草稿 `service_fee`。金额报价读取 GET /plans 返回的 pricing。

```json
{
  "merchant_id": "mer_example",
  "status": "active",
  "billing_mode": "money_only",
  "wallet": {
    "currency": "CNY",
    "available_fen": 900,
    "reserved_fen": 100,
    "spent_fen": 80
  },
  "points": {
    "available": 0,
    "reserved": 0,
    "spent": 0
  }
}
```

订单 `state` 表示处理结果，`billing` 保留状态字符串，`service_fee` 表示报价和结算；不要仅按成功消息判定已结算。

```json
{
  "state": "succeeded",
  "billing": "charged",
  "points": 0,
  "service_fee": {
    "version": "cny-tiered-v1",
    "unit": "fen",
    "status": "charged",
    "category": "standard",
    "standard_fen": 80,
    "verification_fen": 100,
    "max_fen": 100,
    "reserved_fen": 0,
    "charged_fen": 80,
    "returned_fen": 20
  }
}
```

| 字段 | 说明 |
| --- | --- |
| account.wallet.available_fen / reserved_fen / spent_fen | 可用、冻结、累计服务费，单位整数分 |
| account.billing_mode | 当前为 money_only |
| service_fee.version / unit | 本单报价版本；新单单位为 fen，实际单位以 unit 判定 |
| service_fee.category | standard 普通成功；verification 人机最终成功；pending 分类待确认 |
| service_fee.status | none 未冻结；reserved 已冻结；charged 已收费；released 已释放且未收费 |
| service_fee.waived | 仅明确人工免收的订单返回 true；未免收时省略 |
| service_fee.reserved_fen / charged_fen / returned_fen | 当前冻结、已收取、已释放金额；未知值不能当作 0 |
| service_fee.standard_fen / verification_fen / max_fen | 本单普通成功价、人机成功价、最高冻结额 |

**单笔人工免收：**当 `service_fee.waived=true` 时，本单冻结服务费已退回，`billing` 与 `service_fee.status` 为 `released`，`charged_fen=0`；以后确认成功也不再收费，失败也不重复退回。订单仍可能为 `review_required`，应继续查询原单。免收服务费不代表银行卡退款、付款失败或可以重新付款。

已有客户端必须读取 wallet 和 service_fee；金额订单的 points 为 0，不表示免费。旧 points 字段仅兼容历史，available 为 0，reserved/spent 如有值仅表示历史，不可用于新业务或计入金额余额。历史 legacy_point 订单仅显示“历史记录，不计入当前余额”。旧点数卡密已停用，不可消费、恢复或转入，不兑换金额；历史记录保留。管理员返回 billing_unit=legacy_point、billing_retired=true，仅用于辨识历史记录。

管理员金额充值提交 `amount_fen`，新卡密发行提交 `amount_fen_per_card`、`count`、`reference`、`label`。卡密 `billing_unit:"fen"`，余额读取 wallet，发行额读取 issued_amount_fen。网页预览返回 transfer_amount_fen，确认提交相同 amount_fen；成功返回 redemption.transferred_amount_fen。商户服务端 `POST /credits/recharge` 仍仅提交 card_code。删除金额卡密返回 deletion.reclaimed_fen。所有重试保留同一流水号与收款账号。

## 1. 接入步骤

1. 在[注册页面](https://xiaoxiaobao.online/register)注册并验证邮箱，登录[账号中心](https://xiaoxiaobao.online/console)，再进入“API Key”自行创建具有所需权限的专属密钥。仅未撤销、未过期的有效 Key 可用；已有商户的账号迁移见第 21.7 节。
2. 只在自己的服务端保存 Key；先调用 `/account`、`/plans`、`/orders` 完成只读验收。
3. 从 `/plans` 读取套餐与地区能力；收集该客户已授权的完整 Session，以及该方式需要的支付卡。
4. 用唯一 `client_order_no` 创建草稿，保存返回的 `order.id`。草稿阶段没有付款。
5. 客户明确确认本单后，再调用原单 `/confirm`。收到 HTTP 202 仍须查询结果。
6. 查询原单，分别读取开通 `state`、付款 `payment`、关闭续费 `renewal`，服务费看 `billing` / `service_fee`，不要用其中一项推断其他结果。当前新订单没有本站嵌入的人机验证入口；仅历史原单仍有有效验证上下文时，保留本人验证入口。

**需要自动收到结果的客户，请在正式创建订单前为使用的 Key 配置统一通知。** 登录账号中心选择该 Key，填写一个接收地址；服务端依次调用 `GET /key-webhook`、`POST /key-webhook`，保存该 Key 首次返回的 `signing_secret`，再发送不收费的测试事件。默认关闭时仅提供查询。接入步骤见[统一回调](https://xiaoxiaobao.online/docs#webhooks)；已有双通道兼容见第 22 节。

接入示例默认只读，不会自动创建或确认付款。没有单独的公共沙箱地址；本地调试可模拟响应，正式写入须使用本人授权资料并明确确认。

需要补充金额余额时，登录[金额钱包](https://xiaoxiaobao.online/console#wallet)兑换本站金额卡密，或联系管理员充值。邮箱账号无需先创建 Key 即可兑换；之后创建的 Key 与网页共用该账号钱包，不是给某一把 Key 单独加款。旧点数卡密已停用，不可转入，见第 3 节。卡密充值是独立的余额操作，不能放进只读验收或自动付款测试。

## 2. 鉴权与通信约定

### 2.1 API Key

每个商户有固定 `merchant_id` 和自己的 API Key。请求头恰好包含一个 `X-API-Key`，值为本人账号中心创建的有效专属 Key；历史商户已获授权的有效密钥仍按相同策略校验。邮箱登录密码、邮箱验证链接和网页登录 Cookie 不能代替 API Key。

```text
X-API-Key: EXAMPLE_DOWNSTREAM_KEY_NOT_REAL
Accept: application/json
Content-Type: application/json
```

`Content-Type` 用于 JSON POST。Cookie 不参与商户 API 鉴权，API 也不发放浏览器登录 Cookie。对接系统应只在服务端保存 Key，不要嵌入浏览器脚本、移动端应用或公开代码，也不要写入 URL、截图或日志。持有人可以在本站订单查询或卡密充值页面手动输入自己的凭据；页面不会持久保存完整凭据。

同一商户的多把 Key 共用订单查询、金额余额和商户限流；统一回调按每把 Key 独立配置并仅通知该 Key 关联订单，旧双通道按第 22 节兼容；每把 Key 的查询/下单权限、到期时间、IP 白名单与金额用量上限分别生效。查询权限仍覆盖所属商户的历史，不按 Key 拆分为不同客户。每把 Key 是否可用以当前状态和策略为准，不能仅凭创建时间判断权限或有效期。已撤销或过期的 Key 不可用；升级不会恢复它们，也不会自动更换仍有效的 Key。

新创建的 API 草稿绑定创建它的 Key，确认时必须继续使用原 Key，并再次校验权限、到期时间、IP、用量上限和账户余额。换 Key 可以在有查询权限时查原单，不能借此绕过原 Key 限制；订单归属不随 Key 轮换改变。撤销或过期的 Key 不能继续调用。暂停商户不能创建或首次确认新单，但具有相应权限的有效 Key 仍可查询、核对和处理历史原订单验证。策略与迁移方法见第 21 节。

商户只能看到自己的订单。商户网站中不同终端客户的隔离，由商户自己的服务端负责；不要把同一商户 Key 发给所有终端客户。

### 2.2 请求格式

| 项目 | 要求 |
| --- | --- |
| 传输 | 正式请求使用本站 HTTPS 地址 |
| 调用位置 | 仅商户服务端；请求不得带 `Origin` 或 `Sec-Fetch-Site` |
| 数据格式 | JSON 对象，UTF-8，字段名与枚举值区分大小写 |
| POST 请求头 | 单个 `Content-Type: application/json` 与合法、单个 `Content-Length` |
| 正文大小 | 创建订单和 `/account/subscription`、`/account/codex`、`/account/promotion` 带 Session 查询的 JSON 正文最大 2 MiB；完整 Session 的 UTF-8 序列化上限为 1 MiB；其他公开 `/api/v1` 接口正文最大 256 KiB（262144 字节），包括 `/key-webhook` 配置、密钥轮换、`/key-webhook/test` 及事件 retry；兼容路径 `/webhooks/{kind}/test` 也遵循此限制 |
| 禁止格式 | 分块 `Transfer-Encoding`、重复 JSON 字段、NaN、非对象正文、未定义业务字段 |
| 路径 | 使用本文路径原样调用，不加重复斜杠、点路径或编码别名 |
| 查询参数 | `GET /orders` 支持第 10 节所列参数；`GET /key-webhook-events` 支持第 20 节 limit/cursor；兼容 `GET /webhook-events` 按第 22 节；其他接口不接受查询参数 |
| POST 正文 | 创建草稿按业务字段提交；其他订单操作固定为 `{}`；卡密充值固定为 `{"card_code":"本站卡密"}`；统一通知的配置、轮换、测试及 retry 使用第 20 节字段；旧双通道事件 retry 仍为 `{}`，见第 22 节 |

上述大小限制针对服务端 `/api/v1` API。网页客户订单查询 `POST /api/order-query` 另有 8 KiB（8192 字节）上限，属于网页查询入口，不属于本 API v1。

正常响应没有统一 `success` 或 `data` 外壳：账号直接返回账号对象，套餐返回 `plans`，单笔订单返回 `order`。错误返回 `error` 和 `request_id`。不要假定 HTTP 200/201/202 就是开通成功。

### 2.3 接口总览

以下路径相对 Base URL。

| 方法 | 路径 | 正常响应 | 作用 |
| --- | --- | --- | --- |
| GET | `/account` | 200 | 查询本商户状态、金额余额 |
| GET | `/plans` | 200 | 查询当前商品及可用参数 |
| POST | `/account/subscription` | 200 | 只读查询当前订阅快照 |
| POST | `/account/codex` | 200 | 只读查询 Codex 剩余点数 |
| POST | `/account/promotion` | 200 | 只读查询 Plus 半价资格与购买准入 |
| POST | `/credits/recharge` | 200 | 将本站卡密剩余金额按原单位整笔转入当前商户 |
| POST | `/orders` | 首次 201，重复 200 | 创建未确认草稿 |
| POST | `/orders/{id}/confirm` | 202 | 确认并受理原草稿 |
| GET | `/orders/{id}` | 200 | 查询原单 |
| GET | `/orders` | 200 | 查询本商户订单列表 |
| POST | `/orders/{id}/reconcile` | 200 | 主动核对原单 |
| POST | `/orders/{id}/cancel` | 200 | 取消未确认草稿 |
| POST | `/orders/{id}/verification-link` | 200 | 历史兼容：仅有效原单验证上下文可申请 |
| GET | `/key-webhook` | 200 | 当前 Key 的统一通知配置 |
| POST | `/key-webhook` | 200 | 保存或停用当前 Key 的通知 |
| POST | `/key-webhook/rotate-secret` | 200 | 轮换当前 Key 的签名密钥 |
| POST | `/key-webhook/test` | 202 | 发送测试通知，不建单、不扣费 |
| GET | `/key-webhook-events` | 200 | 当前 Key 的投递记录与分页 |
| GET | `/key-webhook-events/{event_id}` | 200 | 当前 Key 的单个事件 |
| POST | `/key-webhook-events/{event_id}/retry` | 200 | 按当前配置版本补送原事件 |

原 `/webhooks`、`/webhooks/{kind}` 及 `/webhook-events` 双通道接口保留，详见第 22 节兼容附录。

`{id}` 必须用本站返回的 `order.id`；不要代入 `client_order_no`。回调配置与投递记录见第 20 节；本文未列出的开卡、支付卡充值、退款和批量提交接口不属于当前 API。本站服务费卡密充值只使用 `/credits/recharge`，不为支付卡增加资金。

## 3. 账户、余额与充值

### 3.1 查询账户

`GET /account` 不需要正文。

```json
{
  "merchant_id": "mer_example",
  "status": "active",
  "points": {
    "available": 0,
    "reserved": 0,
    "spent": 0
  },
  "billing_mode": "money_only",
  "wallet": {
    "currency": "CNY",
    "available_fen": 900,
    "reserved_fen": 100,
    "spent_fen": 80
  }
}
```

| 字段 | 类型 | 说明 |
| --- | --- | --- |
| merchant_id | string | 商户标识，创建后保持不变 |
| status | string | `active` 可接单；`suspended` 已暂停新业务 |
| wallet | object | 当前可用、冻结与累计服务费金额，单位分 |

账户接口不返回 API Key、商户名称或其他商户数据。余额为 0 可以只读查询；确认金额不足返回 `402 insufficient_money`。

### 3.2 金额结算规则

以余额 10.00 元为例，所有字段均为整数分：

| 阶段 | available_fen | reserved_fen | spent_fen |
| --- | ---: | ---: | ---: |
| 创建草稿 | 1000 | 0 | 0 |
| 首次确认 | 900 | 100 | 0 |
| 普通成功 | 920 | 0 | 80 |
| 或人机最终成功 | 900 | 0 | 100 |
| 或明确失败 | 1000 | 0 | 0 |
| 结果或分类未知 | 900 | 100 | 0 |

冻结释放是本站服务费账务操作，不是银行卡退款；订阅实际扣款和 Codex 购买数量另计。

### 3.3 如何充值金额

登录[金额钱包](https://xiaoxiaobao.online/console#wallet)兑换本站金额卡密，或联系管理员充值。卡密剩余金额整笔转入当前邮箱账号的钱包，无需先创建 API Key；以后创建的 Key 与网页共享这笔余额。通过 API 兑换时，收款方是有效 Key 所属商户的钱包，充值不改变密钥状态或策略。新发行卡密为 **20 位大写英文字母与数字**，请按管理员交付的完整内容填写。旧点数卡密已停用，只保留历史记录。

金额卡密可分次开通或一次性转入剩余金额。例如 10.00 元卡密完成三笔普通成功单，余额为 7.60 元；可继续开通，或把 7.60 元整笔转入一个账号，不能拆分给多个账号。有处理中或冻结订单时暂不能转入。转入后原卡密不能再次消费或充值。

### 3.4 服务端卡密充值接口

本节为金额业务示例；费用按 wallet 与 service_fee 读取。示例没有提供可核实的付款资料，因此 payment 保持未知；草稿是否已确认仍按 state 判断。

`POST /credits/recharge`，使用第 2 节的 `X-API-Key` 鉴权。**收款账号由请求头中的有效 Key 决定**；不能在正文指定另一个商户。首次转入要求该商户与卡密均可用。

| 字段 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| card_code | string | 是 | 管理员发放的完整本站卡密，作为敏感凭据处理 |

正文严格只有 `card_code`，不接受 `merchant_id`、`points`、`amount`、`api_key`、`client_order_no` 或其他字段。不是订单创建接口，不需要 Session、支付卡或付款确认。

```json
{
  "card_code": "EXAMPLE_SITE_VOUCHER_NOT_REAL"
}
```

首次成功与同一目标商户的幂等重试均返回 HTTP 200：

```json
{
  "redemption": {
    "voucher_id": "vc_example",
    "merchant_id": "mer_example",
    "redeemed_at": "2026-10-14T01:00:00Z",
    "transferred_amount_fen": 700
  },
  "account": {
    "id": "mer_example",
    "name": "示例商户",
    "status": "active",
    "points": {
      "available": 0,
      "reserved": 0,
      "spent": 0
    },
    "created_at": "2026-10-13T01:00:00Z",
    "updated_at": "2026-10-14T01:00:00Z",
    "billing_mode": "money_only",
    "wallet": {
      "currency": "CNY",
      "available_fen": 900,
      "reserved_fen": 100,
      "spent_fen": 80
    }
  },
  "replayed": false
}
```

| 字段 | 说明 |
| --- | --- |
| redemption.voucher_id | 本站卡密记录编号；不是可用于消费的完整卡密 |
| redemption.merchant_id | 本次实际收款商户，须与预期商户一致 |
| redemption.transferred_amount_fen | 本次转入金额，正整数分 |
| redemption.redeemed_at | 原始转入完成时间，UTC RFC 3339 |
| account | 当前收款账号快照；这里使用 `id`，而 `/account` 顶层使用 `merchant_id` |
| account.wallet | 查询时当前金额余额 |
| replayed | 首次完成为 false；同卡同目标商户再次核对为 true，不会再次入账 |

这项操作只按原单位转移卡密剩余金额，不迁移该卡密的历史订单、浏览器订单权限或已消耗记录。充值完成不会改变已有商户账号、Key 和商户原订单。

### 3.5 充值幂等、失败与超时

同一卡密只能成功转入一个商户一次。充值幂等依据是**同一卡密和同一目标商户**，不依赖订单单号或额外幂等请求头。同一商户轮换后的有效 Key 仍代表同一收款账号；换成另一商户的 Key 会被拒绝，也不会返回原收款商户信息。

遇到网络超时或 HTTP 5xx，保留原卡密与原目标商户。可先查询 `/account`，但余额可能同时受到其他订单影响，不能只凭余额差判断这次转入。需要确定结果时，显式重试同一 `/credits/recharge` 请求；若首次已完成，会返回原 `redemption` 和 `replayed:true`。不要换卡、换收款账号或循环自动充值。

| HTTP | code | 含义与处理 |
| --- | --- | --- |
| 400 | invalid_request | 正文必须且只能包含 `card_code`；修正多余或缺失字段 |
| 401 | invalid_api_key | Key 无效或已撤销，先核对目标商户凭据 |
| 401 | invalid_voucher | 卡密格式不正确或不存在；不返回卡密细节 |
| 403 | merchant_suspended | 收款商户已暂停，联系管理员后再首次转入 |
| 409 | voucher_busy | 卡密有关联订单正在处理、等待验证、待核对或存在冻结；先处理原单 |
| 409 | voucher_empty | 当前无可转入额度；如有原订单，请先核对其最终结果 |
| 409 | voucher_unavailable | 卡密暂停、已删除、已转入其他账号或不可继续使用；不披露其他账号信息 |
| 429 | rate_limited | 按 `Retry-After` 等待后核对；充值计入商户总限流与写入限流 |
| 500 / 503 | internal_error / store_unavailable | 结果暂不确定，保留原卡密与收款账号，核对同一次转入 |

`voucher_empty` 与 `voucher_busy` 可能因校验顺序返回其中一个；不能据此跳过原订单核对。已成功转入同一商户的请求会返回原成功凭证，不重复入账。其余 HTTP 通信错误遵循第 14 节。

### 3.6 网页自助充值

打开[卡密充值页面](https://xiaoxiaobao.online/recharge)，输入自己的 API Key 与本站卡密，先点击“核对账号与额度”。页面展示收款账号、Key 尾部和待转入金额；**再次点击“确认转入”才执行充值**。输入变更后需重新核对，金额或收款账号变化时会拒绝旧确认。

若页面提示结果未确认，保留当前输入，点击“核对本次转入结果”处理同一次请求。成功或离开页面会清空页面中的完整 Key 和卡密；本页不使用浏览器存储保存它们。不要通过截图、聊天或公开日志传递完整凭据。

### 3.7 管理员发行与交付

此步骤仅供本站管理员使用：登录后台，进入[卡密管理](https://xiaoxiaobao.online/admin/downstream/vouchers)，查看可继续分配额度，填写每张金额（元，最多两位小数）、张数、批次备注及唯一批次流水号，核对本批总金额后再确认生成。每批 1–100 张，接口提交整数分。

已发行卡密与客户 API 账号的未消耗金额共同占用可分配额度，包含可用与冻结额度。发行时再次核验，超出额度不发行；暂停卡密也不释放额度。卡密转入商户是原单位已分配额度的转移，不会新增额度。

完整卡密仅在首次成功响应展示一次，请立即按需复制或下载本批 TXT 并妥善保管。刷新、关闭或隐藏后，列表仅展示前缀、金额；同一批次重复核对只返回元数据，不再次生成或恢复明文。发行响应丢失时保留原批次流水号核对，不能为了找回卡密而另建一批；请联系管理员核对原批次及交付情况。

管理员可在卡密管理中删除单张卡密，核对脱敏标识与未使用额度后再次确认。删除后不能继续消费或充值，回收未使用金额并保留订单、账务，不撤回已经转入客户商户钱包的金额；有处理中、等待验证、待核对订单或冻结金额时暂不能删除。删除结果不明时，应核对同一张卡的原操作。

## 4. 当前商品与只读预检

`GET /plans` 返回本次可用商品。必须同时读取顶层 `accepting_orders` 和商品的 `enabled`、`regions`、`upgrade_modes`、`credit_quantities`、`team_options`；不要把下表当作实时开放承诺。未启用商品不能创建新单。历史订单仍按创建时的商品和参数查询。

| plan_id | 商品 | 条件 |
| --- | --- | --- |
| plus | Plus 正价 | 账号准入以实时核验为准 |
| plus50 | Plus 半价 | 可主动查询优惠资格；最终准入由提交接口核验，资格失效不自动改正价 |
| go | Go | 按开放地区提交 |
| pro5 | Pro 5X 自动开通/升级 | 在线订阅决定新开或差价升级，使用本次支付卡 |
| pro20 | Pro 20X 自动开通/升级 | 在线订阅决定新开或差价升级，使用本次支付卡 |
| pro500 | Pro 500 | 按开放地区提交 |
| pro500_auto | Pro 500 自动开通/升级 | 在线订阅决定新开或差价升级，使用本次支付卡 |
| plus_to_5x | 升级至 5X | 仅 new_card，需要本次支付卡 |
| to_20x | 升级至 20X | 仅 new_card，需要本次支付卡 |
| to_500 | 升级至 Pro 500 | 仅 new_card，需要本次支付卡 |
| codex | Codex 点数 | credit_quantity 仅 250、500、1000、2500，以实时开放为准 |
| team | Team 新工作区 | team_plan 必填，保留个人订阅 |

地区代码支持 PH、US、JP、EG、CL、IN，但每种商品只接受 `/plans` 当前列出的地区。仅 Codex 在实时能力提供 auto 时支持自动地区；其他商品不接受自动地区。服务端 API 创建 Codex 订单时自动地区写作 `"region":"AUTO"`（大写），不能照搬能力展示中的小写 auto。历史同卡升级、20X 续费和 25X 商品不再作为新单能力。

### 4.0 商品响应字段

| 字段 | 类型 | 解释 |
| --- | --- | --- |
| accepting_orders | boolean | 当前是否接收新单；false 时保留商品展示，但停止新单提交 |
| capability_profile | string | 当前商品能力版本；不要用它替代商品自身 enabled |
| pricing | object | 本站服务费报价，单位整数分，见金额计费章节 |
| plans | array | 本次返回的商品；空数组表示当前没有可用条目 |
| plans[].id / name | string | 稳定商品代码和展示名称；创建订单 plan_id 取 id |
| plans[].enabled | boolean | 该商品是否开放；仍需同时通过地区、参数和账号核验 |
| plans[].regions / default_region | array / string | 允许的请求地区及默认建议，不代表账号已有订阅币种 |
| plans[].upgrade_modes | array | 允许的升级方式；空数组表示不配置升级方式 |
| plans[].credit_quantities | array | 允许的 Codex 整数规格；其他商品不提交点数参数 |
| plans[].team_options | object 或 null | Team 地区、周期和席位规则；其他商品通常为 null |
| plans[].region_source_plans | object | 以地区为键的来源套餐列表，逐地区判断；不存在时不猜测 |
| plans[].points_cost | integer | 历史兼容字段；金额业务为 0，不表示本单免费 |

以下只演示单个商品结构，不表示当前线上已经开放该地区：

```json
{
  "capability_profile": "current",
  "accepting_orders": true,
  "pricing": {
    "version": "cny-tiered-v1",
    "currency": "CNY",
    "unit": "fen",
    "standard_fen": 80,
    "verification_fen": 100,
    "max_fen": 100,
    "billing_mode": "money_only"
  },
  "plans": [
    {
      "id": "plus",
      "name": "Plus",
      "enabled": true,
      "regions": ["PH"],
      "default_region": "PH",
      "upgrade_modes": [],
      "credit_quantities": [],
      "team_options": null,
      "region_source_plans": {"PH": ["free"]},
      "points_cost": 0
    }
  ]
}
```

每次进入选购页和正式创建前读取能力；不要高频轮询。查询开放能力不锁定银行卡账单价格，也不保证账号资格已通过。创建前核对商品、地区和参数组合，变更能力不改变既有订单。

### 4.1 主动只读查询

以下接口使用原 `X-API-Key`，不创建订单、不付款、不扣服务费。完整 Session 仅用于本次请求，不回传访问凭据，不改变原单结果。

订阅、Codex 余额和 Plus 半价资格查询均为可选操作。客户网页和 API 提交不以这些查询成功为前提，本站在准备、提交及查询原单结果时均不会自动调用账号查询或半价资格查询。普通 Plus 按 `plus` 提交，Plus 半价按 `plus50` 提交，由客户选择套餐，本站不自动切换。提交仍校验 Session 格式、套餐地区及参数、服务额度及同一订单的幂等性；开通成功与否以提交接口返回的实际结果为准。主动查询失败不代表订单已提交，也不能据此判断支付结果。 本站不会根据查询快照中的当前订阅、订阅到期时间或优惠资格提前判定开通失败；符合基本提交要求的申请交给开通接口处理。用户看到的是本次申请的实际结果及脱敏后的业务原因，不包含渠道品牌、地址、凭据或渠道订单编号。

| 方法 | 路径 | 用途 |
| --- | --- | --- |
| POST | /account/subscription | 当前订阅、套餐、有效期和续费快照 |
| POST | /account/codex | Codex 剩余点数快照 |
| POST | /account/promotion | Plus 半价资格 eligible 和购买准入 purchase_supported |

```json
{
  "session": {
    "accessToken": "EXAMPLE_COMPLETE_TOKEN",
    "user": {
      "email": "example@example.com",
      "id": "user_example_not_real"
    },
    "account": {
      "planType": "free",
      "id": "account_example_not_real"
    },
    "sessionToken": "EXAMPLE_COMPLETE_SESSION_TOKEN_NOT_REAL",
    "expires": "2099-01-01T00:00:00Z"
  },
  "region": "PH"
}
```

region 可选，默认 PH，使用 PH、US、JP、EG、CL、IN，不接受 AUTO 或 auto；用于查询网络地区，不代表账单国家。余额未知应保留 null，不得当作零；查询异常不代表没有订阅或优惠。查询快照不证明某笔订单扣款。遇限流按 Retry-After 等待，只重试查询。

### 4.2 Plus 半价与 Team

plus50 本次只支付当前月。实时资格可能提供 1–3 个半价账期：1 个月活动在付款成功后尝试关闭续费；2–3 个月活动保留按月续费，优惠结束后可能恢复原价。分别检查资格、购买准入、付款和续费结果，不默认关闭多月活动续费。

team_plan 使用 workspace_name、billing_interval、standard_seats、premium_seats。 可选 promotion 对象包含 type（code 优惠码或 campaign 活动券）与 code；代码须以字母或数字开头，仅使用字母、数字、下划线和连字符，最长 128 字符。campaign 仅限月付；不传 promotion 保持原购买流程。优惠失效或未生效时停止付款，不自动按原价继续。工作区名称必填；标准席位至少 1、合计 2–200，高级席位仅月付开放；周期与地区同时受 team_options 限制。新建工作区保留个人订阅。付款和工作区席位交付分别确认，已付款但席位未确认时查询原单，不再次付款。 地区与周期按实时 team_options.routes 的 enabled 开关开放；公开 standard_enabled 据此派生，premium_enabled 还要求月付。单席历史价格元数据不作为报价或开放依据。开放组合不代表已取得本单实际报价；付款前仍须通过实际报价、币种和零税费核验。

### 4.3 只读查询返回字段

三种查询直接返回对象，没有 `data` 外壳。下列 `null` 均表示未知或当前无可用事实，不能擅自改成 `false`、`0` 或“已取消”。`checked_at` 为本次快照的带时区时间或 `null`。请求方法虽然是 POST，但这三种操作不创建订单、不扣服务费。

| 查询 | 主要返回字段 | 正确解释 |
| --- | --- | --- |
| subscription | subscription_status、subscription_state、current_plan、current_state、entitlement_until、will_renew | 当前权益和续费事实；有效期与历史账单分开，不按单一字段推断订单成功 |
| subscription | billing_plan、billing_until、billing_status、billing_scope、billing_validity、billing_currency | 账单层事实；可能属于历史记录，不能替代当前权益 |
| subscription | facts_version、evidence_level、warnings、missing_fields、conflicts | facts_version 可能为 3；其余描述证据完整度与字段冲突，未知时继续保留未知 |
| codex | query_status、balance、has_credits、unlimited、plan_type | balance 是十进制字符串或 null，不是本站钱包余额；缺少余额时 query_status 为 unknown |
| promotion | eligible、purchase_supported、purchase_block_reason | 优惠资格与购买准入分别判定，两项都不能把 null 当 true |
| promotion | discount_percent、duration_months、billing_period、charge_mode、renewal_policy | 可用优惠事实；月数可能为 1、2、3，不承诺所有账号都相同 |

Codex 查询结果示例：

```json
{
  "checked_at": "2026-10-18T01:00:00Z",
  "balance": "250.5",
  "has_credits": true,
  "unlimited": false,
  "plan_type": "plus",
  "query_status": "confirmed",
  "reason_code": "credits_observed"
}
```

订阅的 `will_renew:false` 只是当前快照不续费，不等于某笔订单的关闭续费操作已经完成。订单续费结果读取 `order.renewal`；成功凭证读取 `order.receipt`。三种查询返回 `400 account_query_failed` 时保留原结果，提示重新核对完整 Session 或所选地区。

## 5. 创建订单字段说明

`POST /orders` 保留本站草稿、确认与查询流程。商户必须先取得客户对资料用途与本次开通的明确授权；API 正文只接受下表字段，不传网页专用的 `consent`、`verification_id` 或 `idempotency_key`。创建幂等使用 `client_order_no`，付款确认是下一步独立操作。

| 字段 | 要求 |
| --- | --- |
| client_order_no | 必填，1–64 个英文字母、数字、下划线或连字符；同商户唯一，重放同一请求只返回原单 |
| plan_id | 必填，当前已启用商品 id |
| region | 必填，该商品开放的地区 |
| session | 必填，完整 Session JSON 对象，不能只提交 Access Token |
| payment_card | 必填，本次授权支付卡对象，card_number、exp_month、exp_year、cvv |
| upgrade_mode | 升级单仅 new_card |
| credit_quantity | Codex 必填，仅当前开放预设整数 |
| team_plan | Team 必填，见上节 |

完整 Session 至少包含 `user.id`、`account.id`、`account.planType`、`accessToken`、`sessionToken` 和带时区的 `expires`；保留接口返回的其他字段。不能把示例中的占位值拼成真实 Session。完整 Session 的 UTF-8 序列化上限为 1 MiB；accessToken 最多 16384 字符，sessionToken 最多 32000 字符；保持完整，不截断、不删除字段。Session 中邮箱和手机号可选，不另行增加必填联系方式。文件导入支持完整 TXT / JSON，读取文件不会自动提交。

本站仅在需使用资料的流程中短期保留完整授权和卡资料，公开响应不返回 Token、完整卡号、安全码或内部支付链接。成功凭证默认隐藏部分邮箱，完整邮箱显示须由本人主动选择；分享前核对隐私范围。

### 5.1 嵌套字段、类型与条件

| 字段 | 类型与格式 | 规则 |
| --- | --- | --- |
| session | object | 原样完整 JSON；不是字符串，不是只含 Token 的对象 |
| session.user.id / account.id | string | 必须存在非空账号身份；不使用邮箱替代 |
| session.account.planType | string | 原 Session 的套餐字段；目标套餐另用 plan_id |
| session.accessToken / sessionToken | string | 完整访问令牌和 Session 令牌；格式与长度校验见上文，不自行截短 |
| session.expires | string | 带时区且未过期的 ISO 8601 时间，须来自完整 Session |
| payment_card.card_number | string | 完整卡号；仅用于本次明确授权的付款，不写入日志 |
| payment_card.exp_month | integer | 月份 1–12，不能传字符串 |
| payment_card.exp_year | integer | 四位有效期年份，提交时必须有效 |
| payment_card.cvv | string | 3–4 位数字字符串，保留前导零 |
| team_plan.workspace_name | string | 去首尾空白后 1–80 字符，不含控制字符 |
| team_plan.billing_interval | string | month 或 year，须在所选地区开放 |
| team_plan.standard_seats / premium_seats | integer | 标准至少 1，高级至少 0，合计 2–200；年付高级为 0 |
| team_plan.promotion | object，可选 | 仅 type、code；格式与月付限制见第 4.2 节 |

条件字段不能混用：非 Codex 不带 credit_quantity，非 Team 不带 team_plan，不支持升级的商品不带 upgrade_mode。请求中不需要额外 API Key 字段，鉴权只使用请求头。

## 6. 请求示例

以下资料均为占位，只有明确授权后才可创建草稿；创建草稿不付款，确认原草稿才执行。

```json
{
  "client_order_no": "example_unique_001",
  "plan_id": "plus",
  "region": "PH",
  "session": {
    "accessToken": "EXAMPLE_COMPLETE_TOKEN",
    "account": {
      "planType": "free",
      "id": "account_example_not_real"
    },
    "user": {
      "id": "user_example_not_real"
    },
    "sessionToken": "EXAMPLE_COMPLETE_SESSION_TOKEN_NOT_REAL",
    "expires": "2099-01-01T00:00:00Z"
  },
  "payment_card": {
    "card_number": "4242424242424242",
    "exp_month": 12,
    "exp_year": 2030,
    "cvv": "123"
  }
}
```

升级：使用 plus_to_5x、to_20x 或 to_500，并增加 `"upgrade_mode":"new_card"`，仍提交本次 payment_card。

Codex：使用 `"plan_id":"codex","credit_quantity":1000`。

Team：使用 `"plan_id":"team"`，并增加：

```json
{
  "team_plan": {
    "workspace_name": "示例工作区",
    "billing_interval": "month",
    "standard_seats": 2,
    "premium_seats": 0
  }
}
```

## 7. 订单对象与完整响应

### 7.1 订单字段

| 字段 | 类型 | 说明 |
| --- | --- | --- |
| id | string | 本站订单号，后续路径使用此值；视为不透明标识，不自行构造 |
| client_order_no | string | 接入方的原客户单号 |
| plan_id | string | 原单套餐 |
| region | string | 原单请求地区；Codex 新单在实时能力开放自动地区时可为 AUTO（大写），不表示已解析出实际结账国家；其他套餐使用开放的国家码 |
| upgrade_mode | string | 当前升级单为 `new_card`，历史订单保留原值 |
| credit_quantity | integer | 仅 Codex 订单出现，原单充值数量 |
| state | string | 第 8 节中的订单状态 |
| billing | string | 第 8 节中的账务结算状态 |
| service_fee | object | 本单报价版本、计费单位和费用状态，见开头“订单费用字段”；金额按 unit 区分 |
| points | integer | 新订单固定为 0，仅兼容旧客户端，不能用作当前计费 |
| message | string | 可展示的处理结果或失败原因；不是稳定机器枚举，可能随核对变化 |
| created_at | string | UTC RFC 3339 创建时间 |
| finished_at | string 或 null | 本站最终结算/结束时间；非终态为 null，不等同于账号订阅到期时间 |
| next_action | object 或 null | 建议下一操作，见下表 |
| payment | object | 本单付款与权益事实，固定 status、entitlement_status、amount、currency 四个字段，见第 7.9 节 |
| renewal | object | 自动续费处理状态和最近保存的核对时间，见第 7.7 节 |
| receipt | object 或 null | 本站成功凭证的结构化数据，见第 7.8 节；不是支付卡收据或订阅发票 |

| next_action | 适用情况 |
| --- | --- |
| `{"type":"confirm"}` | 草稿，可由客户明确确认 |
| `{"type":"poll","after_seconds":3}` | 处理中或结果待核对，继续查询原单 |
| `{"type":"verification_link"}` | 历史兼容：仅有效原单验证上下文可申请入口 |
| `null` | 已终结，无自动下一动作 |

公开订单不返回完整 Session、卡号、CVV、支付卡余额、内部凭据、逐条执行日志或管理员字段。客户端应容忍新增字段，不把未公开字段当作接入前提。

### 7.2 创建草稿成功

本节为金额业务示例；费用按 wallet 与 service_fee 读取。

首次创建为 HTTP 201；同内容幂等重复为 HTTP 200，均使用以下外壳。此时未冻结、未付款。

```json
{
  "order": {
    "id": "ord_example_draft",
    "client_order_no": "example-plus",
    "plan_id": "plus",
    "region": "PH",
    "state": "draft",
    "billing": "none",
    "points": 0,
    "message": "草稿已创建，请在有效期内确认。",
    "created_at": "2026-10-13T01:00:00Z",
    "finished_at": null,
    "next_action": {
      "type": "confirm"
    },
    "service_fee": {
      "version": "cny-tiered-v1",
      "unit": "fen",
      "status": "none",
      "category": "pending",
      "standard_fen": 80,
      "verification_fen": 100,
      "max_fen": 100,
      "reserved_fen": 0,
      "charged_fen": 0,
      "returned_fen": 0
    },
    "payment": {
      "status": "unknown",
      "entitlement_status": "unknown",
      "amount": null,
      "currency": null
    },
    "renewal": {
      "status": "unconfirmed",
      "checked_at": null
    },
    "receipt": null
  }
}
```

### 7.3 确认已受理

HTTP 202 只表示本次确认请求已受理。后台可能排队，响应也可能已是更晚状态，始终读取 `state`。

```json
{
  "order": {
    "id": "ord_example_draft",
    "client_order_no": "example-plus",
    "plan_id": "plus",
    "region": "PH",
    "state": "processing",
    "billing": "reserved",
    "points": 0,
    "message": "请求已受理，请查询原订单结果。",
    "created_at": "2026-10-13T01:00:00Z",
    "finished_at": null,
    "next_action": {
      "type": "poll",
      "after_seconds": 3
    },
    "service_fee": {
      "version": "cny-tiered-v1",
      "unit": "fen",
      "status": "reserved",
      "category": "pending",
      "standard_fen": 80,
      "verification_fen": 100,
      "max_fen": 100,
      "reserved_fen": 100,
      "charged_fen": 0,
      "returned_fen": 0
    },
    "payment": {
      "status": "unknown",
      "entitlement_status": "unknown",
      "amount": null,
      "currency": null
    },
    "renewal": {
      "status": "unconfirmed",
      "checked_at": null
    },
    "receipt": null
  }
}
```

### 7.4 开通成功的完整结果

查询原单返回 HTTP 200。成功以 `state="succeeded"` 且 `billing="charged"` 为准。下面以新卡 20X 升级为例：

```json
{
  "order": {
    "id": "ord_example_upgrade",
    "client_order_no": "example-20x-new-card",
    "plan_id": "to_20x",
    "region": "PH",
    "upgrade_mode": "new_card",
    "state": "succeeded",
    "billing": "charged",
    "points": 0,
    "message": "已核实开通成功。",
    "created_at": "2026-10-13T01:00:00Z",
    "finished_at": "2026-10-13T01:02:00Z",
    "next_action": null,
    "service_fee": {
      "version": "cny-tiered-v1",
      "unit": "fen",
      "status": "charged",
      "category": "standard",
      "standard_fen": 80,
      "verification_fen": 100,
      "max_fen": 100,
      "reserved_fen": 0,
      "charged_fen": 80,
      "returned_fen": 20
    },
    "payment": {
      "status": "success",
      "entitlement_status": "confirmed",
      "amount": null,
      "currency": null
    },
    "renewal": {
      "status": "cancelled",
      "checked_at": "2026-10-13T01:02:05Z"
    },
    "receipt": {
      "order_id": "ord_example_upgrade",
      "plan_id": "to_20x",
      "account_email": "customer@example.com",
      "submitted_at": "2026-10-13T01:00:10Z",
      "completed_at": "2026-10-13T01:02:00Z",
      "completed_at_source": "service_confirmation",
      "renewal": {
        "status": "cancelled",
        "checked_at": "2026-10-13T01:02:05Z"
      }
    }
  }
}
```

### 7.5 订单明确失败的完整结果

订单已经受理后失败，查询仍可返回 HTTP 200；业务失败放在 `order.state`，不会为了订单失败把查询变成 HTTP 500。下面为冻结金额已释放的示例，实际 `message` 以该单返回为准：

```json
{
  "order": {
    "id": "ord_example_failed",
    "client_order_no": "example-plus-failed",
    "plan_id": "plus",
    "region": "PH",
    "state": "failed",
    "billing": "released",
    "points": 0,
    "message": "当前账号仍在订阅有效期内，无法再次开通。",
    "created_at": "2026-10-13T01:00:00Z",
    "finished_at": "2026-10-13T01:00:30Z",
    "next_action": null,
    "service_fee": {
      "version": "cny-tiered-v1",
      "unit": "fen",
      "status": "released",
      "category": "pending",
      "standard_fen": 80,
      "verification_fen": 100,
      "max_fen": 100,
      "reserved_fen": 0,
      "charged_fen": 0,
      "returned_fen": 100
    },
    "payment": {
      "status": "unknown",
      "entitlement_status": "unknown",
      "amount": null,
      "currency": null
    },
    "renewal": {
      "status": "unconfirmed",
      "checked_at": null
    },
    "receipt": null
  }
}
```

若草稿阶段已经确定未提交并结束，`state` 可为 `failed` 而 `billing` 为 `none`。无需再调用“释放冻结金额”接口。`review_required` 不是失败，不能按失败释放你自己系统里的订单约束或自动补单。

### 7.6 请求被拒绝的完整错误

下面为 HTTP 400，新请求未创建订单：

```json
{
  "error": {
    "code": "invalid_region",
    "message": "该套餐不支持所选地区，请查询套餐列表。",
    "retryable": false
  },
  "request_id": "req_example"
}
```

`error` 表示此次接口调用未正常完成，与一个已存在订单的最终付款结果不同。尤其确认请求超时或异常时，必须先查原单。

### 7.7 自动续费结果

开通结果看 `state`，本单付款和到账事实看 `payment`，自动续费处理结果看 `renewal.status`，服务费看 `billing` 和 `service_fee`，分别判断。`succeeded` 不表示自动续费一定已关闭；关闭失败也不会把已经成功的开通变成失败或触发退款、重新付款。

| renewal.status | 含义 | 建议显示 |
| --- | --- | --- |
| not_started | 关闭流程尚未开始 | 尚未开始处理 |
| processing | 仍在处理关闭流程 | 正在处理 |
| cancelled | 已确认关闭自动续费 | 已关闭自动续费 |
| retained | 按该套餐安排保留自动续费 | 保留自动续费 |
| not_applicable | 本次业务不适用关闭操作 | 本单不适用自动续费处理 |
| failed | 本次自动关闭流程失败 | 关闭自动续费失败，请核对原单 |
| unconfirmed | 暂无可靠结果或字段无法识别 | 尚未确认 |

`renewal.checked_at` 为该笔原单最近保存的核对时间（带时区 ISO 8601 字符串），没有可靠时间时为 `null`。它不是调用本接口的时间，也不是订阅到期时间。历史订单只有已确认关闭的旧标记时兼容为 `cancelled`；没有已确认结果时为 `unconfirmed`，不能把缺失、0 或 false 当作关闭失败。

续费结果可能晚于开通结果更新。普通查询返回已保存结果；需要刷新结果时，可按第 9.3 节显式核对同一原单，再读取 `renewal`，不再次创建或确认订单。可按第 20 节为当前 Key 启用统一订单通知，首次结果及续费后续变化会通过同一地址接收；默认关闭，查询接口仍可用于核对原单。

### 7.8 成功凭证数据

同一商户的订单详情、列表、客户单号精确查询及原单核对响应使用相同字段。`receipt` 是 JSON 数据，可供接入方生成自己的结果卡片或图片，不是 PNG 地址；本站网页仍可预览并生成 PNG 图片。接口没有新增凭证下载地址。

仅在开通已确认成功、原单具有可核实凭证资料且没有冲突结果时返回 `receipt`；未完成、失败、结果待核对、原始凭证资料不可用时为 `null`。`receipt:null` 不是新的失败状态，不改变费用，也不能据此补单或重新付款。成功后的续费关闭失败仍可返回凭证，但必须展示真实续费状态。

| receipt 字段 | 类型 | 含义 |
| --- | --- | --- |
| order_id | string | 与外层 `order.id` 相同的本站订单号 |
| plan_id | string | 该笔原单套餐，不按当前选择框或其他订单替换 |
| account_email | string 或 null | 原单保存的完整账号邮箱；没有保存时为 null，不猜测或回填 |
| submitted_at | string 或 null | 该原单确认提交时间，不等同于草稿创建时间 |
| completed_at | string 或 null | 已核实完成时间；不可用时可使用本站首次确认成功时间 |
| completed_at_source | string 或 null | service_confirmation 表示完成结果提供的时间；local_confirmation 表示本站首次确认成功时间；没有时间时为 null |
| renewal | object | 与外层 `renewal` 完全一致，包含 status 和 checked_at |
| credit_quantity | integer | 仅在可核实的 Codex 凭证中出现，表示实际对应的原单数量 |

以下是“开通成功，但关闭自动续费失败”的字段节选，非完整响应：

```json
{
  "state": "succeeded",
  "renewal": {"status": "failed", "checked_at": "2026-10-18T02:20:00Z"},
  "receipt": {
    "order_id": "ord_example_success",
    "plan_id": "plus",
    "account_email": "customer@example.com",
    "submitted_at": "2026-10-18T02:18:00Z",
    "completed_at": "2026-10-18T02:19:00Z",
    "completed_at_source": "service_confirmation",
    "renewal": {"status": "failed", "checked_at": "2026-10-18T02:20:00Z"}
  }
}
```

这些字段只随所属商户鉴权后的订单返回；不能凭其他商户订单号查询。API Key 只放服务器端。展示给最终客户前，接入方仍须核对自己的用户归属；分享凭证时默认打码邮箱，仅在本人明确选择后显示完整邮箱。凭证不能包含授权资料、卡资料、内部订单引用或处理渠道信息。

### 7.9 付款与权益事实

**开通、付款、关闭续费分别判断。** `state` 是本站订单的开通/交付处理结果；`payment.status` 是该笔原单的付款结论；`payment.entitlement_status` 是目标权益或点数的核实情况；`renewal.status` 是关闭续费结果。银行卡付款成功不自动等于权益已到账，开通成功也不自动等于已关闭续费。不要按提示文字推导这些结论。

`payment` 固定返回以下四个字段，没有可靠事实时保持未知：

| 字段 | 类型 | 含义 |
| --- | --- | --- |
| status | string | pending、processing、success、failed、not_charged、action_required、unknown、not_applicable |
| entitlement_status | string | not_started 尚未开始；pending 核对中；confirmed 已确认；unconfirmed 尚未确认；unknown 无法确定；not_applicable 本单不适用 |
| amount | string 或 null | 已核实该笔付款的非负十进制主货币金额，尾部无意义的零已去掉；如 `"982.14"`，不能再除以 100 |
| currency | string 或 null | 与已核实金额对应的三字母大写币种，如 PHP、USD；没有可靠对应关系时为 null |

| payment.status | 含义与处理 |
| --- | --- |
| pending | 付款尚待处理；继续保存并查询原单 |
| processing | 原单付款仍在处理或核对；不要另建付款 |
| success | 本笔付款已确认成功；仍须分别核对权益、订单和续费结果 |
| failed | 本笔付款已明确失败；不表示其他交易或整张银行卡均未扣款 |
| not_charged | 本笔原单已有未扣款证据；不是银行卡退款状态 |
| action_required | 需要持卡人验证；不等于付款成功，也不是允许自动重付的指令 |
| unknown | 本笔付款尚无可靠结论；核对原单及银行卡记录，不自动补单 |
| not_applicable | 本单不适用付款处理 |

只有付款已确认、金额与币种事实一致时才返回 `amount` 和 `currency`；即使 `status=success`，金额资料不足仍可能都为 `null`。`null` 不是零元。`service_fee` 的整数分表示本站服务费，与 `payment.amount` 的订阅付款金额是不同账目，不能混合汇总。

**银行卡 3DS / OTP 验证与服务费：**`payment.status=action_required` 保留银行要求持卡人验证这一事实。若该笔原订单已明确结束失败，且本站已核实服务费无需收取，订单返回 `state=failed`、`billing=released`，`service_fee.charged_fen=0`，冻结金额通过 `returned_fen` 体现释放；付款状态仍可为 `action_required`，不必改成 `failed`。若原单仍在处理或证据不足，则继续查询原单并保留冻结。银行验证不等同于人机验证成功，不会仅因出现验证而收取 1.00 元。服务费释放也不代表银行卡退款，接入方应分别判断订单、服务费与银行卡付款结果。

以下为“付款已确认，交付结果仍待核对”的字段节选；它不是新的下单指令，也不会产生开通成功回调：

```json
{
  "state": "review_required",
  "payment": {
    "status": "success",
    "entitlement_status": "unconfirmed",
    "amount": "20",
    "currency": "USD"
  },
  "renewal": {"status": "unconfirmed", "checked_at": null},
  "receipt": null
}
```

详情、列表和原单核对返回同一付款对象；回调 `data.order.payment` 也使用这四个字段。公开对象不提供内部付款链接、支付身份、原始失败码或原文。历史订单资料不足时不会用当前账号、其他订单或套餐标价补值。

## 8. 状态与账务结算

| state | 含义 | billing | 接入方下一步 |
| --- | --- | --- | --- |
| draft | 已建草稿，未确认 | none | 显示确认入口或取消 |
| processing | 已确认，排队或处理中 | reserved | 按 next_action 继续轮询原单，不转为失败或自动补单 |
| requires_action | 需要本人安全验证或核对 | reserved | 当前新订单核对原单；仅历史有效验证上下文保留本站验证链接 |
| review_required | 结果未知或待核对，非终态 | reserved | 按 next_action 继续查询或核对原单，必要时联系管理员 |
| succeeded | 已核实成功 | charged | 完成该单，不再提交 |
| failed | 已核实失败或确定未提交 | released；从未冻结则 none | 展示实际原因，核对原单 |
| expired | 未确认草稿已过期 | none | 核对后由客户决定是否重新准备 |
| cancelled | 未确认草稿已取消 | none | 停止该草稿 |

`billing` 的四个值：`none` 未冻结；`reserved` 暂时冻结；`charged` 成功结算；`released` 明确失败后归还冻结额度。

正常订单明确仍在排队或处理中时保持 `processing`，主动核对不会仅因尚未完成而改成 `review_required`。已因超时、异常或结果未知进入 `review_required` 的订单，仍需可靠结果才能解除待核对；再次收到“处理中”本身不代表问题已消除。

`processing` 和 `review_required` 都不是失败终态。客户端不能一收到 `review_required` 就停止后续查询；按 `next_action` 继续查询同一订单，并保留待核对提示及冻结约束。不得据此自动退款、释放本地约束或另建订单。

同一原单重复查询、确认或核对不会再次扣费。 当前接口下，明确使用不同客户单号的新订单可独立提交，包括同账号仍有结果未知原单的情况；旧单继续核对并保留冻结。不同订单都可能最终成功并产生付款及服务费，不能用再次提交推断旧单失败。超时重试必须保留原客户单号，不要自动换号创建新单。若后续信息与已经结算的终态冲突，系统保留账本并交管理员核实，不自动逆向修改账务或生成新订单。你自己的系统也应把异常交给人工核对。

## 9. 确认、查询与主动核对

### 9.1 确认原草稿

`POST /orders/{id}/confirm`，正文固定为 `{}`。

```http
POST /api/v1/orders/ord_example_draft/confirm
X-API-Key: EXAMPLE_DOWNSTREAM_KEY_NOT_REAL
Content-Type: application/json
Content-Length: 2

{}
```

此操作须在客户明确确认本次订单后由你的服务端调用。首次确认按草稿固定的计价单位原子冻结：100 分，然后受理原单，HTTP 202；重复确认已受理原单仍返回原单，不再次冻结或提交。

订单所用余额不足为 HTTP 402；草稿已过期或取消为 HTTP 409。订单可能因资源占用而排队，不要另换客户单号提交。返回成功、失败或等待验证等状态时，按该状态处理。

### 9.2 查询原单

`GET /orders/{id}`，不带正文或查询参数，HTTP 200 返回 `{"order":...}`。读取并同步本站已知的原单状态；已获得可靠结果时，同步可能保存订单结果并完成该原单的幂等账务结算，不产生新的付款提交。重复查询不会重复扣费。

建议按 `next_action.after_seconds` 轮询，间隔至少 3 秒；多订单共用商户总限流。不要用刷新页面或无间隔循环代替正常轮询。

处理中的订单尚无最终结果回调。未配置或未收到回调时，仍须继续原单查询；展示最终结果时使用最新返回的 `order.state`、`billing`、`service_fee` 与 `renewal`，不要一直保留首次响应的“处理中”或“待核对”界面。

### 9.3 主动核对原单

`POST /orders/{id}/reconcile`，正文 `{}`，HTTP 200 返回原单。适用于长时间处理中、结果未知或验证完成后的核对。

原单仍正常排队或处理中时返回 `processing`；已经存在超时、异常或未知结果的订单可继续保持 `review_required`，查询失败或无法取得可靠结果时也可能进入该状态，额度继续冻结。这个接口只核对已有单，不补单、不换卡、不重新付款；取得可靠结果后可保存并完成原单结算。结果通知可按第 20 节启用该 Key 的统一回调；未启用或通知未到时，仍查询同一原单。

## 10. 查询历史订单与分页

`GET /orders` 默认按创建时间倒序返回本商户订单。同一创建时间用订单号稳定排序；不跨商户查询。

| 参数 | 类型 | 默认 | 约束 |
| --- | --- | --- | --- |
| limit | 查询字符串整数 | 20 | 1–100 |
| client_order_no | string | 不传 | 按客户单号精确查询，1–64 位合法字符 |
| cursor | string | 不传 | 原样使用上一页的 `next_cursor`，不得自行构造 |

不能同时传非空 `client_order_no` 与 `cursor`，不能重复同名参数或加入其他参数。

```text
GET /api/v1/orders?limit=20
GET /api/v1/orders?client_order_no=example-plus
GET /api/v1/orders?limit=20&cursor=RETURNED_CURSOR_URL_ENCODED
```

非空列表响应示例：

```json
{
  "orders": [
    {
      "id": "ord_example_draft",
      "client_order_no": "example-plus",
      "plan_id": "plus",
      "region": "PH",
      "state": "draft",
      "billing": "none",
      "points": 0,
      "message": "草稿已创建，请在有效期内确认。",
      "created_at": "2026-10-13T01:00:00Z",
      "finished_at": null,
      "next_action": {
        "type": "confirm"
      },
      "service_fee": {
        "version": "cny-tiered-v1",
        "unit": "fen",
        "status": "none",
        "category": "pending",
        "standard_fen": 80,
        "verification_fen": 100,
        "max_fen": 100,
        "reserved_fen": 0,
        "charged_fen": 0,
        "returned_fen": 0
      },
      "renewal": {
        "status": "unconfirmed",
        "checked_at": null
      },
      "receipt": null
    }
  ],
  "next_cursor": null
}
```

无结果返回 `{"orders":[],"next_cursor":null}`。有下一页时 `next_cursor` 为字符串，URL 编码后原样传回；游标绑定商户。按 `client_order_no` 精确查询最多返回一笔且不分页，这是创建响应丢失时推荐的恢复方法。

API 订单历史与某个浏览器的 Cookie 无关。当前没有公开的历史归档或批量删除接口，不要把浏览器历史有效期理解为 API 历史删除时间。

### 10.1 网页订单查询：本站卡密或 API Key

打开[订单查询页面](https://xiaoxiaobao.online/query)，手动输入本站发行的完整卡密或 API Key，点击“查询”。可再输入本站订单号或商户自定义单号精确筛选；使用“上一页”“下一页”浏览历史，或刷新当前页。时间统一显示北京时间（UTC+8）。查询只读取本站已保存的记录，不提交开通、不充值，也不触发付款。

**同一卡密或同一 API 账号的凭据持有人，可跨设备查看相应历史及订单中已保存的完整账号邮箱。** 不希望不同客户相互看到记录时，应分别交付卡密或分别创建 API 账号；同账号下新建多把 Key 不会隔离历史。网页展示对应订单的完整邮箱、套餐、地区、订单状态和账务结算；不展示 Session、支付卡资料或完整凭据。完整邮箱仅向对应卡密、对应 API 账号具有查询权限的有效 Key 持有人、该账号已登录本人及已登录管理员展示，不可凭订单号单独查询。旧订单未保存邮箱时显示“未记录”，不猜测或回填。商户自定义单号如与完整卡密、支付卡号等敏感内容格式重合，会隐藏显示；仍可输入原单号精确查询。

| 查询凭据 | 可查看的历史 | 状态说明 |
| --- | --- | --- |
| 本站卡密（新旧格式均可） | 该张卡密直接使用的订单，包括不同浏览器提交的记录 | 零余额、已用完、已转入或暂停仍可只读查询；删除后不可查询 |
| 本站有效 API Key | 该客户账号的订单，包括同账号其他 Key 创建的历史 | 需要 read 权限并通过有效期与 IP 校验；Key 轮换不迁移订单，暂停账号仍可凭符合条件的 Key 只读查询 |

卡密剩余金额转入 API Key 后，两边历史不会合并：原卡密只查自己的旧订单，Key 只查所属 API 账号的订单。页面仅接受本站发行的凭据，不能用于查询其他服务的凭据历史。

查询页展示的是保存的状态记录。仅查询不会核对付款结果或生成新的验证链接；需要确认开通、人工验证或核对处理中订单时，继续从原提交页面或服务端原订单接口操作。查询成功不会授予其他浏览器原订单的操作权限。

凭据通过同站请求正文提交，不放入页面地址；更换输入、清空或离开页面后不会保留上一次结果。查询页不把完整凭据写入浏览器本地存储，关闭后需重新输入。请勿将完整凭据发到公开群组或截图中。

已有邮箱账号可直接登录[账号中心的“我的订单”](https://xiaoxiaobao.online/console#orders)，查看本账号网页与 API 的记录，无须再次输入完整 Key。登录邮箱不代表被开通账号的邮箱；记录始终取每笔原单已保存的账号邮箱。服务端接入仍使用本节 `GET /orders`，API v1 与原订单号不变；调用仍须使用未撤销、未过期且具备对应权限的有效 Key。

### 10.2 管理员订单中心

管理员登录后可进入[订单中心](https://xiaoxiaobao.online/admin/downstream/orders)，按订单号、完整卡密或 API Key 查询，也可从卡密管理或客户账号进入相应使用记录。中心汇总本站已有订单，支持状态筛选与历史分页；卡密标识保持脱敏，账号邮箱按各订单已保存的完整值展示。旧订单未保存邮箱时显示“未记录”，不猜测或回填。管理员可查看中心内订单的邮箱；客户仍只能凭对应卡密或所属 API 账号的有效 Key 查看授权范围内的记录。

只有摘要的旧外部凭据显示“历史外部凭据”，不能恢复完整卡密；管理员可以输入原凭据精确匹配。客户查询页没有管理员全量查询权限。


## 11. 取消草稿

`POST /orders/{id}/cancel`，正文 `{}`，HTTP 200 返回该单。

仅能取消未确认草稿，结果为 `state="cancelled"`、`billing="none"`。重复取消已取消草稿仍返回原单；已确认订单返回 `409 order_state_conflict`。

此接口不是撤销付款、取消订阅或退款接口。对已提交、等待验证、未知或已完成订单，应查询原单，需要帮助时联系管理员。

## 12. 人工验证与历史订单

当前新订单没有本站嵌入的人机验证入口，verification 为 null 或省略；不要等待本站验证码弹窗，也不要据此判定付款成功或失败。仅历史原单仍有有效验证上下文时保留本人验证入口，仍只操作该原单。验证完成不等于付款成功，验证不得自动解答或重复付款。

## 13. 幂等、超时与恢复

### 13.1 创建幂等身份

`(merchant_id, client_order_no)` 唯一。同号同稳定内容返回原单；异内容返回 `409 client_order_conflict`，不会覆盖原资料。

稳定内容包括套餐、请求地区、Codex 数量、账号 ID、用户 ID、升级方式，以及需要自备卡时的卡号和有效期。相同账号的 Session 令牌刷新不改变创建幂等身份；CVV 也不用于建立一个“新订单”。重复请求不会替换原单临时资料或延长草稿有效期。

当前升级仅支持 new_card；同一业务单号不能更换商品、地区、支付卡或其他业务内容。

### 13.2 不同故障的处理

| 情况 | 正确处理 |
| --- | --- |
| 创建响应丢失 | 先用 `GET /orders?client_order_no=...` 查原单；需要重试时保留原单号及稳定资料 |
| 确认响应丢失 | 用本站 `order.id` 查询或核对原单，不新建订单 |
| 轮询遇到网络错误或 HTTP 5xx | 保留原状态，等待后继续查原单，不能当作失败释放冻结金额 |
| 历史原单需要验证 | 仅仍有有效验证上下文时保留入口，交本人完成后查询同一原单；当前新订单不提供本站嵌入验证 |
| 结果为 review_required | 非失败、非终态；保留冻结和原单，按 next_action 继续查询或核对，必要时提供订单号给管理员 |
| 草稿过期或取消 | 原单已终结；核对确定没有提交后，由客户明确决定是否新建资料 |
| 返回明确业务失败 | 展示原单原因和结算状态；是否再次尝试由客户重新判断，系统不自动补单 |
| 卡密充值响应丢失 | 保留原卡密与原目标商户，按第 3.5 节显式核对同一次转入，不换卡重充 |

HTTP 超时只是没有取得结果，不是“没有付款”。禁止通过换 Key、客户单号、套餐或卡片来自动补交未知订单。重复确认同一原单是幂等的，但应优先查询，避免不必要的写请求。

### 13.3 时间、重启与资料保存

| 项目 | 当前行为 |
| --- | --- |
| 订单时间格式 | UTC RFC 3339，如 `2026-10-13T01:00:00Z`；界面可转换本地时区 |
| Session 时间 | `expires` 带时区且未过期；正式请求另检查令牌自身时间 |
| 未确认草稿 | 最长 15 分钟；`created_at` 起算，不因查询或幂等重试延长 |
| 排队中的临时资料 | 从草稿创建起满 15 分钟仍未能提交，安全结束并释放冻结金额 |
| 验证链接/会话 | 最长 5 分钟且受原挑战到期限制，以返回 `expires_at` 为准 |
| 服务重启 | 未确认草稿过期；确定未提交且资料丢失的结束并释放；可能已提交的保留原关联核对 |
| 订单及金额账本 | 持久保存；不因 Key 轮换或浏览器 Cookie 到期而删除 |
| 完整 Session、完整卡号、CVV | 不写入商户账本或公开订单；本次处理的临时资料保留在内存，到期、取消或提交后释放 |

重启可能清除内存限流窗口、订单验证令牌和短期验证会话；这不改变商户账号、已生效余额和既有订单结算。邮箱账号的登录会话单独持久保存，规则见第 21.5 节。接入方也应限制敏感资料留存，不把请求正文用于普通访问日志或分析埋点。

## 14. 限流、HTTP 状态与错误码

### 14.1 限流

| 范围 | 当前上限 |
| --- | --- |
| 同商户所有调用 | 滚动 60 秒最多 60 次，多把 Key 共用 |
| 创建、确认、卡密充值与回调写操作 | 额外共享每 60 秒最多 10 次，并计入总限流；回调写操作包含配置、轮换签名密钥、发送测试通知及人工补送 |
| 正在提交或处理 | 每商户最多 2 笔；当前接口已关联原单且处于 review_required 的待核对订单不占新派单名额，原冻结与核对保留 |
| 有效未确认草稿 | 每商户最多 3 笔 |

带 Session 的三种账号查询每次计入两次总调用额度；仅执行这些查询时，当前最多 30 次/60 秒。本人验证页面的兑换与查询也计入对应商户总限流。上述通用限流的 HTTP 429 带 `Retry-After: 60`；测试回调的同类 30 秒限频返回 `webhook_test_rate_limited`，带 `Retry-After: 30`。按实际响应头等待。业务并发或草稿上限不是只等待一分钟必然解除，需处理原单或取消不用的草稿。

### 14.2 错误响应

```json
{
  "error": {
    "code": "insufficient_money",
    "message": "可用金额不足本单最高冻结金额，请先充值。",
    "retryable": false
  },
  "request_id": "req_example_insufficient"
}
```

此例为 HTTP 402。`request_id` 是该次错误调用的排查标识，不是订单号，也不是幂等键；正常成功响应不保证带此字段。

`retryable` 在 HTTP 429 和 5xx 时为 true，其余为 false。true 表示可以在查询原记录并适当等待后重试相应操作，**不是再次付款授权**。false 也不表示永远不能调用：例如充值后可以确认仍有效的原草稿。

### 14.3 常见错误码

| HTTP | code | 处理 |
| --- | --- | --- |
| 400 | invalid_request | 缺字段、多字段、非法类型、重复查询参数或参数组合错误 |
| 400 | invalid_json | 修正 JSON、重复 JSON 字段、编码或非法常量 |
| 400 | invalid_path | 使用标准路径，不传编码别名、点路径或重复斜杠 |
| 400 | invalid_region | 重新读取 `/plans`，按该套餐选择地区 |
| 400 | invalid_upgrade_mode | 当前升级单仅 new_card；按 `/plans` 返回的 upgrade_modes 提交，普通套餐不传此字段 |
| 400 | invalid_session | 检查完整 Session、有效期、令牌和账号资格 |
| 400 | invalid_payment_card | 检查需要自备卡时的四个字段、卡号格式和有效期 |
| 400 | account_query_failed | 只读账号查询未完成，检查完整 Session 和地区；不要当作无订阅或余额为零 |
| 503 | product_unavailable | 商品暂未开放；重新获取 `/plans`，不自动换商品付款 |
| 400 | invalid_credit_quantity | Codex 数量必须为 250、500、1000、2500 的整数，其他套餐不带该字段 |
| 400 | invalid_cursor | 使用原样返回且属于本商户的分页标记 |
| 401 | invalid_api_key | 提供唯一有效的专属 Key，检查是否被撤销 |
| 401 | api_key_expired | Key 已到期；在账号中心核对该 Key 的有效期，保留原订单再处理 |
| 403 | key_scope_denied | 缺少该操作的 read 或 write 权限；在账号中心核对权限，不改用网页请求绕过 |
| 403 | key_ip_denied | 请求来源不在 Key 的 IP 白名单，核对服务端公网出口 IP |
| 403 | key_client_ip_unavailable | 暂无法可靠确认来源 IP；联系管理员检查入口配置，不伪造转发头 |
| 402 | key_usage_limit | Key 剩余用量不足本单最高冻结额；调整原 Key 上限后继续核对原草稿 |
| 409 | order_key_mismatch | 本单已绑定另一把 Key；查询可以共享，确认须使用创建原单的 Key |
| 401 | invalid_voucher | 本站卡密不正确或不存在，核对完整卡密 |
| 400 | invalid_amount_fen | 金额必须为支持范围内的正整数分，修正后重新核对 |
| 402 | insufficient_money | 本单金额不足最高冻结额 100 分，充值后核对原草稿 |
| 409 | voucher_retired | 旧点数卡密已停用，不可消费或转入，仅可查询历史 |
| 409 | legacy_billing_retired | 旧点数业务已停用，保留原记录并联系管理员 |
| 403 | merchant_suspended | 暂停接单，联系管理员；原单仍可查询 |
| 403 | server_api_only | 改为服务端调用，勿带浏览器来源头 |
| 403 | https_required | 使用正式 HTTPS 入口 |
| 403 | invalid_origin、invalid_host、invalid_proxy | 使用正确本站地址和请求来源，必要时联系管理员 |
| 403 | verification_expired | 验证入口/会话无效、已消费或过期；从原单核对或重新申请 |
| 404 | order_not_found、not_found | 不存在、非本商户订单或路径错误 |
| 405 | method_not_allowed | 按文档使用对应 HTTP 方法 |
| 409 | client_order_conflict | 同客户单号对应其他稳定内容，核对原单，不能覆盖 |
| 409 | order_state_conflict | 当前状态不允许该操作，例如取消已确认订单 |
| 409 | draft_expired | 草稿已过期或取消，先核对原记录 |
| 409 | verification_unavailable | 当前没有有效验证入口，继续核对原单 |
| 409 | voucher_busy | 卡密有处理中、等待验证、待核对订单或冻结金额，暂不能转入 |
| 409 | voucher_empty | 卡密无可转入额度，有原订单时先核对结果 |
| 409 | voucher_unavailable | 卡密暂停、已删除、已经转入其他账号或不可使用，联系管理员 |
| 409 | point_balance_conflict、engine_conflict | 账本或原单关联需要核对，联系管理员，不创建替代订单 |
| 411 | length_required | 提供合法单个 Content-Length |
| 400 | session_too_large | 完整 Session 超过 1 MiB 或令牌超过对应长度限制；本次创建未受理，不要截断资料后重试 |
| 413 | payload_too_large | 创建订单或带 Session 的账号查询 JSON 正文超过 2 MiB；其他公开 `/api/v1` 接口正文上限为 256 KiB（262144 字节）；网关拒绝时响应可能不是 JSON |
| 415 | json_required | 使用 application/json |
| 429 | rate_limited、active_order_limit、draft_limit | 按 Retry-After 等待，处理原单或草稿 |
| 429 | webhook_test_rate_limited | 同类测试不足 30 秒；按 `Retry-After: 30` 等待，再核对同一次测试 |
| 500 | internal_error | 先查原记录，向管理员提供 request_id |
| 503 | store_unavailable、secret_store_unavailable | 服务暂不可用，先查原记录，不盲目重发付款 |

字段校验发生在不同阶段，某些卡片资料缺失也可能返回 `invalid_request`；不要只根据一个错误码推断是否已有原单。业务失败原因见订单 `message`，错误码与消息以实际响应为准。

## 15. 服务端接入示例

### 15.1 cURL：只读验收

下面为 Bash 环境。先由你的服务端密钥管理方式设置 `XIAOXIAOBAO_API_KEY`，不要把实际值写入公开脚本。示例不创建或确认订单。

```bash
BASE='https://xiaoxiaobao.online/api/v1'
: "${XIAOXIAOBAO_API_KEY:?请在服务端环境设置专属 Key}"

curl --silent --show-error --max-time 30 \
  --header "X-API-Key: ${XIAOXIAOBAO_API_KEY}" \
  --header 'Accept: application/json' "$BASE/account"

curl --silent --show-error --max-time 30 \
  --header "X-API-Key: ${XIAOXIAOBAO_API_KEY}" \
  --header 'Accept: application/json' "$BASE/plans"

curl --silent --show-error --max-time 30 \
  --header "X-API-Key: ${XIAOXIAOBAO_API_KEY}" \
  --header 'Accept: application/json' \
  --get --data-urlencode 'limit=20' "$BASE/orders"
```

不要加入 `-v`、打印请求头或转存敏感调试日志。响应不是预期 JSON 时先排查，不自动跟随未知站点重定向。

### 15.2 cURL：客户明确准备资料后创建草稿

下面只创建草稿，不调用确认。`request.json` 应由你自己的服务端生成，内容使用第 6 节对应结构及真实授权资料；文件访问应限本次处理，及时清理。不要把完整资料粘贴进命令行历史。

```bash
curl --silent --show-error --max-time 30 \
  --header "X-API-Key: ${XIAOXIAOBAO_API_KEY}" \
  --header 'Accept: application/json' \
  --header 'Content-Type: application/json' \
  --data-binary @request.json "$BASE/orders"
```

收到 201/200 后保存 `order.id`。不要把确认命令紧接在创建后自动执行。创建超时先精确查询：

```bash
curl --silent --show-error --max-time 30 \
  --header "X-API-Key: ${XIAOXIAOBAO_API_KEY}" \
  --header 'Accept: application/json' \
  --get --data-urlencode 'client_order_no=YOUR_ORIGINAL_CLIENT_ORDER_NO' \
  "$BASE/orders"
```

### 15.3 Python：只读客户端与原单恢复

使用 Python 标准库，无第三方依赖。代码默认只查询账户与套餐，不含自动创建或确认逻辑。

```python
import json
import os
from urllib.error import HTTPError
from urllib.parse import quote, urlencode
from urllib.request import HTTPRedirectHandler, Request, build_opener

BASE = 'https://xiaoxiaobao.online/api/v1'
API_KEY = os.environ['XIAOXIAOBAO_API_KEY']

class NoRedirect(HTTPRedirectHandler):
    def redirect_request(self, req, fp, code, msg, headers, newurl):
        return None

opener = build_opener(NoRedirect())

def get_json(path, params=None):
    if not path.startswith('/') or '://' in path or path.startswith('//'):
        raise ValueError('只允许本站相对接口路径')
    url = BASE + path
    if params:
        url += '?' + urlencode(params)
    req = Request(url, method='GET', headers={
        'X-API-Key': API_KEY,
        'Accept': 'application/json',
    })
    try:
        with opener.open(req, timeout=30) as response:
            return response.status, json.load(response)
    except HTTPError as exc:
        try:
            result = json.loads(exc.read().decode('utf-8'))
        except (UnicodeError, ValueError):
            result = {'error': {'code': 'non_json_response'}}
        return exc.code, result

def find_original(client_order_no):
    return get_json('/orders', {'client_order_no': client_order_no})

def query_original(order_id):
    return get_json('/orders/' + quote(order_id, safe=''))

if __name__ == '__main__':
    for path in ('/account', '/plans'):
        status, result = get_json(path)
        if status != 200:
            err = result.get('error', {})
            print('调用失败', status, err.get('code'), result.get('request_id'))
            break
        print(path, result)
    # 超时恢复时由业务层调用 find_original 或 query_original。
    # 本示例不会创建草稿、确认订单或自动重试付款。
```

连接异常由调用方捕获，保留原单号后再查；不要给所有 POST 加一个“任何异常都重发”的通用重试器。真实 Session 和支付卡只用于本次已授权请求，禁止打印正文。

### 15.4 cURL：已确认收款账号后的卡密充值

这是实际转入卡密剩余金额的写请求，请先核对 `GET /account` 返回的商户归属，并取得卡密持有人的明确确认。以下环境变量通过你的服务端密钥管理方式注入，不要把真实值写入公开文件或命令记录。示例不会创建或确认开通订单。

```bash
BASE='https://xiaoxiaobao.online/api/v1'
: "${XIAOXIAOBAO_API_KEY:?请设置收款商户 Key}"
: "${XIAOXIAOBAO_CARD_CODE:?请设置本次本站卡密}"

python -c 'import json,os; print(json.dumps({"card_code":os.environ["XIAOXIAOBAO_CARD_CODE"]}))' \
  | curl --silent --show-error --max-time 30 \
      --request POST \
      --header "X-API-Key: $XIAOXIAOBAO_API_KEY" \
      --header 'Accept: application/json' \
      --header 'Content-Type: application/json' \
      --data-binary @- \
      "$BASE/credits/recharge"
```

处理完成后清理本次卡密的临时变量。响应丢失时保留原收款商户和原卡密用于一次明确核对；不要增加自动循环重试、改用另一张卡密或自动跟随站外重定向。首次成功 `replayed:false`，已成功的同卡同商户重试为 `replayed:true`。完整 Key、卡密和请求正文不得写入日志。

### 15.5 Node.js：只读查询与超时

使用支持全局 `fetch` 的 Node.js。密钥从服务端环境变量读取，禁止将这段代码放进网页。示例只有 GET，不创建草稿、不确认付款。

```javascript
const base = 'https://xiaoxiaobao.online/api/v1';
const key = process.env.XIAOXIAOBAO_API_KEY;
if (!key) throw new Error('请在服务端环境设置 API Key');

async function readAccount() {
  const response = await fetch(`${base}/account`, {
    method: 'GET',
    headers: { 'X-API-Key': key, Accept: 'application/json' },
    redirect: 'error',
    signal: AbortSignal.timeout(30000),
  });
  const result = await response.json();
  if (!response.ok) {
    throw new Error(`HTTP ${response.status}: ${result.error?.code ?? 'request_failed'}`);
  }
  return result;
}

readAccount().then(account => {
  console.log(account.merchant_id, account.wallet);
}).catch(() => {
  console.error('读取未完成，请检查网络、HTTP 状态和错误码。');
});
```

网络异常或非 JSON 网关响应不会触发写请求。处理真实订单时另外保存 `client_order_no`、本站 `order.id` 与用户归属；回调验签和业务去重见第 20 节。

## 16. 常见问题

### 16.1 是不是只要拿到 Key 就能正式开通？

还需要账户为 active、金额余额至少 100 分、套餐地区正确，以及完整有效 Session。Key 须有下单权限、未到期并通过 IP 与金额用量校验。当前新单均须提供完整 `payment_card`；升级方式目前仅支持 `new_card`，不能只依赖账号已绑定的默认卡。所选套餐是否开放、地区与升级方式以 `GET /plans` 的实时能力为准。余额为 0 可以用有查询权限的 Key 只读接入，不需要提交付款来测试 Key 是否有效。

### 16.2 历史商品还能创建新单吗？

不能。新单按 `/plans` 当前能力；历史订单保留原单查询与恢复，不转换为新商品。

### 16.3 为什么切换套餐后某些地区不见了？

每种套餐支持地区不同，应使用 `/plans` 的 `regions`。历史订单保留原地区和可查询性，但不能据此新建一个当前不支持地区的订单。

### 16.4 服务费和 Codex 点数有什么区别？

本站服务费以人民币记账；Codex 的 `credit_quantity` 是目标账号要购买的数量。Codex 购买数量以实时套餐列表为准。本站金额订单按普通成功 80 分或人机最终成功 100 分结算，支付卡资金另外计算。

### 16.5 成功响应有没有邮箱、卡号、到期时间或下载凭证？

商户订单新增第 7.7、7.8 节的 `renewal` 和 `receipt`。有效成功凭证可包含该笔原单保存的完整邮箱；缺失时为 null。API 返回凭证数据，网页可生成图片；没有 PNG 下载接口，不返回卡号、CVV、完整支付资料或订阅到期时间。

需要查看邮箱时，持对应本站卡密或所属 API 账号有效 Key 的人可使用第 10.1 节的网页订单查询，管理员可在第 10.2 节订单中心查看。网页显示每笔订单已保存的完整账号邮箱；旧单未保存时显示“未记录”，不猜测或回填。你的业务可以基于自己已获授权保存的客户订单资料和本站最终状态展示结果，不得把本站 `finished_at` 当作订阅到期时间。

### 16.6 验证入口为什么申请失败？

当前新订单没有本站嵌入的人机验证入口，不应申请或等待本站验证码弹窗。此接口仅保留历史原单兼容：原挑战可能已过期、已结束或暂不可用；只有原单仍存在有效验证上下文时才保留入口。先查询同一原单，不把新建订单当作刷新验证码的方式。

### 16.7 能直接在前端调用或者给每个客户同一把 Key 吗？

不能。本 API 仅服务端接入；Key 代表整个商户。商户网站应通过自己的后端调用，并在自己的登录/订单权限中隔离终端客户。

### 16.8 能用卡密给我的商户 Key 自助充值吗？

可以。注册并验证邮箱后，登录账号中心的“金额钱包”，核对卡密金额再确认整笔转入；无需先创建 API Key，以后自建的 Key 与网页共用账号余额。已有有效 Key 的客户也可在网页 `/recharge` 输入该 Key，或由服务端用有 write 权限的有效 `X-API-Key` 调用 `POST /credits/recharge`。三种入口均转入对应商户钱包；有处理中或冻结订单时先核对原单。充值不恢复已撤销或过期的 Key。

### 16.9 为什么失败原因不写成固定几个选项？

账号资格、支付卡、验证超时等原因不同，最终消息以原单实际返回为准。业务逻辑使用 `state`、`billing` 和错误 `code`，对 `message` 使用纯文本显示，不依赖固定句子或把它作为 HTML 执行。

### 16.10 一张卡密既用过网页，又转入了 Key，历史订单会合并吗？

不会。只按原单位转入未消耗金额，商户 API 和持 Key 的网页查询只返回该商户的订单；原卡密持有人可在订单查询页继续查看该卡密自己的旧订单。转入不会合并两边历史，也不会让原卡密持有人查看收款商户的其他订单。

### 16.11 充值超时后再试，会不会重复入账？

同一卡密转入同一商户只入账一次。保留原卡密与原收款账号，明确重试会返回原凭证及 `replayed:true`。不要仅因超时就换卡或换账号，也不要把重试做成持续自动充值。

### 16.12 卡密转入后还能继续使用或改转另一把 Key 吗？

原卡密不能再直接消费，也不能改转另一个商户。同一商户换发的新有效 Key 仍可核对这次转入；不影响已有金额。卡密暂停不等于退款，已转入的卡密不能重新启用。

### 16.13 创建订单返回 400，是否要加 consent？

不用加。API 与网页字段不同：`POST /orders` 不接受 `consent`、`verification_id`、`idempotency_key`。商户应在自己的业务流程中取得客户授权，再按第 5 节字段创建草稿。确认客户单号符合 1–64 字符规则，并提交完整 Session 对象。

### 16.14 收到开通成功回调，能直接显示续费已取消吗？

不能。统一事件 `order.result` 和 `order.result_updated` 都包含同一原单的结果快照；分别按 `data.order.state` 记录开通结果、按 `data.order.renewal.status` 记录关闭续费结果。开通成功时续费仍可能 `processing` 或 `unconfirmed`；关闭续费失败也不会把开通成功改成失败。没有回调时查询原单仍可获得同样的结果与凭证字段。

### 16.15 文档里的字段和套餐是否全部必须传？

按所选商品传对应字段。升级增加 `upgrade_mode:new_card`，Codex 增加 `credit_quantity`，Team 增加 `team_plan`；其他套餐不传无关字段。套餐、地区和规格须以 `GET /plans` 的实时能力为准。

## 17. 接入验收清单

### 17.1 只读验收，不付款

- 注册并验证邮箱后自建有效 Key，调用 `/account` 返回正确商户与余额，不把 Key 暴露给前端。已撤销或过期的 Key 应被拒绝。
- `/plans` 展示当前商品，地区跟随套餐；当前升级方式仅支持 `new_card`，不恢复历史同卡方式。
- `/orders` 只展示该商户自己的订单，分页与客户单号精确查询正常。
- 0 元、401、403、429和网络错误能被正确展示，不触发自动确认或补单。

### 17.2 本地模拟验收

- 当前新单均准备完整 `payment_card` 并校验四个卡字段；升级按当前能力提交 `upgrade_mode:new_card`。
- Codex 只发送整数 `credit_quantity`，其余套餐不带数量字段。
- 创建草稿后停在确认步骤，不自动付款；同单号相同内容重试返回原单。
- 模拟 processing、requires_action、review_required、成功、失败及超时，未知结果始终保留原单。
- 正常处理中反复查询或核对仍为 processing；超时或异常待核对时保留 review_required，并继续原单查询，不自动释放或补单。最终成功只结算一次。
- 模拟七种 renewal 状态、null 时间和 receipt:null；开通成功但取消失败须分别显示，重查更新凭证不得再次付款。
- 检查完整邮箱仅对对应商户可见，历史缺失资料不推断，分享图片默认打码。
- 失败释放与成功收费各只结算一次，查询不会重复付款。
- 验证链接只交原客户本人，日志不记录 Key、Session、完整卡号、CVV和完整验证链接。
- 模拟卡密整笔充值、同卡同商户重复请求、跨商户拒绝、处理中阻止和响应丢失恢复；只成功入账一次。
- 模拟网页预览后输入变更、余额变化与二次确认，确保不会转入未经核对的账号。

### 17.3 正式订单前

由管理员确认服务额度和接入状态；客户确认套餐、地区、账号、升级方式和本次授权后，再明确确认一笔原草稿。正式结果以查询原单为准。任何测试都不应因为复制文档示例而自动产生付款。

## 18. 兼容与更新

2026-10-21（v51）：正常排队或处理中订单的原单核对保持 `processing`，不再仅因尚未完成转为 `review_required`。已有超时、异常或未知结果仍保守待核对；保留已确认终态，避免迟到处理中结果覆盖；补充客户端持续查询、最终结果更新及查询同步结算说明。接口路径、有效 Key、统一回调事件及计费规则不变。

2026-10-18（v48）：新增每 Key 一个地址的统一通知，事件为 `order.result`、`order.result_updated` 和 `webhook.test`，仅通知该 Key 关联订单。原双通道继续按第 22 节兼容，不自动迁移或撤销 Key；金额计费、本站签名算法与原单查询流程不变。

2026-10-18（v47）：新增 `POST /webhooks/{kind}/test` 和账号中心“发送测试通知”，使用独立 `webhook.test` 事件验证接收与验签，不创建订单、不付款、不扣服务费。测试按 `client_test_id` 幂等，每类通知 30 秒限频；事件查询与补送仍使用原路径。工作台新增卡密兑换入口，金额转入当前邮箱账号共享钱包，不按 Key 分配。原订单事件和签名算法不变。

2026-10-18（v46）：订单与回调增量提供 `payment`，区分开通、付款、权益及关闭续费；开通结果的重要付款、服务费或凭证事实更新可提升该类事件 `revision`。补齐有效 Key 配置双回调的三步指引和完整接收规则；同地址签名密钥轮换继续观察原范围内订单，旧待投事件仍取消，不自动重放。原 API Base、业务路径、两类事件 type、签名算法和金额计费不变；仍有效的 Key 无需因该升级更换。

2026-10-18（v42）：新增邮箱注册与账号中心、同一商户的网页/API 共用钱包、Key 查询/下单权限与到期/IP/金额用量策略、登录设备管理和密码重设。原商户通过管理员邮箱邀请绑定，保留原商户余额、历史与双回调配置，以及原密钥的当前状态和策略。API Base、已发布业务路径和金额计费不变；新 API 草稿确认需要原 Key，详细规则见第 21 节。

2026-10-18（文档整理）：新增按任务分组的导航、接口索引、字段检索和 Node.js 只读示例；补齐账号查询返回字段，修正创建草稿的字段白名单、完整 Session 示例与限流说明。该次文档整理不新增业务接口，不改变密钥状态、订单或计费。

2026-10-18（v39）：新增默认关闭的开通结果与自动续费结果独立回调、独立签名密钥、事件投递记录与人工补送。兼容当时仍有效的 API Key、v38 renewal/receipt 和金额计费，详见第 22 节。

2026-10-18：订单对象增量加入 `renewal` 和 `receipt`，覆盖详情、列表、客户单号查询及核对响应。旧字段和 API Key 保持兼容；没有新增图片下载接口。独立结果回调见随后 v39 更新与第 22 节。网页与 PNG 凭证支持当前套餐及续费处理状态。

当前契约为 **API v1**。接口地址、`X-API-Key` 鉴权、商户归属、历史订单保持不变；新业务只按本页金额契约读取，仍有效的 Key 不需要因接口升级重发，撤销、到期和权限检查始终生效。

2026-10-17 新增网页订单查询与管理员订单中心。本站卡密或有效 API Key 持有人可显式跨浏览器查询对应历史；自助开通页原有浏览器订单记录和操作权限保持不变。查询页仅展示保存的记录，不创建订单或执行充值，原服务端 API 接口不变。

2026-10-14 新增 `POST /credits/recharge` 与网页卡密充值，支持本站卡密分次直接使用或剩余金额整笔转入。已有客户端需按金额字段更新余额和费用展示，接口与 Key 保持不变；需要自助充值时再增加该接口。充值不迁移历史订单，不改变现有 Key。

2026-10-15 新发行卡密调整为无前缀的 20 位大写英文字母与数字；旧点数卡密已停用，该格式变化不要求更换仍有效的 API Key。支持管理员删除单张卡密并回收未使用额度，已转入的金额及历史账务保持不变。

当前新单完整采用 `/plans` 返回的商品、地区与参数；升级仅使用新卡，Codex 数量采用开放预设。Codex 仅在当前能力列出 auto 时支持自动地区；历史模式仍仅供原单查询。

新建订单必须使用当前套餐支持地区。历史原单保留原地区、模式、结算和查询入口；客户端不得因能力变化改写旧单。JSON 新增响应字段按兼容扩展处理，客户端应忽略自己暂不使用的字段。

需要帮助时提供本站 `order.id`、`client_order_no` 或错误 `request_id`。不要发送完整 API Key、Session、卡号或 CVV。公开文档位于 [小小宝 API 文档](https://xiaoxiaobao.online/docs)。

## 19. 付款、到账与恢复边界

查询成功仅表示取得订单。按第 7、8 节分别读取 `state`、`payment`、`renewal`、`billing` 和 `service_fee`；不要把 `state` 当作银行卡结论，也不要依赖未公开的内部字段。付款未知、付款已成功但交付未确认时，都保留原单核对，不能推断未扣款并自动重付。`renewal.status=failed` 不推翻已确认的开通；`receipt:null` 也不能作为再次付款的依据。

本站金额订单普通成功 80 分、人机最终成功 100 分、明确失败 0。结果或分类未知保留待核对及冻结状态。本站冻结金额释放不是银行卡退款，Codex 点数也不是本站服务费。任何显示的金额未知均保持 null，不能用 0 代替。

接口调用错误读取 `error.code` 和 `request_id`，以第 14 节及实际响应为准；订单处理结果读取 `state` 与安全的 `message`。当前公开订单没有独立的 `failure_code` 或 `decline_code` 契约，不按中文消息匹配分支，也不假定能够收到其他系统的原因码。遇到待核对结果保留原单号，不根据超时或拒卡文案推断未扣款。

创建或确认响应丢失时，自动重试应按原业务单号和原 order.id 恢复，沿用原幂等身份。客户另行提交并确认不同业务单号的新订单时，同账号旧单结果未知也可独立处理；新旧订单都可能成功扣款，旧单继续核对。不要把超时重试自动转换成另一笔付款。商户可按第 20 节为使用的 Key 配置统一订单通知；通知缺失或结果不明确时仍查询原单，不据此自动重试付款。

## 20. 每 Key 统一订单结果通知

每把 API Key 可设置**一个公网 HTTPS 通知地址和一把独立签名密钥**。同一地址接收该 Key 提交订单的开通、付款、关闭续费和凭证事实，后续变化以结果更新事件送达。默认关闭；未启用时仍可按原订单查询，不会因为配置通知而下单或付款。

**通知范围与钱包范围不同。** 同邮箱账号多把 Key 共用余额和所属商户订单查询权限，但统一回调按提交 Key 隔离。没有明确关联此 Key 的网页订单不会混入它的通知。不要把一个 Key 的配置、事件分页标记或签名密钥用到另一个 Key。

### 20.0 三步接入

1. 注册并验证邮箱，自建有效 Key；使用同一把 Key 调用 `GET /key-webhook`，取得当前 `version`。
2. 在账号中心「API Key → 配置通知」选择这把 Key，填写并启用一个 HTTPS 地址；服务端也可 `POST /key-webhook`。保存首次返回的 `signing_secret` 到接收服务器，不能用 API Key 代替验签密钥。
3. 发送 `webhook.test`，核对接收端验签、持久保存和 HTTP 2xx，再处理后续正式订单。测试不下单、不付款、不冻结或扣除服务费。

```bash
# 读取当前 Key 的配置。
curl --fail-with-body --silent --show-error \
  'https://xiaoxiaobao.online/api/v1/key-webhook' \
  -H 'X-API-Key: YOUR_API_KEY'

# 以刚读取的 version 提交；首次配置示例 version=0。
curl --fail-with-body --silent --show-error \
  'https://xiaoxiaobao.online/api/v1/key-webhook' \
  -H 'X-API-Key: YOUR_API_KEY' -H 'Content-Type: application/json' \
  --data '{"url":"https://merchant.example/webhooks/orders","enabled":true,"expected_version":0}'
```

这些是本站接口契约，路径、字段和签名头须以本文为准。金额计费仍按第 3 节执行，通知模式不改变服务费规则。

### 20.1 统一事件与结果更新

| type | 含义 | 接收端处理 |
|---|---|---|
| order.result | 该 Key 关联订单首次形成可通知的结果 | 记录同一订单的开通、付款、续费及可用凭证事实 |
| order.result_updated | 已通知订单的重要结果事实发生变化 | 按该订单 revision 更新，例如续费稍后关闭、凭证补齐或结果核对更新 |
| webhook.test | 手动发起的连通性测试 | 验签并记录，不能进入订单业务队列；没有 data.order |

`order.result` 不等于成功：读取 `data.order.state` 判断开通结果，`payment.status` 判断付款事实，`payment.entitlement_status` 判断权益核实，`renewal.status` 判断关闭续费结果，`receipt` 判断凭证是否就绪。不能因为收到 2xx、测试成功、付款成功或首次事件就断言其他结果成功。

首次最终结果包括 `succeeded`、`failed`、`expired`、`cancelled`；`processing`、`requires_action`、`review_required` 等未决状态不产生最终结果事件。已成功开通但续费未确认时，会在最多 24 小时内做最多 12 次同一原单的只读结果核对，不重新付款；超过观察窗口仍未知就保留未知。后续本地保存的结果事实变化仍可产生更新事件。

成功开通后续费仍可能 `processing` 或 `unconfirmed`；随后 `order.result_updated` 才给出 `cancelled` 或 `failed`。续费关闭失败不会把已经成功的开通改成失败。结果未知时保留原订单，不能据此重新付款。

事件 `id` 唯一，用于持久去重；`revision` 在同一 Key 的同一订单内递增，跨 Key、跨订单不能比较。网络重试和人工补送沿用原事件编号和正文。不同事件可能乱序到达，接收端只推进已处理的结果版本，保留历史审计，并在有疑问时查询原订单。

### 20.2 配置、停用与签名密钥轮换

| 方法 | 路径 | 请求 | 成功响应 |
|---|---|---|---|
| GET | `/key-webhook` | 无 | 200，`{"config":{...}}` |
| POST | `/key-webhook` | `{"url":"https://merchant.example/webhooks/orders","enabled":true,"expected_version":0}` | 200，config；首次配置另外返回 signing_secret |
| POST | `/key-webhook/rotate-secret` | `{"expected_version":1}` | 200，config 与本次新 signing_secret |
| POST | `/key-webhook/test` | `{"expected_version":1,"client_test_id":"test-001"}` | 202，`{"event":{...}}` |

服务端 API 自动绑定请求头中的当前 Key，不接收任意 `key_id`。GET 需要 read 权限，配置、轮换、测试与补送需要 write 权限；Key 仍须有效、未过期并满足 IP 策略。账号中心只能操作本人账号下的 Key。

`config` 字段：`key_id,url,enabled,version,secret_version,updated_at,has_secret`。默认未配置时 `version=0`、`enabled=false`、`has_secret=false`。`expected_version` 为刚读取的配置整数版本；409 `webhook_version_conflict` 时刷新并核对，不覆盖另一处设置。

```json
{"config":{"key_id":"key_example","url":"https://merchant.example/webhooks/orders","enabled":true,"version":1,"secret_version":1,"updated_at":"2026-10-18T10:00:00Z","has_secret":true},"signing_secret":"ONLY_SHOWN_ON_INITIAL_SETUP_OR_ROTATION"}
```

签名密钥只在首次配置或明确轮换时返回，不出现在 GET 配置和事件接口。页面显示一次后应安全保存；刷新、关闭或隐藏页面会清除展示。丢失时先查版本，再轮换并更新接收端，不重复创建 API Key。

地址须为公网 HTTPS，不接受用户名密码、片段或指向内网/本机/保留地址的目标，也不跟随跳转。更新地址、停用、轮换及 Key 撤销/过期会停止对应待投旧事件；不能把旧事件悄悄改投新目标。已撤销/过期 Key 在账号中心可查历史，不能继续修改、测试或补送。修改一个 Key 不影响其他 Key。

开启配置不会自动补发启用前旧单。旧商户双通道配置不会迁移为这里的配置；兼容规则见第 22 节。

更换通知 URL、关闭后重新启用会重新确定观察起点，仅观察新起点之后创建且属于该 Key 的订单；此前已经在处理的订单不会自动改投新地址，应继续查询原单。单纯轮换签名密钥则保留原观察起点、已见结果和原单续费核对的截止时间，但旧待投事件仍停止。已经发出的网络请求无法撤回，接收端仍须验签、去重并按 revision 处理可能迟到的通知。

### 20.3 通知正文和业务字段

签名正文为 UTF-8 JSON：`id,type,revision,created_at,data`。业务事件含 `data.order`，采用第 7 节同一套公共订单字段；事件管理接口另提供 `key_id,kind,status,attempts` 等投递信息，不能把这些管理字段添加到收到的原始正文后再验签。

```json
{"id":"evt_example_result","type":"order.result_updated","revision":2,"created_at":"2026-10-18T10:01:00Z","data":{"order":{"id":"ord_11111111111111111111111111111111","client_order_no":"merchant-order-001","state":"succeeded","payment":{"status":"success","amount":"982.14","currency":"PHP","entitlement_status":"confirmed"},"renewal":{"status":"cancelled"},"receipt":null}}}
```

这是字段节选，实际公共订单还包含账号、服务费和可用凭证字段。`receipt:null` 不能作为已有凭证；不凭其他事件或另一个账号的资料补齐。订单失败仍分别显示付款及权益事实，`renewal.status=failed` 仅说明关闭续费失败。

回调不传 Session、支付卡、API Key、签名密钥或内部处理资料。邮箱等订单资料仅由所属客户服务端持有，接收端须按保存的商户/订单关系校验用户归属，不能直接公开完整通知正文。

### 20.4 原始字节验签

统一模式继续使用本站 `X-XXB-*` 签名协议，每把 Key 使用自己的签名密钥。一个 URL 对应一把 Key 最便于隔离；共用 URL 的接收服务须通过受控路由找到对应配置，不能相信未验签正文中的归属。

| 请求头 | 内容 |
|---|---|
| X-XXB-Event-Id | 事件 id |
| X-XXB-Event-Type | order.result、order.result_updated 或 webhook.test |
| X-XXB-Timestamp | 本次投递的 Unix 秒时间戳字符串 |
| X-XXB-Signature | `v1=` 加 HMAC-SHA256 小写十六进制摘要 |

签名原文：`timestamp + "." + event_id + "." + raw_body`，其中 `raw_body` 是收到的 UTF-8 **原始字节**。使用签名密钥的 UTF-8 字节计算 HMAC-SHA256，用恒定时间比较。先验签再解析 JSON；拒绝重复关键头、未知类型或签名版本、距离当前时间超过 300 秒的时间戳，保持服务器时钟同步。JSON 解析后再次确认正文 id/type 与头一致。

可靠保存成功后尽快返回任意 2xx；推荐先持久写入 inbox，再由后台幂等处理。数据库写入失败不能返回 2xx。本站单次发送总预算 10 秒，不能依赖等待到最后一秒完成慢业务。

### 20.5 记录、分页与人工补送

| 方法 | 路径 | 请求 | 响应 |
|---|---|---|---|
| GET | `/key-webhook-events?limit=20` | 可带上页原样返回的 cursor | 200，events 与 next_cursor |
| GET | `/key-webhook-events/{event_id}` | 无 | 200，event |
| POST | `/key-webhook-events/{event_id}/retry` | `{"expected_version":1}`，当前配置版本 | 200，event |

这些记录只属于请求的当前 Key。`limit` 默认 20，支持 1–100；`cursor` 仅使用该 Key 上一页原样返回的 `next_cursor`，为 null 时结束。同一商户下其他 Key 的事件仍不可通过本接口读取、补送或使用分页标记；切换 Key 时必须从第一页开始。账号中心先选 Key，再看它的通知记录。

投递状态 `pending`（待发送）、`inflight`（发送中）、`delivered`（收到 HTTP 2xx）、`exhausted`（尝试用尽）、`cancelled`（配置变化或停止）。失败按持久任务重试，最多 8 次；请求超时可能意味着接收端已保存，因此接收端始终按 id 去重。刷新查询不触发新业务订单。

补送使用当前已启用配置，需要显式确认 `expected_version`；针对用尽或停止的事件启动新一轮投递，保留原 id 与正文。待发送、发送中或已送达事件的补送幂等返回，不主动再投。测试补送同样受 30 秒间隔限制。不能以人工补送替代结果核对，更不能据此创建替代订单。

### 20.6 Python 标准库统一接收示例

下面程序对应**一把 Key 的一个接收地址** `/webhooks/orders`。通过环境变量 `XXB_WEBHOOK_SIGNING_SECRET` 注入这一把 Key 的签名密钥，`XXB_WEBHOOK_DB` 指向受保护的持久数据库。多把 Key 应部署隔离接收实例或自行实现受控路径映射，不共用密钥。本示例只接收、验签、持久保存，不向外发请求，不下单和付款；在自己的 HTTPS 443 反向代理后运行。

```python
"""Standard-library receiver: authenticate, durably enqueue, then acknowledge.

Run behind your own HTTPS reverse proxy. This module never sends requests or
performs an order/payment action. A separate worker processes accepted_events.
"""
import hashlib
import hmac
import json
import os
import re
import sqlite3
import time
from contextlib import contextmanager
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer

MAX_BODY_BYTES = 262144
WINDOW_SECONDS = 300
RESULT_TYPES = {'order.result', 'order.result_updated'}
EVENT_TYPES = RESULT_TYPES | {'webhook.test'}
class Rejected(ValueError):
    def __init__(self, code, status=400):
        super().__init__(code)
        self.status = status


def single_header(headers, name):
    values = headers.get_all(name, []) if hasattr(headers, 'get_all') else [headers.get(name)]
    if len(values) != 1 or not isinstance(values[0], str):
        raise Rejected('invalid_header')
    return values[0]


def unique_object(pairs):
    value = {}
    for name, item in pairs:
        if name in value:
            raise Rejected('duplicate_json_key')
        value[name] = item
    return value


def reject_constant(value):
    raise Rejected('invalid_json_number')


def verify_event(raw, headers, signing_secret, now=None):
    if not isinstance(raw, bytes) or not 0 < len(raw) <= MAX_BODY_BYTES:
        raise Rejected('invalid_body_size', 413)
    event_id = single_header(headers, 'X-XXB-Event-Id')
    event_type = single_header(headers, 'X-XXB-Event-Type')
    timestamp = single_header(headers, 'X-XXB-Timestamp')
    signature = single_header(headers, 'X-XXB-Signature')
    if not re.fullmatch(r'evt_[A-Za-z0-9_-]{1,128}', event_id):
        raise Rejected('invalid_event_id')
    if event_type not in EVENT_TYPES:
        raise Rejected('unsupported_event_type')
    if not re.fullmatch(r'[0-9]{1,12}', timestamp):
        raise Rejected('invalid_timestamp')
    if abs((time.time() if now is None else now) - int(timestamp)) > WINDOW_SECONDS:
        raise Rejected('timestamp_outside_window', 401)
    if not re.fullmatch(r'v1=[0-9a-f]{64}', signature):
        raise Rejected('invalid_signature_format', 401)
    if not isinstance(signing_secret, str) or not signing_secret:
        raise Rejected('missing_signing_secret', 503)
    signed = timestamp.encode('ascii') + b'.' + event_id.encode('ascii') + b'.' + raw
    expected = hmac.new(signing_secret.encode('utf-8'), signed, hashlib.sha256).hexdigest()
    if not hmac.compare_digest(signature[3:], expected):
        raise Rejected('signature_mismatch', 401)
    # Decode/parse only after authenticating the exact bytes received.
    try:
        event = json.loads(raw.decode('utf-8'), object_pairs_hook=unique_object,
                           parse_constant=reject_constant)
    except (UnicodeError, json.JSONDecodeError) as error:
        raise Rejected('invalid_json') from error
    if not isinstance(event, dict) or event.get('id') != event_id or event.get('type') != event_type:
        raise Rejected('header_body_mismatch')
    if (type(event.get('revision')) is not int or event['revision'] < 1
            or not isinstance(event.get('created_at'), str)
            or not isinstance(event.get('data'), dict)):
        raise Rejected('invalid_event_shape')
    if event_type == 'webhook.test':
        data = event['data']
        if (event['revision'] != 1 or data.get('test') is not True
                or not isinstance(data.get('message'), str) or 'order' in data):
            raise Rejected('invalid_test_event')
    elif (not isinstance(event['data'].get('order'), dict)
          or not isinstance(event['data']['order'].get('id'), str)):
        raise Rejected('invalid_event_shape')
    return event


class Receiver:
    def __init__(self, database_path, signing_secret):
        if not isinstance(signing_secret, str) or not signing_secret:
            raise ValueError('Configure this Key signing secret')
        self.database_path = os.fspath(database_path)
        self.signing_secret = signing_secret
        with self.connect() as db:
            db.execute('''CREATE TABLE IF NOT EXISTS accepted_events (
                id TEXT PRIMARY KEY, type TEXT NOT NULL, revision INTEGER NOT NULL,
                body_sha256 TEXT NOT NULL, raw_body BLOB NOT NULL,
                received_at INTEGER NOT NULL, processed_at INTEGER
            )''')

    @contextmanager
    def connect(self):
        db = sqlite3.connect(self.database_path, timeout=2)
        try:
            db.execute('PRAGMA synchronous=FULL')
            with db:
                yield db
        finally:
            db.close()

    def accept(self, raw, headers, now=None):
        event = verify_event(raw, headers, self.signing_secret, now)
        digest = hashlib.sha256(raw).hexdigest()
        with self.connect() as db:
            db.execute('BEGIN IMMEDIATE')
            previous = db.execute('SELECT body_sha256 FROM accepted_events WHERE id=?', (event['id'],)).fetchone()
            if previous:
                if not hmac.compare_digest(previous[0], digest):
                    raise Rejected('event_id_body_conflict', 409)
                return False  # Valid signed redelivery; do not duplicate business work.
            received_at = int(time.time() if now is None else now)
            # Persist tests for deduplication, but never enqueue order business work.
            processed_at = received_at if event['type'] == 'webhook.test' else None
            db.execute('''INSERT INTO accepted_events
                (id,type,revision,body_sha256,raw_body,received_at,processed_at)
                VALUES (?,?,?,?,?,?,?)''',
                (event['id'], event['type'], event['revision'], digest, raw,
                 received_at, processed_at))
        # Commit is complete before 2xx. A worker can recover this inbox after a crash.
        return True


def handler_for(receiver):
    class Handler(BaseHTTPRequestHandler):
        route = '/webhooks/orders'

        def do_POST(self):
            status = 400
            try:
                if self.path != self.route:
                    raise Rejected('unknown_route', 404)
                if self.headers.get_all('Transfer-Encoding'):
                    raise Rejected('chunked_body_not_supported')
                content_type = single_header(self.headers, 'Content-Type')
                if content_type.split(';', 1)[0].strip().lower() != 'application/json':
                    raise Rejected('json_required', 415)
                length = single_header(self.headers, 'Content-Length')
                if not re.fullmatch(r'[0-9]{1,9}', length) or not 0 < int(length) <= MAX_BODY_BYTES:
                    raise Rejected('invalid_body_size', 413)
                self.connection.settimeout(5)
                raw = self.rfile.read(int(length))
                if len(raw) != int(length):
                    raise Rejected('incomplete_body')
                fresh = receiver.accept(raw, self.headers)
                status = 202 if fresh else 200
            except Rejected as error:
                status = error.status
            except (OSError, sqlite3.Error):
                status = 503  # Do not acknowledge events that were not durably saved.
            self.send_response(status)
            self.send_header('Content-Length', '0')
            self.send_header('Connection', 'close')
            self.end_headers()
            self.close_connection = True

        def log_message(self, *args):
            pass  # Do not log request headers or payloads.

    return Handler


if __name__ == '__main__':
    receiver = Receiver(os.environ['XXB_WEBHOOK_DB'], os.environ['XXB_WEBHOOK_SIGNING_SECRET'])
    # HTTPS 443 and the public domain belong to your reverse proxy, not this loopback listener.
    with ThreadingHTTPServer(('127.0.0.1', 9100), handler_for(receiver)) as server:
        server.serve_forever()
```

`accepted_events` 保留原始正文供幂等处理；测试 `processed_at` 直接标为已处理，不进入订单队列。后台消费者仅处理 `processed_at IS NULL` 的真实订单事件，校验订单归属并按 revision 防止旧结果覆盖新结果；业务更新和处理标记应保持事务一致。返回 202 只确认接收，不能代替客户自己的业务处理完成。日志不记录密钥或完整正文。

### 20.7 联调和迁移验收

- 先测试正确/错误签名、篡改正文、过期时间、重复事件、重启后去重和持久保存失败。
- 分别模拟首次成功、首次失败、成功但续费处理中、随后续费成功/失败、凭证补齐，以及更新事件乱序。
- 两把 Key 配置不同接收地址；确认订单仅进入创建该单的 Key 通知，另一把 Key 及未关联网页订单不混入。
- 若有旧双通道，先分别识别旧事件和新事件，明确完成切换后再停用旧配置；系统不会自动撤销旧 Key 或删除历史。
- 测试不付款；正式业务仍按创建草稿、客户授权确认和原单核对流程执行。

### 20.8 一键测试，不下单不扣费

账号中心「回调通知」选择 Key 后点击“发送测试通知”，或调用 `POST /key-webhook/test`。仅使用该 Key 已保存且已启用的地址，不接收临时 URL、订单数据或任意 `key_id`。页面未保存修改时禁用测试，先保存再测试。

请求只含 `expected_version` 和 `client_test_id`：前者为当前 config.version；后者为 1–64 字符，首字符字母/数字，其余可含字母、数字、`_ . : -`。同 Key 同 client_test_id 返回原测试，不重复生成；网络结果不明时保留原编号核对，不换编号连点。

```bash
curl --fail-with-body --silent --show-error \
  'https://xiaoxiaobao.online/api/v1/key-webhook/test' \
  -H 'X-API-Key: YOUR_API_KEY' -H 'Content-Type: application/json' \
  --data '{"expected_version":1,"client_test_id":"connectivity-001"}'
```

HTTP 202 表示测试已受理，不代表已经送达。返回管理事件示例（字段节选）：

```json
{"event":{"id":"evt_example_test","key_id":"key_example","kind":"key_result","type":"webhook.test","revision":1,"created_at":"2026-10-18T10:00:00Z","status":"pending","data":{"test":true,"message":"Connectivity test; no order or payment."}}}
```

测试 `data` 不含 `order`，**不创建订单、不付款、不冻结或扣除服务费，不兑换卡密**。`delivered` 仅表示接收端返回 HTTP 2xx，仍需在客户接收端核对验签和持久记录，不代表开通、关闭续费或其他业务成功。

每 Key 新建测试或显式补送已停止/用尽的测试，至少间隔 30 秒；同时计入常规 API 请求限流。429 `webhook_test_rate_limited` 带 `Retry-After: 30`；通用限流带 `Retry-After: 60`。409 `webhook_disabled` 要先保存启用，409 `webhook_version_conflict` 要先刷新核对版本。停止或修改旧配置不代表可以自动换地址重新发送旧测试。

## 21. 邮箱账号中心与密钥策略（v42）

账号中心将网页订单、商户 API、金额钱包、密钥和回调归入同一个客户账号。入口为[账号中心](https://xiaoxiaobao.online/console)；服务端调用本文 `/api/v1` 接口，并使用本人自建的有效 API Key。网页登录使用邮箱密码与安全 Cookie，第三方网站的 API 接入使用 `X-API-Key`，两者不能互相替代。

### 21.1 注册与邮箱验证

新客户在[注册页面](https://xiaoxiaobao.online/register)填写邮箱与密码，完成邮件里的验证后创建独立账号。密码需为 12–128 个字符，UTF-8 不超过 512 字节；密码中的空格也是密码的一部分。邮箱按去除首尾空格及大小写规范化处理。注册时不会按订单邮箱、名称或手中的 Key 自动认领其他商户；新账号初始金额为 0，注册和验证不收费、不下单。

注册验证和密码重设链接有效期为 30 分钟，邀请链接为 24 小时，均只能成功使用一次；重新发送会使前一封相同用途链接失效。链接含私人授权信息，请勿转发。未配置邮件服务时，页面会提示暂不可用并拒绝发送；这不代表已经注册或已经寄出邮件。

### 21.2 网页、API、卡密使用哪个钱包

| 操作入口 | 使用的身份与余额 | 历史范围 |
| --- | --- | --- |
| 登录后“创建订单” | 当前邮箱账号所属商户的钱包，不必再粘贴 API Key | 账号中心可查所属商户的网页与 API 原单 |
| 服务端 API | X-API-Key 所属商户的钱包，同时检查该 Key 的策略 | 有 read 权限的 Key 可查本商户历史 |
| 登录后“金额钱包”兑换卡密 | 核对后把该卡密全部剩余金额转入当前登录账号 | 只转余额，不迁移原卡密历史 |
| 未登录的自助页使用本站卡密 | 使用该卡密自己的余额 | 仍按原卡密与原浏览器规则查询和操作 |

工作台的“兑换卡密”会进入当前账号的金额钱包，先预览卡密剩余金额，再明确确认整笔转入当前邮箱账号。无需先创建 API Key 即可兑换，之后自建的 Key 与网页共享账号钱包；并非向某一把 Key 单独充值。页面刷新或进入钱包不会自动兑换。

同一商户的网页和 API 共享 `available_fen`、`reserved_fen`、`spent_fen`，不存在每把 Key 各有一份充值余额。金额钱包里的入账、冻结、结算和释放流水使用同一账本。普通成功 80 分、人机验证最终成功 100 分、明确失败 0 分的收费规则不变；支付卡套餐扣款另计。

“我的订单”支持按状态、北京时间日期范围、本站订单号、商户单号或已记录邮箱查询，并翻页浏览。详情显示各原单的开通、续费结果及已生成的凭证；未记录邮箱显示“未记录”，不使用当前登录邮箱或本次表单邮箱代替。另一账号不能凭订单号读取这笔记录；商户自己的下游终端客户仍须由商户业务系统隔离。

只有原单仍具备有效的本人验证上下文时，详情才可申请本站短期验证入口；账号登录、跨站保护与订单归属检查均须通过。入口只对应原单，不新建订单，不自动完成验证或再次付款。没有验证入口时继续核对原单，不把按钮缺失当作成功或失败，当前新单能力仍以第 12 节为准。

自己通过账号网页创建、且本次资料仍有效的未确认草稿，可在“我的订单”中明确确认继续原单；刷新或查看详情不会自动提交。API 创建的草稿仍须使用原 Key 确认，账号中心不能代替原 Key 或绕过它的策略。资料已失效时先核对原记录，不恢复不存在的敏感资料或自动补单。

卡密转入分“核对金额”和“确认转入”两步；核对不会转账。确认只对本次核对的卡密、目标账号和金额有效。若卡密有冻结或处理中订单，先核对原订单；成功转入后原卡密不能继续消费。响应丢失时保留原卡密和原收款账号核对，不能改转另一账号。

### 21.3 创建、修改和撤销 API Key

在[账号中心的“API Key”](https://xiaoxiaobao.online/console#keys)新建或管理密钥。完整 Key 只在创建成功时显示一次，列表仅显示脱敏标识；离开前保存在自己的服务端密钥配置中。已有 Key 的完整内容不能重新读取，丢失时新建后替换自己的服务端配置，确认原单处理完再撤销旧 Key。最多保留 50 把未撤销密钥，已到期但未撤销的密钥也计入数量。

| 配置或展示字段 | 含义与限制 |
| --- | --- |
| name | 用途备注，1–80 个字符，例如“生产下单”“只读报表”；不会改变商户归属 |
| scopes | read 查询、write 下单与写操作，至少选一项；只选 write 不自动获得 read |
| expires_at | 到期即拒绝新调用；可不设到期时间。账号中心按日期操作，返回时间为 UTC ISO 字符串或 null |
| allowed_ips | 可留空不限制；最多 64 个 IPv4、IPv6 地址或 CIDR 网段；填写真实服务端公网出口，不填域名 |
| limit_fen | 该 Key 的累计服务费上限，整数分；null 不限制，0 不允许确认新增收费订单；不是充值金额 |
| used_fen / reserved_fen | 已绑定本 Key 订单的已收服务费、当前冻结服务费 |
| remaining_fen | 有上限时为 max(0, limit_fen − used_fen − reserved_fen)；无限制时为 null，不等同账户余额 |
| last_used_at / request_count | 成功通过身份和策略校验的使用时间与次数，不代表订单成功量 |

Key 权限按操作生效：

| 权限 | 对应 API 操作 |
| --- | --- |
| read | 全部 GET；POST `/account/subscription`、`/account/codex`、`/account/promotion`；原单 `/reconcile` 与历史 `/verification-link` |
| write | POST `/orders`、原单 `/confirm`、`/cancel`、`/credits/recharge`；回调配置、签名密钥轮换、发送测试通知及事件人工 retry |

read 允许核对已提交原单的结果，不授权创建新单或再次付款。需要“下单并查询”的服务端通常选择 read 和 write。新增限制也作用于 `/query` 等使用该 Key 的网页入口；换浏览器、伪造来源 IP 或把 Key 改放 Cookie 不能绕过。

IP 白名单检查服务器可信识别的来源地址。使用出口代理时，应填写实际出口公网 IP。若本站暂时无法可靠取得来源地址，受限 Key 会返回 `key_client_ip_unavailable`，不会自动放行；先联系管理员检查接入配置。

**用量上限的计算示例：**设置 limit_fen=1000，已收取 240 分、冻结 100 分，则剩余用量为 660 分。下一个金额订单确认需要再预留最高 100 分；普通成功最终计入 80 分，失败释放冻结。上限不因充值或跨天自动重置。新单确认同时检查账户余额和 Key 剩余用量，二者均须足够。不能把上限调低到已收取加冻结金额之下。

用量明细仅包含确实关联到该 Key 的订单；未关联的旧历史不会猜测归属。账号网页新单不伪装成某把 API Key 的订单，仍从同一钱包扣费。多把 Key 的上限也不是预先分账或额外余额，不能把这些上限相加当成账户资产。

新 API 草稿在创建时绑定原 Key。调整 Key 策略后，未确认草稿确认时重新检查；Key 已撤销、过期、失去 write 权限或剩余用量不足时拒绝确认。其他 Key 和网页登录不能绕过原绑定去首次付款；应先核对原单并处理原 Key 配置。查询所属商户原记录不受“必须同一把 Key”限制，但仍需 read、有效期与 IP 校验。

撤销 Key 不退钱、不删除订单、不取消已提交的付款，也不关闭账号共享回调。已撤销 Key 不能重新启用；其他有效 Key 可继续按权限查询所属账号历史。

### 21.4 用量统计和每 Key 统一回调

Key 详情展示可归属订单用量，以及自统计启用起已通过鉴权的 API 请求次数、错误次数、响应状态、接口分类和耗时摘要。请求通过鉴权但业务失败会计入；被拒绝的未知、撤销、过期或策略不符凭据不计入已鉴权请求统计。历史访问不倒推补录，查询次数不能用来判断已付款或已成功订单数。统计不保存请求正文、完整 Key、Session、卡号或来源凭据。

在[回调通知](https://xiaoxiaobao.online/console#webhooks)选择 Key 后，管理该 Key 的一个通知地址、开关和独立签名密钥。网页设置与服务端 `/key-webhook` 对应同一份 Key 配置，版本冲突先刷新；不同 Key 相互隔离，默认关闭。API Key 列表“配置通知”可直接选中对应 Key。仅已有旧配置的账号显示折叠的双通道兼容区，不要求新客户配置两套通知。

Key 列表中的密钥用于调用本站，回调签名密钥用于验证本站通知，是不同用途。首次配置或明确轮换时出现的签名密钥也只展示一次。事件详情、投递状态、人工补送和验签规则见第 20 节。开启回调不补发启用前旧单；通知缺失时查事件与原订单，不能另建订单或重复付款。

### 21.5 密码、设备会话和忘记密码

[账户安全](https://xiaoxiaobao.online/console#security)显示当前有效登录设备，可退出某个设备或退出全部设备。网页登录会话固定有效 31 天，服务器重启后仍按原有效期校验；清除浏览器 Cookie、主动退出、被撤销或到期后需重新登录，订单与余额不会消失。

修改密码须提供当前密码，成功后撤销全部网页登录会话并重新登录；可明确勾选同时撤销该账号全部 API Key。不勾选时不改变各 Key 的当前状态和策略；仅仍有效的 Key 可继续使用，已撤销或过期的 Key 不会恢复。已创建订单和账务记录不删除，其他客户账号不受影响。

忘记密码时从[找回页面](https://xiaoxiaobao.online/forgot-password)申请邮件，页面始终给出通用响应，不公开该邮箱是否注册。通过有效的一次性链接重设密码后，全部网页登录会话失效，旧链接失效，不自动登录；该操作默认不改变各 API Key 的当前状态和策略，不会恢复已撤销或过期的 Key。若怀疑 Key 也泄漏，重新登录后逐个撤销，或在改密时明确选择撤销全部 Key。

验证邮件和密码重设不是付款授权；页面不会因点击邮件链接而创建或确认订单。验证码/重设令牌放在邮件链接的片段中，页面读取后清除地址栏内容；不要把链接转发给他人。请求过于频繁时会受限，请稍后再试，不连续申请覆盖仍在使用的邮件。

### 21.6 网页账号与服务端接入的边界

`/console`、`/console/create`、`/login`、`/register` 和 `/account` 是本站网页入口。页面内部 `/api/portal/*` 使用当前浏览器的 HttpOnly 登录 Cookie，以及同源校验和与会话绑定的 CSRF 令牌；不接受外站浏览器调用，也不接受 X-API-Key 代替登录。正式 HTTPS 登录 Cookie 带 Secure 与 SameSite=Strict。第三方开发者继续使用第 2.3 节列出的 `/api/v1`，不要复制网页登录请求、提交商户 ID 冒充其他账号，或把网页登录令牌当作 API Key。

新客户注册并验证邮箱后自行创建有效 Key；首次对接只读测试 `/account`、`/plans`、`/orders` 即可，零余额也能检查有 read 权限的有效 Key，不以付款验证连通性。升级不自动更换仍有效的 Key 或改 Base URL。已有商户的迁移规则见下一节。

### 21.7 已有商户迁移与有效密钥兼容

已有商户如果还没有邮箱登录，应联系管理员提供自己的登录邮箱，由管理员向**明确指定的原商户与邮箱**发出一次性邀请。接受邀请并设置密码后，原 merchant_id、余额、历史订单与回调配置保持原归属；密钥状态和策略不会被重置。已绑定账号或已注册邮箱不能被另一份邀请覆盖；不能靠重复注册认领旧账号或替换原账号密码。若已误建独立账号，联系管理员核对，不自行合并余额或历史。

历史密钥仅在未撤销、未过期且具备本次所需权限，并满足 IP、用量等策略时继续有效；升级不自动换有效 Key，也不恢复失效密钥。绑定邮箱不改变这些判断。已经撤销的 Key 不能重新启用；需要新密钥时在自己所属账号中心创建，再更新自己的服务端配置。

API 草稿仍绑定创建它的那把 Key；确认原单时须使用这把仍有效的 Key。它已失效时，先核对原订单，不能用新 Key 或网页绕过原单的付款授权，也不能因换 Key 自动另建订单。已提交订单、已入账余额和历史归属不会因撤销某把 Key 而删除。

## 22. 兼容附录：原商户双通道回调

仅供已接入旧版双通道的客户维护。新接入使用第 22 节每 Key 统一通知；旧地址、密钥、事件类型与接口继续按本附录处理，不会自动迁移。以下原商户级配置与每 Key 新配置是两套独立配置，同时启用可能产生两套事件，须分别验签与去重。


商户可分别启用“开通结果”和“自动续费结果”回调。两类配置、地址和签名密钥相互独立，默认均关闭；继续使用原 `X-API-Key` 管理，不需要重发 Key。关闭回调不影响原单查询、开通流程、计费或第 7 节的 `renewal` / `receipt`。

### 22.0 回调接入三步

HTTP 返回、主动查询、异步回调是三件事：创建/确认返回 201/202 只表示草稿或受理；GET 订单返回目前保存的结果；启用回调后，本站才会主动 POST 到你登记的接收地址。回调接收方是你的服务端，不是浏览器页面。

1. **先读取配置。** 新客户先注册并验证邮箱，在账号中心自建 Key；服务端使用具有 read 权限的有效 Key 调用 `GET /webhooks`，分别记录 `order_result` 和 `renewal_result` 当前的 `version`。后续配置需要 write 权限，密钥还须未撤销、未过期并满足 IP 策略。
2. **分别保存两类地址。** 接收端已部署并准备好验签、持久保存后，对两个 kind 分别提交 `url`、`enabled:true` 和刚读取的 `expected_version`。两种地址可以相同；同地址接收时按 `X-XXB-Event-Type` 选择密钥，验签后再确认正文 `type` 一致。
3. **分别保管签名密钥。** 首次配置各返回一把 `signing_secret`，存入自己服务器的秘密配置。它们与 API Key 不同，也不能互相替代。再次 GET 或重复保存不显示旧密钥；遗失时显式轮换并同步更新接收端。最后重新 GET 确认两类均已启用，再创建后续业务订单。

下面只演示配置，不创建订单、不付款，也不会发送测试通知。`merchant.example.invalid` 是无法用于正式接收的占位地址，须改为你已控制的公网 HTTPS 地址；`expected_version:0` 仅适用于 GET 确认该类尚未配置的情况，已有配置必须使用各自的最新版本。`XIAOXIAOBAO_API_KEY` 由服务器环境变量提供，不写入示例文件或公开日志。POST 响应可能含只显示一次的签名密钥，应直接安全保存。

```bash
# 1. 先读两类配置。
curl --silent --show-error --fail-with-body --max-time 30 \
  'https://xiaoxiaobao.online/api/v1/webhooks' \
  --header "X-API-Key: ${XIAOXIAOBAO_API_KEY}" \
  --header 'Accept: application/json'

# 2a. 首次配置开通结果；先替换接收地址及实际 expected_version。
curl --silent --show-error --fail-with-body --max-time 30 \
  'https://xiaoxiaobao.online/api/v1/webhooks/order_result' \
  --header "X-API-Key: ${XIAOXIAOBAO_API_KEY}" \
  --header 'Content-Type: application/json' \
  --data '{"url":"https://merchant.example.invalid/callbacks/order-result","enabled":true,"expected_version":0}'

# 2b. 首次配置关闭续费结果；保存此响应中另一把 signing_secret。
curl --silent --show-error --fail-with-body --max-time 30 \
  'https://xiaoxiaobao.online/api/v1/webhooks/renewal_result' \
  --header "X-API-Key: ${XIAOXIAOBAO_API_KEY}" \
  --header 'Content-Type: application/json' \
  --data '{"url":"https://merchant.example.invalid/callbacks/renewal-result","enabled":true,"expected_version":0}'
```

**交给接入开发者的最短清单：**

- 接入地址固定 `https://xiaoxiaobao.online/api/v1`；注册验证邮箱后自行创建有效 Key，只在服务端使用。
- 回调接收用公网 HTTPS；配置两类通知、分别保存两把签名密钥，确认启用状态。
- 验签使用原始正文；先持久入队，再快速返回 2xx；同一事件重复收到只处理一次。
- 保存本站 `order.id` 与自己的 `client_order_no`、用户归属；分别更新开通和关闭续费结果，同一类型只接受更新的 revision。
- 保存并启用配置后，可按第 22.8 节显式发送测试通知，先验证接收地址、密钥和投递记录；测试不会创建订单或扣费。
- 上线前用合成数据检查两类通知、重复/乱序、错误签名、超时和重启；收到通知后能查原单核对，收不到通知时能查事件投递记录。配置成功不等于已经完成真实订单联调。

只需先完成这份清单即可开始接收；签名、字段、重试与完整 Python 接收程序见下面各小节。第 22.6 节示例固定使用两个路径；若使用同一个 URL，应在自己的接收端实现上述按类型选密钥、验签及分流，不能把两类通知都交给只接受一种类型的路径。本站未提供按文档示例自动付款的联调方式。

### 22.1 事件什么时候产生

| 配置 kind | 事件 type | 触发条件 |
| --- | --- | --- |
| order_result | order.activation_result | 已提交原单 state 为 succeeded 或 failed；成功开通会先产生开通结果，不等待自动续费处理结束；未提交草稿 cancelled / expired 不通知 |
| renewal_result | order.renewal_result | 成功原单的 renewal.status 已确认属于 cancelled、failed、retained、not_applicable |

续费 `not_started`、`processing`、`unconfirmed` 不产生确定结果通知；不得把未知结果当作取消失败或已关闭。`renewal.status=failed` 只是本次关闭自动续费失败，已成功开通仍是成功；两种事件可能先后到达，不保证跨类型或跨订单的投递顺序。处理事件时用本站订单号关联，并分别记录两类结果。

只观察该类配置启用起点 `since` 之后创建（`order.created_at >= since`）且已提交的订单。首次启用、修改地址、关闭或重新启用不自动推送历史订单；修改地址或重新启用后按新的 `since` 观察。只轮换签名密钥时，地址不变，保留原 `since`、已观察的结果和原跟进截止时间，继续等候原范围内订单的后续结果，但不重放已产生的旧事件。

续费结果未确认时，后台只核对同一笔原订单，间隔按 60 / 120 / 300 秒逐步调整，最多跟进至开通成功后 24 小时；确认终态即停止主动核对。24 小时后仍无确定结果，保持未知并由商户查询原单。以后若原单保存的结果发生明确变化，仍可能形成新事件。`review_required`、`requires_action` 和其他非终态不产生开通结果通知；应继续查询同一原单，不能因为没收到回调就把结果当作失败。整个过程不会创建新单、重新确认付款或重试支付。

### 22.2 配置、关闭与密钥轮换

所有下列路径相对 Base URL，均使用现有 `X-API-Key`，只操作当前商户资料。

| 方法 | 路径 | 正文或参数 | 正常响应 |
| --- | --- | --- | --- |
| GET | `/webhooks` | 无 | 200，`{"configs":[...]}` |
| POST | `/webhooks/{kind}` | `{"url":"https://merchant.example/callbacks/order-result","enabled":true,"expected_version":0}` | 200，`{"config":{...},"signing_secret":"仅首次配置返回"}` |
| POST | `/webhooks/{kind}/rotate-secret` | `{"expected_version":1}` | 200，`{"config":{...},"signing_secret":"本次新密钥"}` |
| POST | `/webhooks/{kind}/test` | `{"expected_version":1,"client_test_id":"demo-test-001"}` | 202，`{"event":{...}}`，详见 22.8 |
| GET | `/webhook-events` | `limit`、`cursor` 可选 | 200，`{"events":[...],"next_cursor":null}` |
| GET | `/webhook-events/{evt}` | 无 | 200，`{"event":{...}}` |
| POST | `/webhook-events/{evt}/retry` | `{}` | 200，`{"event":{...}}` |

`{kind}` 只能是 `order_result` 或 `renewal_result`。`{evt}` 使用本站返回的事件 `id`，不是订单号。首次配置前先 GET，未配置项的 `url:null`、`enabled:false`、`version:0`。`expected_version` 必须是刚读取的对应配置版本，防止两个操作互相覆盖；版本不匹配返回 HTTP 409 `webhook_version_conflict`，重新读取并核对后再决定修改。

配置对象字段固定如下：

| 字段 | 含义 |
| --- | --- |
| kind | order_result 或 renewal_result |
| url | 已保存的 HTTPS 接收地址；未配置时 null |
| enabled | 是否启用，默认 false |
| version | 配置版本，供下一次 expected_version 使用 |
| secret_version | 该类型签名密钥的版本；不是 API Key 版本 |
| since | 订单观察起点，带时区 ISO 8601 或 null；只轮换密钥时保留原起点 |
| updated_at | 配置最近修改时间，带时区 ISO 8601 或 null |

首次配置示例（占位域名不用于实际发送）：

```json
{
  "url": "https://merchant.example/callbacks/order-result",
  "enabled": true,
  "expected_version": 0
}
```

```json
{
  "config": {
    "kind": "order_result",
    "url": "https://merchant.example/callbacks/order-result",
    "enabled": true,
    "version": 1,
    "secret_version": 1,
    "since": "2026-10-18T01:00:00Z",
    "updated_at": "2026-10-18T01:00:00Z"
  },
  "signing_secret": "EXAMPLE_SIGNING_SECRET_NOT_REAL"
}
```

自动续费结果单独向 `/webhooks/renewal_result` 配置，例如地址 `https://merchant.example/callbacks/renewal-result`。两个地址可以位于同一受控服务，但必须分别保存两把签名密钥。签名密钥不是 API Key；GET 配置和事件接口均不返回它。首次配置（包括首次设置 enabled:false）或轮换时立即放入服务端密钥管理，不能写进前端、URL、截图或日志；响应丢失或密钥丢失时，读取当前版本并明确轮换，不能假定重复配置会再次返回旧密钥。

常见拒绝：HTTP 400 `invalid_cursor` 表示事件游标无效；404 `webhook_kind_not_found` 表示类型不存在；404 `webhook_not_configured` 表示尚未配置、不能轮换；409 `webhook_disabled` 表示当前配置未启用、不能人工补送。配置版本冲突时不要自动覆盖另一个操作。

关闭某类回调时，向该类配置接口提交当前 `url`、`enabled:false` 和当前 `expected_version`。未改变配置的重复提交不重置版本或 `since`。改变配置、禁用或轮换密钥会取消旧版本未完成投递的事件，不会自动把历史事件转向新地址或重新签名发送；已在途的请求无法撤回。

同地址轮换密钥与更换接收地址不同：轮换会保留原订单观察范围、已见结果和原跟进截止时间，后续重要结果变化仍可产生新事件；原来已排队的事件仍被取消，需人工决定是否补送。改地址或禁用会停止旧配置的订单观察。轮换后新投递使用新密钥；本站不同时发送多个签名，也不承诺自动保留旧密钥过渡窗口。若接收方为已在途请求短暂保留旧密钥验签，仍必须通过时间窗、去重和版本策略限制它。

商户停用（状态非 `active`）时，回调后台暂停新订单观察登记、对原单的主动核对和后续投递；`pending` 事件保留，不因商户停用自动取消。商户恢复 `active` 后，按当前配置与既定跟进、重试期限继续处理。已在途的请求无法撤回。商户停用与关闭某类回调不同，关闭配置仍按上段规则取消旧版本未完成事件。

只有人工明确调用事件 `/retry`，才会用当前已启用配置补送同一事件：原事件 ID、revision、created_at 和原始正文保持不变，目标地址、时间戳与签名使用当前配置。不要把重试回调当作重新开通或重新付款。

接收地址只能使用 HTTPS、公网域名及 443 端口（可省略端口）。不接受 IP 地址、账号密码、query 查询串或 fragment 片段；URL 中不要携带密钥。每次发送都会重新检查 DNS 和 IP 结果，私网、回环、链路本地及其他非公网目标不允许；不跟随 HTTP 重定向。接收服务必须具有有效 TLS 证书，不依赖 301/302 跳转完成接收。

### 22.3 事件正文、字段与独立版本

两类订单结果的 HTTP 正文为 UTF-8 JSON 对象，结构为 `id,type,created_at,revision,data:{order:...}`。独立测试事件 `webhook.test` 使用相同顶层字段，但 `data` 仅含测试说明，不含 `order`，详见第 22.8 节。`data.order` 使用第 7 节公共订单字段白名单，包含本站订单号、客户单号、状态、服务费、四字段 `payment`、`renewal` 和可用的 `receipt`；不是数据库或处理服务原文。不会传出支付卡、Session、API Key、签名密钥、内部订单引用或处理渠道资料。凭证邮箱可能为原单完整邮箱，只在商户服务端按用户归属保存和展示，分享图片默认打码。

`revision` 在同一商户、同一订单、同一类结果内递增，两类不能互相比较。满足第 22.1 节触发条件后，以下重要结果变化会形成新事件，事件 `type` 不变、`id` 更新、该类型 `revision` 增加：

| 事件类型 | 产生更新的事实 |
| --- | --- |
| order.activation_result | `state`、`billing`、`service_fee`，`payment` 的四个字段，以及 `receipt` 的可用性、原单身份、邮箱、提交/完成时间、时间来源和 Codex 数量 |
| order.renewal_result | `renewal.status` 所表示的续费业务状态；仍限成功原单且续费状态属于第 22.1 节确定结果 |

`checked_at` 刷新、查询时间和提示文案变动不会单独触发事件。凭证中 `renewal` 的改变由续费类型承载，不额外制造开通事件；付款、费用或凭证其他字段改变不额外制造续费事件。升级前已有的观察记录不会仅因新增 `payment` 字段而批量重发历史结果。事件正文是生成时的快照，重试仍发送原正文；需要当前最新结果时查询原订单。

开通成功、续费尚在处理的事件（`data.order` 为字段节选）：

```json
{
  "id": "evt_example_activation",
  "type": "order.activation_result",
  "created_at": "2026-10-18T01:02:00Z",
  "revision": 1,
  "data": {
    "order": {
      "id": "ord_example_success",
      "client_order_no": "merchant-example-001",
      "plan_id": "plus",
      "state": "succeeded",
      "billing": "charged",
      "payment": {"status": "success", "entitlement_status": "confirmed", "amount": "982.14", "currency": "PHP"},
      "renewal": {"status": "processing", "checked_at": "2026-10-18T01:02:00Z"},
      "receipt": {
        "order_id": "ord_example_success",
        "plan_id": "plus",
        "account_email": "customer@example.invalid",
        "submitted_at": "2026-10-18T01:00:10Z",
        "completed_at": "2026-10-18T01:02:00Z",
        "completed_at_source": "service_confirmation",
        "renewal": {"status": "processing", "checked_at": "2026-10-18T01:02:00Z"}
      }
    }
  }
}
```

之后收到独立的续费取消失败事件（字段节选，开通仍成功）：

```json
{
  "id": "evt_example_renewal",
  "type": "order.renewal_result",
  "created_at": "2026-10-18T01:04:00Z",
  "revision": 1,
  "data": {
    "order": {
      "id": "ord_example_success",
      "client_order_no": "merchant-example-001",
      "plan_id": "plus",
      "state": "succeeded",
      "billing": "charged",
      "payment": {"status": "success", "entitlement_status": "confirmed", "amount": "982.14", "currency": "PHP"},
      "renewal": {"status": "failed", "checked_at": "2026-10-18T01:04:00Z"},
      "receipt": {
        "order_id": "ord_example_success",
        "plan_id": "plus",
        "account_email": "customer@example.invalid",
        "submitted_at": "2026-10-18T01:00:10Z",
        "completed_at": "2026-10-18T01:02:00Z",
        "completed_at_source": "service_confirmation",
        "renewal": {"status": "failed", "checked_at": "2026-10-18T01:04:00Z"}
      }
    }
  }
}
```

### 22.4 签名验证：必须使用原始字节

每次投递均带以下头部，`X-XXB-Timestamp` 为该次投递的 Unix 秒时间戳；它与事件 `created_at` 不同，重试会刷新时间戳。

```text
Content-Type: application/json
X-XXB-Event-Id: evt_example_activation
X-XXB-Event-Type: order.activation_result
X-XXB-Timestamp: 1792285320
X-XXB-Signature: v1=<64位小写十六进制摘要>
```

精确签名规则：

```text
signed_bytes = ASCII(timestamp) + b'.' + ASCII(event_id) + b'.' + raw_body_bytes
digest = HMAC-SHA256(UTF8(signing_secret), signed_bytes)
X-XXB-Signature = 'v1=' + lowercase_hex(digest)
```

保留接收到的 UTF-8 原始正文，不先解析再序列化、不改空格或换行、不重新排序字段。按事件类型选择独立密钥，使用恒定时间比较（Python `hmac.compare_digest`）；拒绝重复关键头部、未知签名版本和距离接收时间超过 300 秒的过去或未来时间戳，并保持服务器时钟同步。签名通过后才解析 JSON，再确认正文 `id` / `type` 与头部完全一致，并与接收路径预期类型一致。

测试通知的头部类型统一为 `webhook.test`，实际签名使用被测试配置自己的密钥。可以按独立接收路径限定密钥；下面的通用示例先用两把已配置密钥验证原始字节，再解析已认证正文，并要求 `data.callback_kind` 与验签通过的密钥及接收路径一致。共用一个 URL 时也必须保留这个对应关系，不能凭未验签的正文决定可信结果。

验签只证明签名消息的来源与完整性；真实订单结果的用户归属仍须按 `data.order.id` 和自己保存的业务映射核对。按事件 ID 持久去重，先把已验签事件可靠写入本地 inbox/队列，再快速返回任意 2xx；新事件示例返回 202、已接受的重复事件返回 200。建议在 1 秒内完成可靠接收，业务处理在独立工作线程或进程完成，并将业务更新与处理标记保持幂等或放入同一事务。本站单次发送总预算为 10 秒，连接/TLS/读写阶段也各有独立上限，不能据此把响应拖到第 10 秒。数据库保存失败不能返回成功，不要在等待慢业务处理时占用回调请求。

### 22.5 投递状态、自动重试与人工补送

回调为至少一次投递：网络超时、接收方已经保存但响应丢失、发送进程恢复都可能导致重复。只有 2xx 视为本次投递成功；非 2xx（包括 3xx）与连接错误均为失败，不跟随重定向。单轮自动投递最多 8 次，首次失败后的 7 次等待间隔为 5 / 30 / 120 / 600 / 1800 / 7200 / 21600 秒。调度时间可能因服务恢复或队列而延后，不承诺精确到秒。

`GET /webhook-events` 支持 `limit`（整数 1–100，默认 20）和不透明 `cursor`。返回按 `created_at,id` 从新到旧排列；`next_cursor:null` 表示没有下一页。游标只适用于当前商户，不要自行构造或跨商户使用。

事件查询对象在原事件五个字段外增加：

| 字段 | 含义 |
| --- | --- |
| kind | 对应 order_result / renewal_result |
| status | pending 待发送；inflight 正在发送；delivered 已收到 2xx；exhausted 本轮自动尝试耗尽；cancelled 已因配置变更等取消 |
| attempts | 累计发送尝试次数；人工补送开启新轮后可超过 8 |
| attempts_remaining | 当前这一轮剩余自动尝试次数 |
| next_attempt_at | 下次计划尝试时间，带时区 ISO 或 null |
| last_attempt_at | 最近尝试时间，带时区 ISO 或 null |
| delivered_at | 已确认收到 2xx 的时间，带时区 ISO 或 null |
| last_error_code | 安全错误码或 null，不包含接收方响应正文、地址或密钥 |

列表同时返回真实结果和 `webhook.test` 测试记录，须先按 `type` 分支；测试事件没有 `data.order`，不能读取订单号或更新订单状态。

事件查询不返回接收 URL、签名或密钥；实际 HTTP 回调正文也不包含上述投递管理字段。`delivered` 只表示你的端点已确认接收，不替代商户自身业务处理完成状态。常见错误码包括 `http_500` 等 `http_<状态码>`、`configuration_changed`、`delivery_interrupted`、`dns_failed`、`target_blocked`、`tls_failed`、`delivery_timeout`。错误处理以返回的安全码为准，不把对端原文展示给用户。

确需补送某个耗尽或取消事件时，先修复接收方并核对当前地址/密钥，再由授权人员调用 `POST /webhook-events/{evt}/retry`，正文 `{}`。当前配置必须启用；`pending`、`inflight`、`delivered` 的重复 retry 不重复开启新轮。人工补送保留原事件与原正文，即使它比当前订单结果旧，也不会改造成最新事件；按类型内 revision 避免旧结果覆盖新结果。禁止给 retry 接口配置无条件循环。

### 22.6 Python 标准库接收示例

下面示例只接收、验签并持久保存事件，不向外发请求，也不创建、确认或支付订单。保存为独立接收程序，在自己的 HTTPS 443 反向代理后运行；本地监听只绑定 `127.0.0.1:9100`，不是可直接登记的公网回调地址。分别通过服务端环境变量注入 `XXB_ACTIVATION_SIGNING_SECRET`、`XXB_RENEWAL_SIGNING_SECRET`，`XXB_WEBHOOK_DB` 指向仅服务账号可读写的持久文件位置。示例需要 Python 3，不依赖第三方库。

```python
"""Standard-library receiver: authenticate, durably enqueue, then acknowledge.

Run behind your own HTTPS reverse proxy. This module never sends requests or
performs an order/payment action. A separate worker processes accepted_events.
"""
import hashlib
import hmac
import json
import os
import re
import sqlite3
import time
from contextlib import contextmanager
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer

MAX_BODY_BYTES = 262144
WINDOW_SECONDS = 300
RESULT_TYPES = {'order.activation_result', 'order.renewal_result'}
EVENT_TYPES = RESULT_TYPES | {'webhook.test'}
CALLBACK_TYPES = {'order_result': 'order.activation_result',
                  'renewal_result': 'order.renewal_result'}


class Rejected(ValueError):
    def __init__(self, code, status=400):
        super().__init__(code)
        self.status = status


def single_header(headers, name):
    values = headers.get_all(name, []) if hasattr(headers, 'get_all') else [headers.get(name)]
    if len(values) != 1 or not isinstance(values[0], str):
        raise Rejected('invalid_header')
    return values[0]


def unique_object(pairs):
    value = {}
    for name, item in pairs:
        if name in value:
            raise Rejected('duplicate_json_key')
        value[name] = item
    return value


def reject_constant(value):
    raise Rejected('invalid_json_number')


def verify_event(raw, headers, secrets_by_type, now=None):
    if not isinstance(raw, bytes) or not 0 < len(raw) <= MAX_BODY_BYTES:
        raise Rejected('invalid_body_size', 413)
    event_id = single_header(headers, 'X-XXB-Event-Id')
    event_type = single_header(headers, 'X-XXB-Event-Type')
    timestamp = single_header(headers, 'X-XXB-Timestamp')
    signature = single_header(headers, 'X-XXB-Signature')
    if not re.fullmatch(r'evt_[A-Za-z0-9_-]{1,128}', event_id):
        raise Rejected('invalid_event_id')
    if event_type not in EVENT_TYPES:
        raise Rejected('unsupported_event_type')
    if not re.fullmatch(r'[0-9]{1,12}', timestamp):
        raise Rejected('invalid_timestamp')
    if abs((time.time() if now is None else now) - int(timestamp)) > WINDOW_SECONDS:
        raise Rejected('timestamp_outside_window', 401)
    if not re.fullmatch(r'v1=[0-9a-f]{64}', signature):
        raise Rejected('invalid_signature_format', 401)
    candidates = RESULT_TYPES if event_type == 'webhook.test' else {event_type}
    signed = timestamp.encode('ascii') + b'.' + event_id.encode('ascii') + b'.' + raw
    matched_types = set()
    for candidate in candidates:
        secret = secrets_by_type.get(candidate)
        if not isinstance(secret, str) or not secret:
            raise Rejected('missing_signing_secret', 503)
        expected = hmac.new(secret.encode('utf-8'), signed, hashlib.sha256).hexdigest()
        if hmac.compare_digest(signature[3:], expected):
            matched_types.add(candidate)
    if not matched_types:
        raise Rejected('signature_mismatch', 401)
    # Decode/parse only after authenticating the exact bytes received.
    try:
        event = json.loads(raw.decode('utf-8'), object_pairs_hook=unique_object,
                           parse_constant=reject_constant)
    except (UnicodeError, json.JSONDecodeError) as error:
        raise Rejected('invalid_json') from error
    if not isinstance(event, dict) or event.get('id') != event_id or event.get('type') != event_type:
        raise Rejected('header_body_mismatch')
    if (type(event.get('revision')) is not int or event['revision'] < 1
            or not isinstance(event.get('created_at'), str)
            or not isinstance(event.get('data'), dict)):
        raise Rejected('invalid_event_shape')
    if event_type == 'webhook.test':
        data = event['data']
        kind = data.get('callback_kind')
        if (event['revision'] != 1 or data.get('test') is not True
                or not isinstance(kind, str) or kind not in CALLBACK_TYPES
                or not isinstance(data.get('message'), str) or 'order' in data):
            raise Rejected('invalid_test_event')
        if CALLBACK_TYPES[kind] not in matched_types:
            raise Rejected('test_signing_channel_mismatch', 401)
    elif (not isinstance(event['data'].get('order'), dict)
          or not isinstance(event['data']['order'].get('id'), str)):
        raise Rejected('invalid_event_shape')
    return event


class Receiver:
    def __init__(self, database_path, secrets_by_type):
        if (set(secrets_by_type) != RESULT_TYPES
                or any(not isinstance(value, str) or not value for value in secrets_by_type.values())
                or len(set(secrets_by_type.values())) != 2):
            raise ValueError('Configure two independent signing secrets')
        self.database_path = os.fspath(database_path)
        self.secrets_by_type = dict(secrets_by_type)
        with self.connect() as db:
            db.execute('''CREATE TABLE IF NOT EXISTS accepted_events (
                id TEXT PRIMARY KEY, type TEXT NOT NULL, revision INTEGER NOT NULL,
                body_sha256 TEXT NOT NULL, raw_body BLOB NOT NULL,
                received_at INTEGER NOT NULL, processed_at INTEGER
            )''')

    @contextmanager
    def connect(self):
        db = sqlite3.connect(self.database_path, timeout=2)
        try:
            db.execute('PRAGMA synchronous=FULL')
            with db:
                yield db
        finally:
            db.close()

    def accept(self, raw, headers, expected_type, now=None):
        event = verify_event(raw, headers, self.secrets_by_type, now)
        route_type = (CALLBACK_TYPES[event['data']['callback_kind']]
                      if event['type'] == 'webhook.test' else event['type'])
        if route_type != expected_type:
            raise Rejected('event_route_mismatch')
        digest = hashlib.sha256(raw).hexdigest()
        with self.connect() as db:
            db.execute('BEGIN IMMEDIATE')
            previous = db.execute('SELECT body_sha256 FROM accepted_events WHERE id=?', (event['id'],)).fetchone()
            if previous:
                if not hmac.compare_digest(previous[0], digest):
                    raise Rejected('event_id_body_conflict', 409)
                return False  # Valid signed redelivery; do not duplicate business work.
            received_at = int(time.time() if now is None else now)
            # Persist tests for deduplication, but never enqueue order business work.
            processed_at = received_at if event['type'] == 'webhook.test' else None
            db.execute('''INSERT INTO accepted_events
                (id,type,revision,body_sha256,raw_body,received_at,processed_at)
                VALUES (?,?,?,?,?,?,?)''',
                (event['id'], event['type'], event['revision'], digest, raw,
                 received_at, processed_at))
        # Commit is complete before 2xx. A worker can recover this inbox after a crash.
        return True


def handler_for(receiver):
    class Handler(BaseHTTPRequestHandler):
        routes = {'/callbacks/order-result': 'order.activation_result',
                  '/callbacks/renewal-result': 'order.renewal_result'}

        def do_POST(self):
            status = 400
            try:
                expected_type = self.routes.get(self.path)
                if expected_type is None:
                    raise Rejected('unknown_route', 404)
                if self.headers.get_all('Transfer-Encoding'):
                    raise Rejected('chunked_body_not_supported')
                content_type = single_header(self.headers, 'Content-Type')
                if content_type.split(';', 1)[0].strip().lower() != 'application/json':
                    raise Rejected('json_required', 415)
                length = single_header(self.headers, 'Content-Length')
                if not re.fullmatch(r'[0-9]{1,9}', length) or not 0 < int(length) <= MAX_BODY_BYTES:
                    raise Rejected('invalid_body_size', 413)
                self.connection.settimeout(5)
                raw = self.rfile.read(int(length))
                if len(raw) != int(length):
                    raise Rejected('incomplete_body')
                fresh = receiver.accept(raw, self.headers, expected_type)
                status = 202 if fresh else 200
            except Rejected as error:
                status = error.status
            except (OSError, sqlite3.Error):
                status = 503  # Do not acknowledge events that were not durably saved.
            self.send_response(status)
            self.send_header('Content-Length', '0')
            self.send_header('Connection', 'close')
            self.end_headers()
            self.close_connection = True

        def log_message(self, *args):
            pass  # Do not log request headers or payloads.

    return Handler


if __name__ == '__main__':
    receiver = Receiver(os.environ['XXB_WEBHOOK_DB'], {
        'order.activation_result': os.environ['XXB_ACTIVATION_SIGNING_SECRET'],
        'order.renewal_result': os.environ['XXB_RENEWAL_SIGNING_SECRET'],
    })
    # HTTPS 443 and the public domain belong to your reverse proxy, not this loopback listener.
    with ThreadingHTTPServer(('127.0.0.1', 9100), handler_for(receiver)) as server:
        server.serve_forever()
```

`accepted_events` 保存已验签的原始正文。真实订单事件的 `processed_at` 初始为 null；测试事件在同一事务内直接标为已处理，仅记录连通性，不进入订单业务队列。接入方的后台消费者仅读取 `processed_at IS NULL` 的未处理事件，仍须明确拒绝将 `webhook.test` 作为订单结果，校验自己的用户归属并幂等更新业务结果，成功后再设置处理时间；不能仅因为这里返回 202 就向客户宣称自己的后续业务已经完成。数据库和备份可能包含账号邮箱，应限制访问和保存期限；日志只记录必要的本站事件号、订单号与安全状态，不打印正文或秘密。

### 22.7 本地验收与故障恢复

先用本地合成正文完成验签、错密钥、篡改字节、过期/未来时间戳、头部与正文不一致、重复事件、重启后去重、保存失败和乱序 revision 测试。两类回调分别验收，成功开通与取消失败可以同时存在。追加“付款成功但权益未确认”“成功后才补齐凭证”“同地址轮换后继续收到后续结果”三种情形。默认关闭的配置不能发通知；改地址、禁用和轮换后旧事件不能悄悄改投新目标。

启用正式回调前确认接收 URL 归属、TLS、持久 inbox 和两把密钥已就绪，再以当前配置版本显式启用，并分别发送第 22.8 节测试通知。测试送达只确认 HTTP 接收成功，不能代替真实订单状态和凭证字段验收。没有收到回调时，先查事件投递记录，再查询原订单；不能因没有通知而补单或重新付款。余额、账务、已保存订单、v38 凭证数据以及普通成功 0.80 元 / 人机验证成功 1.00 元的定价均保持原契约；API 调用仍需有效 Key，升级不会恢复已撤销或过期的 Key。

### 22.8 发送测试通知与联调

完成配置与接收端验签后，在账号中心“回调通知”中分别点击“发送测试通知”，或调用 `POST /webhooks/{kind}/test`。只会使用该类**已保存且已启用**的配置，不接受临时 URL、签名密钥或订单数据；页面有未保存修改时须先保存。测试会向保存的接收地址发送通知，但不创建订单、不付款、不冻结或扣除服务费，也不改变卡密与钱包余额。

请求字段：

| 字段 | 类型 | 规则 |
| --- | --- | --- |
| expected_version | 整数 | 当前对应配置的 version；先 GET /webhooks 核对 |
| client_test_id | 字符串 | 1–64 个字符，符合 `^[A-Za-z0-9][A-Za-z0-9_.:-]{0,63}$`；建议 UUID，同一次请求及网络重试始终使用原值 |

先读配置版本，再显式发送一次。下面的测试编号是示例，同一次重试保持不变；需要新测试时才生成新编号。不要直接把示例编号放进无限重试循环。

```bash
curl --silent --show-error --fail-with-body --max-time 30 \
  'https://xiaoxiaobao.online/api/v1/webhooks/order_result/test' \
  --header "X-API-Key: ${XIAOXIAOBAO_API_KEY}" \
  --header 'Content-Type: application/json' \
  --data '{"expected_version":1,"client_test_id":"demo-test-20261018-001"}'
```

HTTP 202 表示测试已受理，响应包裹为 `{"event":{...}}`。下面节选初次受理的事件；查询时还会有第 22.5 节投递管理字段。`pending` 需要继续查同一事件号；不能把受理当作送达。

```json
{
  "event": {
    "id": "evt_example_test",
    "type": "webhook.test",
    "kind": "order_result",
    "revision": 1,
    "created_at": "2026-10-18T02:00:00Z",
    "data": {
      "callback_kind": "order_result",
      "test": true,
      "message": "这是一条回调连通性测试通知，不代表任何订单结果，不创建订单或扣费。"
    },
    "status": "pending",
    "attempts": 0
  }
}
```

实际 HTTP 通知正文仍只有 `id,type,created_at,revision,data`，`kind/status/attempts` 等仅在事件管理接口中返回。测试 `revision` 固定为 1，没有 `data.order`；禁止用它新增订单、发放权益、确认成功或变更续费状态。使用 `renewal_result` 路径时，`data.callback_kind` 为 `renewal_result`，使用该类自己的签名密钥；验签规则与普通通知相同。

同一商户、同一 kind、同一 `client_test_id` 只对应一个测试事件；重复请求返回该原事件，不再创建或发送一条测试。此幂等回放优先于配置版本检查：即使之后轮换或停用配置，原编号也只返回原记录，不会再次向任何地址发送。API 客户端应在发请求前持久保存编号；账号中心在同一浏览器标签页按账号保存未确认编号，网络不明时用“核对上次测试”恢复。不要因超时换编号；确认原请求后再决定新测试。

每个商户的每类通知创建新测试，或显式重发已耗尽/已取消的测试时，须至少间隔 30 秒；同时仍计入第 14 节商户 API 总限流与写操作限流。`pending`、`inflight`、`delivered` 的 retry 仍只是幂等返回，不重复投递；自动重试沿用第 22.5 节规则。两类通知分别测试，不能因为其中一类送达就认定另一类也可用。

| HTTP / error.code | 含义与处理 |
| --- | --- |
| 400 / invalid_request | 测试编号、版本或正文不合法；检查字段，不传临时地址 |
| 409 / webhook_disabled | 新测试对应配置未保存启用；先核对并保存 |
| 409 / webhook_version_conflict | 新测试使用的配置版本已变化；重新读取后明确测试当前配置 |
| 429 / webhook_test_rate_limited | 未满 30 秒，响应头 `Retry-After: 30`；等待后沿用同一未确认编号，不频繁生成新编号 |

测试与真实通知共享事件查询、错误码、签名、投递重试及配置变更取消规则。改地址、禁用或轮换会取消旧配置尚未完成的测试投递；如需补送已取消事件，须明确调用原事件 `/retry`，届时使用当前已启用地址及密钥，原正文与事件号不变。接收方应允许已验签的重复测试返回 2xx，并按事件号去重。

验收顺序：分别发送两类测试 → 查询该事件显示 `delivered` → 在接收端核对验签与持久记录 → 确认测试未进入订单业务队列。`delivered` 只代表接收端返回 HTTP 2xx，不代表开通成功、付款成功、续费已关闭或商户后续业务已完成。正式订单仍按原订单查询与两类结果契约处理。
