商户 API 对接指南
说明如何通过 XMan 开放接口创建订单、查询账号与流水、发起转账,并接收带签名的异步通知。完整契约以仓库内 services/api/docs/merchant-open-api.md 为准;订单 notify 字段细节另见 services/api/docs/orders-and-notify.md。
概览
- 除
GET /health外,开放接口路径均带前缀/api - 鉴权 Header:
x-api-key(商户后台 apiKey);写操作与验签使用secretKey(勿暴露到浏览器) - 创建订单、发起转账均需 HMAC-SHA256 签名(
sign);异步通知由平台签名、商户验签 - 列表类接口(账号、流水、转账)分页响应为
{ items, total, page, limit } - 本 Demo:收银台
/、转账/transfer、notify 接收POST /api/checkout/notify(端口默认 9005)
API 索引
共 13 个开放接口;本地联调基址默认 test-frontend.xman.com。
| 方法 | 路径 | 权限 |
|---|---|---|
| GET | /health | — |
| GET | /api/devices | devices:list |
| GET/PATCH | /api/accounts… | accounts:* |
| POST/GET | /api/orders… | orders:* |
| GET | /api/transactions… | transactions:* |
| GET/POST | /api/transfers… | transfers:* |
对接流程
- 在 Admin 商户详情获取 apiKey(
sk_api_…)、secretKey(sk_sign_…),配置默认notifyUrl(HTTPS)。系统 Webhook 验签密钥为sk_hook_…(Admin → Webhooks)。 POST /api/orders创建订单(带 sign),保存响应中的id(UUID)、orderId(商户 15 位)、platformOrderId(平台 20 位)、targetAccount、reconcileAmount。不浮动金额下同付款账号已有同额待付单时返回409,用响应data继续原单支付。- 引导用户向
targetAccount转入正确金额(收银台展示reconcileAmount ?? amount)。 - 平台定时拉银行流水对账;匹配成功后订单为
success。 - 平台
POST你的notifyUrl;验签后处理业务,响应体为纯文本success(非 JSON 字符串)。 - 也可用
GET /api/orders/:id主动查询(注意限频)。
订单 expired 时不会发送 notify。
鉴权
每个请求携带:
x-api-key: <商户 apiKey,如 sk_api_...>无效或缺失返回 401 Unauthorized。
另需商户组勾选接口权限,例如 orders:create、orders:read、transfers:create 等;未授权返回 403。
创建订单
POST /api/orders · Content-Type: application/json · 成功 201
| 字段 | 必填 | 说明 |
|---|---|---|
| orderId | 是 | 15 位数字:UTC YYYYMMDD + 7 位序号;同商户唯一 |
| fromAccount | 是 | 付款账号 |
| amount | 是 | 名义金额,> 0 |
| title | 是 | 订单标题 |
| sign | 是 | 见「签名算法」 |
| notifyUrl | 否 | 本单通知地址;与后台默认二选一,不能都为空 |
| returnUrl | 否 | 浏览器跳转(不参与 notify 签名) |
| app | 否 | 渠道如 mymo;可选渠道筛选 |
| param | 否 | 扩展 JSON,通知中原样回传 |
响应 data 含 id、orderId、platformOrderId、targetAccount、reconcileAmount、expiresAt、reconcileUntilAt、status 等。
不浮动金额(base_mode=none)时,同渠道、收款户、付款账号下若已有相同应付金额的待处理订单,返回 409,body 含 error: active_reconcile_order_exists 与 data(前单详情,字段与 201 一致),可据此引导用户继续支付原单。
{
"ok": true,
"data": {
"id": "550e8400-e29b-41d4-a716-446655440000",
"merchantId": "mch_xxx",
"orderId": "202601011234567",
"platformOrderId": "202601011234560012",
"fromAccount": "1234567890",
"amount": 100,
"currency": "THB",
"reconcileAmount": 100,
"title": "商品充值",
"targetAccount": "020474502919",
"app": "mymo",
"status": "pending",
"expiresAt": "2026-01-01T12:10:00.000Z",
"reconcileUntilAt": "2026-01-01T12:11:40.000Z"
}
}{
"ok": false,
"error": "active_reconcile_order_exists",
"message": "同收款配置下已存在相同应付金额的待处理订单",
"data": {
"id": "660e8400-e29b-41d4-a716-446655440001",
"orderId": "202601011234500",
"fromAccount": "1234567890",
"amount": 100,
"currency": "THB",
"reconcileAmount": 100,
"targetAccount": "020474502919",
"status": "pending",
"expiresAt": "2026-01-01T12:10:00.000Z"
}
}收到 409 且 error=active_reconcile_order_exists 时,应使用 data.id(publicId)引导用户进入原收银台,勿重复创建。
curl -sS -X POST "test-frontend.xman.com/api/orders" \
-H "x-api-key: sk_api_xxx" \
-H "content-type: application/json" \
-d '{
"orderId": "202601011234567",
"fromAccount": "1234567890",
"amount": 100,
"title": "商品充值",
"sign": "<按下文算法计算的小写 hex>",
"notifyUrl": "https://merchant.example.com/pay/notify",
"param": { "userId": "u1" }
}'业务账号
GET /api/accounts — 分页列表,Query 可选 app、enable、citizenId、account、page、limit。响应字段含 citizenId、account、enable(无对外 status)。
GET /api/accounts/:id、 PATCH /api/accounts/:id(body 仅 enable)— 权限分别为 accounts:read / accounts:write。
流水
GET /api/transactions — Query 中 app、account 均可选;分页 page / limit。
GET /api/transactions/:id — 路径为列表 items[].id。
发起转账
POST /api/transfers · Content-Type: application/json · 成功 201 · 权限 transfers:create
| 字段 | 必填 | 参与签名 | 说明 |
|---|---|---|---|
| sign | 是 | 否 | 见「签名算法」 |
| app | 否 | 非空时 | 渠道;不传则在 gateway 已启用设备中自动选号 |
| accountTo | 是 | 是 | 收款账号 |
| amount | 是 | 是 | 转账金额(THB),> 0 |
| type | 否 | 是 | 默认 bank;未传时签名按 bank |
| typeCode | bank 时必填 | 非空时 | 银行码(如 GSB 为 30) |
| citizenId | 否 | 非空时 | 付款方证件号;与 account 可指定出款账号 |
| account | 否 | 否 | 业务出款账号;须与 citizenId 等在可用账号中唯一匹配 |
| timeoutMs | 否 | 非空时 | gateway 超时毫秒 |
成功 data 含 id、citizenId、account、transRefCode、出收款账号等。gateway 失败返回 502,body 可含 data.phase(prePost/post)。
本仓库转账 Demo:/transfer → BFF POST /api/transfer(服务端算 sign,body 可含未参与签名的 account)。
curl -sS -X POST "test-frontend.xman.com/api/transfers" \
-H "x-api-key: sk_api_xxx" \
-H "content-type: application/json" \
-d '{
"accountTo": "1234567890",
"amount": 1,
"type": "bank",
"typeCode": "30",
"sign": "<按下文算法计算的小写 hex>"
}'查询订单
GET /api/orders/:id — :id 为创建返回的 UUID(data.id)。
GET /api/orders/:id/callback-logs — 查看每次 notify 投递记录(排障用)。
创建与查询共用 min_interval_ms 限频(默认约 1s),过频返回 429。
异步通知(notifyUrl)
订单进入终态且非 expired 后,平台向 notifyUrl 发起:
{
"orderId": "202601011234567",
"fromAccount": "1234567890",
"platformOrderId": "202601011234560012",
"status": "success",
"amount": "100",
"title": "商品充值",
"targetAccount": "020474502919",
"timestamp": "1735689600000",
"sign": "<平台 HMAC,小写 hex>"
}商户必须:验签通过后处理业务;HTTP 2xx 且响应 body trim 后等于纯文本 success,平台才停止重试。
重试间隔约 5s / 30s / 120s;用尽后平台侧 callbackStatus 为 failed。通知中 amount 为字符串,验签须按字符串参与。
验签:对实际收到的 JSON 去掉 sign 后,仅对其中非空字段按下方 buildSign 规则计算(空值键平台不会 POST,例如无扩展参数时不含 param)。可选字段 reconcileAmount、platformOrderId 有则参与;不含 appOrderNo。
签名算法
创建订单、发起转账与异步通知使用同一套 buildSign 规则:
- 除
sign外,取 value 非 null 且非空字符串的字段 - 键名按字典序排序
- 拼接
key=signFieldValue(value),用&连接 - HMAC-SHA256(secretKey, UTF-8),输出 hex 小写
创建订单参与:orderId、fromAccount、amount、title,及非空的 app、returnUrl、notifyUrl、param(param 用 JSON.stringify)。
发起转账参与:accountTo、amount、type(未传按 bank)、及非空的 app、typeCode、citizenId、timeoutMs;不含 account。
异步通知参与:以平台 POST 的 JSON 为准,除 sign 外所有非空字段(见「异步通知」一节)。
import { createHmac, timingSafeEqual } from "node:crypto";
function signFieldValue(v) {
if (v === null || v === undefined) return "";
if (typeof v === "object") return JSON.stringify(v);
if (typeof v === "string") return v;
if (typeof v === "number" || typeof v === "boolean" || typeof v === "bigint")
return String(v);
if (typeof v === "symbol") return v.toString();
return "";
}
function buildSign(payload, secretKey) {
const str = Object.keys(payload)
.filter((k) => k !== "sign" && payload[k] != null && payload[k] !== "")
.sort()
.map((k) => `${k}=${signFieldValue(payload[k])}`)
.join("&");
return createHmac("sha256", secretKey).update(str).digest("hex");
}状态说明
| status | 含义 |
|---|---|
| pending | 待付款 / 待对账 |
| processing | 对账处理中 |
| success | 流水匹配成功 |
| failed | 失败终态 |
| expired | 超时;不发 notify |
| anomaly | 对账前置条件异常等 |
常见错误
400— 参数、sign(missing/invalid sign)、notifyUrl 校验失败401— apiKey 无效409— orderId(商户单号)重复;或active_reconcile_order_exists(不浮动金额下同付款账号已有同额待付单,响应含前单 data)429— 创建/查询过于频繁
notifyUrl 须为 HTTPS,且通过平台 SSRF 校验(生产勿关闭校验)。
联调清单
- □后台配置 notifyUrl(HTTPS)与 secretKey
- □商户组开通 orders / accounts / transactions / transfers 等权限
- □实现 buildSign / 验签,创建订单与 POST /api/transfers 均带 sign
- □收银台或自有页面展示 targetAccount 与 reconcileAmount
- □notify 接口幂等处理,验签后返回纯文本 success
- □用 GET /api/orders/:id 与 callback-logs 排障
环境变量 NEXT_PUBLIC_XCORE_API_BASE 可配置文档中的 API 根地址(当前展示:test-frontend.xman.com)。