Skip to main content

Crypto Callback

TOPPAY TeamAbout 6 min

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.

ScenarioTriggerCallback URL source
Pay-in notificationOn-chain deposit confirmed, order credited or faileddownNotifyUrl of the pay-in order
Pay-out notificationPayout succeeded, failed, or order cancelleddownNotifyUrl 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

FieldRequiredTypeDescription
Content-TypeMStringFixed 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

FieldRequiredTypeDescription
platOrderNumMStringPlatform order number
versionMStringCallback version, currently always v1
mchNoMStringMerchant number
orderNumMStringMerchant order number
amountMNumberCollected quantity (8 decimal places)
feeMNumberFee (8 decimal places)
currencyMStringCoin, example: USDT
netWorkMStringChain network, example: TRC20
inAddressMStringPlatform deposit address
mchUserIdOStringMerchant-side user identifier, same as in the order request
sendAddressOStringPayer address (on-chain sender)
Omitted or null when the channel does not report it
hashCodeOStringOn-chain transaction hash
Omitted or null when the channel does not report it
statusMStringOrder status, see the table below
signMStringSignature, 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.

statusDescriptionFinal
INIT_ORDEROrder processing (created in cashier mode, coin not selected yet)No
NO_PAYCreated but unpaid (deposit address assigned, awaiting on-chain funds)No
SUCCESSCollected successfully, funds creditedYes
PAY_ERROROrder creation failedYes
PAY_CANCELCharged backYes
EXCEPTIONOrder exception, contact supportNo
INVALIDInvalid orderYes

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=="
}

Pay-out notification

Notification body

FieldRequiredTypeDescription
platOrderNumMStringPlatform order number
versionMStringCallback version, currently always v1
mchNoMStringMerchant number
orderNumMStringMerchant order number
amountMNumberPayout quantity (8 decimal places)
feeMNumberFee (8 decimal places)
feeTypeMNumberFee bearer
0: deducted from the payout quantity; 1: charged separately
currencyMStringCoin, example: USDT
netWorkMStringChain network, example: TRC20
inAddressMStringBeneficiary address, same as in the order request
sendAddressOStringSending address (platform on-chain sender)
Omitted or null when the channel does not report it
hashCodeOStringOn-chain transaction hash
Returned after a successful payout, usable on a block explorer
statusMNumberOrder status code, see the table below
statusMsgMStringDetailed status description, see the table below
signMStringSignature, verification described below

Pay-out status values

statusstatusMsg (callback)DescriptionFinal
0INITIAL_STATE_PENDING_ORDERSInitial state (awaiting acceptance)No
1ORDER_RECEIVED_PROCESSINGOrder received (processing)No
2PROCESSED_SUCCESSFULLYPayout succeededYes
3CANCELED_SUCCESSFULLYCancelled (funds unfrozen and returned)Yes
4PROCESSING_FAILEDPayout failed (funds unfrozen and returned)Yes
5LOANINGOn-chain transfer in progressNo
99PENDING_ORDERAwaiting acceptanceNo

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=="
}

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

  1. Read the sign field from the notification body and remove it (sign and stressTag never take part in the concatenation).
  2. Sort the keys of the remaining non-null fields in ascending ASCII order.
  3. Concatenate only the values in that order, with no separators, to obtain the plaintext.
  4. RSA-decrypt sign with the platform public key (Base64-decode, then decrypt in blocks).
  5. 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();
    }
}

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

ItemValue
MethodPOST with Content-Type: application/json
Connect / read / write timeout5 seconds each
Success criteriaHTTP 200 and a body of SUCCESS (case-insensitive)
Maximum retries5 after the first failed delivery, with increasing intervals
Short-term de-duplicationAfter a successful delivery, the same order is not re-delivered for 70 seconds
Exhausted retriesDelivery 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: downNotifyUrl must 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 SUCCESS within 5 seconds and avoid triggering retries.
  • Be idempotent: use platOrderNum as 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, amount and other fields only after the signature is verified. On verification failure, return something other than SUCCESS and raise an alert.
  • Re-check the amount: confirm orderNum, amount and currency against 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.


References