# Pushlib 商户接入指南

服务地址：`https://platform.pushlib.cn`。机器可读的接口定义：[`https://platform.pushlib.cn/openapi.json`](https://platform.pushlib.cn/openapi.json)。本文讲 OpenAPI 说不清的部分：流程、签名、回调、注意事项。

## 1. 流程

```text
商户系统  ── POST /api/merchant/orders ──▶  Pushlib    创建订单，返回 matchAmount 与 payUrl
商户系统  ── 把付款人带到 cashierUrl（托管收银台）；或自己把 payUrl 编码成二维码，展示 matchAmount
付款人    ── 扫码，按 matchAmount 付款
Pushlib   ── 收到到账，匹配到订单 ──▶  POST <notifyUrl>   已签名的支付成功回调
商户系统  ── 验签，按 orderId 幂等处理，返回 HTTP 200
```

- 同一价格的订单各自分到一个只差几分的 `matchAmount`（等于标价，或高 0.01–0.09 元）。到账金额就是用来认出订单的依据，所以必须让付款人支付 `matchAmount`，不是 `amount`
- 订单到期（`expiresAt`，Unix 毫秒）后变为 `expired`。超时和商户关单都不回调；要得知超时，自己对照 `expiresAt`，或查询订单看 `paymentStatus`
- `expired` 不是最终结果：付款人超时后才付款时，运营者可能把这笔到账人工关联到原订单，订单随之变为 `paid` 并发出支付成功回调。超时后关闭业务单或释放库存的，要能处理之后到来的这类回调
- 需要新的付款尝试时，用新的 `merchantOrderId`。商户单号在你的账户内唯一，终态后也不回收

## 2. 接入步骤

1. 在控制台取得商户密钥：它有两部分，公开的**密钥编号**（`mk_` 开头，下称 `KEY_ID`）和**密钥**（`SECRET`）。以下示例里的 `KEY_ID` 与 `SECRET` 从环境变量或配置读取，不要硬编码
2. 实现签名（第 3 节），用第 4 节的测试向量核对：同样的输入必须得到同样的签名
3. 调 `GET /api/merchant/ping`。返回 `{"ok":true,"role":"merchant"}` 说明密钥和签名都对；`401` / `403` 看 `error` 字段排查（第 8 节）
4. 创建订单，把付款人带到 `cashierUrl`（第 6 节「托管收银台」），或自己展示 `matchAmount` 与 `payUrl` 的二维码
5. 提供 `notifyUrl` 端点，验签后返回 200（第 7 节）
6. 联调：没有沙箱，创建订单会真实占用匹配金额。用小额订单，不再需要时调用关闭接口

## 3. 签名

每个请求（包括重试）都生成新的 nonce 并重新签名。

请求头：

| 头                    | 值                                                   |
| --------------------- | ---------------------------------------------------- |
| `X-Pushlib-Key-Id`    | 商户密钥编号（`mk_` 加 26 位字符），公开，可写进日志 |
| `X-Pushlib-Timestamp` | 当前 Unix 秒，十进制整数                             |
| `X-Pushlib-Nonce`     | 每个请求一个新值，1–64 个可见 ASCII，不含空白        |
| `X-Pushlib-Signature` | 下文公式得到的 64 位小写 hex                         |

规范串，五行，以 `\n` 连接，末尾不加换行：

```text
{timestamp}\n{nonce}\n{METHOD}\n{path}\n{body_digest}
```

| 段                   | 规则                                                                                                                                                                  |
| -------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `timestamp`、`nonce` | 与请求头完全相同                                                                                                                                                      |
| `METHOD`             | HTTP 方法大写，如 `GET`、`POST`                                                                                                                                       |
| `path`               | 请求 URL 的 pathname 加 query，以 `/` 开头，不含主机，按实际发送的（已百分号编码的）形式。列单接口有 query，必须把它连同 `?` 一起签进去，且签名后不得改参数顺序或编码 |
| `body_digest`        | 实际发送的请求体字节的 SHA-256，小写 hex。没有请求体时是空串的摘要 `e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855`                                 |

```text
signature = hex_lowercase( HMAC_SHA256( key = SECRET 的 UTF-8 字节, message = 规范串的 UTF-8 字节 ) )
```

要点：

- 对**实际发送的原始请求体字节**取摘要，签名之后不得重新序列化。先序列化得到字节，再签名，再发送同一份字节
- 服务端按 `X-Pushlib-Key-Id` 找到密钥；编号不存在、已吊销，或不是商户密钥，一律返回 `401 bad_key`
- 服务端接受时间戳与服务器时间相差不超过 300 秒；同一个密钥的同一个 nonce 在窗口内只能用一次，重复返回 `409 replay`
- 租户可以在控制台设置 IP 白名单：配置后只接受来自白名单地址的请求，其他地址返回 `403 ip_not_allowed`；白名单为空则不限制。白名单对应的是服务端看到的请求来源地址（商户服务器的出口地址）
- 同一密钥每分钟至多 600 次请求，超出返回 `429 rate_limited` 与 `Retry-After`（秒）；按该秒数等待后再重试，重试要换新 nonce
- 路径里的商户单号要 `encodeURIComponent`，签名用编码后的路径。建议商户单号只用字母、数字、`-`、`_`
- 密钥只用于计算 HMAC，不在任何请求里传输；编号不是机密
- 换密钥不必停机：一个租户最多同时有两把有效的商户密钥。先新建第二把，把系统切到新密钥，再吊销旧的；吊销立即生效

## 4. 签名测试向量

实现签名后，用下面的输入核对，得到的签名必须与此相同。

密钥：`01JTESTSECRET00000000000000`

向量一：

```text
timestamp = 1700000000
nonce     = nonce-0001
method    = POST
path      = /api/merchant/orders
body      = {"merchantOrderId":"ord-1","channel":"wechat","amount":"1.00","notifyUrl":"https://merchant.example/callback"}
body_digest = 66ee3cdb1566c0d53bac64febabcfb8c4be2da969d650c4f4938a5b97b8a83b6
signature = 346665403513b75c240c3be4f7eb61f7d058f2153f45726e1e2f8f211ecc251c
```

向量二（无请求体）：

```text
timestamp = 1700000000
nonce     = nonce-0002
method    = GET
path      = /api/merchant/orders/by-merchant/ord-1
body      =（空）
signature = fc578993b16fea605111b0f1de9290a14a8eb1e08f1e7145da9e5b8703743705
```

## 5. 示例

### Python（仅标准库）

```python
import hashlib
import hmac
import json
import os
import secrets
import time
import urllib.error
import urllib.request

BASE = "https://platform.pushlib.cn"
KEY_ID = os.environ["PUSHLIB_MERCHANT_KEY_ID"]
SECRET = os.environ["PUSHLIB_MERCHANT_SECRET"]


def call(method: str, path: str, payload: dict | None = None) -> tuple[int, dict]:
    body = b"" if payload is None else json.dumps(payload, separators=(",", ":"), ensure_ascii=False).encode("utf-8")
    timestamp = str(int(time.time()))
    nonce = secrets.token_urlsafe(18)
    canonical = "\n".join([timestamp, nonce, method.upper(), path, hashlib.sha256(body).hexdigest()])
    signature = hmac.new(SECRET.encode("utf-8"), canonical.encode("utf-8"), hashlib.sha256).hexdigest()
    request = urllib.request.Request(
        BASE + path,
        data=body if method != "GET" else None,
        method=method,
        headers={
            "Content-Type": "application/json",
            "X-Pushlib-Key-Id": KEY_ID,
            "X-Pushlib-Timestamp": timestamp,
            "X-Pushlib-Nonce": nonce,
            "X-Pushlib-Signature": signature,
        },
    )
    try:
        with urllib.request.urlopen(request, timeout=15) as response:
            return response.status, json.loads(response.read())
    except urllib.error.HTTPError as error:
        return error.code, json.loads(error.read() or b"{}")


print(call("GET", "/api/merchant/ping"))

status, order = call("POST", "/api/merchant/orders", {
    "merchantOrderId": "demo-0001",
    "channel": "wechat",
    "amount": "1.00",
    "notifyUrl": "https://merchant.example/callback",
})
print(status, order)  # 201 新单；200 同内容重放；order["matchAmount"] 才是应付金额
```

### Node / Bun

```js
import { createHash, createHmac, randomBytes } from "node:crypto";

const BASE = "https://platform.pushlib.cn";
const KEY_ID = process.env.PUSHLIB_MERCHANT_KEY_ID;
const SECRET = process.env.PUSHLIB_MERCHANT_SECRET;

async function call(method, path, payload) {
  const body = payload === undefined ? "" : JSON.stringify(payload);
  const timestamp = String(Math.floor(Date.now() / 1000));
  const nonce = randomBytes(18).toString("base64url");
  const digest = createHash("sha256").update(body, "utf8").digest("hex");
  const canonical = [timestamp, nonce, method.toUpperCase(), path, digest].join("\n");
  const signature = createHmac("sha256", SECRET).update(canonical, "utf8").digest("hex");
  const response = await fetch(BASE + path, {
    method,
    headers: {
      "content-type": "application/json",
      "X-Pushlib-Key-Id": KEY_ID,
      "X-Pushlib-Timestamp": timestamp,
      "X-Pushlib-Nonce": nonce,
      "X-Pushlib-Signature": signature,
    },
    body: method === "GET" ? undefined : body,
  });
  return { status: response.status, body: await response.json() };
}

console.log(await call("GET", "/api/merchant/ping"));
```

## 6. 订单

创建请求体：

```json
{ "merchantOrderId": "ord-1", "channel": "wechat", "amount": "1.00", "notifyUrl": "https://merchant.example/callback" }
```

可选再加 `"returnUrl": "https://merchant.example/orders/ord-1/done"`（见下文「托管收银台」）和 `"payeeId": "<收款终端编号>"`。

- `channel`：`wechat` 或 `alipay`
- `amount`：元字符串，最多两位小数，大于 0，不超过 `999999.99`。多于两位小数、非数字、零、负数都返回 `400 bad_amount`，不会四舍五入
- `notifyUrl`：必填，绝对 http(s) 地址，最长 2048，没有默认地址
- `payeeId`：可选，收款终端编号（在控制台的终端页）。租户恰有 1 个启用的终端时可以省略；否则必须指定，缺失返回 `400 payee_required`，编号不存在返回 `400 payee_not_found`，终端已停用返回 `403 payee_disabled`
- `returnUrl`：可选，付款人在收银台页看到已支付后，浏览器跳转的地址。只能是 `https`（本机 `localhost`、`127.0.0.1`、`[::1]` 联调可用 `http`），不含账号口令，最长 2048，不合法返回 `400 bad_request`。它在请求体里，被请求签名一并覆盖

幂等：同一个 `merchantOrderId`，`amount`、`channel`、`notifyUrl`、`returnUrl` 与收款终端与第一次完全相同（字符串精确相等；没传 `returnUrl` 与原单没有 `returnUrl` 视为相同）时返回原订单（`200`，包括原来的 `orderId`、`matchAmount`、`payUrl`、`cashierUrl`、`expiresAt`），不要求监控在线，不重新占用金额。任一字段不同返回 `409 merchant_order_conflict`。因此请求超时后，可以带着同样的内容放心重试

订单字段（完整结构见 OpenAPI 的 `Order`）：

| 字段                      | 说明                                                                                     |
| ------------------------- | ---------------------------------------------------------------------------------------- |
| `orderId`                 | 服务端生成的订单号（UUID）                                                               |
| `payeeId`                 | 收款终端编号：这笔订单由哪个终端的收款码收款                                             |
| `amount` / `matchAmount`  | 标价 / 付款人应付金额，元字符串                                                          |
| `payUrl`                  | 收款链接。它是要编码成二维码的**字符串**，不是图片                                       |
| `cashierUrl`              | 托管收银台页的地址，见下文                                                               |
| `returnUrl`               | 创建时给的跳转地址，没给为 `null`                                                        |
| `paymentStatus`           | `pending` 待支付，`paid` 已支付，`expired` 已过期，`cancelled` 已关闭                    |
| `notifyStatus`            | `idle` 未支付，`pending` 回调投递中，`succeeded` 回调成功，`failed` 六次投递都没得到 200 |
| `expiresAt` / `createdAt` | Unix 毫秒。`expiresAt` 在创建时写定，之后不变                                            |

### 托管收银台

不想自己画二维码时，把付款人带到订单的 `cashierUrl`（302 跳转，或在新页面打开，**不要嵌入 iframe**，页面禁止被嵌入）。页面展示收款二维码、必须支付的 `matchAmount`、到期倒计时，付款成功后自动更新；订单带 `returnUrl` 时，约 2 秒后跳转过去。`payUrl` 与 `matchAmount` 的含义不变，仍可自己展示

- 地址是 `APP_URL` 加 `/pay/<48 位 hex>`；服务端没有设置 `APP_URL` 时，取你这次请求所到的 origin，所以请让运营者设置它。它随订单固定，重放与查单返回同一个
- 那串 hex 就是凭据，拿到链接的人能看到这一单的渠道、金额与收款链接，且只在订单待支付、未过期时。不要写进日志、不要放进公开页面或第三方统计
- **跳转到 `returnUrl` 不代表你已收到钱**：它只说明付款人的页面看到了「已支付」，回调可能还在路上，`returnUrl` 也能被人直接打开。发货、入账以支付成功回调与查单的 `paymentStatus` 为准
- 跳转不附带任何参数，需要关联自己的订单，把标识放在你给出的 `returnUrl` 里（例如路径中的你自己的单号，不要放可被用来领取权益的凭据）

查询：`GET /api/merchant/orders/{orderId}` 或 `GET /api/merchant/orders/by-merchant/{merchantOrderId}`，不存在返回 `404 not_found`。

关闭：`POST /api/merchant/orders/{orderId}/close` 或 `POST /api/merchant/orders/by-merchant/{merchantOrderId}/close`。`pending` 变为 `cancelled`；已 `cancelled` 原样返回；`paid` 或 `expired` 返回 `409 not_cancellable`。关闭不回调。

### 按时间区间列单（对账）

`GET /api/merchant/orders?from=<毫秒>&to=<毫秒>`，取这段时间内**创建**的订单，用来对账、补漏。

- `from` 含、`to` 不含，都是 Unix 毫秒整数，两个都必填，`to` 不得早于 `from`；与时区无关。想查一整天，就用当天零点和次日零点的毫秒值
- 可选 `channel`（`wechat` / `alipay`）、`status`（`pending` / `paid` / `expired` / `cancelled`）过滤，例如只看已支付的
- 返回 `{"items": [订单…]}`，订单结构与查询接口相同；按创建时间从旧到新排。每页默认 50 条、最多 100 条（`limit`，超过按 100），用 `offset` 翻页，某页不足 `limit` 条就是读完了。从旧到新排，翻页途中新创建的订单会落在末尾，不会让已翻过的页错位
- 参数缺失或不合法返回 `400 bad_request`
- 签名的 path 带 query：例如签 `/api/merchant/orders?from=1700000000000&to=1700086400000`，发送的 URL 也必须与之完全一致
- 对账以订单的 `paymentStatus` 为准；回调没收到的订单，可用它找出 `paid` 但自己系统还没记账的单

## 7. 支付成功回调

订单变为 `paid` 后，服务端向该订单的 `notifyUrl` 发 `POST`，`content-type: application/json`：

```json
{
  "orderId": "…",
  "merchantOrderId": "ord-1",
  "channel": "wechat",
  "amount": "1.00",
  "matchAmount": "1.00",
  "paymentStatus": "paid"
}
```

回调带有与请求相同的四个签名头，算法同第 3 节；用租户最新有效的商户密钥签名，`X-Pushlib-Key-Id` 是签名所用密钥的编号，商户据此选密钥（两把密钥并存期间，回调可能用其中任一把）；`path` 取 `notifyUrl` 的 pathname 加 query。商户必须：

1. 用**实际收到的原始请求体字节**验签，常量时间比较签名
2. 拒绝时间戳超出 300 秒窗口的回调
3. 记住窗口内见过的 nonce，拒绝重复
4. 按 `paymentStatus` 判断结果，不要假设收到回调就是支付成功；遇到不认识的取值，不做业务处理，直接返回 `200`
5. 按 `orderId` 幂等处理业务（同一订单可能重复收到）
6. 处理完成后返回 HTTP `200`

成功的判定只看状态码是否恰好 200，响应正文不参与。非 200、网络错误、超过 10 秒未响应都算失败，也不会跟随重定向（`notifyUrl` 不要依赖 301/302）。`notifyUrl` 的域名解析到回环、私有、链路本地、CGNAT、组播、云元数据等内网地址时，服务端不会发出请求，该次同样算失败，所以回调地址必须是公网可达的。失败后按 60、300、900、3600、21600 秒的间隔重试，共 6 次投递；仍没有得到 200，`notifyStatus` 变为 `failed`，不再投递，此后用查询接口核对。

验签示例（Python）：

```python
import hashlib
import hmac
import time


def verify_callback(method: str, path: str, body: bytes, headers: dict, secret: str, seen_nonces: set) -> bool:
    timestamp = headers["X-Pushlib-Timestamp"]
    nonce = headers["X-Pushlib-Nonce"]
    signature = headers["X-Pushlib-Signature"]
    # headers["X-Pushlib-Key-Id"] names the key to use: pick `secret` by it when you hold more than one
    if not timestamp.isdigit() or abs(time.time() - int(timestamp)) > 300:
        return False
    canonical = "\n".join([timestamp, nonce, method.upper(), path, hashlib.sha256(body).hexdigest()])
    expected = hmac.new(secret.encode("utf-8"), canonical.encode("utf-8"), hashlib.sha256).hexdigest()
    if not hmac.compare_digest(expected, signature):
        return False
    if nonce in seen_nonces:  # 生产环境用带过期时间的共享存储
        return False
    seen_nonces.add(nonce)
    return True
```

## 8. 错误

错误响应体统一为 `{"error": "<code>"}`，靠 HTTP 状态码加 `error` 判断。

| HTTP | `error`                   | 含义与处理                                                                                          |
| ---- | ------------------------- | --------------------------------------------------------------------------------------------------- |
| 400  | `bad_json`                | 请求体不是合法 JSON                                                                                 |
| 400  | `bad_request`             | 字段缺失或不合法（单号、渠道、`notifyUrl`、`returnUrl`；列单的 `from`、`to`、`limit`、`offset` 等） |
| 400  | `bad_amount`              | 金额不合法                                                                                          |
| 400  | `payee_required`          | 租户没有恰好一个启用的终端，请求里须带 `payeeId`                                                    |
| 400  | `payee_not_found`         | `payeeId` 不是本租户的终端                                                                          |
| 401  | `missing_headers`         | 四个签名头（含 `X-Pushlib-Key-Id`）缺失                                                             |
| 401  | `bad_timestamp`           | 时间戳不是整数秒，或与服务器相差超过 300 秒。检查本机时钟                                           |
| 401  | `bad_nonce`               | nonce 形态不合法                                                                                    |
| 401  | `bad_signature`           | 签名不对。核对密钥、方法、path、原始请求体字节，用第 4 节向量自检                                   |
| 401  | `bad_key`                 | 密钥编号不存在、已吊销，或不是商户密钥。核对 `KEY_ID`，必要时在控制台新建                           |
| 403  | `tenant_inactive`         | 租户尚未激活，不能收款，等待平台激活                                                                |
| 403  | `tenant_suspended`        | 租户已被停用，联系平台                                                                              |
| 403  | `payee_disabled`          | 指定的收款终端已停用                                                                                |
| 403  | `ip_not_allowed`          | 请求来源地址不在租户的 IP 白名单内。核对商户服务器的出口地址，或在控制台「设置」里修改白名单        |
| 429  | `rate_limited`            | 同一密钥请求过于频繁（每分钟至多 600 次）。按 `Retry-After` 秒数后重试，重试要换新 nonce            |
| 404  | `not_found`               | 订单不存在                                                                                          |
| 409  | `replay`                  | nonce 已用过。每个请求（包括重试）换新 nonce                                                        |
| 409  | `amount_busy`             | 同渠道可分配的匹配金额用尽。稍后重试，或换金额                                                      |
| 409  | `merchant_order_conflict` | 同一商户单号已存在且内容不同。换新单号                                                              |
| 409  | `not_cancellable`         | 订单已支付或已过期，不能关闭                                                                        |
| 503  | `monitor_offline`         | 收款监控端离线，暂不接新单。稍后重试，联系运营者                                                    |
| 503  | `qrcode_missing`          | 该渠道没有配置收款链接，联系运营者                                                                  |

两类 `409` 同状态码，用 `error` 区分。网络超时后重试创建订单是安全的（幂等，见第 6 节），重试要换新 nonce。

接入遇到问题可联系 help@pushlib.cn。
