整体流程
你的系统只需要做两件事:下单时调一次我们的接口拿收银台链接,收款后接收一次我们的回调。剩下的拉起微信/支付宝、轮询状态、对账,都由我们处理。
建议先用沙盒跑通再上生产。沙盒模式下所有流程与生产完全一致(同样的签名、同样的回调、同样的手续费计算),只是不产生真实扣款。见第 06 节。
01拿到接入凭据
联系运营开通商户后,你会一次性拿到下面四项。密钥只显示一次,请立刻保存到你的密钥管理系统。
私钥泄露等于资金风险。任何人拿到你的私钥就能以你的名义下单、退款。请只在服务端使用,不要提交进 Git 仓库,不要写在配置文件里明文保存。
若怀疑泄露,立即联系运营重置密钥对。
02商户后台配置
登录 mch.tcozon.top,用商户号和密码进入,在「接入配置」页完成三项设置。
回调地址(必填)
顾客付款成功后,我们会 POST 通知到这个地址。必须是公网可访问的 HTTPS 或 HTTP 地址,且能在 5 秒内响应。
店铺资料(建议填)
店铺名称和 Logo 会显示在顾客的付款页上。填了顾客能认出是向谁付款,付款成功率更高。留空则显示默认名称。
IP 白名单(可选,建议配)
填写你服务器的出口 IP 后,只有这些 IP 能调用接口。多个 IP 用逗号分隔,留空表示不限制。这是私钥万一泄露时的第二道防线。
不确定出口 IP,在你的服务器上执行 curl ifconfig.me 即可。
03签名规则
所有请求都要签名,我们会验签。算法是 SHA256withRSA,结果做 Base64 编码。规则只有三步。
- 把除
sign外的所有参数,按参数名的 ASCII 码升序排序。 - 拼成
key=value&key=value,跳过值为空的参数,末尾不留&。 - 用商户私钥对这个字符串做 SHA256withRSA 签名,Base64 编码后作为
sign一起提交。
两个最常见的坑:参数值参与签名时用原始值,不要 URL 编码;金额是整数分,签名串里就是 totalAmount=100,不是 1.00。
拼串示例
mchId=DEMO_MCH_001&nonceStr=72a66a99dc4644e69ee3&outTradeNo=SHOP20260830001&payType=CASHIER&subject=咖啡一杯&totalAmount=100
各语言实现
import java.security.*; import java.security.spec.PKCS8EncodedKeySpec; import java.util.*; public static String sign(Map<String,Object> params, String privateKeyBase64) throws Exception { // 1) 排序 + 拼串(跳过 sign 与空值) StringBuilder sb = new StringBuilder(); for (Map.Entry<String,Object> e : new TreeMap<>(params).entrySet()) { if ("sign".equals(e.getKey()) || e.getValue() == null) continue; String v = String.valueOf(e.getValue()); if (v.isEmpty()) continue; if (sb.length() > 0) sb.append('&'); sb.append(e.getKey()).append('=').append(v); } // 2) SHA256withRSA 签名 → Base64 PrivateKey key = KeyFactory.getInstance("RSA") .generatePrivate(new PKCS8EncodedKeySpec(Base64.getDecoder().decode(privateKeyBase64))); Signature sig = Signature.getInstance("SHA256withRSA"); sig.initSign(key); sig.update(sb.toString().getBytes("UTF-8")); return Base64.getEncoder().encodeToString(sig.sign()); }
function spoonSign(array $params, string $privateKeyBase64): string { unset($params['sign']); $params = array_filter($params, fn($v) => $v !== null && $v !== ''); ksort($params); // ASCII 升序 $parts = []; foreach ($params as $k => $v) { $parts[] = $k . '=' . $v; } $content = implode('&', $parts); $pem = "-----BEGIN PRIVATE KEY-----\n" . chunk_split($privateKeyBase64, 64, "\n") . "-----END PRIVATE KEY-----\n"; openssl_sign($content, $sig, $pem, OPENSSL_ALGO_SHA256); return base64_encode($sig); }
import base64 from cryptography.hazmat.primitives import hashes, serialization from cryptography.hazmat.primitives.asymmetric import padding def spoon_sign(params: dict, private_key_b64: str) -> str: items = sorted((k, v) for k, v in params.items() if k != 'sign' and v is not None and str(v) != '') content = '&'.join(f'{k}={v}' for k, v in items) key = serialization.load_der_private_key(base64.b64decode(private_key_b64), None) sig = key.sign(content.encode('utf-8'), padding.PKCS1v15(), hashes.SHA256()) return base64.b64encode(sig).decode()
const crypto = require('crypto'); function spoonSign(params, privateKeyBase64) { const content = Object.keys(params) .filter(k => k !== 'sign' && params[k] != null && String(params[k]) !== '') .sort() // ASCII 升序 .map(k => `${k}=${params[k]}`) .join('&'); const pem = `-----BEGIN PRIVATE KEY-----\n${privateKeyBase64.match(/.{1,64}/g).join('\n')}\n-----END PRIVATE KEY-----\n`; return crypto.sign('sha256', Buffer.from(content, 'utf8'), pem).toString('base64'); }
04调用接口
所有接口都是 POST,请求体 application/json,地址前缀 https://api.tcozon.top。
收银台下单 /v1/pay/cashier
推荐用这个。返回一个收银台链接,你把链接给顾客(做成二维码、发到微信、跳转过去都行),顾客打开就能选微信或支付宝付款。你不需要关心 openid、不需要处理各渠道差异。
| 参数 | 必填 | 说明 |
|---|---|---|
mchId | 必填 | 你的商户号 |
outTradeNo | 必填 | 你自己的订单号,同一商户下唯一。重复提交同一单号会返回原订单(幂等) |
totalAmount | 必填 | 金额,单位分。100 表示 ¥1.00 |
payType | 必填 | CASHIER 顾客自选WX_LITE 仅微信 · ALI_NATIVE 仅支付宝 |
subject | 选填 | 商品描述,显示在付款页 |
nonceStr | 选填 | 随机字符串,建议每次请求都带,防重放 |
sign | 必填 | 按第 03 节规则生成的签名 |
{
"mchId": "DEMO_MCH_001",
"outTradeNo": "SHOP20260830001",
"totalAmount": 100,
"payType": "CASHIER",
"subject": "咖啡一杯",
"nonceStr": "72a66a99dc4644e69ee3",
"sign": "GSs+Nn7kiVEQo6OzcBJCm7iwr0SMCohOZEXO5LF7Hzo+..."
}
{
"code": "0000",
"message": "成功",
"data": {
"cashierUrl": "https://api.tcozon.top/checkout.html?t=P5d511fddfca745f0bfb3",
"tradeNo": "P5d511fddfca745f0bfb3", // 平台流水号,退款/查询时可用
"outTradeNo": "SHOP20260830001"
},
"sign": "kQ8D3oJi8qJFFf9b99FjadVO0qJkQoin4sC702CoSh/..." // 可用平台公钥验签
}
收银台链接里不含金额明文,顾客改不了价格。链接有效期内可重复打开,付过一次后不会重复扣款。
订单查询 /v1/pay/query
回调没收到、或需要主动核对时用。参数 mchId、outTradeNo、nonceStr、sign。
{
"tradeNo": "P5d511fddfca745f0bfb3",
"outTradeNo": "SHOP20260830001",
"state": "SUCCESS" // NOTPAY 待支付 / SUCCESS 已支付 / CLOSED 已关闭 / REFUND 已退款
}
申请退款 /v1/pay/refund
仅已支付订单可退,支持部分退款。退款原路退回付款人。
| 参数 | 必填 | 说明 |
|---|---|---|
mchId | 必填 | 你的商户号 |
outTradeNo | 必填 | 原订单号 |
outRefundNo | 必填 | 你自己的退款单号,唯一 |
refundAmount | 必填 | 退款金额(分),不能超过原订单金额 |
sign | 必填 | 签名 |
也可以直接在商户后台「我的订单」里点退款,不用写代码。
05接收支付回调
顾客付款成功后,我们会 POST 一个 JSON 到你配置的回调地址。这是你给自己订单发货 / 开通服务的触发点。
{
"mchId": "DEMO_MCH_001",
"outTradeNo": "SHOP20260830001", // 你的订单号
"tradeNo": "P5d511fddfca745f0bfb3", // 平台流水号
"totalAmount": 100, // 分
"state": "SUCCESS",
"sign": "..." // 用平台公钥验证
}
你必须做三件事
| 步骤 | 做什么 | 为什么 |
|---|---|---|
| 验签 | 用平台公钥验证 sign,规则与你签名时一致(排序拼串 → SHA256withRSA 验证) | 防止伪造回调骗取发货 |
| 校验金额 | 核对 totalAmount 与你本地订单金额一致 | 防止金额被篡改 |
| 返回 SUCCESS | 响应体纯文本 SUCCESS(不是 JSON) | 没收到这个我们会重试,并标记为通知失败 |
回调必须做幂等。网络重试可能让同一笔订单的回调到达多次。请先查自己的订单状态,已处理过就直接返回 SUCCESS,不要重复发货。
// 1. 验签 if (!verifyWithPlatformPublicKey(body, body.sign)) return "FAIL"; // 2. 查自己的订单,做幂等 Order o = myOrderDao.findByOrderNo(body.outTradeNo); if (o == null) return "FAIL"; if (o.isPaid()) return "SUCCESS"; // 已处理过,直接确认 // 3. 校验金额后再发货 if (o.getAmountFen() != body.totalAmount) return "FAIL"; o.markPaid(); deliverGoods(o); return "SUCCESS"; // 纯文本
回调万一没送达(你的服务当时不可用),联系运营在「系统日志」里可以查到失败记录并一键重发。你也可以用订单查询接口主动补偿。
06沙盒联调
正式收款前,先在沙盒里把整条链路跑通。沙盒与生产共用同一套接口、同一套签名规则、同一套回调格式,唯一区别是不产生真实扣款。
开通沙盒
联系运营把你的商户切换到沙盒模式。切换后,商户后台所有页面顶部会出现橙色提示条,收银台页面也会显示沙盒标识——你随时能确认自己在哪个环境。
沙盒下怎么"付款"
下单流程完全不变(还是调 /v1/pay/cashier,还是拿到 cashierUrl)。打开收银台链接后,页面不再显示微信/支付宝按钮,而是显示两个模拟按钮:
| 按钮 | 效果 | 用来测什么 |
|---|---|---|
| 模拟支付成功 | 订单置为 SUCCESS,并向你的回调地址发送与生产完全一致的通知 | 你的发货逻辑、验签、幂等 |
| 模拟支付失败 | 订单置为 CLOSED | 你的超时/失败分支 |
也可以直接调接口(自动化测试用)
# 参数 t 就是下单返回的 tradeNo curl -X POST "https://api.tcozon.top/v1/pay/sandbox/pay?t=P5d511fddfca745f0bfb3" # 响应 { "code": "0000", "data": { "state": "SUCCESS", "amount": 100, "sandbox": true } } # 模拟失败 curl -X POST "https://api.tcozon.top/v1/pay/sandbox/fail?t=P5d511fddfca745f0bfb3"
接口是幂等的:对同一笔已成功的订单重复调用,不会重复发送回调,也不会重复计费。
沙盒与生产的一致性
下面这些在沙盒里和生产里完全一样,所以沙盒跑通就意味着生产能跑通:
| 环节 | 沙盒 | 生产 |
|---|---|---|
| 接口地址与参数 | 完全相同 | |
| 签名 / 验签规则 | 完全相同 | |
| 手续费与实收计算 | 完全相同(按你的真实费率) | |
| 回调报文格式与签名 | 完全相同 | |
| 订单状态流转 | 完全相同 | |
| 退款(全额 / 部分) | 完全相同 | |
| 顾客付款方式 | 点「模拟支付成功」 | 真实微信 / 支付宝 |
| 资金变动 | 无 | 真实扣款与结算 |
沙盒订单不计入你的经营数据统计,也不会产生真实的手续费与结算。商户后台里能看到这些测试单,但它们与真实业绩是分开的。
07切换到生产
沙盒跑通、自查清单过完之后,联系运营把商户切回生产模式。
你的代码一行都不用改——接口地址、签名方式、回调处理全部不变。切换后收银台会恢复显示微信/支付宝按钮,顾客扫码即可真实付款。
切生产后建议先用小额验证一次。自己下一笔 ¥0.01 或 ¥1 的单,真实付款,确认:钱到账、回调收到、你的订单状态更新、商户后台能查到这笔。确认无误再对外开放。
微信发货规则
走微信小程序支付的订单,微信要求支付成功后上传发货信息,否则影响资金结算。你的处理方式取决于卖的是虚拟还是实物。
| 你卖的是 | 怎么发货 | 你要做什么 |
|---|---|---|
| 虚拟商品 / 服务 默认模式 |
平台支付成功后自动按虚拟发货上传 | 什么都不用做 |
| 实物商品 | 需真实物流单号,平台不会自动上传 | ①商户后台把发货模式设为「实物商品」 ②发货后填单号(后台点一下,或调发货接口) |
卖实物却按虚拟发货上报,是向微信提供不实信息,可能导致小程序被处罚。请务必把发货模式设对。
方式一:商户后台点一下
商户后台 →「发货管理」,能看到所有需发货的订单,逐单发货或一键虚拟发货,也能在这里切换发货模式。
方式二:调发货接口(适合有系统的商户)
// 实物发货 { "mchId": "DEMO_MCH_001", "outTradeNo": "SHOP20260830001", "logisticsType": 1, // 1快递 2同城 3虚拟 4自提 "trackingNo": "SF1234567890123", // 物流单号 "expressCompany": "SF", // SF顺丰 YTO圆通 ZTO中通 JD京东 "nonceStr": "...", "sign": "..." } // 虚拟发货(服务类,不需要单号) { "mchId": "...", "outTradeNo": "...", "logisticsType": 3, "sign": "..." }
签名规则与其他接口一致。接口幂等,同一订单重复发货不会重复上报。完整参数见《开放接口手册》。
订单上的发货状态
| 状态 | 含义 | 要做什么 |
|---|---|---|
| (无标记) | 非微信小程序支付 | 无 |
| 📦 待发货 | 虚拟:平台处理中;实物:等你发货 | 实物请尽快填单号 |
| 📦 已发货 | 微信已确认收到 | 无 |
| 📦 发货异常 | 上传失败 | 发货管理页看原因重试,处理不了联系运营 |
错误码
所有响应都带 code,0000 表示成功,其余为失败,message 里有具体原因。
| code | 含义 | 怎么处理 |
|---|---|---|
0000 | 成功 | — |
2001 | 参数错误 | 看 message,通常是必填项缺失或金额格式不对 |
2002 | 商户不存在或已禁用 | 核对 mchId;若被禁用请联系运营 |
3001 | 签名验证失败 | 见下方常见问题,九成是拼串规则或密钥用错 |
4001 | 未登录或登录已过期 | 商户后台重新登录 |
4004 | 订单不存在 | 核对 outTradeNo,注意订单归属于哪个商户号 |
5001 | 上游渠道处理失败 | message 含渠道原因,可重试;持续失败请联系我们 |
9999 | 系统内部错误 | 请联系我们并提供 outTradeNo |
上线自查
正式收款前,逐项确认。
- 商户私钥只存在服务端,没有进 Git、没有下发到客户端
- 金额全链路用整数分,没有出现浮点计算
- 回调地址公网可访问,返回纯文本
SUCCESS - 回调处理做了验签、金额校验、幂等三件事
- 订单号
outTradeNo在你系统内唯一,不会重复生成 - 有一个兜底任务:对超时未收到回调的订单调查询接口补偿
- 已在沙盒模式下完整跑通:下单 → 模拟支付 → 收到回调 → 订单状态更新
- 已在沙盒里测过失败分支(模拟支付失败)与退款
- 已切换到生产模式,并用小额(¥0.01 或 ¥1)真实付过一笔验证
- 若走微信小程序支付:发货模式已设对(虚拟/实物),实物已跑通发货流程
- 已在商户后台配置店铺名称与 Logo
- 已配置 IP 白名单(如果你的出口 IP 固定)
常见问题
提示 3001 签名验证失败
按这个顺序排查,基本能定位:
- 是否用了商户私钥签名(不是平台公钥,也不是别的商户的密钥)
- 拼串时是否跳过了 sign 字段和所有空值参数
- 排序是否按参数名 ASCII 升序(不是插入顺序,不是中文拼音序)
- 参数值是否用了原始值(没有 URL 编码、没有转义)
- 金额是否是整数分(
100而不是1.00或"100"带引号在拼串里多了引号) - 编码是否统一 UTF-8(含中文的 subject 尤其注意)
顾客付了款,但我没收到回调
先用订单查询接口确认订单状态。若查到 SUCCESS 而你没收到回调,多半是回调地址当时不可访问。联系运营在系统日志里重发即可。建议你的系统本身就有定时补偿:对创建超过 5 分钟仍未支付的订单调一次查询接口。
可以只用微信或只用支付宝吗
可以。下单时 payType 传 WX_LITE 只走微信,传 ALI_NATIVE 只走支付宝,传 CASHIER 则由顾客在收银台自选。
收银台链接能做成固定二维码贴在店里吗
不建议。每个链接对应一笔订单和固定金额。固定收款码场景请联系运营,我们有专门的方案。
手续费怎么算
按你的费率从每笔交易中扣除,实收 = 订单金额 − 手续费。当前费率在商户后台「接入配置」页可以看到,每笔订单的手续费与实收在「我的订单」里逐笔可查。