Migration from AMLBot
If your service already screens addresses and transactions through the AMLBot API, you can move to Leeri without touching its request code. Leeri exposes an AMLBot-compatible endpoint that reproduces the wire contract field for field: the same form-encoded requests, the same MD5 token, the same response envelope and the same pending and recheck loop. This page describes that contract as Leeri implements it.
Switch in one day
Three environment variables change, and nothing else.
| Variable | Repoint to |
|---|---|
AMLBOT_URL | https://api.leeri.ai/compat/amlbot |
AMLBOT_ID | The accessId of your compat credential. |
AMLBOT_KEY | The secret of the same compat credential. |
Request construction, token computation, form encoding, field names and your pending loop stay exactly as they are.
A compat credential is issued by Leeri together with your account: write to hello@leeri.ai. It is separate from the ak_ API keys used on the native API, and the reason is technical. The protocol needs the server to recompute the MD5 token from the plaintext secret, while an API key is stored irreversibly and cannot be recovered. A compat credential has its own accessId, its own secret and its own rotation. During a rotation both the old and the new secret are accepted for a while, so moving your service to the new one is a configuration change and not an outage.
A compat credential belongs to an environment, sandbox or live, like an API key. Start with a sandbox credential: see Testing the pending loop.
Requests
Both request types are a POST with Content-Type: application/x-www-form-urlencoded to the same URL. The fields present decide which one it is.
| Field | Address check | Transaction check |
|---|---|---|
accessId | required | required |
token | required | required |
hash | the address to screen | the transaction hash |
asset | required | required |
address | not sent | the address of the transaction to screen |
direction | not sent | deposit or withdrawal |
asset is a symbol exactly as listed in Supported assets. Unknown fields are rejected. direction accepts the shortened value withdrawa that some clients send and reads it as withdrawal.
The token
token = MD5("{hash}:{key}:{accessId}")
hash is the value of the hash field you send, key is your compat secret and accessId is your compat accessId. The result is the lowercase 32-character hex digest.
The token is computed once, from the original check request, and resent unchanged on every recheck. It is never recomputed from the uid of the check. Your client already behaves this way; do not change it.
- curl
- Node.js
- Python
TOKEN=$(printf '%s' "$ADDRESS:$AMLBOT_KEY:$AMLBOT_ID" | openssl md5 | awk '{print $NF}')
curl -s -X POST "$AMLBOT_URL" \
--data-urlencode "accessId=$AMLBOT_ID" \
--data-urlencode "token=$TOKEN" \
--data-urlencode "hash=$ADDRESS" \
--data-urlencode "asset=TRX"
import { createHash } from 'node:crypto';
const { AMLBOT_URL, AMLBOT_ID, AMLBOT_KEY } = process.env;
const token = createHash('md5').update(`${address}:${AMLBOT_KEY}:${AMLBOT_ID}`).digest('hex');
const response = await fetch(AMLBOT_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({ accessId: AMLBOT_ID, token, hash: address, asset: 'TRX' }),
});
const body = await response.json();
import hashlib
import os
import requests
url = os.environ["AMLBOT_URL"]
access_id = os.environ["AMLBOT_ID"]
key = os.environ["AMLBOT_KEY"]
token = hashlib.md5(f"{address}:{key}:{access_id}".encode()).hexdigest()
response = requests.post(
url,
data={"accessId": access_id, "token": token, "hash": address, "asset": "TRX"},
timeout=30,
)
body = response.json()
For a transaction check, send the transaction hash as hash and add address and direction. The token is still MD5("{hash}:{key}:{accessId}") with that transaction hash.
Responses
Only result and balance sit at the top level. The payload is nested under data.
{
"result": true,
"balance": 4210,
"data": {
"status": "success",
"uid": "5b0c3f64-1a52-4c8e-9f3e-2f6c1d7a9e10",
"riskscore": 0.74,
"signals": {
"atm": 0,
"child_exploitation": 0,
"dark_market": 0,
"dark_service": 0,
"decentralized_exchange_contract": 0,
"enforcement_action": 0,
"exchange": 0.05,
"exchange_fraudulent": 0,
"gambling": 0,
"illegal_service": 0,
"infrastructure_as_a_service": 0,
"liquidity_pools": 0,
"malware": 0,
"marketplace": 0,
"merchant_services": 0,
"miner": 0,
"mixer": 0.12,
"other": 0,
"p2p_exchange": 0,
"p2p_exchange_mlrisk_high": 0,
"payment": 0,
"ransom": 0,
"risky_exchange": 0,
"sanctions": 0,
"scam": 0,
"seized_assets": 0,
"stolen_coins": 0,
"terrorism_financing": 0,
"unnamed_service": 0.31,
"wallet": 0,
"unnamed_entity_low_risk": 0,
"unnamed_entity_medium_risk": 0,
"unnamed_entity_high_risk": 0
},
"address": "TEXAMPLEexampleEXAMPLEexample00000",
"fiat_code_effective": "USD",
"counterparty": null,
"reportedAddressBalance": null,
"addressDetailsData": null,
"blackListsConnections": false,
"hasBlackListFlag": false,
"pdfReport": "https://…",
"memo": null,
"asset": "TRX",
"network": "tron",
"timestamp": "2026-10-27 09:14:04"
}
}
Values are illustrative. A transaction check answers with the same fields and adds amount, risky_volume, risky_volume_fiat, fiat, direction and tx.
The four fields your control flow uses
| Field | Branch it drives |
|---|---|
result | Success (true) or failure (false). |
data.status | Final answer or still pending. |
balance | The out-of-balance branch. |
data.uid | What the recheck polls. |
Every other field is display data.
Status
data.status | Meaning |
|---|---|
success | The check is final and data carries the result. |
pending | The check is queued. data carries only status, uid, asset, network, address and timestamp. Poll as described in Pending and recheck. |
failed, error | Terminal statuses of the contract. A further recheck never resolves them, so stop polling. Leeri reports a check that ended without a verdict through result: false, described below. |
Failures answer HTTP 200
A failure is not an HTTP error. The endpoint answers 200 and sets result to false:
{
"result": false,
"balance": 0,
"description": "AML check failed\nInvalid token"
}
description has two lines. The first is the fixed text AML check failed and the reason is on the second line, which is where your client already reads it.
| Reason on the second line | Meaning | What to do |
|---|---|---|
Invalid token | The accessId is unknown, revoked or expired, the token does not match, or a recheck names a uid that was never accepted for this credential. All of these answer identically. | Check the credential and the token computation. On a recheck, check that you resend the original token. |
Out of balance | Your quota and your prepaid balance are both used up. | Top up, then retry. Retrying earlier does not help. |
Analysis unavailable, please retry | A data source the verdict depends on was unavailable. Nothing is charged. | Retry later. |
Service temporarily unavailable, please retry | An unexpected fault on our side. | Retry with backoff. |
| A validation message | The asset is not supported, the address or transaction hash has the wrong shape for the asset, direction is not a direction, or a field is missing or unknown. | Fix the request. Retrying unchanged returns the same answer. |
| A capacity message | Lookup capacity is full. Nothing is accepted or charged. | Retry after a short pause. |
The only non-200 answer on this endpoint is rate limiting: it sits behind the same general limiters as the native API and answers 429 with Retry-After. See Rate limits and quotas.
Balance
balance is how many checks Leeri will still serve you: the remaining quota of your plan plus what your prepaid balance can still buy. It is not the literal prepaid balance, which reads 0 for most of a healthy billing period because quota is spent first. The exact prepaid balance is on your invoice and in your dashboard. After an authentication failure balance is 0.
Risk score and signals
riskscoreis a fraction from 0 to 1, scaled from Leeri's 0 to 100 score. It is never a whole number out of 100.signalsholds the fixed 33-key vocabulary shown above. Every key is present on every response and most are0. Each value is a fraction of exposure. Leeri's findings and exposure are grouped into these keys for wire compatibility; the grouping is a bucketing and not a one-to-one match of categories.- When the checked address itself carries a designation that accounts for most of the score, for example an address that belongs to a sanctioned entity,
signalsnames that category at1and every other key at0.riskscoreis unchanged. - A wallet security warning, which warns the owner of the address and says nothing about its risk, is never reflected in
riskscoreorsignals. blackListsConnectionsisfalse, not an empty array, when there is no hit. Test forfalsespecifically, since an empty array is truthy.timestampis a string in the formYYYY-MM-DD HH:MM:SS, never a Unix number.
The evidence report
data.pdfReport is a permanent link to the evidence report of the check, included in the check price. The PDF is rendered when the link is first opened. The link is null in the rare case the report could not be built, and the rest of the answer is unaffected. A pending check has no pdfReport yet. Anyone holding the link can read the report, so treat it as you treat the report.
Fields that stay empty
reportedAddressBalance, addressDetailsData and memo are always null. counterparty is null unless a finding names an exchange as the owner of the address. The balances of the screened address at the time of the check are in the evidence report.
Pending and recheck
When a check cannot finish inside the request, the answer has result: true, data.status: "pending" and a uid. Poll with:
POST https://api.leeri.ai/compat/amlbot/recheck
The path has no trailing slash. The form fields are accessId, uid and the original token.
- curl
- Node.js
- Python
curl -s -X POST "$AMLBOT_URL/recheck" \
--data-urlencode "accessId=$AMLBOT_ID" \
--data-urlencode "uid=$UID" \
--data-urlencode "token=$TOKEN"
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
let body = firstResponse;
for (let attempt = 1; body.result && body.data.status === 'pending' && attempt <= 10; attempt += 1) {
await sleep(30_000);
const response = await fetch(`${AMLBOT_URL}/recheck`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({ accessId: AMLBOT_ID, uid: body.data.uid, token }),
});
body = await response.json();
}
import time
body = first_response
attempt = 0
while body["result"] and body["data"]["status"] == "pending" and attempt < 10:
attempt += 1
time.sleep(30)
body = requests.post(
f"{url}/recheck",
data={"accessId": access_id, "uid": body["data"]["uid"], "token": token},
timeout=30,
).json()
- The answer is the same envelope as the original check: still
pending, a final result, orresult: false. - A recheck reads the check that was already accepted. It starts nothing new and is not charged.
- The token must match the one presented on the original request. A different token, or a
uidthis credential never accepted, answersInvalid token. - The loop your client already runs polls every 30 seconds for up to 10 attempts, a budget of 5 minutes. Leeri keeps the pairing between the
uidand the original token for 30 minutes after it accepts the check, so that loop is well inside it. After that a recheck answersInvalid token.
Testing the pending loop
With a sandbox credential, an address check for asset BTC and address 1PENDNGCHECKADDRESSFRSANDBXTEST99 always answers pending, so you can exercise the whole loop on day one. See Sandbox and live. The same address under a live credential completes normally.
Billing
A compat check is billed by the same rules as a native one. It is paid from the remaining quota first and then from the prepaid balance. Checking the same address on the same chain again within 7 days is free. A failed check, a refused request and a verdict that is partial because a data source was down are not charged. The evidence report is part of the check. Details are in Rate limits and quotas. Plans and prices are on the pricing page.
When you have time: the native API
The compatible endpoint keeps your integration unchanged. The native API is what to move to when you want more from each verdict:
- Every finding links the source it rests on, and the verdict is pinned so it can be reproduced later. See Address checks and Rederive.
- A webhook delivers each finished check, so there is nothing to poll.
Both surfaces run on the same engine and the same ruleset, and you can use them side by side with the same account. Questions about the migration: hello@leeri.ai.