数字货币回调通知
概述
订单状态发生变化时,平台会以 HTTP POST + JSON 的方式,向下单请求中的 downNotifyUrl 推送异步通知。代收与代付各自使用下单时传入的地址,可以配置为不同的接口。
| 场景 | 触发时机 | 回调地址来源 |
|---|---|---|
| 代收通知 | 链上到账确认、订单入账成功或失败 | 代收下单的 downNotifyUrl |
| 代付通知 | 出款成功、出款失败、订单撤销 | 代付下单的 downNotifyUrl |
必须返回 SUCCESS
商户接口须返回 HTTP 200 且响应体为字符串 SUCCESS(不区分大小写,不要包裹 JSON)。其余任何响应平台都视为通知失败并进入重试。
请求头
| 字段 | 必填 | 类型 | 描述 |
|---|---|---|---|
| Content-Type | M | String | 固定值:application/json |
回调不带 Nonce
下单请求需要 Nonce 请求头,但平台回调不会携带 Nonce。回调的真实性通过报文中的 sign 字段验签保证。
代收通知
通知体参数
| 字段 | 必填 | 类型 | 描述 |
|---|---|---|---|
| platOrderNum | M | String | 平台订单号 |
| version | M | String | 回调版本,当前固定 v1 |
| mchNo | M | String | 商户编号 |
| orderNum | M | String | 商户订单号 |
| amount | M | Number | 收款数量(8 位小数) |
| fee | M | Number | 手续费(8 位小数) |
| currency | M | String | 币种,示例:USDT |
| netWork | M | String | 链网络,示例:TRC20 |
| inAddress | M | String | 平台收款地址 |
| mchUserId | O | String | 商户侧用户标识,与下单请求一致 |
| sendAddress | O | String | 付款人地址(链上发送方) 通道未回传时不返回或为 null |
| hashCode | O | String | 链上交易哈希 通道未回传时不返回或为 null |
| status | M | String | 订单状态,取值见下表 |
| sign | M | String | 签名,验签方式见下文 |
代收状态取值
代收回调返回的是订单明细状态,与查询接口的统一展示状态不同。
| status | 说明 | 是否终态 |
|---|---|---|
INIT_ORDER | 订单处理中(收银台模式下单后,尚未选定币种) | 否 |
NO_PAY | 已下单未支付(已分配收款地址,等待链上到账) | 否 |
SUCCESS | 收款成功,资金已入账 | 是 |
PAY_ERROR | 下单失败 | 是 |
PAY_CANCEL | 退单 | 是 |
EXCEPTION | 订单异常,需联系客服核查 | 否 |
INVALID | 无效订单 | 是 |
只在 SUCCESS 时发货
请仅在 status 为 SUCCESS 时为用户入账或发货,并按 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=="
}
SUCCESS
代付通知
通知体参数
| 字段 | 必填 | 类型 | 描述 |
|---|---|---|---|
| platOrderNum | M | String | 平台订单号 |
| version | M | String | 回调版本,当前固定 v1 |
| mchNo | M | String | 商户编号 |
| orderNum | M | String | 商户订单号 |
| amount | M | Number | 代付数量(8 位小数) |
| fee | M | Number | 手续费(8 位小数) |
| feeType | M | Number | 手续费承担方式0:代付数量内扣除;1:手续费另计 |
| currency | M | String | 币种,示例:USDT |
| netWork | M | String | 链网络,示例:TRC20 |
| inAddress | M | String | 收款地址,与下单请求一致 |
| sendAddress | O | String | 出款地址(平台链上发送方) 通道未回传时不返回或为 null |
| hashCode | O | String | 链上交易哈希 出款成功后返回,可用于区块链浏览器核查 |
| status | M | Number | 订单状态码,取值见下表 |
| statusMsg | M | String | 订单明细状态描述,取值见下表 |
| sign | M | String | 签名,验签方式见下文 |
代付状态取值
| status | statusMsg(回调) | 说明 | 是否终态 |
|---|---|---|---|
0 | INITIAL_STATE_PENDING_ORDERS | 初始态(待接单) | 否 |
1 | ORDER_RECEIVED_PROCESSING | 已接单(处理中) | 否 |
2 | PROCESSED_SUCCESSFULLY | 出款成功 | 是 |
3 | CANCELED_SUCCESSFULLY | 已撤销(资金已解冻退回) | 是 |
4 | PROCESSING_FAILED | 出款失败(资金已解冻退回) | 是 |
5 | LOANING | 链上出款中 | 否 |
99 | PENDING_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=="
}
{
"platOrderNum": "CW20260825150229851",
"version": "v1",
"mchNo": "JIMMY001",
"orderNum": "PHPSZ1234ooi5610",
"amount": 10.00000000,
"fee": 0.70000000,
"feeType": 0,
"currency": "USDT",
"netWork": "TRC20",
"inAddress": "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t",
"status": 3,
"statusMsg": "CANCELED_SUCCESSFULLY",
"sign": "Ba5+HadcBSsRV+OQAanWylH6parQfQiBxfWXg5uXUHzl6cQmI/FSjescNJi+CdPAptWJgiu1LZ4RSJkeJcHl+Sa5P9wUWIkyCg2J0aZTQv0VbOiA2rpQ6ZzZmeIelZ7iCY4lvldoDSXqpNH2Y00gTEfrOY7N4P5QTfptO1Pm/v9czHoNhExSpEqlIW5u8WZvpiwBKRqlawnd2KpFF9y80FQBNGkjWHMxiRTF3ulsYXCiBLveAvY9CDrZwNTeQdcTucBz80aJ5N3Sk2LcPy6h8jvKRNskLrV4/R/nFYT5X3QkGBWCviEVIUB7eXABhnOinkmY+Gu/KPmP7jY57ERsgA=="
}
SUCCESS
回调验签
回调由平台私钥签名,商户使用平台公钥验签。平台公钥在商户后台 API 配置 中获取。
验签步骤
- 从通知体中取出
sign字段并移除(sign与stressTag不参与拼接)。 - 将剩余非
null字段的 Key 按 ASCII 升序排序。 - 按排序后的顺序只拼接参数值,无任何分隔符,得到待验签明文。
- 用平台公钥对
sign做 RSA 公钥解密(Base64 解码后分段解密)。 - 解密结果与第 3 步明文一致即验签通过。
规则与请求签名完全对称,完整说明与其他语言示例见 签名规则。
明文以实际报文为准
拼接时必须使用收到的原始报文中的值,包括金额的 8 位小数形式(如 10.00000000)。请勿先反序列化为业务对象再重新格式化数字,否则明文不一致会导致验签失败。
验签明文示例
以上文代收通知为例,参与签名的 Key 按 ASCII 升序为 amount、currency、fee、inAddress、mchNo、mchUserId、netWork、orderNum、platOrderNum、status、version,依次取值拼接得到:
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
/**
* 数字货币回调验签
*
* @param string $body 平台 POST 过来的完整 JSON
* @param string $platformPubKey 商户后台获取的平台 RSA 公钥(Base64)
*/
function verifyCryptoCallback(string $body, string $platformPubKey): bool
{
$params = json_decode($body, true);
if (!isset($params['sign'])) {
return false;
}
$sign = $params['sign'];
unset($params['sign'], $params['stressTag']);
// Key 按 ASCII 升序,仅拼接非空值
$keys = array_keys($params);
sort($keys, SORT_STRING);
$plain = '';
foreach ($keys as $key) {
if ($params[$key] !== null) {
$plain .= is_bool($params[$key])
? ($params[$key] ? 'true' : 'false')
: (string) $params[$key];
}
}
$publicKey = openssl_pkey_get_public(
"-----BEGIN PUBLIC KEY-----\n" .
wordwrap($platformPubKey, 64, "\n", true) .
"\n-----END PUBLIC KEY-----"
);
$details = openssl_pkey_get_details($publicKey);
$blockSize = $details['bits'] / 8;
$encrypted = base64_decode($sign);
$decrypted = '';
while (strlen($encrypted) > 0) {
$chunk = substr($encrypted, 0, $blockSize);
$encrypted = substr($encrypted, $blockSize);
$part = '';
openssl_public_decrypt($chunk, $part, $publicKey);
$decrypted .= $part;
}
return strcasecmp($plain, $decrypted) === 0;
}
数字精度陷阱
PHP / JavaScript 等语言在 json_decode / JSON.parse 后,10.00000000 会变成 10,导致明文与平台不一致。请在解析前用正则或字符串方式提取原始数值,或使用支持大数/保留原文的 JSON 解析选项(PHP 可用 JSON_BIGINT_AS_STRING,JavaScript 建议使用 json-bigint 等库)。
重试机制
| 项目 | 说明 |
|---|---|
| 请求方式 | POST,Content-Type: application/json |
| 连接 / 读取 / 写入超时 | 各 5 秒 |
| 成功判定 | HTTP 200 且响应体为 SUCCESS(不区分大小写) |
| 最大重试次数 | 5 次(首次投递失败后重试,间隔逐次递增) |
| 短时去重 | 通知成功后 70 秒内不再对同一订单重复投递 |
| 超限处理 | 超过最大重试次数后停止投递,订单通知状态标记为失败 |
通知失败后的补救
若回调最终失败,请使用 订单查询 主动对账;也可联系运营在后台对指定订单重新发送通知。
商户侧接入要求
- 可公网访问:
downNotifyUrl必须为公网可达的 HTTP/HTTPS 地址,不能要求鉴权头或客户端证书。 - 快速返回:请先落库再异步处理业务,确保 5 秒内返回
SUCCESS,避免因超时触发重试。 - 幂等处理:以
platOrderNum为幂等键。同一订单可能收到多次通知(重试、状态多次变更),重复通知不应重复发货或重复记账。 - 先验签再信任:验签通过后再读取
status、amount等字段;验签失败应返回非SUCCESS并记录告警。 - 金额二次校验:入账前请核对
orderNum、amount、currency与本地订单一致。 - 状态回退容忍:网络重排可能导致后到的通知携带较旧状态,商户侧应忽略非终态覆盖终态的情况。
沙箱联调
沙箱环境同样会向 downNotifyUrl 推送通知。若需手动触发,可在商户后台的沙箱订单详情页使用「更改订单状态」「发送通知」按钮模拟状态流转与回调。
