四方支付商户接口对接文档
本文档提供给商户服务端系统对接,用于接入平台下单、查单和订单状态通知。除固定联调产品编码 0001 外,所有示例中的商户号、密钥、产品编码和域名均为占位内容,实际值以平台后台提供为准。
1. 对接资料
平台运营会给商户技术或对接系统提供以下资料:
| 名称 | 说明 |
|---|---|
| mchId | 商户号。每个商户唯一,下单、查单和验签都需要使用。 |
| secretKey | 商户签名密钥。运营后台在开户或重置密钥时展示;绑定商户群内的授权管理员/操作人执行开户或查询对接信息后,机器人会将密钥以折叠遮罩写入当前商户群。请妥善保存并勿转发,只在商户服务端使用。 |
| productId | 产品编码。固定联调产品 0001 会自动绑定全部现有及后续新增商户,费率固定为0且不可修改或解绑;平台内部上游测试编码 0002 不向正式商户开放。 |
| 下单地址 | {平台域名}/api/v1/pay/create_order |
| 查单地址 | {平台域名}/api/v1/pay/query_order |
| 回调IP | 平台通知商户时的公网 IPv4 出口。商户如果做通知来源白名单,应放行平台提供的全部 IPv4;无需配置 IPv6。 |
| 商户API白名单 | 商户调用下单/查单接口的服务器公网 IP,需要提前提供给平台配置。 |
notifyUrl 传入本笔订单的通知地址。
2. 编码、URL 解码与字段约束
请求和通知均使用 application/x-www-form-urlencoded; charset=UTF-8。签名与业务处理都以服务端完成一次 Form URL 解码后的字段名和值为准,不以 HTTP 原始编码串为准。
- 客户端先使用 UTF-8 表示逻辑字段值,再按 Form 规则编码。
%XX在服务端只解码一次;+解为一个空格,字面加号必须编码为%2B。 - 计算签名时使用解码后的原始值。例如标题为
商品 A+B时,参与签名的是包含空格和+的 UTF-8 值,不是%E5...或+的传输表达。 - 同一个字段只能提交一次;只要出现重复字段,服务端就会拒绝整个请求,不会取第一个或最后一个值。
- 字段名区分大小写,只有小写
sign被排除在签名外。不要发送未在本文档声明的非空字段,它们会参与签名但不构成业务字段。 - 所有下表字段必须是有效 UTF-8,且不得含控制字符(包括 TAB、CR、LF、NUL)。标识字段
mchId、mchOrderNo、productId不得有前导或尾随空白。 - 除签名字段外,空白不会在签名阶段自动删除;空字符串才会被排除。请不要依赖服务端对业务字段的后续 trim 行为。
| 字段 | 最大值 | 补充约束 |
|---|---|---|
| mchId | 64 UTF-8 字节 | 必填标识,不得有首尾空白。 |
| mchOrderNo | 128 UTF-8 字节 | 必填标识,不得有首尾空白。 |
| productId | 64 UTF-8 字节 | 必填标识,不得有首尾空白。 |
| amount | 1 至 9,007,199,254,740,991 分 | 仅 ASCII 十进制正整数;上限为 JavaScript Number.MAX_SAFE_INTEGER,避免 JSON 数字在商户系统中被静默舍入。实际通道金额规则通常远小于该值。 |
| notifyUrl | 2048 UTF-8 字节 | 必填;生产环境必须是公网 https 地址,平台会在每次通知前重新校验解析结果。 |
| subject | 128 UTF-8 字节 | 可选订单标题。 |
| body | 512 UTF-8 字节 | 可选订单描述。 |
| param1 / param2 | 各 256 UTF-8 字节 | 可选透传字段。 |
| reqTime | 14 UTF-8 字节 | 固定为 14 位数字 yyyyMMddHHmmss。 |
| signVersion | 16 UTF-8 字节 | 新开户固定必填 v2;只有后台仍明确保留 V1 最低策略的存量商户,省略时才按 v1 兼容。 |
| sign | 64 UTF-8 字节 | 必填;V2 为 64 位大写 HMAC-SHA256,V1 为 32 位大写 MD5。 |
3. 签名规则
新开户统一强制使用 signVersion=v2。存量商户在迁移期间可由管理员保留 V1 最低策略,升级完成后应提升为 V2;平台会记住首次下单使用的签名版本,并用相同版本签署该订单后续通知。查单请求也必须满足该商户当前最低签名策略。
V2:HMAC-SHA256(推荐)
- 参数中明确加入
signVersion=v2,取完成一次 Form URL 解码后的全部参数,只排除小写sign;V2 会保留空字符串字段。 - 按参数名 ASCII 从小到大排序,以 UTF-8 字节处理字段名和值。
- 签名原文先写入固定字节
SFPAY-MERCHANT-SIGN-V2\0;随后对每个字段依次写入:4 字节无符号大端字段名字节长度、字段名字节、4 字节无符号大端值字节长度、值字节。 - 使用商户密钥作为 HMAC key,对完整原文计算 HMAC-SHA256,结果转为大写 64 位十六进制。
reqTime 默认必传并参与签名,格式为 yyyyMMddHHmmss。服务器允许时间误差为 5 分钟,超时会拒绝,防止重放攻击。
V2 Node.js 签名示例
import crypto from 'node:crypto';
function makeSign(params, secretKey) {
const chunks = [Buffer.from('SFPAY-MERCHANT-SIGN-V2\0', 'utf8')];
for (const key of Object.keys(params).filter((k) => k !== 'sign').sort()) {
const keyBytes = Buffer.from(key, 'utf8');
const valueBytes = Buffer.from(String(params[key] ?? ''), 'utf8');
const keyLength = Buffer.alloc(4); keyLength.writeUInt32BE(keyBytes.length);
const valueLength = Buffer.alloc(4); valueLength.writeUInt32BE(valueBytes.length);
chunks.push(keyLength, keyBytes, valueLength, valueBytes);
}
return crypto.createHmac('sha256', Buffer.from(secretKey, 'utf8'))
.update(Buffer.concat(chunks)).digest('hex').toUpperCase();
}
V1:旧 MD5 兼容期
只有后台仍明确保留 V1 最低策略的存量商户,省略 signVersion(或传 v1)时,才按“排除空值与 sign、ASCII 排序、拼接 key=value&...、末尾追加 &key=密钥、计算大写 MD5”验证。为消除旧格式字段边界碰撞,V1 的非空字段名和值不得含 & 或 =,字段名 key 也不允许使用。带查询参数的 notifyUrl 必须升级 V2。新开户和已提升到 V2 策略的商户会直接拒绝 V1。
4. 下单接口
地址:POST {平台域名}/api/v1/pay/create_order
| 字段 | 必填 | 最大值 | 说明 |
|---|---|---|---|
| mchId | 是 | 64 字节 | 商户号;不得有前导或尾随空白。 |
| mchOrderNo | 是 | 128 字节 | 商户订单号。同一商户下必须唯一,且不得有前导或尾随空白。重复请求只有金额、产品、通知地址、标题、描述、param1、param2 和签名版本全部一致时才返回原订单,任何一项变化都会返回 409。 |
| productId | 是 | 64 字节 | 平台提供的产品编码;首次联调固定使用 0001;不得有前导或尾随空白。 |
| amount | 是 | 1 至 9,007,199,254,740,991 分 | 订单金额,单位为分,必须为十进制正整数。例如 100.00 元传 10000。 |
| notifyUrl | 必填 | 2048 字节 | 本笔订单状态通知地址。生产环境必须使用公网 https,且不允许内网、localhost、链路本地地址;本地联调只有在服务器显式开启测试开关时才允许 HTTP。 |
| subject | 否 | 128 字节 | 订单标题。为空时平台会使用默认标题。 |
| body | 否 | 512 字节 | 订单描述。 |
| param1 | 否 | 256 字节 | 商户自定义透传字段,订单状态通知时原样返回。 |
| param2 | 否 | 256 字节 | 商户自定义透传字段,订单状态通知时原样返回。 |
| reqTime | 是 | 14 字节 | 请求时间,固定为 14 位数字格式 yyyyMMddHHmmss,参与签名。 |
| signVersion | 新接入是 | 16 字节 | 固定传 v2;旧接入省略时按 V1。 |
| sign | 是 | 64 字节 | 按第 3 节对应版本生成。 |
请求示例
curl -X POST 'https://pay.example.com/api/v1/pay/create_order' \
-H 'Content-Type: application/x-www-form-urlencoded; charset=UTF-8' \
--data-urlencode 'mchId=M10001' \
--data-urlencode 'mchOrderNo=TEST202607090001' \
--data-urlencode 'productId=0001' \
--data-urlencode 'amount=10000' \
--data-urlencode 'notifyUrl=https://merchant.example.com/pay/notify' \
--data-urlencode 'subject=商品订单' \
--data-urlencode 'param1=merchant-extra-1' \
--data-urlencode 'reqTime=20260709213000' \
--data-urlencode 'signVersion=v2' \
--data-urlencode 'sign=按V2规则计算后的HMAC-SHA256大写值'
成功响应
{
"code": 0,
"message": "ok",
"data": {
"order_no": "SF20260709213000A1B2C3D4",
"merchant_order_no": "TEST202607090001",
"pay_url": "https://pay.example.com/api/v1/pay/open/随机付款令牌",
"amount": 10000,
"status": 0
},
"request_id": "..."
}
code=0 和 status=0 只表示平台已受理,不代表付款成功。商户入账必须以后续通知或查单 status=1 为准。
pay_url 是平台生成的安全打开链接,请原样交给最终付款用户打开。商户下单和查单都不需要、也不接受设备类型字段。平台仅在付款用户实际打开该链接时,根据浏览器 User-Agent 保守识别 iOS、安卓或电脑;无法可靠识别时保持“未知”,不影响跳转和支付。
data.order_created=true 时还会返回原 order_no、merchant_order_no、status 和动作 QUERY_OR_RETRY_SAME_MCH_ORDER_NO。商户必须先查原单,或以完全相同的全部字段和同一个 mchOrderNo 重试;禁止生成新 mchOrderNo,否则原单随后成功时可能造成重复支付。即使网络中断没有拿到响应体,也按同一规则处理。
1000 至 100000 分),不受外部上游通道开关或平台正式业务停单时段影响。商户下单、订单持久化、查单、签名通知和通知重试都走与正式订单相同的线上接口;付款页使用平台内置联调收银台,点击“模拟支付成功”或“模拟支付失败”后,平台会向本笔订单必填的 notifyUrl 真实发送签名通知。0001 不会把正式商户的测试流量发送给外部供应商,也不进入商户余额、供应商余额、对账或经营统计。
5. 查单接口
地址:POST {平台域名}/api/v1/pay/query_order
| 字段 | 必填 | 最大值 | 说明 |
|---|---|---|---|
| mchId | 是 | 64 字节 | 商户号;不得有前导或尾随空白。 |
| mchOrderNo | 是 | 128 字节 | 商户订单号;不得有前导或尾随空白。 |
| reqTime | 是 | 14 字节 | 请求时间,固定为 14 位数字格式 yyyyMMddHHmmss,参与签名。 |
| signVersion | 新接入是 | 16 字节 | 固定传 v2;旧接入省略时按 V1。 |
| sign | 是 | 64 字节 | 按第 3 节对应版本生成。 |
成功响应
{
"code": 0,
"message": "ok",
"data": {
"order_no": "SF20260709213000A1B2C3D4",
"merchant_order_no": "TEST202607090001",
"amount": 10000,
"status": 1,
"pay_url": "https://pay.example.com/api/v1/pay/open/随机付款令牌"
},
"request_id": "..."
}
6. 订单状态通知
订单进入终态后,平台会从已公布的公网 IPv4 出口向商户下单时传入的 notifyUrl 发送 UTF-8 POST Form 通知:交易成功为 status=1,交易失败为 status=2;如果订单之后出现可信的上游成功结果或成功订单被交易冲正,平台会再次发送对应的状态更正通知。进行中状态不会发送终态通知。Content-Type 为 application/x-www-form-urlencoded; charset=UTF-8。商户收到每次通知后都必须按第 3 节对解码后的字段验签、校验金额和订单归属,并按订单号幂等更新状态;处理成功后响应纯文本 success。
status=2,避免订单无限等待。该状态表示“平台等待时限已到”,不代表上游绝不可能晚到成功;平台仍会核验原上游。默认安全策略下,后续经认证的成功结果先进入管理员二次复核,只有管理员同意后平台才把原订单更正为 status=1 并再次通知;管理员拒绝时订单继续保持失败。商户必须允许同一订单在平台复核通过后从 2 更正为 1,同时保证成功入账只执行一次。
| 字段 | 说明 |
|---|---|
| mchId | 商户号。 |
| payOrderId | 平台订单号。 |
| mchOrderNo | 商户订单号。 |
| amount | 订单金额,单位为分。 |
| status | 1 表示交易成功;2 表示交易失败;3 表示该成功状态已被冲正,是必须处理的更正通知。状态含义见第 7 节。 |
| param1 | 下单时传入的自定义字段,可能为空。 |
| param2 | 下单时传入的自定义字段,可能为空。 |
| reqTime | 通知时间,格式 yyyyMMddHHmmss。 |
| signVersion | 首次下单为 V2 时固定返回 v2;历史 V1 订单不返回此字段。 |
| sign | 平台按该订单首次下单时的签名版本和商户密钥生成。 |
通知示例
mchId=M10001
payOrderId=SF20260709213000A1B2C3D4
mchOrderNo=TEST202607090001
amount=10000
status=1
param1=merchant-extra-1
param2=
reqTime=20260709213120
signVersion=v2
sign=平台签名
同一订单发生冲正时,平台会再次发送上述字段,其中 status=3,并使用新的 reqTime 和对应签名。商户不得因为该订单曾处理过 status=1 而忽略这次更正。
商户处理要求
- 先验签,签名失败直接拒绝处理。
- 按
mchId、payOrderId和mchOrderNo查询本地订单,确认订单归属一致。 - 校验
amount与本地订单金额完全一致。 - 状态更新必须幂等:
status=1对同一订单只能入账一次;status=2不能入账;平台管理员复核通过后如再次通知同单status=1,必须更正为成功且只入账一次;收到status=3时,如已按status=1入账,必须只冲账或扣回一次,并把本地订单更新为冲正。 - 处理成功后响应纯文本
success,不要返回 JSON、HTML 或额外空格内容。
status=1、status=2 还是 status=3,商户未返回 success 时平台都会自动重试;短期重试耗尽后会标记“通知失败”用于告警,但不会永久停止,会继续进行最长每日一次的长尾重试。重启或队列短暂故障后也会从数据库恢复,后台同时支持人工补发通知。
交易状态与通知状态
交易状态表示订单本身的处理结果;通知状态只表示平台向商户 notifyUrl 投递该结果后,是否已收到符合协议的确认响应。二者相互独立,通知失败不会把交易成功改成交易失败。
| 后台通知状态 | 含义 |
|---|---|
| 等待通知 | 订单尚未进入成功、失败或冲正终态,平台正在等待可通知的最终结果。 |
| 通知成功 | 订单已进入终态,并且商户接口已返回 HTTP 2xx 和纯文本 success。 |
| 通知失败 | 订单已进入终态,但平台尚未收到商户 notifyUrl 返回的符合协议确认响应;平台会按重试策略继续投递。该状态不改变订单交易结果。 |
status=1 与交易失败 status=2 都会生成商户回调;商户不能只处理成功通知,必须验签后按状态幂等更新本地订单。
超时与自动重试
每次通知 HTTP 请求最长等待 10 秒。首次通知立即发起;只有收到 HTTP 2xx 且响应体去除首尾空白后不区分大小写等于 success 才视为成功。请固定返回小写纯文本 success。网络错误、超时、非 2xx 或其他响应体均会重试。
| 发送次数 | 计划时间 | 说明 |
|---|---|---|
| 1 | 订单进入成功、失败或冲正后立即 | 当前状态的首次通知。 |
| 2 | 第 1 次失败后约 2 秒 | 自动重试。 |
| 3-9 | 随后约 5、10、20、30、60、120、180 秒 | 短期自动重试;第 9 次仍失败时进入告警状态。 |
| 长尾 | 随后约 5 分钟、30 分钟、1、3、6、12、24 小时 | 自动恢复重试,不需要人工重新开启。 |
| 持续失败 | 每 24 小时 | 按封顶节奏永久继续,直到商户返回 success;运营仍可人工立即补发。 |
7. 状态与错误
订单状态
| status | 含义 | 商户处理建议 |
|---|---|---|
| 0 | 待支付 | 不要入账,等待通知或继续查单。 |
| 1 | 交易成功 | 验签、校验金额后入账。 |
| 2 | 交易失败 | 不要入账,可提示用户重新发起支付。若属于平台等待超时,平台管理员复核并同意已认证的迟到成功后,仍可能收到同单状态 1 更正通知,必须验签并幂等更新。 |
| 3 | 交易冲正 | 平台会发送可靠的更正通知。商户侧如已按状态 1 入账,验签并幂等校验后必须冲账或扣回,并将订单最终状态更新为冲正。 |
通用响应结构
{
"code": 0,
"message": "ok",
"data": {},
"request_id": "..."
}
| HTTP | code | 常见含义 |
|---|---|---|
| 400 | 40000 | 参数错误、重复字段、金额不合法、签名版本不支持或 reqTime 超出允许范围。 |
| 401 | 40100 | 签名验证失败。 |
| 403 | 40300 | 请求 IP 不在白名单、商户停用、产品不可用或未绑定产品费率。 |
| 404 | 40400 | 订单不存在。 |
| 409 | 40900 | 商户订单号重复,但本次任一不可变下单字段与原订单不一致。 |
| 413 | 40000 | 请求体超过 1 MiB;包括没有 Content-Length 的 chunked 请求。 |
| 502 | 50200 | 渠道处理异常;如果 data.order_created=true,必须查询或用同一商户单号和完全相同字段重试。 |
| 503 | 50200 | 当前金额没有可用通道;如果 data.order_created=true,原订单已经保存,禁止换新单号。 |
| 500 | 50000 | 系统繁忙;若响应含 data.order_created=true 或客户端未拿到完整响应,先查询原商户单号,禁止直接换新单号。 |
8. 上线检查
- 商户号和密钥只保存在商户服务端,不放到任何公开页面、客户端代码或日志中。
- 新接入下单、查单都带
signVersion=v2、reqTime和sign,并按解码后的 UTF-8 字段签名。 - 金额按分传整数,不传元、不传小数。
- 提交前校验字段 UTF-8 字节长度、控制字符和标识字段首尾空白。
- 下单超时、网络中断或非 200 时先查原单,只能用同一
mchOrderNo和完全相同字段重试。 - 通知接口先验签,再校验金额,最后幂等入账。
- 通知处理成功只返回
success。
- 商户调用服务器公网 IP 已提供给平台加入 API 白名单。
- 正式产品编码已绑定给该商户且状态启用;固定联调产品
0001由系统自动绑定、费率固定为0,使用平台内置联调收银台并真实签名通知商户,但不发送外部供应商、不进入平台资金账;内部编码0002仅供平台固定测试商户验证真实上游。 - 商户
notifyUrl是公网可访问地址。 - 商户服务器已放行平台提供的回调 IP。
- 服务器时间已同步,和北京时间误差不超过 5 分钟。