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 (PHPphp://input, Expressexpress.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 defaultcallback_urlis 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
- Read the
ANONY-TIMESTAMP/ANONY-NONCE/ANONY-SIGNrequest headers; the request body is the Base64 ciphertext. - Validate the time window (±5 minutes) and dedupe the nonce (reject replayed callbacks).
- Verify the signature over
canonical(ts, nonce, body)with the platform public key (see the protocol spec for the canonical string format). - OAEP-decrypt with the merchant private key to get the business JSON.
- 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+decryptcan 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
SUCCESSfor 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 type | You return | Platform behavior |
|---|---|---|
deposit | SUCCESS | Marks the push as delivered |
deposit | Anything else / timeout | Retries with backoff |
withdraw | SUCCESS | Advances the order status |
withdraw | FAIL | Rejects the withdrawal and refunds (WithdrawRefund) |
withdraw | Anything else / timeout | Retries with backoff |
withdrawalPendingConfirm | SUCCESS | Approves the withdrawal and continues the transfer flow |
withdrawalPendingConfirm | FAIL | Rejects 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:
| Field | Type | Description |
|---|---|---|
businessType | string | Always deposit |
txid | string | On-chain transaction hash |
address | string | Receiving address |
addressType | string | Address type (live: trx/solana/eth_bnb/ton; btc is reserved) |
fromAddress | string | Sender address |
amount | string | Credited amount |
currency | string | Currency symbol (see the API overview for the enum) |
fee | string | Fee |
apiOrder | string | Platform order number |
userOrder | string | Merchant 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:
| Field | Type | Description |
|---|---|---|
businessType | string | Always withdraw |
toAddress | string | Withdrawal destination address |
amount | string | Withdrawal amount |
currency | string | Currency symbol |
fee | string | Fee |
apiOrder | string | Platform order number |
userOrder | string | Merchant order number |
withdrawType | string | Withdrawal type (normal/financialConfirmation) |
message | string | Remark |
txid | string | On-chain transaction hash (may be empty before completion) |
orderStatus | string | Order 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, uppercasepython
# 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, uppercasejs
// 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 callVerify/verifyto check the signature andDecrypt/decryptto decrypt, and finally dispatch onbusinessType.
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.