openapi: 3.1.0
info:
  title: T1 Pay · 结算接口文档
  version: 1.0.0
  summary: T1 Pay 结算平台的商户接入接口：代收下单、代付下单、订单查询、银行表与账户余额，全部请求以请求头签名。
  description: |
    # 阅读指引

    本文档面向接入 T1 Pay 结算平台的商户技术人员。平台提供两类结算业务：
    **代收**（付款人向商户付款）与**代付**（商户向收款人付款）。每一笔业务都以
    一张订单记录，订单进入终态后平台向商户投递一份带签名的**回单**；商户凭回单
    入账，凭查询接口对账。

    平台只接受签过名的请求，不设免签通道。每个请求携带一次性的时间戳与随机数，
    平台据此拒绝重放。密钥缺失与密钥无效返回同一个 `INVALID_MERCHANT`，平台不会
    透露某个密钥是否存在。

    本文档分五章：第 1 章为总览；第 2 章讲清楚签名与回单验签，请先跑通再往下读；
    第 3 至 5 章逐个接口说明。接口统一位于 `/t1` 之下，请求与响应均为 JSON。

    > **字段表怎么看：** 标有**必填**的字段缺失时返回 `INVALID_PARAMS`；未标记的
    > 字段按需传入。有前置条件的字段，会在说明里写明条件。

    # 凭证

    平台为每个商户签发一对凭证：

    | 凭证 | 形态 | 用途 |
    |---|---|---|
    | **API key** | `pk_` 开头，后接 24 个字符 | 放在 `X-Api-Key` 请求头里明文传送，用于标识商户，不是秘密。 |
    | **API secret** | 48 个字符 | 计算 HMAC 签名的密钥。**不得出现在任何请求里**，也不得下发到浏览器或移动端。 |

    **领取方式：** 登录商户后台，进入 **账户资料**，点选「查看接入凭证」，输入验证
    器当前的 6 位动态码后显示。

    > **secret 仅显示一次。** 显示后请立即写入服务端配置；再次打开不会重复显示。
    >
    > **secret 遗失怎么办：** 无法找回。请联系结算专员重置，重置会签发**新的
    > key 与 secret**，旧凭证在重置完成的瞬间失效，请预留切换时间。

    商户账户须先开通接口权限，否则所有请求返回 `API_V2_NOT_ENABLED`。若商户已登记
    出口 IP 白名单，其他来源的请求返回 `IP_RESTRICTION`。

    # 响应信封

    平台的每个接口，不论成败，都以同一个信封返回：

    ```json
    {
      "result": 1,
      "code": "SUCCESS",
      "message": null,
      "data": { ... },
      "ref_id": "7c2e9b41-..."
    }
    ```

    * `result` —— `1` 成功，`0` 失败。
    * `code` —— 结果码，见下一节。
    * `message` —— 给人看的补充说明，常为 `null`。
    * `data` —— 业务数据；失败时为 `[]` 或 `{}`。
    * `ref_id` —— 平台为本次请求分配的 UUID。向结算专员反映问题时请附上它。

    HTTP 状态码的约定：鉴权失败 **403**，参数校验失败 **422**，触发频率限制
    **429**，平台内部错误 **500**（`code` 固定为 `FAIL`，不外泄异常细节）。

    # 结果码

    | 结果码 | 含义 |
    |---|---|
    | `SUCCESS` | 处理成功。 |
    | `FAIL` | 未归类的失败：平台内部错误，或未细分的订单失败。 |
    | `INVALID_MERCHANT` | `X-Api-Key` 缺失或无法识别。 |
    | `MERCHANT_BLOCKED` | 商户账户已停用。 |
    | `API_V2_NOT_ENABLED` | 凭证有效，但商户尚未开通接口权限；请联系结算专员。 |
    | `SIGNATURE_EXPIRED` | `X-Timestamp` 不是数字，或与平台时间相差超过 300 秒；nonce 长度不合规或 10 分钟内重复出现**同样**返回此码。 |
    | `INVALID_SIGNATURE` | `X-Signature` 与平台按待签串算出的值不一致。 |
    | `IP_RESTRICTION` | 商户登记了 IP 白名单，本次请求来源不在其中。 |
    | `INVALID_PARAMS` | 请求参数未通过校验，`message` 给出第一条不合规项。 |
    | `DUPLICATE_TRANSACTION` | 商户单号 `ticket_ref` 已用过；或同一 `customer_id` 名下未完结订单过多。 |
    | `SERVICE_NOT_AVAILABLE` | 商户未开通代收（或代付）业务。 |
    | `PAYMENT_METHOD_NOT_SUBSCRIBE` | 商户未订购该笔金额所落入的通道档位。 |
    | `MERCHANT_INSUFFICIENT_BALANCE` | 代付金额加手续费超过商户可用余额。 |
    | `TRANSACTION_NOT_FOUND` | 商户名下没有该 `ticket_ref` 的订单。 |
    | `NO_SERVICE_PROVIDED` | 暂时分配不到收款账户；请稍后重试或联系结算专员。 |
    | `SERVICE_UNDER_MAINTENANCE` | 所选通道维护中。 |
    | `PAYMENT_GATEWAY_MAINTENENCE` | 签名服务维护中，罕见；拼写以平台实际返回为准。 |
    | `TOO_MANY_REQUEST` | 超出频率限制。 |
    | `PATH_NOT_FOUND` | 内部路由不存在；按本文档调用不会遇到。 |

    # 频率限制

    以 `X-Api-Key` 解析出的商户为单位，每分钟 300 次。密钥无法解析的请求，按来源
    IP 共用每分钟 20 次的小配额。

    # 回单

    订单进入终态时，平台向下单时给出的 `notify_url` 以 POST 投递回单，请求头带
    `X-Timestamp`、`X-Nonce`、`X-Signature`。回单是资金变动的唯一凭据：先验签，
    再入账；回单可能重复投递，处理逻辑必须幂等。两种回单分别见第 3.3 节与第 4.3 节。
  contact:
    name: T1 Pay 结算专员
  x-profile-digest: sha256:70a0bd9e84074c56f099ee5b9560c85c763475b7281c2c5f7aaa999b68e91150
servers:
  - url: https://api.t1payonline.asia
    description: T1 Pay 结算网关
tags:
  - name: 签名与验签
    description: |
      # 四个请求头

      发往平台的每个请求都带这四个请求头：

      | 请求头 | 取值 |
      |---|---|
      | `X-Api-Key` | 商户的 API key，`pk_` 开头。 |
      | `X-Timestamp` | 发出请求时的 unix 时间，**秒**，纯数字。须落在平台时间 ±300 秒内，请用 NTP 校时。 |
      | `X-Nonce` | 本次请求专用的随机串，16 至 64 个字符。10 分钟内重复出现的 nonce 会被拒绝。 |
      | `X-Signature` | 按本章算出的签名，小写十六进制。 |

      # 序列化请求体

      先把要发送的 JSON 序列化成**一个**字符串并保存下来：后面对它取哈希，也原样
      把它发出去，两者必须逐字节相同。

      > 接入时最常见的失误就在这一步：对一份 JSON 取了哈希，HTTP 客户端却在发送
      > 时重新序列化了另一份（键序变了、空白变了），结果必然是 `INVALID_SIGNATURE`。
      > 请对实际发出的字节取哈希。

      `GET` 请求没有请求体，按空字符串 `""` 处理。

      # 计算请求体哈希

      `bodyHash = sha256_hex(body)`，小写十六进制。空请求体的哈希固定为
      `e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855`。

      # 拼接待签串

      以固定前缀 `v2:` 起头，五段之间用换行符 `\n` 连接：

      ```
      "v2:" + METHOD + "\n" + PATH + "\n" + TIMESTAMP + "\n" + NONCE + "\n" + sha256hex(body)
      ```

      | 段 | 取值 |
      |---|---|
      | `METHOD` | 大写的 HTTP 方法：`POST` 或 `GET`。 |
      | `PATH` | 只取路径，以 `/` 开头，**不含域名与查询串**，例如 `/t1/tickets`。按单号查询时把单号带进路径：`/t1/tickets/T1-2026-09-000418`。 |
      | `TIMESTAMP` | 与 `X-Timestamp` 请求头逐字节相同。 |
      | `NONCE` | 与 `X-Nonce` 请求头逐字节相同。 |
      | `sha256hex(body)` | 上一节算出的 bodyHash。 |

      `v2:` 前缀与 `/t1/` 路径前缀都是协议的固定部分，请当作常量写死，不要当作
      需要跟随版本变化的值。

      # 计算签名

      ```
      X-Signature = hmac_sha256_hex( apiSecret, 待签串 )
      ```

      小写十六进制，放进 `X-Signature` 请求头，与其余三个请求头一并发送。

      # 用参考向量自检

      接入真实凭证之前，请先用下面这组固定输入复现结果；结果一致，签名实现即正确。

      API key `pk_t1sealdemo2026090000000hk`，secret
      `t1sealsecrett1sealsecrett1sealsecrett1sealsecret`，`X-Timestamp: 1790812800`，
      `X-Nonce: 5e8c2a71d94f0b36`，向 `POST /t1/tickets` 发送如下请求体：

      ```json
      {"channel":"bank","amount":"1200.00","ticket_ref":"T1-2026-09-000418","notify_url":"https://shop.example.hk/t1pay/settle","return_url":"https://shop.example.hk/t1pay/done"}
      ```

      * **bodyHash** = `bc1abe13edb742af1de4b17d798f920ac995834f7c6fe6e73e3eabad0c6260dd`
      * **待签串**（五行）：
        ```
        v2:POST
        /t1/tickets
        1790812800
        5e8c2a71d94f0b36
        bc1abe13edb742af1de4b17d798f920ac995834f7c6fe6e73e3eabad0c6260dd
        ```
      * **X-Signature** = `aed55b7d7b516ad29f0a6955a060f4f2604e27f3c20e8a54ed8d1013528cb126`

      同一组时间戳与 nonce，请求体为空的 `GET /t1/wallet`，签名为
      `6b3b7e7f267f909a82799eaeff7a0eb5a1c34701988c1af4f9878a2dd7b64fb6`。

      把你的实现算出的 bodyHash、待签串与签名，同 **[签名自检页](tester.html)**
      逐项比对，可以最快定位偏差出在哪一段。

      # 排错

      | 平台返回 | 通常原因 |
      |---|---|
      | `INVALID_SIGNATURE` | 取哈希的请求体与发出的不是同一份字节；`PATH` 带了域名或查询串；待签串里的时间戳或 nonce 与请求头不一致。 |
      | `SIGNATURE_EXPIRED` | 服务器时钟偏差超过 5 分钟；时间戳不是纯数字；nonce 长度不对或已经用过。 |
      | `INVALID_MERCHANT` | `X-Api-Key` 缺失或不存在。 |
      | `API_V2_NOT_ENABLED` | 凭证正确，但商户尚未开通接口权限。 |

      # 回单验签

      回单的待签串**不含方法与路径**——回单地址可能经过商户自己的反向代理改写，
      因此只对报文字节做绑定：

      ```
      "v2-callback:" + TIMESTAMP + "\n" + NONCE + "\n" + sha256hex(body)
      ```

      以 API secret 为密钥计算 HMAC-SHA256，与回单请求头里的 `X-Signature` 做恒定
      时间比较。请对**收到的原始报文字节**取哈希，不要解析后再序列化。时间戳超出
      自定容忍窗口的回单应拒收，重复出现的 nonce 应视为重放。

      参考向量：时间戳 `1790812800`，nonce `5e8c2a71d94f0b36`，报文
      `{"ticket_no":"DP1790812800T1SL7K2Q9M","ticket_ref":"T1-2026-09-000418","payer_name":"-","stage":"COMPLETE","gross":"1200.00","fee":"12.00","net":"1188.00"}`
      → 报文 sha256 为 `daf32e64eb82fa69e56225c5c821115b49a6bda57c14f6e2240dd304230ba02a`，
      `X-Signature` 为 `b7a468371300051f4f1d0dc2d861fd72ab94d185604922ddcdbb182461fcc054`。
  - name: 代收结算
    description: |
      付款人把款项付给商户。流程是：商户下单 → 平台分配收款账户并返回 →
      商户引导付款人转账 → 平台投递回单 → 商户按需查询对账。
  - name: 代付结算
    description: |
      商户把款项付给收款人。流程与代收相同，只是少了付款人这一环：商户下单，
      等回单告知款项是否已付出。
  - name: 账户与银行
    description: |
      两个只读接口：代付下单要用的银行代码表，以及商户当前可用余额。

security:
  - ApiKey: []
    Timestamp: []
    Nonce: []
    Signature: []

paths:
  /t1/tickets:
    post:
      tags: [代收结算]
      operationId: createDeposit
      summary: 代收下单
      description: |
        建立一笔代收订单。平台返回平台单号、手续费拆分，以及 `data.payment_details`
        ——本单专用的收款账户信息。

        ## 收款账户信息

        `data.payment_details` 的形态：

        ```json
        "payment_details": {
          "bank_name": "Harbour Trust Bank",
          "account_name": "T1 PAY SETTLEMENT LIMITED",
          "account_number": "600238471905",
          "allocated_amount": "1200.00",
          "redirect_url": "https://pay.t1payonline.asia/s/DP1790812800T1SL7K2Q9M"
        }
        ```

        两种用法任选：把 `bank_name`、`account_name`、`account_number` 与
        `allocated_amount` 展示在商户自己的页面上，请付款人照此转账；或把付款人
        引导到 `return_url`，使用平台托管的收款页。

        ## 注意

        * `ticket_ref` 是商户单号，同一商户下不得重复（不区分大小写）；重复即返回
          `DUPLICATE_TRANSACTION`。
        * `amount` 须落在商户账户配置的代收限额之内，最多两位小数。
        * 请付款人转账的金额以 `allocated_amount`（缺省时以 `gross`）为准，
          不要用商户提交的数字——平台可能为了区分订单微调几分钱。
        * 通道档位由金额决定；商户须订购该档位，否则返回
          `PAYMENT_METHOD_NOT_SUBSCRIBE`。
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/DepositCreateRequest'
            examples:
              referenceVector:
                summary: 参考向量请求体（与签名自检页一致）
                description: |
                  以 key `pk_t1sealdemo2026090000000hk`、secret
                  `t1sealsecrett1sealsecrett1sealsecrett1sealsecret`、
                  `X-Timestamp: 1790812800`、`X-Nonce: 5e8c2a71d94f0b36` 签这份请求体，
                  得到 `X-Signature: aed55b7d7b516ad29f0a6955a060f4f2604e27f3c20e8a54ed8d1013528cb126`。
                value:
                  channel: bank
                  amount: "1200.00"
                  ticket_ref: T1-2026-09-000418
                  notify_url: https://shop.example.hk/t1pay/settle
                  return_url: https://shop.example.hk/t1pay/done
              everyday:
                summary: 日常请求体
                value:
                  channel: bank
                  amount: "1200.00"
                  ticket_ref: T1-2026-09-000419
                  notify_url: https://shop.example.hk/t1pay/settle
                  return_url: https://shop.example.hk/t1pay/done
                  payer_name: TSANG WAI KIN
      responses:
        '200':
          description: 代收订单已建立。
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/Envelope'
                  - type: object
                    properties:
                      data:
                        $ref: '#/components/schemas/DepositCreateData'
              examples:
                created:
                  value:
                    result: 1
                    code: SUCCESS
                    message: null
                    data:
                      ticket_no: DP1790812800T1SL7K2Q9M
                      ticket_ref: T1-2026-09-000418
                      gross: "1200.00"
                      fee: "12.00"
                      net: "1188.00"
                      payer_name: ""
                      payment_details:
                        bank_name: Harbour Trust Bank
                        account_name: T1 PAY SETTLEMENT LIMITED
                        account_number: "600238471905"
                        allocated_amount: "1200.00"
                        redirect_url: https://pay.t1payonline.asia/s/DP1790812800T1SL7K2Q9M
                    ref_id: 7c2e9b41-5d80-4f36-a1c7-3e6d2b9f0a54
        '403': { $ref: '#/components/responses/AuthError' }
        '422': { $ref: '#/components/responses/ValidationError' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/ServerError' }

  /t1/tickets/{ticket_ref}:
    get:
      tags: [代收结算]
      operationId: getDeposit
      summary: 代收订单查询
      description: |
        按商户单号 `ticket_ref` 查询一笔代收订单的当前状态（不区分大小写，只查本商户
        名下）。用于回单漏收时的对账兜底；不要用轮询代替回单处理。

        提醒：参与签名的 `PATH` 含单号本身，例如
        `/t1/tickets/T1-2026-09-000418`。
      parameters:
        - $ref: '#/components/parameters/TicketRef'
      responses:
        '200':
          description: 订单当前状态；单号不存在时信封内 `code` 为 `TRANSACTION_NOT_FOUND`。
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/Envelope'
                  - type: object
                    properties:
                      data:
                        $ref: '#/components/schemas/DepositInquiryData'
              examples:
                settled:
                  value:
                    result: 1
                    code: SUCCESS
                    message: null
                    data:
                      ticket_no: DP1790812800T1SL7K2Q9M
                      ticket_ref: T1-2026-09-000418
                      payer_name: "-"
                      stage: COMPLETE
                      gross: "1200.00"
                      fee: "12.00"
                      net: "1188.00"
                    ref_id: a91f3c07-6e2b-4d58-b3f0-8c47e15d2a96
                unknown:
                  value:
                    result: 0
                    code: TRANSACTION_NOT_FOUND
                    message: null
                    data: []
                    ref_id: a91f3c07-6e2b-4d58-b3f0-8c47e15d2a96
        '403': { $ref: '#/components/responses/AuthError' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/ServerError' }

  /t1/payouts:
    post:
      tags: [代付结算]
      operationId: createWithdrawal
      summary: 代付下单
      description: |
        建立一笔代付订单。两条通道择一，并按通道传入对应字段；示例见下方的
        **银行转账** 与 **转数快** 两份请求体。

        | 通道 | 必填字段 |
        |---|---|
        | `bank` | `channel`、`amount`、`ticket_ref`、`notify_url`、`bank_code`、`account_no`、`account_name` |
        | `fps` | `channel`、`amount`、`ticket_ref`、`notify_url`、`mobile_no`、`holder_name` |

        `account_name` 与 `holder_name` 都是收款人姓名，分成两个字段是因为两条通道
        在后台落入不同的记录。走 `fps` 请传 `holder_name`，不要传 `account_name`。

        ## 金额与手续费

        代付的手续费**外加**：商户余额扣减 `net = amount + fee`。
        余额不够时返回 `MERCHANT_INSUFFICIENT_BALANCE`。

        代付的 `amount` 必须**恰好**两位小数（写 `3500.00`，不要写 `3500` 或
        `3500.5`），比代收更严格。
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/WithdrawCreateRequest'
            examples:
              bankTransfer:
                summary: 银行转账请求体（channel = bank）
                value:
                  channel: bank
                  amount: "3500.00"
                  ticket_ref: T1-W-2026-09-000077
                  notify_url: https://shop.example.hk/t1pay/settle
                  account_no: "9021447318"
                  account_name: HO YUET MEI
                  bank_code: "016"
              fasterPayment:
                summary: 转数快（channel = fps）
                value:
                  channel: fps
                  amount: "3500.00"
                  ticket_ref: T1-W-2026-09-000078
                  notify_url: https://shop.example.hk/t1pay/settle
                  mobile_no: "61208745"
                  holder_name: HO YUET MEI
      responses:
        '200':
          description: 代付订单已建立，等待付出。
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/Envelope'
                  - type: object
                    properties:
                      data:
                        $ref: '#/components/schemas/WithdrawCreateData'
              examples:
                created:
                  value:
                    result: 1
                    code: SUCCESS
                    message: null
                    data:
                      ticket_no: WT1790812800T1SL4H8N2R
                      ticket_ref: T1-W-2026-09-000077
                      gross: "3500.00"
                      fee: "15.00"
                      net: "3515.00"
                    ref_id: c4d07e28-1b9a-4f63-92e5-6a1b8d3f7c20
        '403': { $ref: '#/components/responses/AuthError' }
        '422': { $ref: '#/components/responses/ValidationError' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/ServerError' }

  /t1/payouts/{ticket_ref}:
    get:
      tags: [代付结算]
      operationId: getWithdrawal
      summary: 代付订单查询
      description: |
        按商户单号 `ticket_ref` 查询一笔代付订单的当前状态（不区分大小写，只查本商户
        名下）。签名路径同样含单号。
      parameters:
        - $ref: '#/components/parameters/TicketRef'
      responses:
        '200':
          description: 订单当前状态；单号不存在时信封内 `code` 为 `TRANSACTION_NOT_FOUND`，`data` 为空。
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/Envelope'
                  - type: object
                    properties:
                      data:
                        $ref: '#/components/schemas/WithdrawInquiryData'
              examples:
                paidOut:
                  value:
                    result: 1
                    code: SUCCESS
                    message: null
                    data:
                      ticket_no: WT1790812800T1SL4H8N2R
                      ticket_ref: T1-W-2026-09-000077
                      stage: COMPLETE
                      gross: "3500.00"
                      fee: "15.00"
                      net: "3515.00"
                    ref_id: 2b8e6f13-9a47-4c05-8d1e-f30a5c7b9e64
        '403': { $ref: '#/components/responses/AuthError' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/ServerError' }

  /t1/banks:
    get:
      tags: [账户与银行]
      operationId: listBanks
      summary: 银行代码表
      description: |
        返回平台当前支持代付的银行。走 `bank` 通道下代付单时，`bank_code` 取本表
        的值。表会随平台开通的银行变化，请定期刷新，不要写死。
      responses:
        '200':
          description: 当前支持的银行列表。
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/Envelope'
                  - type: object
                    properties:
                      data:
                        type: array
                        items:
                          $ref: '#/components/schemas/Bank'
              examples:
                banks:
                  value:
                    result: 1
                    code: SUCCESS
                    message: null
                    data:
                      - { bank_id: 3, bank_name: Harbour Trust Bank, bank_code: "016" }
                      - { bank_id: 7, bank_name: Victoria Savings Bank, bank_code: "041" }
                    ref_id: e5a1c9d3-7f26-4b80-9c34-1d8e6a2f0b75
        '403': { $ref: '#/components/responses/AuthError' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/ServerError' }

  /t1/wallet:
    get:
      tags: [账户与银行]
      operationId: getBalance
      summary: 可用余额查询
      description: |
        返回商户当前可用余额，两位小数。代付下单前可先查此值，避免
        `MERCHANT_INSUFFICIENT_BALANCE`。

        参考向量：key `pk_t1sealdemo2026090000000hk`、secret
        `t1sealsecrett1sealsecrett1sealsecrett1sealsecret`、
        `X-Timestamp: 1790812800`、`X-Nonce: 5e8c2a71d94f0b36`、请求体为空，
        `GET /t1/wallet` 的签名为
        `X-Signature: 6b3b7e7f267f909a82799eaeff7a0eb5a1c34701988c1af4f9878a2dd7b64fb6`。
      responses:
        '200':
          description: 商户当前可用余额。
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/Envelope'
                  - type: object
                    properties:
                      data:
                        type: object
                        properties:
                          wallet_balance:
                            type: number
                            description: 可用余额，两位小数。
                            examples: [86420.15]
              examples:
                balance:
                  value:
                    result: 1
                    code: SUCCESS
                    message: null
                    data: { wallet_balance: 86420.15 }
                    ref_id: 61d4b0f8-3e97-4a2c-b5d6-0f9c8e3a1d27
        '403': { $ref: '#/components/responses/AuthError' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/ServerError' }

webhooks:
  depositCallback:
    post:
      tags: [代收结算]
      operationId: depositCallback
      security: []
      summary: 代收回单
      description: |
        代收订单进入终态时（以及结算专员手工重推时），平台向下单时的
        `notify_url` 以 POST 投递。报文是裸 JSON，**不套**接口用的响应信封。

        回单是入账凭据，处理逻辑必须幂等——同一笔订单可能收到不止一次。

        **验签**：每份回单带三个请求头。

        | 请求头 | 取值 |
        |---|---|
        | `X-Timestamp` | 平台签发回单时的 unix 秒。 |
        | `X-Nonce` | 32 位小写随机串，每份回单不同。 |
        | `X-Signature` | 以商户 API secret 为密钥、对回单待签串算出的 HMAC-SHA256，小写十六进制。 |

        回单待签串不含方法与路径，只绑定报文字节：

        ```
        "v2-callback:" + TIMESTAMP + "\n" + NONCE + "\n" + sha256hex(body)
        ```

        对**收到的原始报文字节**重算 HMAC，恒定时间比较。时间戳超出自定窗口的
        回单拒收，重复的 nonce 视为重放。第 2.8 节有完整说明。

        参考向量：时间戳 `1790812800`，nonce `5e8c2a71d94f0b36`，报文
        `{"ticket_no":"DP1790812800T1SL7K2Q9M","ticket_ref":"T1-2026-09-000418","payer_name":"-","stage":"COMPLETE","gross":"1200.00","fee":"12.00","net":"1188.00"}`
        → 报文 sha256 `daf32e64eb82fa69e56225c5c821115b49a6bda57c14f6e2240dd304230ba02a`，
        `X-Signature` `b7a468371300051f4f1d0dc2d861fd72ab94d185604922ddcdbb182461fcc054`。
      parameters:
        - $ref: '#/components/parameters/CallbackTimestamp'
        - $ref: '#/components/parameters/CallbackNonce'
        - $ref: '#/components/parameters/CallbackSignature'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/DepositCallbackBody'
            examples:
              referenceVector:
                summary: 参考向量回单报文
                value:
                  ticket_no: DP1790812800T1SL7K2Q9M
                  ticket_ref: T1-2026-09-000418
                  payer_name: "-"
                  stage: COMPLETE
                  gross: "1200.00"
                  fee: "12.00"
                  net: "1188.00"
      responses:
        '200':
          description: |
            收到后请回 HTTP 200。非 200 的回应会被结算专员视为未送达，可能重推。

  withdrawCallback:
    post:
      tags: [代付结算]
      operationId: withdrawCallback
      security: []
      summary: 代付回单
      description: |
        代付订单进入终态时，平台向该单的 `notify_url` 以 POST 投递。裸 JSON，
        不套信封；字段与代收回单相同，只少了代收才有的 `payer_name`。

        同样带 `X-Timestamp` / `X-Nonce` / `X-Signature`，待签串为

        ```
        "v2-callback:" + TIMESTAMP + "\n" + NONCE + "\n" + sha256hex(body)
        ```

        密钥为商户 API secret。验签步骤与代收回单一字不差，见第 3.3 节；处理逻辑
        同样必须幂等。
      parameters:
        - $ref: '#/components/parameters/CallbackTimestamp'
        - $ref: '#/components/parameters/CallbackNonce'
        - $ref: '#/components/parameters/CallbackSignature'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/WithdrawCallbackBody'
            examples:
              paidOut:
                value:
                  ticket_no: WT1790812800T1SL4H8N2R
                  ticket_ref: T1-W-2026-09-000077
                  stage: COMPLETE
                  gross: "3500.00"
                  fee: "15.00"
                  net: "3515.00"
      responses:
        '200':
          description: 收到后请回 HTTP 200。

components:
  securitySchemes:
    ApiKey:
      type: apiKey
      in: header
      name: X-Api-Key
      description: 商户 API key，`pk_` 开头共 27 个字符；用于标识商户，可明文传送。
    Timestamp:
      type: apiKey
      in: header
      name: X-Timestamp
      description: 发出请求时的 unix 秒，纯数字，须在平台时间 ±300 秒内。
    Nonce:
      type: apiKey
      in: header
      name: X-Nonce
      description: 16 至 64 个字符的随机串，同一商户 10 分钟内不得重复。
    Signature:
      type: apiKey
      in: header
      name: X-Signature
      description: |
        以商户 API secret 为密钥，对待签串
        `"v2:" + METHOD + "\n" + PATH + "\n" + TIMESTAMP + "\n" + NONCE + "\n" + sha256hex(body)`
        算出的 HMAC-SHA256，小写十六进制；`GET` 的 body 为空串。

  parameters:
    TicketRef:
      name: ticket_ref
      in: path
      required: true
      description: 商户单号，6 至 200 个字符，查询时不区分大小写。
      schema:
        type: string
        minLength: 6
        maxLength: 200
      example: T1-2026-09-000418
    CallbackTimestamp:
      name: X-Timestamp
      in: header
      required: true
      description: 平台签发这份回单时的 unix 秒。
      schema: { type: string }
      example: "1790812800"
    CallbackNonce:
      name: X-Nonce
      in: header
      required: true
      description: 本份回单专用的随机串，每次投递都不同。
      schema: { type: string }
      example: 5e8c2a71d94f0b36
    CallbackSignature:
      name: X-Signature
      in: header
      required: true
      description: >-
        以商户 API secret 为密钥，对 `"v2-callback:" + TIMESTAMP + "\n" + NONCE +
        "\n" + sha256hex(body)` 算出的 HMAC-SHA256，小写十六进制。前缀是
        `v2-callback:` 而非请求用的 `v2:`，且待签串里没有方法与路径。
      schema: { type: string }
      example: b7a468371300051f4f1d0dc2d861fd72ab94d185604922ddcdbb182461fcc054

  responses:
    AuthError:
      description: |
        鉴权未通过。`code` 为 `INVALID_MERCHANT`、`MERCHANT_BLOCKED`、
        `SIGNATURE_EXPIRED`、`INVALID_SIGNATURE`、`IP_RESTRICTION` 之一，
        或 `SERVICE_NOT_AVAILABLE`（商户未开通该项业务）。
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Envelope'
          examples:
            badSignature:
              value:
                result: 0
                code: INVALID_SIGNATURE
                message: null
                data: []
                ref_id: d0f7a3c5-2e81-4b69-8a4f-7c3e1b6d9f02
    ValidationError:
      description: 参数校验未通过，`message` 指出第一条不合规项。
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Envelope'
          examples:
            badParams:
              value:
                result: 0
                code: INVALID_PARAMS
                message: "channel 只能是 bank 或 fps"
                data: []
                ref_id: d0f7a3c5-2e81-4b69-8a4f-7c3e1b6d9f02
    RateLimited:
      description: 超出频率限制：每商户每分钟 300 次；密钥无法解析时按 IP 每分钟 20 次。
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Envelope'
    ServerError:
      description: 平台内部错误，一律以 `code:"FAIL"` 返回，不带异常细节。
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Envelope'
          examples:
            internal:
              value:
                result: 0
                code: FAIL
                message: null
                data: []
                ref_id: 38c2e7a9-4d15-4f80-b6c3-2a9e0d7f5b18

  schemas:
    Envelope:
      type: object
      description: 平台所有接口共用的响应信封。
      required: [result, code, data, ref_id]
      properties:
        result:
          type: integer
          enum: [0, 1]
          description: 1 为成功，0 为失败。
        code:
          $ref: '#/components/schemas/ErrorCode'
        message:
          type: [string, 'null']
          description: 给人看的补充说明；成功时多为 null。
        data:
          description: 业务数据；失败时为空数组或空对象。
        ref_id:
          type: string
          format: uuid
          description: 平台为本次请求分配的 UUID，反映问题时请附上。

    ErrorCode:
      type: string
      description: 结果码，机器可读；含义见总览第 1.4 节。
      enum:
        - SUCCESS
        - FAIL
        - INVALID_MERCHANT
        - MERCHANT_BLOCKED
        - API_V2_NOT_ENABLED
        - SIGNATURE_EXPIRED
        - INVALID_SIGNATURE
        - IP_RESTRICTION
        - INVALID_PARAMS
        - DUPLICATE_TRANSACTION
        - SERVICE_NOT_AVAILABLE
        - PAYMENT_METHOD_NOT_SUBSCRIBE
        - MERCHANT_INSUFFICIENT_BALANCE
        - TRANSACTION_NOT_FOUND
        - NO_SERVICE_PROVIDED
        - SERVICE_UNDER_MAINTENANCE
        - PAYMENT_GATEWAY_MAINTENENCE
        - TOO_MANY_REQUEST
        - PATH_NOT_FOUND

    DepositChannel:
      type: string
      description: |
        代收通道。`bank` 为银行转账，`fps` 为转数快；其他取值返回
        `INVALID_PARAMS`。
      enum: [bank, fps]

    WithdrawChannel:
      type: string
      description: 代付通道，`bank` 或 `fps`。
      enum: [bank, fps]

    OrderStatus:
      type: string
      description: |
        订单状态。

        | 状态 | 含义 |
        |---|---|
        | `PENDING` | 处理中，尚未结算。 |
        | `COMPLETE` | 已结算。只有这个状态表示款项真的动了。 |
        | `REJECT` | 已拒绝或已失败。 |
        | `OVERTIME` | 仅代收：付款人超时未付，订单作废。 |

        `COMPLETE` 与 `REJECT` 是终态。其他取值——包括本表未列出的——一律当作
        处理中，继续等回单，不得按失败处理。
      enum: [PENDING, COMPLETE, REJECT, OVERTIME]

    DepositCreateRequest:
      type: object
      required: [channel, amount, ticket_ref, notify_url, return_url, payer_name]
      properties:
        channel:
          $ref: '#/components/schemas/DepositChannel'
        amount:
          type: string
          description: |
            代收金额，最多两位小数，须在商户的代收限额之内。建议以字符串传送，
            避免浮点格式化出错；数字也接受。
          examples: ["1200.00"]
        ticket_ref:
          type: string
          minLength: 6
          maxLength: 200
          description: 商户单号；同一商户下唯一，不区分大小写。
        notify_url:
          type: string
          format: uri
          description: 接收代收回单的地址，见第 3.3 节。
        return_url:
          type: string
          format: uri
          description: 付款人在平台托管收款页完成后跳回的地址。
        payer_name:
          type: string
          maxLength: 200
          description: |
            付款人姓名：将要发起转账的个人或企业名称。
    DepositCreateData:
      type: object
      description: 代收下单成功时 `data` 的内容。
      properties:
        ticket_no:
          type: string
          description: 平台单号，`DP` 开头。
        ticket_ref:
          type: string
        gross:
          type: string
          description: 订单金额，如 "1200.00"。
        fee:
          type: string
          description: 从代收金额中扣除的手续费。
        net:
          type: string
          description: 商户实际入账净额，等于 gross 减 fee。
        payer_name:
          type: string
          description: |
            订单上记录的付款人姓名；未记录时为 `""`——商户传了但账户开启了
            付款人自填姓名时也是如此。
        payment_details:
          type: object
          additionalProperties: true
          description: |
            本单专用的收款账户与托管收款页地址，原样交给付款人即可。里面的键是
            平台自己的收款账户记录，随通道而异：银行转账给出银行、户名、账号与
            应转的准确金额；转数快单据可能改为给出收款人姓名与转数快识别码。
            请当作透传数据处理——第 3.1 节展示了银行转账的一例——不要假定某个
            键一定存在。

    DepositInquiryData:
      type: object
      description: 代收订单状态，字段与代收回单报文相同。
      properties:
        ticket_no: { type: string }
        ticket_ref: { type: string }
        payer_name:
          type: string
          description: 付款人姓名；未记录时为 "-"。
        stage:
          $ref: '#/components/schemas/OrderStatus'
        gross:
          type: string
          description: |
            本单实际入账的金额。付款人转账数字与下单不同时，此值会与下单时返回的
            `gross` 不一致。
        fee: { type: string }
        net: { type: string }
        seal:
          type: string
          description: |
            单据封印：对本记录其余字段、按本接口的字段名算出的 HMAC-SHA256 摘要。
            它不在参考向量之内，验签也用不到它（`X-Signature` 已绑定整个报文）；
            它的用处是把同一张单据的回单与查询结果对上。

    WithdrawCreateRequest:
      type: object
      required: [channel, amount, ticket_ref, notify_url]
      properties:
        channel:
          $ref: '#/components/schemas/WithdrawChannel'
        amount:
          type: string
          description: |
            代付金额，恰好两位小数，须在商户的代付限额之内。手续费在此金额之外
            另计。
          examples: ["3500.00"]
        ticket_ref:
          type: string
          minLength: 6
          maxLength: 200
          description: 商户单号，同一商户下唯一。
        notify_url:
          type: string
          format: uri
          description: 接收代付回单的地址。
        bank_code:
          type: string
          description: |
            **`channel: bank` 必填**：收款银行，取 `GET /t1/banks` 返回的
            `bank_code`。`fps` 通道不用。
        account_no:
          type: string
          description: '**`channel: bank` 必填**：收款人账号。按字符串传送，保留前导零。'
        account_name:
          type: string
          description: '**`channel: bank` 必填**：收款人户名，须与银行登记一致。'
        mobile_no:
          type: string
          description: '**`channel: fps` 必填**：收款人的转数快手机号或识别码。'
        holder_name:
          type: string
          description: |
            **`channel: fps` 必填**：转数快用来同 `mobile_no` 一起核对的收款人
            姓名。`bank` 通道不用，改传 `account_name`。
        return_url:
          type: string
          format: uri
          description: '**可选**：随订单保存，不参与代付路由。'

    WithdrawCreateData:
      type: object
      description: 代付下单成功时 `data` 的内容。
      properties:
        ticket_no:
          type: string
          description: 平台单号，`WT` 开头。
        ticket_ref: { type: string }
        gross:
          type: string
          description: 商户申请付出的金额。
        fee:
          type: string
          description: 外加在代付金额之上的手续费。
        net:
          type: string
          description: 商户余额实际扣减的总额，等于 gross 加 fee。

    WithdrawInquiryData:
      type: object
      description: 代付订单状态，字段与代付回单报文相同。
      properties:
        ticket_no: { type: string }
        ticket_ref: { type: string }
        stage:
          $ref: '#/components/schemas/OrderStatus'
        gross: { type: string }
        fee: { type: string }
        net: { type: string }
        seal:
          type: string
          description: |
            单据封印：对本记录其余字段、按本接口的字段名算出的 HMAC-SHA256 摘要。
            它不在参考向量之内，验签也用不到它（`X-Signature` 已绑定整个报文）；
            它的用处是把同一张单据的回单与查询结果对上。

    DepositCallbackBody:
      type: object
      description: 代收回单报文。
      required: [ticket_no, ticket_ref, stage, gross, fee, net]
      properties:
        ticket_no: { type: string }
        ticket_ref: { type: string }
        payer_name:
          type: string
          description: 付款人姓名；未记录时为 "-"。
        stage:
          $ref: '#/components/schemas/OrderStatus'
        gross: { type: string }
        fee: { type: string }
        net: { type: string }
        seal:
          type: string
          description: |
            单据封印：对本记录其余字段、按本接口的字段名算出的 HMAC-SHA256 摘要。
            它不在参考向量之内，验签也用不到它（`X-Signature` 已绑定整个报文）；
            它的用处是把同一张单据的回单与查询结果对上。

    WithdrawCallbackBody:
      type: object
      description: 代付回单报文。
      required: [ticket_no, ticket_ref, stage, gross, fee, net]
      properties:
        ticket_no: { type: string }
        ticket_ref: { type: string }
        stage:
          $ref: '#/components/schemas/OrderStatus'
        gross: { type: string }
        fee: { type: string }
        net: { type: string }
        seal:
          type: string
          description: |
            单据封印：对本记录其余字段、按本接口的字段名算出的 HMAC-SHA256 摘要。
            它不在参考向量之内，验签也用不到它（`X-Signature` 已绑定整个报文）；
            它的用处是把同一张单据的回单与查询结果对上。

    Bank:
      type: object
      properties:
        bank_code:
          type: string
          description: 走 `bank` 通道代付时填入 `bank_code` 的值。
          examples: ["016"]
        bank_name:
          type: string
          description: 银行名称，供商户在自己的界面上展示。
          examples: ["Harbour Trust Bank"]
        bank_id:
          type: integer
          description: 平台内部编号，请忽略；一律以 `bank_code` 为准。
