Crypto Callback
Overview
Whenever an order status changes, the platform pushes an asynchronous notification via HTTP POST with a JSON body to the downNotifyUrl supplied at order creation. Pay-in and pay-out each use the URL from their own order request, so they can point to different endpoints.
| Scenario | Trigger | Callback URL source |
|---|---|---|
| Pay-in notification | On-chain deposit confirmed, order credited or failed | downNotifyUrl of the pay-in order |
| Pay-out notification | Payout succeeded, failed, or order cancelled | downNotifyUrl of the pay-out order |
You must return SUCCESS
Your endpoint must respond with HTTP 200 and the plain string SUCCESS (case-insensitive, not wrapped in JSON). Any other response is treated as a delivery failure and triggers a retry.
Request headers
| Field | Required | Type | Description |
|---|---|---|---|
| Content-Type | M | String | Fixed value: application/json |
Callbacks carry no Nonce
Outbound API requests require the Nonce header, but platform callbacks do not include Nonce. Callback authenticity is guaranteed by verifying the sign field in the payload.
Pay-in notification
Notification body
| Field | Required | Type | Description |
|---|---|---|---|
| platOrderNum | M | String | Platform order number |
| version | M | String | Callback version, currently always v1 |
| mchNo | M | String | Merchant number |
| orderNum | M | String | Merchant order number |
| amount | M | Number | Collected quantity (8 decimal places) |
| fee | M | Number | Fee (8 decimal places) |
| currency | M | String | Coin, example: USDT |
| netWork | M | String | Chain network, example: TRC20 |
| inAddress | M | String | Platform deposit address |
| mchUserId | O | String | Merchant-side user identifier, same as in the order request |
| sendAddress | O | String | Payer address (on-chain sender) Omitted or null when the channel does not report it |
| hashCode | O | String | On-chain transaction hash Omitted or null when the channel does not report it |
| status | M | String | Order status, see the table below |
| sign | M | String | Signature, verification described below |
Pay-in status values
Pay-in callbacks return the detailed order status, which differs from the unified display status of the inquiry endpoint.
| status | Description | Final |
|---|---|---|
INIT_ORDER | Order processing (created in cashier mode, coin not selected yet) | No |
NO_PAY | Created but unpaid (deposit address assigned, awaiting on-chain funds) | No |
SUCCESS | Collected successfully, funds credited | Yes |
PAY_ERROR | Order creation failed | Yes |
PAY_CANCEL | Charged back | Yes |
EXCEPTION | Order exception, contact support | No |
INVALID | Invalid order | Yes |
Only fulfil on SUCCESS
Credit the user or release goods only when status is SUCCESS, and make the handler idempotent on platOrderNum.
Notification example
{
"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
Pay-out notification
Notification body
| Field | Required | Type | Description |
|---|---|---|---|
| platOrderNum | M | String | Platform order number |
| version | M | String | Callback version, currently always v1 |
| mchNo | M | String | Merchant number |
| orderNum | M | String | Merchant order number |
| amount | M | Number | Payout quantity (8 decimal places) |
| fee | M | Number | Fee (8 decimal places) |
| feeType | M | Number | Fee bearer0: deducted from the payout quantity; 1: charged separately |
| currency | M | String | Coin, example: USDT |
| netWork | M | String | Chain network, example: TRC20 |
| inAddress | M | String | Beneficiary address, same as in the order request |
| sendAddress | O | String | Sending address (platform on-chain sender) Omitted or null when the channel does not report it |
| hashCode | O | String | On-chain transaction hash Returned after a successful payout, usable on a block explorer |
| status | M | Number | Order status code, see the table below |
| statusMsg | M | String | Detailed status description, see the table below |
| sign | M | String | Signature, verification described below |
Pay-out status values
| status | statusMsg (callback) | Description | Final |
|---|---|---|---|
0 | INITIAL_STATE_PENDING_ORDERS | Initial state (awaiting acceptance) | No |
1 | ORDER_RECEIVED_PROCESSING | Order received (processing) | No |
2 | PROCESSED_SUCCESSFULLY | Payout succeeded | Yes |
3 | CANCELED_SUCCESSFULLY | Cancelled (funds unfrozen and returned) | Yes |
4 | PROCESSING_FAILED | Payout failed (funds unfrozen and returned) | Yes |
5 | LOANING | On-chain transfer in progress | No |
99 | PENDING_ORDER | Awaiting acceptance | No |
statusMsg differs from the inquiry endpoint
The pay-out callback statusMsg is the detailed status (e.g. PROCESSED_SUCCESSFULLY), whereas order creation and inquiry return the unified display status (e.g. SUCCESS). Base your business logic on the numeric status and treat statusMsg as display/logging only.
Notification example
{
"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
Callback signature verification
Callbacks are signed with the platform private key; merchants verify them with the platform public key, available in the merchant portal under API configuration.
Verification steps
- Read the
signfield from the notification body and remove it (signandstressTagnever take part in the concatenation). - Sort the keys of the remaining non-
nullfields in ascending ASCII order. - Concatenate only the values in that order, with no separators, to obtain the plaintext.
- RSA-decrypt
signwith the platform public key (Base64-decode, then decrypt in blocks). - The signature is valid when the decrypted result equals the plaintext from step 3.
The rule is fully symmetric with request signing. See Signature for the complete description and additional language samples.
Build the plaintext from the raw payload
Concatenate the values exactly as received, including the 8-decimal form of amounts (e.g. 10.00000000). Do not deserialize into a business object and re-format the numbers, otherwise the plaintext will not match and verification will fail.
Plaintext example
For the pay-in notification above, the signing keys in ascending ASCII order are amount, currency, fee, inAddress, mchNo, mchUserId, netWork, orderNum, platOrderNum, status, version, producing:
10.00000000USDT1.10000000TP5wzCqNsJFNT83m1VXdF2Aj1ageFWgqW2JIMMY001CASHIER_TESTTRC20TEST1788506800926CR20260904152640640SUCCESSv1
Recommended approach
Do not hard-code the plaintext layout. Iterate over the key set of the received JSON, sort and concatenate (see the code samples below), so that adding or removing fields requires no change to your verification logic.
Code samples
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;
/**
* Crypto callback signature verifier
*/
public class CryptoCallbackVerifier {
/** Fields excluded from the signature */
private static final List<String> IGNORE_KEYS = Arrays.asList("sign", "stressTag");
/**
* Build the plaintext: non-null keys sorted ascending by ASCII, values only, no separators
*/
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();
}
/**
* Verify: decrypt sign with the platform public key and compare with the plaintext
*
* @param body the full JSON posted by the platform
* @param platformPubKey the platform RSA public key (Base64) from the merchant portal
*/
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 block decryption */
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
/**
* Verify a crypto callback signature
*
* @param string $body the full JSON posted by the platform
* @param string $platformPubKey the platform RSA public key (Base64) from the merchant portal
*/
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']);
// Sort keys ascending by ASCII, concatenate non-null values only
$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;
}
Numeric precision pitfall
In PHP, JavaScript and similar languages, json_decode / JSON.parse turns 10.00000000 into 10, so the plaintext will not match the platform. Extract the raw numeric text before parsing, or use a parser that preserves the original literal (PHP: JSON_BIGINT_AS_STRING; JavaScript: libraries such as json-bigint).
Retry policy
| Item | Value |
|---|---|
| Method | POST with Content-Type: application/json |
| Connect / read / write timeout | 5 seconds each |
| Success criteria | HTTP 200 and a body of SUCCESS (case-insensitive) |
| Maximum retries | 5 after the first failed delivery, with increasing intervals |
| Short-term de-duplication | After a successful delivery, the same order is not re-delivered for 70 seconds |
| Exhausted retries | Delivery stops and the order notification status is marked as failed |
Recovery after a failed callback
If a callback ultimately fails, reconcile via Order Inquiry, or ask operations to resend the notification for a specific order from the portal.
Merchant-side requirements
- Publicly reachable:
downNotifyUrlmust be reachable from the internet and must not require auth headers or client certificates. - Respond quickly: persist first and process asynchronously so you can return
SUCCESSwithin 5 seconds and avoid triggering retries. - Be idempotent: use
platOrderNumas the idempotency key. The same order may be notified multiple times (retries, multiple status changes); duplicates must not cause double fulfilment or double booking. - Verify before trusting: read
status,amountand other fields only after the signature is verified. On verification failure, return something other thanSUCCESSand raise an alert. - Re-check the amount: confirm
orderNum,amountandcurrencyagainst your local order before crediting. - Tolerate out-of-order delivery: network reordering may deliver an older status later, so ignore non-final statuses that would overwrite a final one.
Sandbox testing
Sandbox also pushes notifications to downNotifyUrl. To trigger one manually, use the "change order status" and "send notification" buttons on the sandbox order detail page in the merchant portal.
