方传智付

开放 API 参考

统一前缀 /openapi/v1,HMAC-SHA256 签名鉴权。本文档由服务端单一数据源渲染,与实现保持一致。

1. 基本信息

  • 统一前缀(Base Path):/openapi/v1
  • 服务地址示例:http://localhost:3000(生产请替换为平台域名)
  • 鉴权:HMAC-SHA256 签名 + 请求头传递
  • 响应结构:{"code":"0","message":"success","data":{...},"request_id":"..."};错误:{"code":"E1002","message":"...","request_id":"..."}
  • 金额:返回双字段 xxx_fen(整数分)与 xxx(元字符串);入参可用 amount_fen 或 amount

2. 鉴权与签名

每个请求须在请求头携带以下四项,sign 由它们 + 业务参数按规则算出:

请求头说明
X-App-Id应用 ID
X-Timestamp秒级时间戳(10 位),与服务端偏差 ≤ 300 秒
X-Nonce随机串(8~64 位字母/数字/连字符),5 分钟内不可重复
X-Sign签名值(小写十六进制)

签名算法

  1. 收集待签名参数 = 业务参数(query + body 顶层标量)+ 公共参数 app_id/timestamp/nonce;
  2. 追加 method(大写)与 path(完整路径,含 /openapi/v1,不含 query);
  3. 剔除 sign、空字符串、对象/数组字段;
  4. 按参数名 ASCII 升序拼接 k1=v1&k2=v2(值不 URL 编码,统一转字符串);
  5. sign = HMAC-SHA256(canonical, app_secret).hexdigest()(小写十六进制)。

注意:app_id/timestamp/nonce 既用于签名也放在请求头;签名串里的 path 必须是完整路径。

3. 接口列表

方法路径所需 Scope
GET /openapi/v1/ping 无(仍需签名)
GET /openapi/v1/system/status 无(仍需签名)
GET /openapi/v1/balance balance.read
GET /openapi/v1/transactions transactions.read
POST /openapi/v1/pay pay.debit
POST /openapi/v1/recharge wallet.recharge
GET /openapi/v1/kyc/status kyc.read

4. 接口详情

GET /openapi/v1/ping

校验签名与鉴权是否通过,并返回服务器时间,可用于本地时间校准。无需业务 Scope,但仍需签名。

响应 data 字段

字段类型说明
app_idstring当前应用标识
server_timeinteger服务器秒级时间戳

响应示例

{
  "code": "0",
  "message": "success",
  "data": {
    "app_id": "fcxxxxxxxx",
    "server_time": 1700000000
  },
  "request_id": "req-xxx"
}

GET /openapi/v1/system/status

返回全局支付服务状态,供第三方在前端展示「暂停 / 正常」提示。无需业务 Scope。

响应 data 字段

字段类型说明
statusstringnormal=正常;halted=全局止付(支付服务暂停)

响应示例

{
  "code": "0",
  "message": "success",
  "data": {
    "status": "normal"
  },
  "request_id": "req-xxx"
}

GET /openapi/v1/balance

按 user_id 或 phone 查询用户账户余额与实名 / 账户状态。

必填:user_id 或 phone 至少其一

参数

名称位置类型必填说明
user_idqueryinteger否用户 ID(与 phone 二选一)
phonequerystring否注册手机号,1[3-9] 开头 11 位

响应 data 字段

字段类型说明
user_idinteger用户 ID
balance_feninteger余额(整数分)
balancestring余额(元,字符串,2 位小数)
currencystring币种,固定 CNY
kyc_statusstring实名状态:verified / pending / failed / none
statusstring账户状态:active / frozen / disabled

响应示例

{
  "code": "0",
  "message": "success",
  "data": {
    "user_id": 12345,
    "balance_fen": 50000,
    "balance": "500.00",
    "currency": "CNY",
    "kyc_status": "verified",
    "status": "active"
  },
  "request_id": "req-xxx"
}

可能错误码:`E1001`、`E1002`、`E1004`、`E2001`、`E4002`、`E4003`

GET /openapi/v1/transactions

分页查询指定用户的交易流水,可按日期区间过滤。

必填:user_id 或 phone 至少其一

参数

名称位置类型必填说明
user_idqueryinteger否用户 ID(与 phone 二选一)
phonequerystring否注册手机号
pagequeryinteger否页码,1~1000,默认 1
page_sizequeryinteger否每页条数,1~50,默认 20
fromquerystring否起始日期 YYYY-MM-DD
toquerystring否结束日期 YYYY-MM-DD

响应 data 字段

字段类型说明
user_idinteger用户 ID
totalinteger总记录数
pageinteger当前页
page_sizeinteger每页条数
listarray流水数组
list[].tx_nostring交易号
list[].typestring类型:pay/transfer/recharge/...
list[].directionstringcredit=入账 / debit=出账
list[].amount_feninteger金额(分)
list[].amountstring金额(元)
list[].statusstring交易状态
list[].remarkstring备注
list[].created_atstring时间(ISO)

响应示例

{
  "code": "0",
  "message": "success",
  "data": {
    "user_id": 12345,
    "total": 1,
    "page": 1,
    "page_size": 20,
    "list": [
      {
        "tx_no": "RC...",
        "type": "recharge",
        "direction": "credit",
        "amount_fen": 5000,
        "amount": "50.00",
        "status": "success",
        "remark": "",
        "created_at": "2026-01-01T00:00:00.000Z"
      }
    ]
  },
  "request_id": "req-xxx"
}

可能错误码:`E1001`、`E1002`、`E1004`、`E2001`、`E4002`、`E4003`

POST /openapi/v1/pay

从指定用户余额扣减金额(代付)。out_trade_no 为幂等键,重复提交同号返回首次结果(replayed=true)。

必填:user_id/phone 至少其一;amount_fen/amount 至少其一

参数

名称位置类型必填说明
user_idbodyinteger否目标用户 ID(与 phone 二选一)
phonebodystring否目标用户手机号
amount_fenbodyinteger否金额(分,正整数,上限 100000000)
amountbodystring否金额(元,格式 \d{1,7}(\.\d{1,2})?)
out_trade_nobodystring是商户订单号,6~64 位字母/数字/下划线/连字符,幂等键
remarkbodystring否备注,≤60 字

响应 data 字段

字段类型说明
tx_nostring平台交易号
user_idinteger用户 ID
amount_feninteger本次金额(分)
amountstring本次金额(元)
balance_feninteger交易后余额(分)
statusstring交易状态
replayedboolean是否因幂等复用已有结果

响应示例

{
  "code": "0",
  "message": "success",
  "data": {
    "tx_no": "RC...",
    "user_id": 12345,
    "amount_fen": 5000,
    "amount": "50.00",
    "balance_fen": 45000,
    "status": "success",
    "replayed": false
  },
  "request_id": "req-xxx"
}

可能错误码:`E1001`、`E1002`、`E1004`、`E2001`、`E3001`、`E3002`、`E3005`、`E4001`、`E4002`、`E4003`、`E9001`、`E5000`

POST /openapi/v1/recharge

为指定用户增加余额(入账)。out_trade_no 为幂等键,重复提交同号返回首次结果(replayed=true)。

必填:user_id/phone 至少其一;amount_fen/amount 至少其一

参数

名称位置类型必填说明
user_idbodyinteger否目标用户 ID(与 phone 二选一)
phonebodystring否目标用户手机号
amount_fenbodyinteger否金额(分,正整数)
amountbodystring否金额(元)
out_trade_nobodystring是商户订单号,6~64 位,幂等键
remarkbodystring否备注,≤60 字

响应 data 字段

字段类型说明
tx_nostring平台交易号
user_idinteger用户 ID
amount_feninteger本次金额(分)
amountstring本次金额(元)
balance_feninteger交易后余额(分)
statusstring交易状态
replayedboolean是否因幂等复用已有结果

响应示例

{
  "code": "0",
  "message": "success",
  "data": {
    "tx_no": "RC...",
    "user_id": 12345,
    "amount_fen": 5000,
    "amount": "50.00",
    "balance_fen": 55000,
    "status": "success",
    "replayed": false
  },
  "request_id": "req-xxx"
}

可能错误码:`E1001`、`E1002`、`E1004`、`E2001`、`E3002`、`E3005`、`E4001`、`E4002`、`E4003`、`E5000`

GET /openapi/v1/kyc/status

按 user_id 或 phone 查询用户实名认证状态。

必填:user_id 或 phone 至少其一

参数

名称位置类型必填说明
user_idqueryinteger否用户 ID(与 phone 二选一)
phonequerystring否注册手机号

响应 data 字段

字段类型说明
user_idinteger用户 ID
kyc_statusstring实名状态:verified / pending / failed / none
verifiedboolean是否已实名(kyc_status==verified)

响应示例

{
  "code": "0",
  "message": "success",
  "data": {
    "user_id": 12345,
    "kyc_status": "verified",
    "verified": true
  },
  "request_id": "req-xxx"
}

可能错误码:`E1001`、`E1002`、`E1004`、`E2001`、`E4002`、`E4003`

5. 回调通知(Webhook)

平台在资金变动 / 实名状态变更时,向应用配置的 callback_url 发送 POST application/json。

头值
Content-Typeapplication/json
X-FC-App-Id应用 ID
X-FC-Timestamp秒级时间戳
X-FC-Nonce随机串
X-FC-SignatureHMAC-SHA256(timestamp.nonce.rawBody, app_secret) 小写十六进制
X-FC-Event-Id事件唯一 ID(幂等去重用)

验签规则:signature = HMAC-SHA256(`${timestamp}.${nonce}.${rawBody}`, app_secret) 的小写十六进制;rawBody 为请求原文(未解析的 JSON 字符串)

重试策略:第三方必须按 X-FC-Event-Id 做幂等处理;平台失败按 10s/60s/300s/1800s/7200s 指数退避重试,最多 5 次

事件类型

  • transaction.success:{"tx_no":"RC...","type":"pay","status":"success","user_id":12345,"direction":"debit","amount_fen":5000,"amount":"50.00","balance_fen":45000,"balance":"450.00","remark":"","created_at":"2026-01-01T00:00:00.000Z"}
  • kyc.status_changed:{"user_id":12345,"kyc_status":"verified"}

6. 错误码

错误码HTTP说明
0200成功
E1001400参数错误(字段缺失或格式不正确)
E1002400业务校验失败(如兑换码已被使用、用户状态不允许)
E1004404资源不存在(用户 / 接口不存在)
E2001401未登录 / 未携带有效鉴权
E3001400余额不足
E3002400交易金额超出限额
E3005403账户被冻结 / 状态不允许该操作
E4001429调用频率超出应用 QPS 配额
E4002401接口鉴权失败(缺参数 / 时间戳过期 / 签名错误 / 应用停用)
E4003403应用缺少对应 Scope 权限
E4004403开放接口已全局关闭
E9001403支付服务暂停(全局 / 单用户止付),/pay 在止付期间返回此码
E5000500系统繁忙,请稍后重试(详情写入服务端日志)

7. SDK 示例

Node.js(零依赖)

'use strict';

/**
 * 方传智付 开放 API —— Node.js 接入示例(零依赖,仅用内置 crypto / https)
 * ---------------------------------------------------------------------------
 * 用法:
 *   export FC_APP_ID=fcxxxxxxxx FC_APP_SECRET=sk_xxxx FC_HOST=http://localhost:3000
 *   node sdk-node.js
 *
 * 签名规则与服务端 utils/sign.js 完全一致:
 *   1. 业务参数(query + body 顶层标量)+ 公共参数 app_id / timestamp / nonce;
 *   2. 追加 method(大写)、path(完整路径,含 /openapi/v1);
 *   3. 剔除 sign、空值、对象/数组;
 *   4. 按参数名 ASCII 升序拼接 k=v(值不做 URL 编码);
 *   5. sign = HMAC-SHA256(canonical, app_secret).hexdigest() 小写。
 */

const crypto = require('crypto');

const CONFIG = {
  host: process.env.FC_HOST || 'http://localhost:3000',
  appId: process.env.FC_APP_ID || 'YOUR_APP_ID',
  appSecret: process.env.FC_APP_SECRET || 'YOUR_APP_SECRET'
};

/** 构造待签名串(与服务端一致) */
function buildCanonical(params, method, path) {
  const merged = {};
  for (const [k, v] of Object.entries(params || {})) {
    if (k === 'sign') continue;
    if (v === undefined || v === null || v === '') continue;
    if (typeof v !== 'string' && typeof v !== 'number' && typeof v !== 'boolean') continue;
    merged[k] = String(v);
  }
  merged.method = String(method || 'GET').toUpperCase();
  merged.path = String(path || '');
  return Object.keys(merged)
    .sort()
    .map((k) => `${k}=${merged[k]}`)
    .join('&');
}

/** 计算签名 */
function sign(params, method, path, secret) {
  const canonical = buildCanonical(params, method, path);
  return crypto.createHmac('sha256', secret).update(canonical).digest('hex');
}

/**
 * 发起一次 API 调用
 * @param {string} method  HTTP 方法
 * @param {string} path    完整路径,例如 /openapi/v1/balance
 * @param {object} query   查询参数
 * @param {object} body    JSON 请求体(POST)
 */
function request(method, path, query, body) {
  const qs = query || {};
  const bs = body || {};
  const timestamp = String(Math.floor(Date.now() / 1000));
  const nonce = crypto.randomBytes(12).toString('hex');

  // 服务端会把 query+body+公共参数一起参与签名,因此这里也要并入
  const params = { ...qs, ...bs, app_id: CONFIG.appId, timestamp, nonce };
  const sig = sign(params, method, path, CONFIG.appSecret);

  const url = new URL(CONFIG.host.replace(/\/$/, '') + path);
  for (const [k, v] of Object.entries(qs)) url.searchParams.set(k, String(v));

  const payload = Object.keys(bs).length ? JSON.stringify(bs) : null;
  const headers = {
    'X-App-Id': CONFIG.appId,
    'X-Timestamp': timestamp,
    'X-Nonce': nonce,
    'X-Sign': sig,
    'Content-Type': 'application/json'
  };

  return new Promise((resolve, reject) => {
    const req = require('https').request(
      {
        hostname: url.hostname,
        port: url.port || 443,
        path: url.pathname + url.search,
        method,
        headers
      },
      (res) => {
        let data = '';
        res.on('data', (c) => (data += c));
        res.on('end', () => {
          let parsed = null;
          try { parsed = data ? JSON.parse(data) : null; } catch (e) { parsed = data; }
          resolve({ status: res.statusCode, body: parsed });
        });
      }
    );
    req.on('error', reject);
    if (payload) req.write(payload);
    req.end();
  });
}

/* ------------------------------- 业务示例 ------------------------------- */

async function main() {
  console.log('== ping ==');
  console.log(await request('GET', '/openapi/v1/ping'));

  console.log('== 查询余额(按手机号)==');
  console.log(await request('GET', '/openapi/v1/balance', { phone: '13800000001' }));

  console.log('== 代付(扣款)==');
  console.log(
    await request('POST', '/openapi/v1/pay', null, {
      phone: '13800000001',
      amount: '10.00',
      out_trade_no: 'ORDER_' + Date.now(),
      remark: 'SDK 示例代付'
    })
  );

  console.log('== 入账(充值)==');
  console.log(
    await request('POST', '/openapi/v1/recharge', null, {
      phone: '13800000001',
      amount_fen: 5000,
      out_trade_no: 'RECH_' + Date.now(),
      remark: 'SDK 示例入账'
    })
  );
}

if (require.main === module) {
  main().catch((e) => {
    console.error('调用失败:', e.message);
    process.exit(1);
  });
}

module.exports = { buildCanonical, sign, request, CONFIG };

Python(仅标准库)

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
方传智付 开放 API —— Python 接入示例(零依赖,仅用标准库)
---------------------------------------------------------------------------
用法:
    export FC_APP_ID=fcxxxxxxxx FC_APP_SECRET=sk_xxxx FC_HOST=http://localhost:3000
    python3 sdk-python.py

签名规则与服务端 utils/sign.js 完全一致:
  1. 业务参数(query + body 顶层标量)+ 公共参数 app_id / timestamp / nonce;
  2. 追加 method(大写)、path(完整路径,含 /openapi/v1);
  3. 剔除 sign、空值、对象/数组;
  4. 按参数名 ASCII 升序拼接 k=v(值不做 URL 编码);
  5. sign = HMAC-SHA256(canonical, app_secret).hexdigest() 小写。
"""

import hashlib
import hmac
import json
import os
import time
import urllib.request
import urllib.parse
import urllib.error

CONFIG = {
    "host": os.environ.get("FC_HOST", "http://localhost:3000"),
    "app_id": os.environ.get("FC_APP_ID", "YOUR_APP_ID"),
    "app_secret": os.environ.get("FC_APP_SECRET", "YOUR_APP_SECRET"),
}


def build_canonical(params, method, path):
    """构造待签名串(与服务端一致)"""
    merged = {}
    for k, v in (params or {}).items():
        if k == "sign":
            continue
        if v is None or v == "":
            continue
        if not isinstance(v, (str, int, float, bool)):
            continue
        merged[k] = str(v)
    merged["method"] = str(method or "GET").upper()
    merged["path"] = str(path or "")
    return "&".join("{}={}".format(k, merged[k]) for k in sorted(merged))


def sign(params, method, path, secret):
    canonical = build_canonical(params, method, path)
    return hmac.new(secret.encode("utf-8"), canonical.encode("utf-8"), hashlib.sha256).hexdigest()


def request(method, path, query=None, body=None):
    """
    发起一次 API 调用
    :param method: HTTP 方法
    :param path:   完整路径,例如 /openapi/v1/balance
    :param query:  查询参数字典
    :param body:   JSON 请求体(POST 用)
    """
    q = query or {}
    b = body or {}
    timestamp = str(int(time.time()))
    import os as _os
    nonce = _os.urandom(12).hex()

    # 服务端会把 query+body+公共参数一起参与签名,因此这里也要并入
    params = {**q, **b, "app_id": CONFIG["app_id"], "timestamp": timestamp, "nonce": nonce}
    sig = sign(params, method, path, CONFIG["app_secret"])

    url = CONFIG["host"].rstrip("/") + path
    if q:
        url += "?" + urllib.parse.urlencode(q)
    data = json.dumps(b).encode("utf-8") if b else None

    req = urllib.request.Request(url, data=data, method=method)
    req.add_header("X-App-Id", CONFIG["app_id"])
    req.add_header("X-Timestamp", timestamp)
    req.add_header("X-Nonce", nonce)
    req.add_header("X-Sign", sig)
    req.add_header("Content-Type", "application/json")

    try:
        with urllib.request.urlopen(req, timeout=10) as resp:
            raw = resp.read().decode("utf-8")
            return {"status": resp.status, "body": json.loads(raw) if raw else None}
    except urllib.error.HTTPError as e:
        raw = e.read().decode("utf-8")
        return {"status": e.code, "body": json.loads(raw) if raw else None}


def main():
    print("== ping ==")
    print(request("GET", "/openapi/v1/ping"))

    print("== 查询余额(按手机号)==")
    print(request("GET", "/openapi/v1/balance", {"phone": "13800000001"}))

    print("== 代付(扣款)==")
    print(request("POST", "/openapi/v1/pay", None, {
        "phone": "13800000001",
        "amount": "10.00",
        "out_trade_no": "ORDER_" + str(int(time.time())),
        "remark": "SDK 示例代付",
    }))

    print("== 入账(充值)==")
    print(request("POST", "/openapi/v1/recharge", None, {
        "phone": "13800000001",
        "amount_fen": 5000,
        "out_trade_no": "RECH_" + str(int(time.time())),
        "remark": "SDK 示例入账",
    }))


if __name__ == "__main__":
    main()