跳至主要內容

数字货币回调通知

TOPPAY Team大约 8 分钟

概述

订单状态发生变化时,平台会以 HTTP POST + JSON 的方式,向下单请求中的 downNotifyUrl 推送异步通知。代收与代付各自使用下单时传入的地址,可以配置为不同的接口。

场景触发时机回调地址来源
代收通知链上到账确认、订单入账成功或失败代收下单的 downNotifyUrl
代付通知出款成功、出款失败、订单撤销代付下单的 downNotifyUrl

必须返回 SUCCESS

商户接口须返回 HTTP 200 且响应体为字符串 SUCCESS(不区分大小写,不要包裹 JSON)。其余任何响应平台都视为通知失败并进入重试。


请求头

字段必填类型描述
Content-TypeMString固定值:application/json

回调不带 Nonce

下单请求需要 Nonce 请求头,但平台回调不会携带 Nonce。回调的真实性通过报文中的 sign 字段验签保证。


代收通知

通知体参数

字段必填类型描述
platOrderNumMString平台订单号
versionMString回调版本,当前固定 v1
mchNoMString商户编号
orderNumMString商户订单号
amountMNumber收款数量(8 位小数
feeMNumber手续费(8 位小数
currencyMString币种,示例:USDT
netWorkMString链网络,示例:TRC20
inAddressMString平台收款地址
mchUserIdOString商户侧用户标识,与下单请求一致
sendAddressOString付款人地址(链上发送方)
通道未回传时不返回或为 null
hashCodeOString链上交易哈希
通道未回传时不返回或为 null
statusMString订单状态,取值见下表
signMString签名,验签方式见下文

代收状态取值

代收回调返回的是订单明细状态,与查询接口的统一展示状态不同。

status说明是否终态
INIT_ORDER订单处理中(收银台模式下单后,尚未选定币种)
NO_PAY已下单未支付(已分配收款地址,等待链上到账)
SUCCESS收款成功,资金已入账
PAY_ERROR下单失败
PAY_CANCEL退单
EXCEPTION订单异常,需联系客服核查
INVALID无效订单

只在 SUCCESS 时发货

请仅在 statusSUCCESS 时为用户入账或发货,并按 platOrderNum 做幂等处理。

通知示例

{
    "platOrderNum": "CR20260904152640640",
    "version": "v1",
    "mchNo": "JIMMY001",
    "orderNum": "TEST1788506800926",
    "amount": 10.00000000,
    "fee": 1.10000000,
    "currency": "USDT",
    "netWork": "TRC20",
    "inAddress": "TP5wzCqNsJFNT83m1VXdF2Aj1ageFWgqW2",
    "mchUserId": "CASHIER_TEST",
    "status": "SUCCESS",
    "sign": "a8tjzZUlkMm+lw16osbkbs2X12Pd/d7vbeP8R/CaCu1mSiUM40LdNUG4Ys+yvRva0wg8tQWBUx156ajwXzCcoz1bNas0F/aTx59pQXkN937U+UM8s9qg9NH5nsw6YhzJcKRqI13xWNgjOO/HHCFcWcnQeSqDWhM9HHq6aE6EJKWcU6HqXJK+XI3tvehexYD/h3mk5laHJ+Fkz47wZ2asCjfn5I5nEZM8WGWMhGHY46VtlcxPwRpamABCsAL5s+F2EgkkFVgVTayU3lRL1diIF/ESiUxsiJsxIJceLnDK9mbKuxPWen4aPEuc8N0GKWS4EQqQ+lBfnohi2aWZbF2frg=="
}

代付通知

通知体参数

字段必填类型描述
platOrderNumMString平台订单号
versionMString回调版本,当前固定 v1
mchNoMString商户编号
orderNumMString商户订单号
amountMNumber代付数量(8 位小数
feeMNumber手续费(8 位小数
feeTypeMNumber手续费承担方式
0:代付数量内扣除;1:手续费另计
currencyMString币种,示例:USDT
netWorkMString链网络,示例:TRC20
inAddressMString收款地址,与下单请求一致
sendAddressOString出款地址(平台链上发送方)
通道未回传时不返回或为 null
hashCodeOString链上交易哈希
出款成功后返回,可用于区块链浏览器核查
statusMNumber订单状态码,取值见下表
statusMsgMString订单明细状态描述,取值见下表
signMString签名,验签方式见下文

代付状态取值

statusstatusMsg(回调)说明是否终态
0INITIAL_STATE_PENDING_ORDERS初始态(待接单)
1ORDER_RECEIVED_PROCESSING已接单(处理中)
2PROCESSED_SUCCESSFULLY出款成功
3CANCELED_SUCCESSFULLY已撤销(资金已解冻退回)
4PROCESSING_FAILED出款失败(资金已解冻退回)
5LOANING链上出款中
99PENDING_ORDER待接单

statusMsg 与查询接口不同

代付回调statusMsg 是明细状态(如 PROCESSED_SUCCESSFULLY),而下单与查询接口返回的是统一展示状态(如 SUCCESS)。业务判断请以数值 status 为准statusMsg 仅用于展示与日志。

通知示例

{
    "platOrderNum": "CW20260825143024553",
    "version": "v1",
    "mchNo": "JIMMY001",
    "orderNum": "PHPSZ1234ooi567",
    "amount": 10.00000000,
    "fee": 0.70000000,
    "feeType": 0,
    "currency": "USDT",
    "netWork": "TRC20",
    "inAddress": "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t",
    "status": 2,
    "statusMsg": "PROCESSED_SUCCESSFULLY",
    "sign": "ctHsTCGRQMK0i1UeJ253kRZDWnfh964EJbu5mxvz0J1JoKDJYrwXxNKE9H0B+P7187ZZh1qSLG+DjlqNvv/SEEf3KNI8XZJqldxEqCG/ROiRjrjqTqstmPz8cjcBDleGiT1zM3/Qn5knj+BeW2cqXdgbuVdNP+udsDQVgsqcTI1czHoNhExSpEqlIW5u8WZvpiwBKRqlawnd2KpFF9y80FQBNGkjWHMxiRTF3ulsYXCiBLveAvY9CDrZwNTeQdcTucBz80aJ5N3Sk2LcPy6h8jvKRNskLrV4/R/nFYT5X3QkGBWCviEVIUB7eXABhnOinkmY+Gu/KPmP7jY57ERsgA=="
}

回调验签

回调由平台私钥签名,商户使用平台公钥验签。平台公钥在商户后台 API 配置 中获取。

验签步骤

  1. 从通知体中取出 sign 字段并移除signstressTag 不参与拼接)。
  2. 将剩余null 字段的 Key 按 ASCII 升序排序。
  3. 按排序后的顺序只拼接参数值,无任何分隔符,得到待验签明文。
  4. 平台公钥sign 做 RSA 公钥解密(Base64 解码后分段解密)。
  5. 解密结果与第 3 步明文一致即验签通过。

规则与请求签名完全对称,完整说明与其他语言示例见 签名规则

明文以实际报文为准

拼接时必须使用收到的原始报文中的值,包括金额的 8 位小数形式(如 10.00000000)。请勿先反序列化为业务对象再重新格式化数字,否则明文不一致会导致验签失败。

验签明文示例

以上文代收通知为例,参与签名的 Key 按 ASCII 升序为 amountcurrencyfeeinAddressmchNomchUserIdnetWorkorderNumplatOrderNumstatusversion,依次取值拼接得到:

10.00000000USDT1.10000000TP5wzCqNsJFNT83m1VXdF2Aj1ageFWgqW2JIMMY001CASHIER_TESTTRC20TEST1788506800926CR20260904152640640SUCCESSv1

建议做法

不要手工推导明文。请直接遍历收到的 JSON 的 Key 集合排序拼接(见下方代码示例),这样字段增减时无需改动验签逻辑。

代码示例

import com.alibaba.fastjson2.JSONObject;
import org.apache.commons.codec.binary.Base64;

import javax.crypto.Cipher;
import java.io.ByteArrayOutputStream;
import java.nio.charset.StandardCharsets;
import java.security.KeyFactory;
import java.security.interfaces.RSAPublicKey;
import java.security.spec.X509EncodedKeySpec;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;

/**
 * 数字货币回调验签工具
 */
public class CryptoCallbackVerifier {

    /** 不参与签名的字段 */
    private static final List<String> IGNORE_KEYS = Arrays.asList("sign", "stressTag");

    /**
     * 拼接待验签明文:非空字段 Key 按 ASCII 升序,仅拼接值,无分隔符
     */
    public static String buildSignPlainText(JSONObject params) {
        List<String> keys = new ArrayList<>();
        for (String key : params.keySet()) {
            if (!IGNORE_KEYS.contains(key)) {
                keys.add(key);
            }
        }
        Collections.sort(keys);
        StringBuilder sb = new StringBuilder();
        for (String key : keys) {
            Object val = params.get(key);
            if (val != null) {
                sb.append(val);
            }
        }
        return sb.toString();
    }

    /**
     * 验签:平台公钥解密 sign,与明文比对
     *
     * @param body            平台 POST 过来的完整 JSON
     * @param platformPubKey  商户后台获取的平台 RSA 公钥(Base64)
     */
    public static boolean verify(String body, String platformPubKey) {
        try {
            JSONObject params = JSONObject.parseObject(body);
            String sign = params.getString("sign");
            String plain = buildSignPlainText(params);
            String decrypted = publicDecrypt(sign, getPublicKey(platformPubKey));
            return plain.equalsIgnoreCase(decrypted);
        } catch (Exception e) {
            return false;
        }
    }

    private static RSAPublicKey getPublicKey(String base64) throws Exception {
        byte[] bytes = Base64.decodeBase64(base64.replaceAll("\\s", ""));
        return (RSAPublicKey) KeyFactory.getInstance("RSA")
                .generatePublic(new X509EncodedKeySpec(bytes));
    }

    private static String publicDecrypt(String data, RSAPublicKey publicKey) throws Exception {
        Cipher cipher = Cipher.getInstance("RSA");
        cipher.init(Cipher.DECRYPT_MODE, publicKey);
        return new String(rsaSplit(cipher, Base64.decodeBase64(data),
                publicKey.getModulus().bitLength() / 8), StandardCharsets.UTF_8);
    }

    /** RSA 分段解密 */
    private static byte[] rsaSplit(Cipher cipher, byte[] data, int block) throws Exception {
        ByteArrayOutputStream out = new ByteArrayOutputStream();
        for (int i = 0, offset = 0; offset < data.length; i++, offset = i * block) {
            out.write(cipher.doFinal(data, offset, Math.min(block, data.length - offset)));
        }
        return out.toByteArray();
    }
}

数字精度陷阱

PHP / JavaScript 等语言在 json_decode / JSON.parse 后,10.00000000 会变成 10,导致明文与平台不一致。请在解析前用正则或字符串方式提取原始数值,或使用支持大数/保留原文的 JSON 解析选项(PHP 可用 JSON_BIGINT_AS_STRING,JavaScript 建议使用 json-bigint 等库)。


重试机制

项目说明
请求方式POSTContent-Type: application/json
连接 / 读取 / 写入超时5 秒
成功判定HTTP 200 且响应体为 SUCCESS(不区分大小写)
最大重试次数5 次(首次投递失败后重试,间隔逐次递增)
短时去重通知成功后 70 秒内不再对同一订单重复投递
超限处理超过最大重试次数后停止投递,订单通知状态标记为失败

通知失败后的补救

若回调最终失败,请使用 订单查询 主动对账;也可联系运营在后台对指定订单重新发送通知


商户侧接入要求

  • 可公网访问downNotifyUrl 必须为公网可达的 HTTP/HTTPS 地址,不能要求鉴权头或客户端证书。
  • 快速返回:请先落库再异步处理业务,确保 5 秒内返回 SUCCESS,避免因超时触发重试。
  • 幂等处理:以 platOrderNum 为幂等键。同一订单可能收到多次通知(重试、状态多次变更),重复通知不应重复发货或重复记账。
  • 先验签再信任:验签通过后再读取 statusamount 等字段;验签失败应返回非 SUCCESS 并记录告警。
  • 金额二次校验:入账前请核对 orderNumamountcurrency 与本地订单一致。
  • 状态回退容忍:网络重排可能导致后到的通知携带较旧状态,商户侧应忽略非终态覆盖终态的情况。

沙箱联调

沙箱环境同样会向 downNotifyUrl 推送通知。若需手动触发,可在商户后台的沙箱订单详情页使用「更改订单状态」「发送通知」按钮模拟状态流转与回调。


参考