# WD Pay 客户接入指南

本文档面向接入 WD Pay 的业务系统开发人员，覆盖支付创建、状态查询、关闭订单、退款和业务 Webhook。微信、支付宝渠道证书由 WD Pay 管理，接入方无需持有渠道私钥或证书。

## 1. 接入信息

生产环境：

```text
Base URL: https://py8.co
OpenAPI:  https://py8.co/openapi/v1
```

请从 WD Pay 管理员处获取：商户 ID、OpenAPI API Key、Webhook Key ID 和 Webhook Secret。API Key、Webhook Secret 只能保存在服务端密钥管理或环境变量中，不得写入浏览器、移动端、仓库或日志。

## 2. 通用请求规范

所有 OpenAPI 请求使用 HTTPS，并携带：

```http
Accept: application/json
Authorization: Bearer <OPENAPI_API_KEY>
X-Pay-Merchant-Id: <MERCHANT_ID>
```

创建支付、关闭订单和退款还必须携带 `Content-Type: application/json` 与 `Idempotency-Key`。金额均为整数分，`100` 表示人民币 1 元；时间使用 ISO 8601。排查问题时请提供响应中的 `request_id`，不要发送完整密钥。

统一响应结构：

```json
{"code":"OK","message":"success","data":{},"request_id":"req_xxx"}
```

## 3. 查询支付方式

```bash
curl 'https://py8.co/openapi/v1/providers' \
  -H 'X-Pay-Merchant-Id: mer_xxx' \
  -H 'Authorization: Bearer pk_live_xxx'
```

响应 `data.providers` 中只有 `enabled=true` 的方式才应展示给用户：

```json
{"provider":"wechat","name":"微信支付","enabled":true}
```

支持的 `provider`：`wechat`、`alipay`。

## 4. 创建支付订单

同一次业务支付必须始终使用相同的 `merchant_order_no` 和 `Idempotency-Key`。网络超时后先查询原订单，不要换新编号重复下单。

```bash
curl -X POST 'https://py8.co/openapi/v1/payments' \
  -H 'Content-Type: application/json' \
  -H 'X-Pay-Merchant-Id: mer_xxx' \
  -H 'Authorization: Bearer pk_live_xxx' \
  -H 'Idempotency-Key: payment:order-20260905-001' \
  --data-raw '{
    "merchant_order_no":"order-20260905-001",
    "provider":"wechat",
    "amount_cent":100,
    "currency":"CNY",
    "subject":"订单支付",
    "description":"会员购买",
    "expires_in_seconds":600,
    "callback_url":"https://system-a.example/pay/webhook",
    "attach":{"business_type":"member_order","business_order_no":"order-20260905-001"}
  }'
```

字段规则：

| 字段 | 规则 |
| --- | --- |
| `merchant_order_no` | 商户内唯一；字母、数字、`.`、`_`、`:`、`-`，最长 128 字符 |
| `provider` | `wechat` 或 `alipay` |
| `amount_cent` | 1～5,000,000 分，且符合商户金额范围 |
| `currency` | 当前固定 `CNY` |
| `subject` / `description` | 必填标题最长 128 字符；说明最长 256 字符 |
| `expires_in_seconds` | 300～7200 秒，默认 600 |
| `client_ip` | 可选的真实 IPv4/IPv6 地址 |
| `callback_url` | 可选，最长 2048 字符；订单专属业务 Webhook 地址，覆盖商户默认地址。必须是完整 HTTP(S) URL；生产使用公网 HTTPS。不能包含用户名密码、URL 片段或空白字符。省略或空字符串表示使用商户默认地址；不接受 `null` |
| `attach` | 可选 JSON，最大 8 KiB，不得放密钥或密码 |

成功响应中的 `data` 包含：`payment_no`、`merchant_order_no`、`provider`、`amount_cent`、`status`、`payment_action`、`expires_at`；`payment_action=qr_code` 时提供 `pay_url`、`qr_code_image`，`redirect` 时提供 `redirect_url`，`sdk` 时提供 `invoke_payload`。前端可直接将 `qr_code_image` 设置为 `<img src>`，或自行用 `pay_url` 生成二维码。首次状态可能为 `creating`，此时应查询原订单，不能直接判定失败。

### 4.1 PC、WAP/H5、iOS 和 Android 场景

创建和查询支付订单的响应都会返回 `callback_url`：非空表示该订单指定的业务回调地址；空字符串表示沿用商户默认配置，不代表禁用通知。该字段不会改变支付二维码、跳转链接或 SDK 唤起参数的使用方式。

原有请求不传新字段时仍按 PC 扫码处理，不会改变现有接入。新增字段如下：

| 字段 | 可选值 | 默认值 | 用途 |
| --- | --- | --- | --- |
| `payment_scene` | `native` / `h5` / `app` | `native` | 扫码、手机网页跳转或原生 App SDK |
| `client_type` | `pc` / `wap` / `ios` / `android` | `pc` | 描述实际客户端；微信 H5 会据此填写场景信息 |
| `return_url` | HTTPS/HTTP URL | 空 | H5 支付完成后的前端返回页；不能作为支付成功依据 |
| `client_ip` | IPv4/IPv6 | 空 | 微信 H5 必填，应传最终用户真实 IP |

场景与响应：

| 客户端 | 请求参数 | `payment_action` | 客户端处理 |
| --- | --- | --- | --- |
| PC Web | `native` + `pc` | `qr_code` | 展示 `qr_code_image`，保留现有扫码流程 |
| 手机浏览器/WAP | `h5` + `wap/ios/android` | `redirect` | 在用户点击事件中跳转 `redirect_url` |
| 原生 iOS/Android | `app` + `ios/android` | `sdk` | 把 `invoke_payload` 交给微信/支付宝官方 SDK |

手机网页创建示例：

```json
{
  "merchant_order_no":"order-h5-001",
  "provider":"wechat",
  "amount_cent":100,
  "subject":"手机网页支付",
  "payment_scene":"h5",
  "client_type":"android",
  "client_ip":"203.0.113.10",
  "return_url":"https://merchant.example/payment/result",
  "callback_url":"https://system-b.example/pay/webhook"
}
```

H5 返回示例：

```json
{
  "payment_scene":"h5",
  "client_type":"android",
  "payment_action":"redirect",
  "pay_url":"https://...",
  "redirect_url":"https://..."
}
```

浏览器必须由用户点击按钮后执行跳转，避免弹窗策略拦截：

```javascript
const data = response.data
if (data.payment_action === 'redirect') {
  window.location.assign(data.redirect_url)
}
```

原生 App 创建时使用 `payment_scene: "app"`。微信返回 `invoke_payload.appId`、`partnerId`、`prepayId`、`package`/`packageValue`、`nonceStr`、`timeStamp` 和 `sign`；Android 将 `packageValue` 映射到 `PayReq.packageValue`，iOS 使用 `package`。支付宝返回 `invoke_payload.orderString`，传给支付宝 SDK 的 `payOrder`。SDK 的同步回调、H5 `return_url` 都只说明客户端流程结束，业务入账仍必须以服务端 Webhook 或查询结果 `status=paid` 为准。

渠道侧还必须开通并正确配置对应产品：微信 H5 支付要求商户平台开通 H5 支付并配置支付域名，App 支付要求移动应用 AppID 已与商户号绑定；支付宝需开通手机网站支付或 App 支付。微信内置浏览器不支持普通 H5 支付，该环境需要 JSAPI 支付和用户 `openid`，当前接口不会把 H5 冒充为 JSAPI；应提示用户在系统浏览器打开。微信官方 [H5 下单文档](https://pay.wechatpay.cn/doc/v3/merchant/4012791834)、[H5 调起支付文档](https://pay.wechatpay.cn/doc/v3/merchant/4012791835)、[App 下单文档](https://pay.wechatpay.cn/doc/v3/merchant/4013070347) 和 [App 调起支付文档](https://pay.wechatpay.cn/doc/v3/merchant/4013070351) 可供核对。

同一个 `merchant_order_no` 和 `Idempotency-Key` 重试时，`payment_scene`、`client_type`、`return_url` 及 `callback_url` 也必须保持一致。更改 `callback_url` 会返回 HTTP 409、`IDEMPOTENCY_KEY_CONFLICT`，不能通过重复下单更改旧订单的通知去向。不要拿已创建的扫码订单切换成 H5/App 订单。

### 4.2 同一商户接入多个系统

所有支付场景均通过 `POST /openapi/v1/payments` 的 `callback_url` 指定本系统的接收地址。例如，同一商户的商城传 `https://shop.example/pay/webhook`，会员系统传 `https://member.example/pay/webhook`。两边订单的通知分别投递，不会额外抄送商户默认地址。

App 创建示例（扫码、H5 使用同一个 `callback_url` 字段）：

```json
{
  "merchant_order_no":"member:order-001",
  "provider":"alipay",
  "amount_cent":100,
  "subject":"会员开通",
  "payment_scene":"app",
  "client_type":"ios",
  "callback_url":"https://member.example/pay/webhook"
}
```

多系统共用商户时，`merchant_order_no`、`merchant_refund_no` 及对应操作的 `Idempotency-Key` 仍需在商户范围内唯一。建议使用系统前缀，例如 `shop:order-001`、`member:order-001`，以及 `payment:member:order-001`，避免不同系统撞单。

`callback_url` 用于服务端接收支付和退款通知；`return_url` 用于 H5 用户付款后的前端返回页面，两者独立，不能互相替代。各系统接收端都要实现第 7 节的验签和事件幂等。

## 5. 查询支付状态

```bash
curl 'https://py8.co/openapi/v1/payments/pay_xxx' \
  -H 'X-Pay-Merchant-Id: mer_xxx' \
  -H 'Authorization: Bearer pk_live_xxx'

curl 'https://py8.co/openapi/v1/payments/by-merchant-order/order-20260905-001' \
  -H 'X-Pay-Merchant-Id: mer_xxx' \
  -H 'Authorization: Bearer pk_live_xxx'
```

支付页面可每 2 秒查询一次。只有 `paid` 可以触发发货、开通权益或余额入账：

| 状态 | 含义 | 处理 |
| --- | --- | --- |
| `creating` | 正在创建渠道订单 | 继续查询 |
| `pending` | 等待用户支付 | 展示二维码并继续查询 |
| `paying` | 渠道处理中 | 继续查询，不发货 |
| `paid` | 支付成功 | 幂等执行业务入账 |
| `reviewing` | 结果待核对 | 禁止重复下单 |
| `expired` / `closed` | 订单不可继续支付 | 创建新的业务订单 |
| `failed` | 处理失败 | 按错误信息和业务规则重试 |

## 6. 关闭和退款

关闭订单：

```bash
curl -X POST 'https://py8.co/openapi/v1/payments/pay_xxx/close' \
  -H 'Content-Type: application/json' -H 'X-Pay-Merchant-Id: mer_xxx' \
  -H 'Authorization: Bearer pk_live_xxx' -H 'Idempotency-Key: close:order-001' --data '{}'
```

退款请求：

```bash
curl -X POST 'https://py8.co/openapi/v1/payments/pay_xxx/refunds' \
  -H 'Content-Type: application/json' -H 'X-Pay-Merchant-Id: mer_xxx' \
  -H 'Authorization: Bearer pk_live_xxx' -H 'Idempotency-Key: refund:order-001' \
  --data '{"merchant_refund_no":"refund-001","amount_cent":100,"reason":"用户申请退款"}'
```

`amount_cent` 不传时退全部可退金额；支持多次部分退款。退款查询：`GET /openapi/v1/refunds/{refund_no}`，只有 `succeeded` 表示最终成功。

## 7. Webhook 回调

回调地址按订单选择，规则如下：

| 创建支付时的配置 | 支付、退款通知投递地址 |
| --- | --- |
| 传入非空 `callback_url` | 始终使用该订单保存的地址，覆盖商户默认地址 |
| 省略 `callback_url` 或传空字符串 | 使用商户详情中「业务 Webhook」配置的当前默认地址 |
| 订单没有指定地址，商户默认地址也为空 | 事件保留待投递，配置默认地址后可继续投递 |

订单专属地址在创建时保存；之后修改商户默认地址不会改变这些订单的通知去向。自动重试和人工重投都遵循同一规则。未指定地址的订单（包括升级前的历史订单）保持原有行为，投递时读取商户当前默认地址。

退款继承原支付订单的回调地址，退款接口无需也不支持另传 `callback_url`；后台发起退款同样遵循此规则。支付成功、关闭、过期和退款事件均按所属订单选择地址。

地址应指向接入方已实现的公网 HTTPS 接口。通知使用 POST，请求不携带用户登录态；接收端应按下述签名验证来源，成功处理后返回 HTTP `2xx`。修改地址不能解决接收端自身的验签、请求体解析或业务校验错误。

Webhook 密钥仍按商户管理，不随 `callback_url` 创建新密钥。投递时使用该商户最新创建且已启用、未过期的 Webhook Key。共用商户的各系统需要配置匹配的 Key ID 和 Secret，并协调密钥更新；业务回调地址不同不代表商户身份或密钥隔离。微信、支付宝发往支付中心的渠道回调配置不受此参数影响。

请求头：

```http
X-Pay-Webhook-Key-Id: whk_xxx
X-Pay-Merchant: mer_xxx
X-Pay-Timestamp: 1788220800
X-Pay-Nonce: random-string
X-Pay-Event-Id: evt_xxx
X-Pay-Signature: lowercase-hex-hmac
Content-Type: application/json
```

签名原文为 `timestamp + "." + nonce + "." + raw_request_body`，算法 HMAC-SHA256，小写十六进制。必须使用收到的原始 JSON 字节，不能解析后重新序列化。

Python 验签：

```python
import hashlib, hmac, json, time

def verify(headers, raw_body, merchant_id, key_id, secret):
    ts = headers["X-Pay-Timestamp"]
    if headers.get("X-Pay-Merchant") != merchant_id:
        raise ValueError("merchant mismatch")
    if headers.get("X-Pay-Webhook-Key-Id") != key_id:
        raise ValueError("webhook key mismatch")
    if abs(int(time.time()) - int(ts)) > 300:
        raise ValueError("expired webhook")
    message = f"{ts}.{headers['X-Pay-Nonce']}.".encode() + raw_body
    expected = hmac.new(secret.encode(), message, hashlib.sha256).hexdigest()
    if not hmac.compare_digest(expected, headers.get("X-Pay-Signature", "").lower()):
        raise ValueError("invalid signature")
    event = json.loads(raw_body)
    if event.get("event_id") != headers.get("X-Pay-Event-Id"):
        raise ValueError("event id mismatch")
    return event
```

Node.js 验签：

```javascript
import crypto from 'node:crypto'

export function verify(headers, rawBody, config) {
  const ts = String(headers['x-pay-timestamp'] || '')
  const nonce = String(headers['x-pay-nonce'] || '')
  const signature = String(headers['x-pay-signature'] || '').toLowerCase()
  if (headers['x-pay-merchant'] !== config.merchantId) throw new Error('merchant mismatch')
  if (headers['x-pay-webhook-key-id'] !== config.webhookKeyId) throw new Error('webhook key mismatch')
  if (!ts || Math.abs(Date.now() / 1000 - Number(ts)) > 300) throw new Error('expired webhook')
  const expected = crypto.createHmac('sha256', config.webhookSecret)
    .update(Buffer.concat([Buffer.from(`${ts}.${nonce}.`), rawBody])).digest('hex')
  if (expected.length !== signature.length || !crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signature))) throw new Error('invalid signature')
  const event = JSON.parse(rawBody.toString('utf8'))
  if (event.event_id !== headers['x-pay-event-id']) throw new Error('event id mismatch')
  return event
}
```

支付成功事件类型为 `payment.succeeded`，核心字段包括 `data.payment_no`、`merchant_order_no`、`amount_cent`、`currency`、`provider_trade_no` 和 `attach`。退款成功事件类型为 `refund.succeeded`。接入方必须核对商户、订单号、金额、币种，并以 `event_id` 做唯一幂等；处理成功返回任意 HTTP `2xx`。重复、乱序通知是正常情况。

## 8. 错误与上线检查

常见错误码：`INVALID_ARGUMENT`、`API_KEY_INVALID`、`KEY_INVALID`、`MERCHANT_DISABLED`、`PAYMENT_NOT_FOUND`、`IDEMPOTENCY_KEY_CONFLICT`、`PAYMENT_ALREADY_PAID`、`PAYMENT_NOT_REFUNDABLE`、`RATE_LIMITED`、`PROVIDER_NOT_AVAILABLE`、`PROVIDER_ERROR`、`SERVICE_TEMPORARILY_UNAVAILABLE`。

上线前请验证：HTTPS、服务端保管密钥、稳定幂等键、`creating` 轮询、仅 `paid` 入账、原始 Body 验签、Key ID/商户/金额核对、重复回调幂等、网络超时后查原单、微信和支付宝真实小额支付及退款流程。
