收银台 Demo/商户 API 对接

商户 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 个开放接口;本地联调基址默认 https://api.example.com

方法路径权限
GET/health
GET/api/devicesdevices:list
GET/PATCH/api/accounts…accounts:*
POST/GET/api/orders…orders:*
GET/api/transactions…transactions:*
GET/POST/api/transfers…transfers:*

对接流程

  1. 在 Admin 商户详情获取 apiKeysk_api_…)、secretKeysk_sign_…),配置默认 notifyUrl(HTTPS)。系统 Webhook 验签密钥为 sk_hook_…(Admin → Webhooks)。
  2. POST /api/orders 创建订单(带 sign),保存响应中的 id(UUID)、orderId(商户单号,1–64 位)、platformOrderId(平台 20 位)、targetAccountreconcileAmount。不浮动金额下同付款账号已有同额待付单时返回 409,用响应 data 继续原单支付。
  3. 引导用户向 targetAccount 转入正确金额(收银台展示 reconcileAmount ?? amount)。
  4. 平台定时拉银行流水对账;匹配成功后订单为 success
  5. 平台 POST 你的 notifyUrl;验签后处理业务,响应体为纯文本 success(非 JSON 字符串)。
  6. 也可用 GET /api/orders/:id 主动查询(注意限频)。

订单 expired 时不会发送 notify。

鉴权

每个请求携带:

Header
x-api-key: <商户 apiKey,如 sk_api_...>

无效或缺失返回 401 Unauthorized

另需商户组勾选接口权限,例如 orders:createorders:readtransfers:create 等;未授权返回 403

创建订单

POST /api/orders · Content-Type: application/json · 成功 201

字段必填说明
orderId1–64 位可打印 ASCII;同商户唯一;验签按 JSON 字面
fromAccount付款账号(验签保留前导零等字面)
amount名义金额 > 0;验签按 JSON 原文(5.00 ≠ 5)
title订单标题
sign见「签名算法」
notifyUrl本单通知地址;与后台默认二选一,不能都为空
returnUrl浏览器跳转(不参与 notify 签名)
app渠道如 mymo / bbl;限定在该 app 账户池内选收款账号
targetAccount指定收款账号;须与 app 同传,不可单独传
param扩展 JSON;验签为 JSON 原文子串

响应 data idorderIdplatformOrderIdtargetAccountreconcileAmountexpiresAtreconcileUntilAtstatus 等。

收款账号选号:均不传 app / targetAccount → 全池轮询;仅 app → 该 app 子池轮询;app + targetAccount → 精确匹配;仅 targetAccount → 400。

不浮动金额(base_mode=none)时,同渠道、收款户、付款账号下若已有相同应付金额的待处理订单,返回 409,body 含 error: active_reconcile_order_exists data(前单详情,字段与 201 一致),可据此引导用户继续支付原单。

成功 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"
  }
}
同额待付冲突 409 响应示例
{
  "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 示例
curl -sS -X POST "https://api.example.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 可选 appenablecitizenIdaccountpagelimit。响应字段含 citizenIdaccountenable(无对外 status)。

GET /api/accounts/:id PATCH /api/accounts/:id(body 仅 enable)— 权限分别为 accounts:read / accounts:write

流水

GET /api/transactions — Query 中 appaccount 均可选;分页 page / limit

GET /api/transactions/:id — 路径为列表 items[].id

发起转账

POST /api/transfers · Content-Type: application/json · 成功 201 · 权限 transfers:create

字段必填参与签名说明
sign见「签名算法」
app渠道如 mymo / bbl;须与 account 在商户池内匹配
account业务出款账号;须在商户已启用 account 池
accountTo收款账号
amount转账金额(THB),> 0
type默认 bank;JSON 中未传则不参与签名
typeCodebank 时必填非空时银行码(如 GSB 为 30)
timeoutMs非空时gateway 超时毫秒

成功 data idcitizenIdaccounttransRefCode、出收款账号等。gateway 失败返回 502,body 可含 data.phase(prePost/post)。

本仓库转账 Demo:/transfer → 切换 app 后从 BFF GET /api/banks 加载银行列表,typeCode 取 codeId;提交经 POST /api/transfer(服务端算 sign,app、account 必填)。

curl 示例
curl -sS -X POST "https://api.example.com/api/transfers" \
  -H "x-api-key: sk_api_xxx" \
  -H "content-type: application/json" \
  -d '{
    "app": "mymo",
    "account": "020474502919",
    "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)。可选字段 reconcileAmountplatformOrderId 有则参与;不含 appOrderNo

签名算法

创建订单、发起转账与异步通知均使用 HMAC-SHA256 hex 小写。 建单与转账验签按 POST JSON 原文,异步通知按解析后的 signFieldValue

  1. sign 外,取 value 非 null 且非空字符串的字段
  2. 键名按字典序排序
  3. 拼接 key=value,用 & 连接
  4. HMAC-SHA256(secretKey, UTF-8),输出 hex 小写

创建订单 / 发起转账:对最终 POST 的 JSON 字符串提取顶层参与签名字段。字符串取 JSON 解码值;数字取原文 token(如 5.00);对象/数组取 JSON 原文子串(保留键序)。勿对内存对象 JSON.stringify 后再签。
建单字段:orderId、fromAccount、amount、title 及非空的 app、targetAccount、returnUrl、notifyUrl、param。
转账字段:account、accountTo、amount、app 及非空的 type、typeCode、timeoutMs。
异步通知:以平台 POST 的 JSON 为准,signFieldValue 规则(amount 为字符串等)。

Node.js
import { createHmac, timingSafeEqual } from "node:crypto";

// 异步通知 / Webhook:对已解析对象使用 signFieldValue
function signFieldValue(v) {
  if (v === null || v === undefined) return "";
  if (typeof v === "object") return signJsonValue(v);
  if (typeof v === "string") return v;
  if (typeof v === "number" || typeof v === "boolean" || typeof v === "bigint")
    return String(v);
  return "";
}

function signJsonValue(v) {
  if (v === null || v === undefined) return "null";
  if (typeof v === "boolean") return v ? "true" : "false";
  if (typeof v === "number") return Number.isFinite(v) ? String(v) : "null";
  if (typeof v === "string") return JSON.stringify(v);
  if (Array.isArray(v)) return `[${v.map(signJsonValue).join(",")}]`;
  if (typeof v === "object") {
    const keys = Object.keys(v).sort();
    return `{${keys.map((k) => `${JSON.stringify(k)}:${signJsonValue(v[k])}`).join(",")}}`;
  }
  return JSON.stringify(v);
}

function buildSign(payload, secretKey) {
  const str = Object.keys(payload)
    .filter((k) => k !== "sign" && payload[k] != null && payload[k] !== "")
    .sort()
    .map((k) => `${k}=${payload[k]}`)
    .join("&");
  return createHmac("sha256", secretKey).update(str).digest("hex");
}

// 创建订单 / 发起转账:对「即将 POST 的 JSON 原文」提取顶层字段
const CREATE_ORDER_SIGN_FIELDS = [
  "orderId", "fromAccount", "amount", "title",
  "app", "targetAccount", "returnUrl", "notifyUrl", "param",
];
const TRANSFER_SIGN_FIELDS = [
  "account", "accountTo", "amount", "app", "type", "typeCode", "timeoutMs",
];

function buildSignFromRawJson(rawJsonWithoutSign, fields, secretKey) {
  const fieldsMap = extractTopLevelJsonFields(rawJsonWithoutSign);
  const payload = {};
  for (const k of fields) {
    const t = fieldsMap[k];
    if (t !== undefined && t !== "") payload[k] = t;
  }
  return buildSign(payload, secretKey);
}

// 示例:amount=5.00 须保留两位
const rawOrder =
  '{"orderId":"ST1","fromAccount":"0123456789","amount":5.00,"title":"Pay"}';
const orderSign = buildSignFromRawJson(rawOrder, CREATE_ORDER_SIGN_FIELDS, process.env.MERCHANT_SECRET);

const rawTransfer =
  '{"account":"020485448797","accountTo":"020473056651","amount":5.00,"app":"mymo","type":"bank","typeCode":"030"}';
const transferSign = buildSignFromRawJson(rawTransfer, TRANSFER_SIGN_FIELDS, process.env.MERCHANT_SECRET);

状态说明

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 根地址(当前展示:https://api.example.com)。