# NickPay 接入文档

> 版本 v2.1　2026-09-18　　接口地址 `https://api.nickpay.net`
>
> 2026-09-18 起平台更名 NickPay、接口域名换为 `api.nickpay.net`；原 `api.hans-pay.cc.cd` 继续可用，接口与签名规则不变，请尽快切到新域名。

## 通用约定

- 全部接口：HTTPS + POST + `Content-Type: application/json`，请求带自定义 `User-Agent`（如 `MyShop/1.0`）。
- 运营提供 `merchant_no`（商户号）与 `api_secret`（签名密钥，只展示一次），并登记你的**出口 IP** 与 **`callback_url`**。
- 金额一律是**字符串**：请求传整数 VND（`"50000"`），响应固定 2 位小数（`"50000.00"`）。
- 时间字段（`expire_at`、`paid_at`）为**越南时间 UTC+7**，格式 `YYYY-MM-DD HH:mm:ss`；`timestamp` 为 Unix 秒，与服务器相差超过 5 分钟拒绝。
- 响应外壳：`{"code":"0000","msg":"success","data":{...},"sign":"..."}`，HTTP 恒为 200，看 `code`。

## 1. 签名

1. 取请求 JSON 第一层全部字段（不含 `sign`），去掉值为空字符串的；
2. 按字段名 ASCII 升序拼成 `k1=v1&k2=v2&...`；
3. `sign = hex(HMAC_SHA256(拼接串, api_secret))`，小写。

```python
import hashlib, hmac

def sign(params: dict, secret: str) -> str:
    items = sorted((k, v) for k, v in params.items() if k != "sign" and v != "")
    raw = "&".join(f"{k}={v}" for k, v in items)
    return hmac.new(secret.encode(), raw.encode(), hashlib.sha256).hexdigest()
```

- **响应验签**：顶层 `sign` 是对 `data` 内的字段用同一算法签的；错误响应无 `sign`。
- **通知验签**：对通知 JSON 去掉 `sign` 后的全部字段签。

## 2. 代收

### 2.1 下单 `POST /api/v1/pay/create`

| 字段 | 必填 | 说明 |
|---|---|---|
| `merchant_no` | 是 | 商户号 |
| `merchant_order_no` | 是 | 你的订单号，≤ 64，商户内唯一 |
| `amount` | 是 | 整数 VND 字符串 |
| `currency` | 否 | `VND` |
| `pay_method` | 是 | 支付方式，运营告知（当前 `BANK_QR`） |
| `payer_name` / `payer_email` / `payer_phone` / `payer_account_no` | 是 | 付款人姓名 / 邮箱 / 手机 / 账号，传真实值 |
| `payer_account_type` | 否 | `BANK`（默认）或 `PHONE` |
| `user_id` | 否 | 你系统里的用户 ID |
| `notify_url` | 否 | 这一笔的结果通知地址，≤ 512；不传用运营登记的 `callback_url`。须公网可达的 http(s)，本机/内网地址回 `1002`；不参与幂等比对（同号重试以首次为准）；地址不要做跳转，平台不跟 3xx |
| `timestamp` / `sign` | 是 | |

响应 `data`：

```json
{
  "order_no": "P2408201234",
  "merchant_order_no": "ORD20260820001",
  "amount": "50000.00",
  "currency": "VND",
  "pay_type": "URL",
  "pay_payload": "https://api.nickpay.net/pay/P2408201234?t=3f9c...",
  "expire_at": "2026-08-20 15:30:00"
}
```

- `pay_type=URL`：引导用户打开 `pay_payload`（平台收银台，整段原样使用）；`pay_type=QRCODE`：把 `pay_payload` 渲染成二维码。按字段判断，不要写死。
- 订单 30 分钟有效。
- 同一 `merchant_order_no` 参数一致的重复请求返回同一订单；参数不一致返回 `1005`。

### 2.2 查单 `POST /api/v1/pay/query`

请求：`merchant_no` + `merchant_order_no`（或 `order_no`）+ `timestamp` + `sign`。

响应 `data`：

```json
{
  "order_no": "P2408201234",
  "merchant_order_no": "ORD20260820001",
  "amount": "50000.00",
  "currency": "VND",
  "fee_amount": "1000.00",
  "net_amount": "49000.00",
  "status": "SUCCESS",
  "paid_at": "2026-08-20 15:02:11"
}
```

- `status`：`CREATED`（待支付）/ `SUCCESS` / `FAILED` / `EXPIRED`。
- `paid_at` 仅 `SUCCESS` 时出现。未支付时若已有支付凭据，会附带 `pay_type` / `pay_payload`，可直接给用户续付。

### 2.3 支付结果通知（平台 → 下单时的 `notify_url`，未传则 `callback_url`）

```json
{
  "order_no": "P2408201234",
  "merchant_order_no": "ORD20260820001",
  "amount": "50000.00",
  "currency": "VND",
  "fee_amount": "1000.00",
  "net_amount": "49000.00",
  "status": "SUCCESS",
  "paid_at": "2026-08-20 15:02:11",
  "timestamp": "1755658931",
  "sign": "..."
}
```

- `status` 为 `SUCCESS` 或 `FAILED`；`FAILED` 时 `fee_amount` / `net_amount` 为 `"0.00"`，无 `paid_at`。
- 过期（`EXPIRED`）不通知，请查单。
- 收到后：验签 → 按 `order_no` 幂等 → **无论 `status` 是什么，返回 HTTP 200 + 响应体 `SUCCESS`**，否则平台按 15s ~ 6h 重试 10 次。
- `SUCCESS` 为终态：先收到 `FAILED` 再收到 `SUCCESS` 必须按成功处理；已成功后再收到 `FAILED` 忽略。
- 建议收到通知后查单确认再发货。

## 3. 代付

把 VND 打到收款人银行账户。需运营开通；下单即从你的可用余额冻结 `amount + fee_amount`，成功扣除、失败退回。

### 3.1 银行列表 `POST /api/v1/transfer/banks`

请求：`merchant_no` + `timestamp` + `sign`。响应 `data`：

```json
{
  "banks": [{"bank_code": "970416", "bank_name": "Ngân hàng TMCP Á châu"}],
  "count": "40",
  "banks_hash": "sha256 hex"
}
```

`bank_code` 为 6 位越南银行代码。列表基本不变，可缓存一天。

**怎么拿到 `bank_code`**（推荐做法）：

1. 你的服务端每天调一次 `POST /api/v1/transfer/banks`，把返回的 `banks` 数组缓存起来（验签通过后再核对 `banks_hash`）；
2. 你的出款页面用这份列表做「选择银行」下拉：给用户看 `bank_name`，选中后取对应的 `bank_code`；
3. 发起代付时把这个 `bank_code` 原样传给 `POST /api/v1/transfer/create`，其余收款信息（账号、户名）由用户填写。

不要让用户手填 6 位代码；不在列表里的银行当前不支持，传了会回 `1002`。下面的表是同一份列表，方便对照。

**当前支持的银行（40 家，2026-09-16 与接口返回一致；以接口实时返回为准，新增/停用不另行通知）**：

| `bank_code` | 简称 | 银行名称 |
|---|---|---|
| `970400` | SCB | Ngân hàng TMCP Sài Gòn |
| `970403` | STB | Ngân hàng TMCP Sài gòn Thương tín |
| `970405` | VARB | Ngân hàng Nông Nghiệp và PTNT Việt Nam |
| `970406` | DAB | Ngân hàng TMCP Đông Á |
| `970407` | TCB | Ngân hàng TMCP Kỹ thương Việt Nam |
| `970408` | GPB | Ngân hàng TNHH MTV Dầu khí toàn cầu |
| `970412` | PVCB | Ngân hàng TMCP Đại Chúng Việt Nam |
| `970414` | OJB | Ngân hàng TMCP Đại Dương |
| `970415` | CTG | Ngân hàng TMCP Công thương Việt Nam |
| `970416` | ACB | Ngân hàng TMCP Á châu |
| `970418` | BIDV | Ngân hàng TMCP Đầu tư và phát triển Việt Nam |
| `970419` | NVB | Ngân hàng TMCP Quốc dân |
| `970421` | VRB | Ngân hàng Liên doanh Việt Nga |
| `970422` | MB | Ngân hàng TMCP Quân đội |
| `970423` | TPB | Ngân hàng TMCP Tiên Phong |
| `970424` | SVB | Ngân hàng TNHH MTV Shinhan (Việt Nam) |
| `970425` | ABB | Ngân hàng TMCP An Bình |
| `970426` | MSB | Ngân hàng TMCP Hàng Hải Việt Nam |
| `970427` | VAB | Ngân hàng TMCP Việt Á |
| `970428` | NAB | Ngân hàng TMCP NAM Á |
| `970429` | SGB | Ngân hàng Sài Gòn Công Thương |
| `970430` | PGB | Ngân hàng TNHH MTV Xăng dầu Petrolimex |
| `970431` | EIB | Ngân hàng TMCP Xuất nhập nhẩu Việt Nam |
| `970432` | VPB | Ngân hàng TMCP Việt Nam Thịnh vượng |
| `970433` | VB | Ngân hàng TMCP Việt Nam thương tín |
| `970434` | IVB | Ngân hàng TNHH Indovina |
| `970436` | VCB | Ngân hàng TMCP Ngoại thương Việt Nam |
| `970437` | HDB | Ngân hàng TMCP phát triển TPHCM |
| `970438` | BVB | Ngân hàng TMCP Bảo Việt |
| `970439` | PBVN | Ngân hàng TNHH MTV PUBLIC Việt Nam |
| `970440` | Seab | Ngân hàng TMCP Đông Nam Á |
| `970441` | VIB | Ngân hàng TMCP Quốc tế |
| `970442` | HLB | Ngân hàng TNHH một thành viên Hong Leong Việt Nam |
| `970443` | SHB | Ngân hàng TMCP Sài Gòn Hà Nội |
| `970448` | OCB | Ngân hàng TMCP Phương Đông |
| `970449` | LPB | Ngân hàng TMCP Bưu điện Liên Việt |
| `970452` | KLB | Ngân hàng TMCP Kiên Long |
| `970454` | VCCB | Ngân hàng TMCP Bản Việt |
| `970457` | WOO | Ngân hàng TNHH Woori bank |
| `970458` | UOB | Ngân hàng TNHH MTV United Overseas Bank (Việt Nam) |

### 3.2 发起代付 `POST /api/v1/transfer/create`

| 字段 | 必填 | 说明 |
|---|---|---|
| `merchant_no` | 是 | 商户号 |
| `merchant_order_no` | 是 | 你的代付单号，≤ 64，商户内唯一（与代收单号互不影响） |
| `amount` | 是 | 整数 VND 字符串，收款人实收 |
| `currency` | 否 | `VND` |
| `bank_code` | 是 | 取自 3.1 |
| `account_no` | 是 | 收款账号，≤ 40 |
| `account_name` | 是 | 收款户名，≤ 40，按银行开户名填（通常大写无音调） |
| `memo` | 否 | 转账备注，≤ 40 |
| `notify_url` | 否 | 这一笔的结果通知地址，规则同代收下单 |
| `timestamp` / `sign` | 是 | |

响应 `data`（与查单相同）：

```json
{
  "transfer_no": "D260915120000123456",
  "merchant_order_no": "TRF20260915001",
  "amount": "1000000.00",
  "fee_amount": "8000.00",
  "frozen_amount": "1008000.00",
  "currency": "VND",
  "status": "PROCESSING"
}
```

- `status`：`PROCESSING` / `SUCCESS` / `FAILED`（`FAILED` 时附 `fail_reason`）。
- **请求超时或没拿到响应，用同一 `merchant_order_no` 重发或查单，不要换单号**——换单号就是第二笔出款。同号且 `amount` / `bank_code` / `account_no` 一致返回原单，任一不同返回 `1005`。
- 沙箱不支持代付。

### 3.3 查单 `POST /api/v1/transfer/query`

请求：`merchant_no` + `transfer_no`（或 `merchant_order_no`）+ `timestamp` + `sign`。响应 `data` 同 3.2，`SUCCESS` 时附 `paid_at`，`FAILED` 时附 `fail_reason`。

`PROCESSING` 可能持续几分钟到数小时，不要因此换单号重发。

### 3.4 代付结果通知（平台 → 下单时的 `notify_url`，未传则 `callback_url`）

代收与代付通知可以打到同一个地址：代收报文带 `order_no`（`P…`），代付报文带 `transfer_no`（`D…`），按此分发即可。

```json
{
  "transfer_no": "D260915120000123456",
  "merchant_order_no": "TRF20260915001",
  "amount": "1000000.00",
  "fee_amount": "8000.00",
  "currency": "VND",
  "status": "SUCCESS",
  "paid_at": "2026-09-15 12:01:30",
  "timestamp": "1789474890",
  "sign": "..."
}
```

- `status` 为 `SUCCESS` 或 `FAILED`；`FAILED` 时无 `paid_at`，冻结金额已退回可用余额。
- 处理规则与 2.3 相同：验签 → 按 `transfer_no` 幂等 → 返回 `SUCCESS`；`SUCCESS` 为终态。
- 代付单 `SUCCESS` 但用户说没收到：联系运营，**不要自己再发一笔**。

## 4. 余额 `POST /api/v1/balance/query`

请求：`merchant_no` + `timestamp` + `sign`。响应 `data`：

```json
{"available": "1205554.67", "frozen": "500000.00", "withdrawable": "1205554", "currency": "VND"}
```

`available` 可用余额；`frozen` 处理中的代付/提现占用；`withdrawable` 可提现整数金额。

## 5. 错误码

| code | 含义 |
|---|---|
| 0000 | 成功 |
| 1001 | 验签失败 |
| 1002 | 参数缺失或非法，看 `msg` |
| 1003 | 金额必须为整数 VND |
| 1004 | 商户不存在或已停用 |
| 1005 | 订单号重复且参数不一致，换单号 |
| 1006 | 支付方式不支持，联系运营 |
| 1007 | 订单不存在 |
| 1008 | 请求过期，校准时钟 |
| 1009 | IP 不在白名单，联系运营登记 |
| 1010 | 金额超出单笔限额，`msg` 给出范围 |
| 1012 | 代付：可用余额不足 |
| 1013 | 代付：未开通 |
| 2001 | 渠道暂不可用，稍后重试 |
| 9999 | 系统错误，先查单再联系运营 |

HTTP 403 + 纯文本 `error code: 1010` 不是本接口错误，是安全防护拦了你的 `User-Agent`，换成自定义 UA 即可。

## 6. 沙箱

下单请求多带 `"sandbox": "1"`（参与签名），其余不变：不产生真实支付，`order_no` 以 `T` 开头，`pay_payload` 是模拟收银台，页面上点「模拟支付成功 / 失败」即触发与生产完全一致的通知；查单同样带 `"sandbox": "1"`。**`T` 开头的单号绝不能发货。**

## 附：通知验签示例

```python
def verify_notify(body: dict, secret: str) -> bool:
    got = body.get("sign", "")
    rest = {k: v for k, v in body.items() if k != "sign"}
    return hmac.compare_digest(got, sign(rest, secret))

# 接收端：验签 → 幂等（按「是否已成功」判）→ 无论 status 都回 "SUCCESS"
```
