Skip to content

Callbacks

The platform calls your callback_url whenever a deposit is credited or a withdrawal changes status. The callback body is an OAEP ciphertext, delivered with three request headers — ANONY-TIMESTAMP / ANONY-NONCE / ANONY-SIGN — and it uses the exact same signature-verification + decryption logic as downstream responses, so you can reuse the SDK directly.

Callbacks are the most error-prone part of an integration, and the part with the biggest impact on your ledger. Read this page in full, then verify against the go-live self-check and the testing steps at the end before going to production.

Delivery details

Important implementation detail: what a callback request looks like

  • The HTTP body is a raw Base64 ciphertext string, not a { code, data, message } JSON envelope. Read the raw body directly (PHP php://input, Express express.text(), etc.) — do not run it through a JSON parser.
  • The request header is Content-Type: application/json (but the body is actually ciphertext text — do not treat it as JSON).
  • Each delivery attempt times out after 3 seconds. A timeout counts as a failed attempt, with no synchronous retry (failures go into the backoff retry schedule described below). Push any slow business logic onto an async queue and respond as quickly as possible.
  • Callback URL precedence: an order-level callbackUrl (passed in the createAddress / createWithdrawOrder request) takes priority; if not provided, the merchant-level default callback_url is used (configured in the merchant dashboard, viewable via merchantInfo); if both are empty, no callback is sent at all.
  • Callbacks are triggered by a scheduled job on the platform side — delivery is asynchronous, so expect a short delay after funds arrive or a status changes. This is not a real-time push over a persistent connection.

Handling a callback in 5 steps

  1. Read the ANONY-TIMESTAMP / ANONY-NONCE / ANONY-SIGN request headers; the request body is the Base64 ciphertext.
  2. Validate the time window (±5 minutes) and dedupe the nonce (reject replayed callbacks).
  3. Verify the signature over canonical(ts, nonce, body) with the platform public key (see the protocol spec for the canonical string format).
  4. OAEP-decrypt with the merchant private key to get the business JSON.
  5. On success, respond as agreed with the platform (e.g. plain-text success; see Response semantics below for the exact rules).

Callbacks and responses share the same canonical signature-verification + OAEP decryption logic — the SDK's verifyResponse / verify+decrypt can be reused as-is; see Using the SDK.

Callbacks require a nonce dedupe function (isFreshNonce)

The isFreshNonce parameter of verifyResponse (PHP / Python / Node) skips nonce deduplication when omitted — that is acceptable when handling synchronous responses, but when handling callbacks you must pass a dedupe function backed by atomic storage (e.g. Redis SET key 1 NX EX 900); otherwise callbacks can be replayed. For Go / Java, perform the time-window check and nonce dedupe yourself before calling verify. Set the nonce dedupe key TTL to at least 15 minutes.

Response semantics

Important implementation detail: how the platform interprets your response

  • Deposit callbacks: you must return the plain-text, uppercase string SUCCESS for the push to count as delivered — any other response (including a timeout) triggers a retry.
  • Withdrawal callbacks (businessType: withdraw): SUCCESS = acknowledged, the platform advances the order; FAIL = you reject the withdrawal and the platform issues a refund (WithdrawRefund); anything else = treated as a failure and retried.
  • Withdrawal pending-confirmation callbacks (businessType: withdrawalPendingConfirm): SUCCESS = approve the withdrawal; FAIL = reject and refund.
  • Consequently: if signature verification fails, decryption fails, or your system errors out, never return FAIL (for withdrawal-type callbacks it is interpreted as "merchant actively rejected" and triggers an immediate refund) — return anything else (e.g. HTTP 400 with arbitrary text) and let the platform retry with backoff.
Callback typeYou returnPlatform behavior
depositSUCCESSMarks the push as delivered
depositAnything else / timeoutRetries with backoff
withdrawSUCCESSAdvances the order status
withdrawFAILRejects the withdrawal and refunds (WithdrawRefund)
withdrawAnything else / timeoutRetries with backoff
withdrawalPendingConfirmSUCCESSApproves the withdrawal and continues the transfer flow
withdrawalPendingConfirmFAILRejects and refunds

Retries and manual re-push

Important implementation detail: retry backoff

After a failed delivery attempt, the platform retries on the following backoff schedule:

Interval sequence
2s → 30s → 2min → 5min → 15min → 60min

Delivery stops after 5 cumulative failures. It does not resume automatically — you can trigger a manual re-push for that record from the merchant dashboard. Set up monitoring and alerting on your callback endpoint so an outage on your side does not cause missed orders.

Because retries and manual re-pushes exist, the same callback may be delivered more than once — your handler must be idempotent (see the pseudocode below).

Deposit callback

Business fields after decryption:

FieldTypeDescription
businessTypestringAlways deposit
txidstringOn-chain transaction hash
addressstringReceiving address
addressTypestringAddress type (live: trx/solana/eth_bnb/ton; btc is reserved)
fromAddressstringSender address
amountstringCredited amount
currencystringCurrency symbol (see the API overview for the enum)
feestringFee
apiOrderstringPlatform order number
userOrderstringMerchant order number (provided at address creation)

USDC deposits on TRON are closed

USDC deposits on the TRON chain (TRC-20 USDC) are closed: even if USDC is transferred to your TRON receiving address, the platform will not credit it and will not fire a deposit callback. Do not direct your users to send USDC to TRON receiving addresses — the funds will not be credited automatically. The supported currencies are defined by the enum in the API overview and the response of getCurrencies.

Important implementation detail: trigger condition

A deposit callback fires only when the deposit is successfully credited and the amount is ≥ the currency's minimum deposit (deposit_min). You can query each currency's deposit_min via the public getCurrencies endpoint.

Withdrawal status callback

Business fields after decryption:

FieldTypeDescription
businessTypestringAlways withdraw
toAddressstringWithdrawal destination address
amountstringWithdrawal amount
currencystringCurrency symbol
feestringFee
apiOrderstringPlatform order number
userOrderstringMerchant order number
withdrawTypestringWithdrawal type (normal/financialConfirmation)
messagestringRemark
txidstringOn-chain transaction hash (may be empty before completion)
orderStatusstringOrder status (e.g. success/fail/pending)

Important implementation detail: pending-confirmation callback (withdrawalPendingConfirm)

Besides the final-state callbacks in the table above, the withdrawal flow may also send a pending-confirmation callback with businessType: withdrawalPendingConfirm — at this point the order has not been broadcast on-chain, and the payload contains no txid or orderStatus fields. Your response directly decides the order's fate: return SUCCESS to approve the withdrawal, or FAIL to reject and refund. Always dispatch on businessType — never assume every callback carries txid/orderStatus.

Merchant-side handler example (pseudocode)

Full processing flow: validate time window → dedupe nonce → verify signature → decrypt → dispatch on businessType → handle idempotently → return SUCCESS.

Skeleton examples below (see Using the SDK for each SDK's actual method signatures):

php
<?php
require 'AnonyV2Client.php';

$ts    = $_SERVER['HTTP_ANONY_TIMESTAMP'] ?? '';
$nonce = $_SERVER['HTTP_ANONY_NONCE']     ?? '';
$sign  = $_SERVER['HTTP_ANONY_SIGN']      ?? '';
$body  = file_get_contents('php://input');   // raw Base64 ciphertext, not JSON

$client = new AnonyV2Client($appId, $serverPublicKeyB64, $merchantPrivateKeyB64);

try {
    // time-window check → nonce dedupe (isFreshNonce is required) → verify → decrypt
    $data = $client->verifyResponse($body, $ts, $nonce, $sign, function ($n) use ($redis) {
        // atomic dedupe: first successful write = fresh nonce; duplicate nonce returns false → callback rejected
        return (bool) $redis->set('anony:cb:nonce:' . $n, 1, ['nx', 'ex' => 900]);
    });
} catch (Throwable $e) {
    http_response_code(400);      // do NOT return FAIL: let the platform retry with backoff
    exit('verify failed');
}

switch ($data['businessType'] ?? '') {
    case 'deposit':
        handleDeposit($data);     // idempotency key: txid / apiOrder; return SUCCESS directly on duplicates
        break;
    case 'withdraw':
        handleWithdraw($data);    // idempotency key: apiOrder + orderStatus
        break;
    case 'withdrawalPendingConfirm':
        if (!approveWithdraw($data)) {
            exit('FAIL');         // reject this withdrawal and refund
        }
        break;
}

echo 'SUCCESS';                   // plain text, uppercase
python
# Flask skeleton
from anony_v2 import AnonyV2Client

client = AnonyV2Client(APP_ID, SERVER_PUB_B64, MERCHANT_PRIV_B64)

@app.post("/anony/callback")
def anony_callback():
    ts    = request.headers.get("ANONY-TIMESTAMP", "")
    nonce = request.headers.get("ANONY-NONCE", "")
    sign  = request.headers.get("ANONY-SIGN", "")
    body  = request.get_data(as_text=True)      # raw Base64 ciphertext, not JSON

    def is_fresh_nonce(n: str) -> bool:
        # atomic dedupe: SET NX EX 900, first successful write = fresh nonce
        return bool(redis.set(f"anony:cb:nonce:{n}", 1, nx=True, ex=900))

    try:
        # time-window check → nonce dedupe (is_fresh_nonce is required) → verify → decrypt
        data = client.verify_response(body, ts, nonce, sign, is_fresh_nonce)
    except Exception:
        return "verify failed", 400              # do NOT return FAIL: let the platform retry

    bt = data.get("businessType")
    if bt == "deposit":
        handle_deposit(data)       # idempotency key: txid / apiOrder
    elif bt == "withdraw":
        handle_withdraw(data)      # idempotency key: apiOrder + orderStatus
    elif bt == "withdrawalPendingConfirm":
        if not approve_withdraw(data):
            return "FAIL"          # reject this withdrawal and refund
    return "SUCCESS"               # plain text, uppercase
js
// Express skeleton
const express = require('express');
const { AnonyV2Client } = require('./anony_v2');

const c = new AnonyV2Client(appId, serverPubB64, merchantPrivB64);
const app = express();

// Key point: the body is raw ciphertext text — do NOT use express.json()
app.post('/anony/callback', express.text({ type: '*/*' }), async (req, res) => {
  const ts    = req.get('ANONY-TIMESTAMP') || '';
  const nonce = req.get('ANONY-NONCE')     || '';
  const sign  = req.get('ANONY-SIGN')      || '';
  const body  = req.body;                       // raw Base64 ciphertext

  // atomic dedupe (SET NX EX 900); pass the result to isFreshNonce
  const fresh = await redis.set(`anony:cb:nonce:${nonce}`, '1', { NX: true, EX: 900 });

  let data;
  try {
    // time-window check → nonce dedupe (isFreshNonce is required) → verify → decrypt
    data = c.verifyResponse(body, ts, nonce, sign, () => fresh === 'OK');
  } catch (e) {
    return res.status(400).send('verify failed'); // do NOT return FAIL: let the platform retry
  }

  switch (data.businessType) {
    case 'deposit':
      await handleDeposit(data);                  // idempotency key: txid / apiOrder
      break;
    case 'withdraw':
      await handleWithdraw(data);                 // idempotency key: apiOrder + orderStatus
      break;
    case 'withdrawalPendingConfirm':
      if (!(await approveWithdraw(data))) {
        return res.send('FAIL');                  // reject this withdrawal and refund
      }
      break;
  }
  res.send('SUCCESS');                            // plain text, uppercase
});

The Go / Java SDKs do not wrap isFreshNonce — implement the same sequence yourself: validate the time window and dedupe the nonce first, then call Verify/verify to check the signature and Decrypt/decrypt to decrypt, and finally dispatch on businessType.

Integration testing

  • The merchant dashboard provides a "test callback" button: it sends a real-format push (ciphertext + signature headers) to your configured callback URL so you can verify that your receiver's signature verification, decryption, and response are all correct. Run the test callback successfully before going live, then use the go-live self-check (selfcheck) to validate the upstream path.
  • If signature verification fails, work through Errors & troubleshooting and the FAQ troubleshooting guide in order: is OAEP using SHA-1, is the canonical string ts\nnonce\nbody, are the key roles swapped (callback verification uses the platform public key, decryption uses the merchant private key), and is your server clock NTP-synced.