Self-Check & Troubleshooting
When your integration isn't working, walk through this page in order. The vast majority of problems come down to three things: the OAEP hash isn't SHA-1, the canonical string doesn't match byte for byte, or the server clock is off.
Run selfcheck before anything else
v2/selfcheck is a read-only health-check endpoint — it creates no orders and changes no state, so you can call it as many times as you like. A single successful selfcheck proves both directions at once: upstream (the server accepts your encryption + signature) and downstream (you can verify and decrypt the server's response). It should be the first step in any investigation, and it is the fastest way to narrow down where the problem is.
1. Quick triage table
| Symptom | Likely causes |
|---|---|
signatureFailed | Canonical string mismatch (line endings, field order), signature algorithm isn't SHA256, wrong key, missing/wrong ts/nonce, timestamp outside the window, duplicate nonce |
| Decryption fails / garbage output | OAEP hash isn't SHA-1, chunk size isn't 214/256, wrong key |
tokenExpired | Token has expired — call getToken again |
Oops | Source IP is not on the whitelist |
| Request rejected (body too large) | Request body exceeds the platform limit (128KB by default) |
General troubleshooting order:
- Confirm OAEP uses SHA-1 (see Section 3);
- Confirm the canonical string is joined with
\n(LF) and the field order ists, nonce, body; - Confirm the key roles: upstream, encrypt with the platform public key and sign with the merchant private key; downstream, verify with the platform public key and decrypt with the merchant private key;
- Confirm your server clock is synchronized (NTP).
For the full list of error codes, see Error Codes.
2. Digging into signatureFailed
signatureFailed is the most common error — and the most commonly misread one. It does not only mean "the signature was computed wrong".
One error code, four distinct triggers
The server reports all four of the following as signatureFailed:
| # | Trigger | Details |
|---|---|---|
| 1 | Signature verification failed | Canonical string mismatch, signature algorithm isn't SHA256withRSA, wrong key |
| 2 | Timestamp outside the window | ANONY-TIMESTAMP deviates from server time by more than ±5 minutes (±300 seconds) |
| 3 | Duplicate nonce | The same nonce reappears for the same merchant ID within 900 seconds (server-side Redis SET NX EX 900 dedup) |
| 4 | Missing required header | Any of ANONY-TIMESTAMP / ANONY-NONCE / ANONY-SIGN is missing or empty |
So when your signing code "looks correct", don't stare at the signature alone — clock drift and nonce reuse produce the exact same error.
Work through the checks below, cheapest first:
Step 1: Fix the clock (NTP)
The timestamp window is ±5 minutes, so a server clock that has drifted a few minutes will fail intermittently (working on and off around an NTP restart is the classic symptom).
- Confirm NTP sync is enabled on your server (
timedatectl/chronyc tracking). - Use selfcheck to compare clocks directly: its decrypted response contains a
serverTimefield. Compare it against your localtime()— if the difference exceeds a few dozen seconds, fix the clock before anything else.
Step 2: Every retry needs a fresh nonce
The server deduplicates on (merchant ID, nonce) and rejects any repeat. In practice this means:
- Every HTTP request — including timeout retries and failure retries — must generate a new nonce, take a fresh timestamp, and re-sign.
- Caching the whole "ciphertext + headers" and resending it verbatim is guaranteed to hit the nonce dedup and return
signatureFailed. - The nonce must be a CSPRNG random string of at least 16 bytes (hex or base64 both work; fixed-length hex is recommended). Millisecond timestamps or auto-increment counters do not qualify as nonces and collide easily.
Step 3: Verify the canonical string byte by byte
The input to signing/verification must be exactly this (this is the spec, not an example):
ANONY-TIMESTAMP + "\n" + ANONY-NONCE + "\n" + <body>Check each point:
- The three parts are joined with
\n(LF, 0x0A) — not\r\n(CRLF), not spaces, and with no trailing newline. Windows environments and some template engines are prone to sneaking in a\r. - The field order is fixed:
ts, nonce, body— it cannot be rearranged. <body>is the ciphertext, not the plaintext: in upstream requests,<body>is the OAEP-encrypted Base64 ciphertext (i.e. the actual HTTP body you send); in downstream responses/callbacks,<body>is the Base64 ciphertext from the response body (thedatafield of the outer JSON / the raw callback body). Signing the plaintext business JSON is one of the most frequent mistakes.- The signature algorithm is SHA256withRSA (PKCS#1 v1.5 signature padding) — not MD5, not PSS.
- Sign with the merchant private key; verify downstream signatures with the platform public key — don't swap the two.
Dump your assembled canonical string to a file and inspect it in hex with xxd: the separators must be 0a with no 0d anywhere (see the scripts in Section 4).
3. OAEP must use SHA-1 (all five languages)
Encryption is RSA-OAEP with MGF1 = SHA-1. Many languages default to OAEP-SHA256; if the two sides disagree, decryption simply fails (showing up as "decryption failed / garbage output"). The plaintext is encrypted in 214-byte chunks and the ciphertexts are concatenated then Base64-encoded as a whole; for decryption, Base64-decode first and split into 256-byte chunks.
Explicit settings per language (always specify explicitly — never rely on defaults):
| Language | Setting |
|---|---|
| PHP | openssl_public_encrypt($d,$o,$k, OPENSSL_PKCS1_OAEP_PADDING) (SHA-1 by default) |
| Python | padding.OAEP(mgf=MGF1(SHA1()), algorithm=SHA1(), label=None) |
| Node | { padding: RSA_PKCS1_OAEP_PADDING, oaepHash: 'sha1' } |
| Go | rsa.EncryptOAEP(sha1.New(), rand.Reader, pub, msg, nil) |
| Java | Cipher.getInstance("RSA/ECB/OAEPWithSHA-1AndMGF1Padding") |
php
// PHP's OPENSSL_PKCS1_OAEP_PADDING defaults to SHA-1; no extra parameters needed
openssl_public_encrypt($d, $o, $k, OPENSSL_PKCS1_OAEP_PADDING);python
# cryptography library: both OAEP and MGF1 must explicitly specify SHA1
padding.OAEP(mgf=MGF1(SHA1()), algorithm=SHA1(), label=None)js
// oaepHash must be set to 'sha1' explicitly — it's Node's default, but declare it anyway
{ padding: RSA_PKCS1_OAEP_PADDING, oaepHash: 'sha1' }go
// The first argument determines the OAEP hash — it must be sha1.New()
rsa.EncryptOAEP(sha1.New(), rand.Reader, pub, msg, nil)java
// Hard-code SHA-1 + MGF1 in the transformation string
Cipher.getInstance("RSA/ECB/OAEPWithSHA-1AndMGF1Padding");Using the official SDKs (PHP / Python / Node / Go / Java) sidesteps this pitfall entirely.
4. Manual debugging: reproducing signatures and decryption with openssl
When you're not sure whether to trust the SDK, you can reproduce the entire chain independently on the openssl command line, with no application code involved. The scripts below run as-is on any Linux box with openssl / jq.
Setup: restore the PEM keys (your credentials are in base64(PEM) format — strip one layer of Base64 first):
bash
# private_u = merchant private key (PKCS#8), public_t = platform public key (SPKI)
echo "$MERCHANT_PRIVATE_U_B64" | base64 -d > private_u.pem
echo "$SERVER_PUBLIC_T_B64" | base64 -d > public_t.pem
# If these parse cleanly, the keys were restored correctly
openssl pkey -in private_u.pem -noout -text | head -2
openssl pkey -pubin -in public_t.pem -noout -text | head -2Reproduce the upstream signature (SHA256withRSA over the canonical string):
bash
TS=$(date +%s) # Unix timestamp in seconds
NONCE=$(openssl rand -hex 16) # CSPRNG, at least 16 bytes
BODY='<the actual HTTP body you send, i.e. the Base64 ciphertext, verbatim on one line>'
# Assemble the canonical string with printf: three parts joined by LF, no trailing newline.
# Never use echo — it appends a trailing newline and guarantees a bad signature.
printf '%s\n%s\n%s' "$TS" "$NONCE" "$BODY" > canonical.txt
# Hex self-check: separators must be 0a (LF); 0d (CR) must not appear
xxd canonical.txt | head -3
# Sign with the merchant private key; the Base64 output is the value of the ANONY-SIGN header
openssl dgst -sha256 -sign private_u.pem canonical.txt | base64 -w0Compare this signature against the ANONY-SIGN your SDK produces: given the same ts/nonce/body, the two must be identical. If they differ, your canonical assembly or signature algorithm is wrong.
Verify a downstream signature (same logic for responses and callbacks):
bash
# Save the full response: curl -sD headers.txt -o response.json ...
RESP_TS=$(grep -i '^ANONY-TIMESTAMP' headers.txt | tr -d '\r' | awk '{print $2}')
RESP_NONCE=$(grep -i '^ANONY-NONCE' headers.txt | tr -d '\r' | awk '{print $2}')
grep -i '^ANONY-SIGN' headers.txt | tr -d '\r' | awk '{print $2}' | base64 -d > resp_sign.bin
# Extract data with a proper JSON tool (jq unescapes \/ automatically — never slice the string by hand)
DATA=$(jq -r '.data' response.json)
printf '%s\n%s\n%s' "$RESP_TS" "$RESP_NONCE" "$DATA" > resp_canonical.txt
# Verify with the platform public key; "Verified OK" means it passed
openssl dgst -sha256 -verify public_t.pem -signature resp_sign.bin resp_canonical.txtDecrypt the downstream data (merchant private key, OAEP SHA-1, 256-byte chunks):
bash
printf '%s' "$DATA" | base64 -d > cipher.bin
split -b 256 cipher.bin seg_
for f in seg_*; do
openssl pkeyutl -decrypt -inkey private_u.pem -in "$f" \
-pkeyopt rsa_padding_mode:oaep \
-pkeyopt rsa_oaep_md:sha1 -pkeyopt rsa_mgf1_md:sha1
done > plain.json
cat plain.json # Should be the plaintext business JSONIf openssl can decrypt it but your code cannot, the problem is in your code's OAEP parameters (go back to Section 3). If openssl can't decrypt it either, you're most likely using the wrong key (downstream must be decrypted with the merchant private key private_u, not the platform public key).
5. Deposit not credited / callback never arrived
First run through the usual items on the callbacks side: is callback_url configured and reachable from the public internet, does your endpoint return the expected success response, and are your time-window + nonce dedup checks correct (a buggy dedup will reject legitimate callbacks as replays)? Then note these special cases:
USDC deposits on TRON are closed
The TRON-USDC deposit channel is closed: USDC sent to a TRON deposit address is neither credited nor called back. Do not direct users to deposit USDC on the TRON chain. Live deposit address types (addressType) are trx / solana / eth_bnb / ton; btc is reserved while Bitcoin integration is still coming soon. For withdrawals, the authoritative list is the 13 currency enums in the API reference. If you need USDC, use eth.usdc / solana.usdc / bnb.usdc.
Implementation detail: small deposits don't trigger callbacks
A deposit below the currency's deposit_min (the minimum deposit amount, queryable via the public getCurrencies endpoint) will not trigger a callback. When investigating "funds arrived but no callback", check the amount against this threshold first.
Implementation detail: callback timeout and retries
When the platform calls your callback_url, the timeout is 3 seconds; on failure it retries with a 2s → 30s → 2min → 5min → 15min → 60min backoff, stopping after at most 5 retries. Make sure your callback handler returns quickly (persist first, process asynchronously) — otherwise it will be treated as a timeout and enter the retry cycle. Deposit callbacks must return the plain-text string SUCCESS (uppercase) to count as delivered.
6. Still stuck? Contact the platform
If you've been through everything above and still can't pin it down, contact @anonypay on Telegram and include all of the following (each missing item slows down the diagnosis):
- APP-ID (merchant ID);
- The exact time the problem occurred (to the second, with timezone) so the platform can search its logs;
- The complete request headers: paste
ANONY-APP-ID/ANONY-TIMESTAMP/ANONY-NONCE/ANONY-SIGNverbatim, butANONY-TOKENmust be redacted (e.g. keep only the first 4 characters +****); - The first few characters of the request body ciphertext, and the
code/messageyou received in the response.
Never send your keys
Under no circumstances should you send your merchant private key private_u, your secret, or a complete token to anyone — including people claiming to be platform staff. The platform never needs your private key to investigate an issue.
Related pages
- Go-live selfcheck — where every investigation starts and ends
- Protocol details — the full spec for the canonical string, OAEP, and replay protection
- Error Codes — every error code and what it means
- Callbacks — callback signature verification, dedup, and response conventions
- FAQ