开放 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 | 签名值(小写十六进制) |
签名算法
- 收集待签名参数 = 业务参数(query + body 顶层标量)+ 公共参数
app_id/timestamp/nonce; - 追加
method(大写)与path(完整路径,含/openapi/v1,不含 query); - 剔除
sign、空字符串、对象/数组字段; - 按参数名 ASCII 升序拼接
k1=v1&k2=v2(值不 URL 编码,统一转字符串); 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_id | string | 当前应用标识 |
server_time | integer | 服务器秒级时间戳 |
响应示例
{
"code": "0",
"message": "success",
"data": {
"app_id": "fcxxxxxxxx",
"server_time": 1700000000
},
"request_id": "req-xxx"
}
GET /openapi/v1/system/status
返回全局支付服务状态,供第三方在前端展示「暂停 / 正常」提示。无需业务 Scope。
响应 data 字段
| 字段 | 类型 | 说明 |
|---|---|---|
status | string | normal=正常;halted=全局止付(支付服务暂停) |
响应示例
{
"code": "0",
"message": "success",
"data": {
"status": "normal"
},
"request_id": "req-xxx"
}
GET /openapi/v1/balance
按 user_id 或 phone 查询用户账户余额与实名 / 账户状态。
必填:user_id 或 phone 至少其一
参数
| 名称 | 位置 | 类型 | 必填 | 说明 |
|---|---|---|---|---|
user_id | query | integer | 否 | 用户 ID(与 phone 二选一) |
phone | query | string | 否 | 注册手机号,1[3-9] 开头 11 位 |
响应 data 字段
| 字段 | 类型 | 说明 |
|---|---|---|
user_id | integer | 用户 ID |
balance_fen | integer | 余额(整数分) |
balance | string | 余额(元,字符串,2 位小数) |
currency | string | 币种,固定 CNY |
kyc_status | string | 实名状态:verified / pending / failed / none |
status | string | 账户状态: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_id | query | integer | 否 | 用户 ID(与 phone 二选一) |
phone | query | string | 否 | 注册手机号 |
page | query | integer | 否 | 页码,1~1000,默认 1 |
page_size | query | integer | 否 | 每页条数,1~50,默认 20 |
from | query | string | 否 | 起始日期 YYYY-MM-DD |
to | query | string | 否 | 结束日期 YYYY-MM-DD |
响应 data 字段
| 字段 | 类型 | 说明 |
|---|---|---|
user_id | integer | 用户 ID |
total | integer | 总记录数 |
page | integer | 当前页 |
page_size | integer | 每页条数 |
list | array | 流水数组 |
list[].tx_no | string | 交易号 |
list[].type | string | 类型:pay/transfer/recharge/... |
list[].direction | string | credit=入账 / debit=出账 |
list[].amount_fen | integer | 金额(分) |
list[].amount | string | 金额(元) |
list[].status | string | 交易状态 |
list[].remark | string | 备注 |
list[].created_at | string | 时间(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_id | body | integer | 否 | 目标用户 ID(与 phone 二选一) |
phone | body | string | 否 | 目标用户手机号 |
amount_fen | body | integer | 否 | 金额(分,正整数,上限 100000000) |
amount | body | string | 否 | 金额(元,格式 \d{1,7}(\.\d{1,2})?) |
out_trade_no | body | string | 是 | 商户订单号,6~64 位字母/数字/下划线/连字符,幂等键 |
remark | body | string | 否 | 备注,≤60 字 |
响应 data 字段
| 字段 | 类型 | 说明 |
|---|---|---|
tx_no | string | 平台交易号 |
user_id | integer | 用户 ID |
amount_fen | integer | 本次金额(分) |
amount | string | 本次金额(元) |
balance_fen | integer | 交易后余额(分) |
status | string | 交易状态 |
replayed | boolean | 是否因幂等复用已有结果 |
响应示例
{
"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_id | body | integer | 否 | 目标用户 ID(与 phone 二选一) |
phone | body | string | 否 | 目标用户手机号 |
amount_fen | body | integer | 否 | 金额(分,正整数) |
amount | body | string | 否 | 金额(元) |
out_trade_no | body | string | 是 | 商户订单号,6~64 位,幂等键 |
remark | body | string | 否 | 备注,≤60 字 |
响应 data 字段
| 字段 | 类型 | 说明 |
|---|---|---|
tx_no | string | 平台交易号 |
user_id | integer | 用户 ID |
amount_fen | integer | 本次金额(分) |
amount | string | 本次金额(元) |
balance_fen | integer | 交易后余额(分) |
status | string | 交易状态 |
replayed | boolean | 是否因幂等复用已有结果 |
响应示例
{
"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_id | query | integer | 否 | 用户 ID(与 phone 二选一) |
phone | query | string | 否 | 注册手机号 |
响应 data 字段
| 字段 | 类型 | 说明 |
|---|---|---|
user_id | integer | 用户 ID |
kyc_status | string | 实名状态:verified / pending / failed / none |
verified | boolean | 是否已实名(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-Type | application/json |
X-FC-App-Id | 应用 ID |
X-FC-Timestamp | 秒级时间戳 |
X-FC-Nonce | 随机串 |
X-FC-Signature | HMAC-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 | 说明 |
|---|---|---|
0 | 200 | 成功 |
E1001 | 400 | 参数错误(字段缺失或格式不正确) |
E1002 | 400 | 业务校验失败(如兑换码已被使用、用户状态不允许) |
E1004 | 404 | 资源不存在(用户 / 接口不存在) |
E2001 | 401 | 未登录 / 未携带有效鉴权 |
E3001 | 400 | 余额不足 |
E3002 | 400 | 交易金额超出限额 |
E3005 | 403 | 账户被冻结 / 状态不允许该操作 |
E4001 | 429 | 调用频率超出应用 QPS 配额 |
E4002 | 401 | 接口鉴权失败(缺参数 / 时间戳过期 / 签名错误 / 应用停用) |
E4003 | 403 | 应用缺少对应 Scope 权限 |
E4004 | 403 | 开放接口已全局关闭 |
E9001 | 403 | 支付服务暂停(全局 / 单用户止付),/pay 在止付期间返回此码 |
E5000 | 500 | 系统繁忙,请稍后重试(详情写入服务端日志) |
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()