勺

勺子支付 · 商户接入指南

从拿到账号到第一笔钱到账,照着做完就能上线

接口地址api.tcozon.top
商户后台mch.tcozon.top
金额单位分

整体流程

你的系统只需要做两件事:下单时调一次我们的接口拿收银台链接,收款后接收一次我们的回调。剩下的拉起微信/支付宝、轮询状态、对账,都由我们处理。

i

建议先用沙盒跑通再上生产。沙盒模式下所有流程与生产完全一致(同样的签名、同样的回调、同样的手续费计算),只是不产生真实扣款。见第 06 节。

01
拿凭据
运营开户,下发商户号与密钥
02
配后台
回调地址、店铺名称与 Logo
03
下单
签名调接口,拿收银台链接
04
顾客付款
链接拉起微信 / 支付宝
05
收回调
验签后给自己的订单发货
🧪
沙盒先跑通
不动真钱,全流程验证

01拿到接入凭据

联系运营开通商户后,你会一次性拿到下面四项。密钥只显示一次,请立刻保存到你的密钥管理系统。

mchId
商户号。每次请求都要带,也是登录商户后台的账号。
登录密码
登录商户后台用。建议首次登录后联系运营重置为你自己设定的。
商户私钥
给你的请求签名用。只存在你的服务器上,绝不能放进 App、小程序或前端页面。
平台公钥
验证我们回调与响应签名用,确认消息真的来自勺子支付。
!

私钥泄露等于资金风险。任何人拿到你的私钥就能以你的名义下单、退款。请只在服务端使用,不要提交进 Git 仓库,不要写在配置文件里明文保存。

若怀疑泄露,立即联系运营重置密钥对。

02商户后台配置

登录 mch.tcozon.top,用商户号和密码进入,在「接入配置」页完成三项设置。

回调地址(必填)

顾客付款成功后,我们会 POST 通知到这个地址。必须是公网可访问的 HTTPS 或 HTTP 地址,且能在 5 秒内响应。

店铺资料(建议填)

店铺名称和 Logo 会显示在顾客的付款页上。填了顾客能认出是向谁付款,付款成功率更高。留空则显示默认名称。

IP 白名单(可选,建议配)

填写你服务器的出口 IP 后,只有这些 IP 能调用接口。多个 IP 用逗号分隔,留空表示不限制。这是私钥万一泄露时的第二道防线。

i

不确定出口 IP,在你的服务器上执行 curl ifconfig.me 即可。

03签名规则

所有请求都要签名,我们会验签。算法是 SHA256withRSA,结果做 Base64 编码。规则只有三步。

  1. 把除 sign 外的所有参数,按参数名的 ASCII 码升序排序。
  2. 拼成 key=value&key=value,跳过值为空的参数,末尾不留 &。
  3. 用商户私钥对这个字符串做 SHA256withRSA 签名,Base64 编码后作为 sign 一起提交。
!

两个最常见的坑:参数值参与签名时用原始值,不要 URL 编码;金额是整数分,签名串里就是 totalAmount=100,不是 1.00。

拼串示例

待签名串按 ASCII 升序,跳过空值与 sign
mchId=DEMO_MCH_001&nonceStr=72a66a99dc4644e69ee3&outTradeNo=SHOP20260830001&payType=CASHIER&subject=咖啡一杯&totalAmount=100

各语言实现

Java
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());
}
PHP
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);
}
Pythonpip install cryptography
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()
Node.js零依赖
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 节规则生成的签名
请求POST /v1/pay/cashier
{
  "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/..."   // 可用平台公钥验签
}
i

收银台链接里不含金额明文,顾客改不了价格。链接有效期内可重复打开,付过一次后不会重复扣款。

订单查询 /v1/pay/query

回调没收到、或需要主动核对时用。参数 mchId、outTradeNo、nonceStr、sign。

响应 data
{
  "tradeNo": "P5d511fddfca745f0bfb3",
  "outTradeNo": "SHOP20260830001",
  "state": "SUCCESS"     // NOTPAY 待支付 / SUCCESS 已支付 / CLOSED 已关闭 / REFUND 已退款
}

申请退款 /v1/pay/refund

仅已支付订单可退,支持部分退款。退款原路退回付款人。

参数必填说明
mchId必填你的商户号
outTradeNo必填原订单号
outRefundNo必填你自己的退款单号,唯一
refundAmount必填退款金额(分),不能超过原订单金额
sign必填签名
i

也可以直接在商户后台「我的订单」里点退款,不用写代码。

05接收支付回调

顾客付款成功后,我们会 POST 一个 JSON 到你配置的回调地址。这是你给自己订单发货 / 开通服务的触发点。

我们发给你的POST 你的回调地址
{
  "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";                          // 纯文本
i

回调万一没送达(你的服务当时不可用),联系运营在「系统日志」里可以查到失败记录并一键重发。你也可以用订单查询接口主动补偿。

06沙盒联调

正式收款前,先在沙盒里把整条链路跑通。沙盒与生产共用同一套接口、同一套签名规则、同一套回调格式,唯一区别是不产生真实扣款。

开通沙盒

联系运营把你的商户切换到沙盒模式。切换后,商户后台所有页面顶部会出现橙色提示条,收银台页面也会显示沙盒标识——你随时能确认自己在哪个环境。

沙盒下怎么"付款"

下单流程完全不变(还是调 /v1/pay/cashier,还是拿到 cashierUrl)。打开收银台链接后,页面不再显示微信/支付宝按钮,而是显示两个模拟按钮:

按钮效果用来测什么
模拟支付成功订单置为 SUCCESS,并向你的回调地址发送与生产完全一致的通知你的发货逻辑、验签、幂等
模拟支付失败订单置为 CLOSED你的超时/失败分支

也可以直接调接口(自动化测试用)

模拟支付POST · 仅沙盒订单可用
# 参数 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切换到生产

沙盒跑通、自查清单过完之后,联系运营把商户切回生产模式。

你的代码一行都不用改——接口地址、签名方式、回调处理全部不变。切换后收银台会恢复显示微信/支付宝按钮,顾客扫码即可真实付款。

i

切生产后建议先用小额验证一次。自己下一笔 ¥0.01 或 ¥1 的单,真实付款,确认:钱到账、回调收到、你的订单状态更新、商户后台能查到这笔。确认无误再对外开放。

微信发货规则

走微信小程序支付的订单,微信要求支付成功后上传发货信息,否则影响资金结算。你的处理方式取决于卖的是虚拟还是实物。

你卖的是怎么发货你要做什么
虚拟商品 / 服务
默认模式
平台支付成功后自动按虚拟发货上传 什么都不用做
实物商品 需真实物流单号,平台不会自动上传 ①商户后台把发货模式设为「实物商品」
②发货后填单号(后台点一下,或调发货接口)
!

卖实物却按虚拟发货上报,是向微信提供不实信息,可能导致小程序被处罚。请务必把发货模式设对。

方式一:商户后台点一下

商户后台 →「发货管理」,能看到所有需发货的订单,逐单发货或一键虚拟发货,也能在这里切换发货模式。

方式二:调发货接口(适合有系统的商户)

POST/v1/pay/shipping · 需签名
// 实物发货
{
  "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 则由顾客在收银台自选。

收银台链接能做成固定二维码贴在店里吗

不建议。每个链接对应一笔订单和固定金额。固定收款码场景请联系运营,我们有专门的方案。

手续费怎么算

按你的费率从每笔交易中扣除,实收 = 订单金额 − 手续费。当前费率在商户后台「接入配置」页可以看到,每笔订单的手续费与实收在「我的订单」里逐笔可查。