/v1 Public API
API
One Solana mint in. ok, warn or stop out, with a stop code and every reason. Each check reads live mainnet state.
Quick start
Check one token. No key needed.
BSOAD=https://bsoad.xyz
curl -s "$BSOAD/v1/token/EKpQGSJtjMFqKZ9KQanSqYXRcF8fBopzLHYxdM65zcjm"
// Node 18+, Deno, Bun or the browser: plain fetch, no SDK.
const API = 'https://bsoad.xyz';
const KEY = ''; // optional: from POST /v1/keys
export async function bsoad<T>(path: string, body?: unknown): Promise<T> {
const headers: Record<string, string> = {};
if (KEY) headers['x-api-key'] = KEY;
if (body !== undefined) headers['content-type'] = 'application/json';
const res = await fetch(API + path, {
method: body === undefined ? 'GET' : 'POST',
headers,
body: body === undefined ? undefined : JSON.stringify(body),
});
const data = await res.json();
if (!res.ok) throw new Error(`${res.status} ${data.error}`);
return data as T;
}
const v = await bsoad<Verdict>('/v1/token/EKpQGSJtjMFqKZ9KQanSqYXRcF8fBopzLHYxdM65zcjm');
console.log(v.verdict, v.code); // "ok" "CLEAN"
The TypeScript types for every reply are in Types.
Verdicts
verdictis the level of the worst reason.codeis that reason's code, orCLEAN.reasonslists every finding, worst first. Each haslevel,codeand a plaintext.stopCodeisSTOP: 0x0000B50ADplus the code. Show it on your blue screen.skippedlists checks that could not run:holders,sell-sim. A skipped check never counts as passed.- Verdicts are cached for 45 s. A cached reply has
"cached": true.
Codes
| Code | Level | From | Fires when |
|---|---|---|---|
| FREEZE_AUTHORITY_ACTIVE | STOP | mint account | A freeze authority is set. The issuer can freeze your tokens. |
| TRANSFER_HOOK | STOP | Token-2022 | Every transfer runs a custom program that can block sells. |
| PERMANENT_DELEGATE | STOP | Token-2022 | A delegate can move tokens out of any wallet. |
| NON_TRANSFERABLE | STOP | Token-2022 | The token cannot move at all. |
| DEFAULT_FROZEN | STOP | Token-2022 | New holder accounts start frozen. |
| PAUSABLE | STOP | Token-2022 | An authority can pause all transfers. |
| TRANSFER_FEE | STOP WARN | Token-2022 | A fee on every move. Stop at 10% or more, warn below 10%. |
| SELL_PATH_BLOCKED | STOP | sell sim | A real holder's sell fails on chain: frozen account, hook, or other error. |
| YOUR_ACCOUNT_FROZEN | STOP | wallet scan | Your own token account for this coin is frozen. Wallet and stream only. |
| MINT_AUTHORITY_ACTIVE | WARN | mint account | The issuer can still mint more supply. |
| LOW_LIQUIDITY | WARN | DexScreener | All Solana pairs hold under $3,000 and none is a pump.fun curve. |
| HOLDER_CONCENTRATION | WARN | holders | The top 10 wallets outside pools hold over 50% of supply. |
| NO_SELL_ROUTE | WARN | sell sim | Jupiter has no route to sell into SOL. |
| NOT_A_MINT | WARN | mint account | No account at the address, or it is not a token mint (a pool or a wallet). |
| TX_FAILS | WARN | /v1/simulate | The transaction fails in simulation and no token is worse than ok. |
| CHECK_FAILED | WARN | /v1/simulate | One token check threw. Its error field says why. |
| CLEAN | OK | all | No reason found. Read skipped before you trust it. |
Majors where an active authority is normal skip the authority, liquidity, holder and sell checks: wrapped SOL, USDC, USDT, JUP, mSOL, jitoSOL, BONK, PYUSD. Their meta.known is true. Token-2022 checks still run for them.
How a check runs
- MintgetAccountInfo, parsed
NOT_A_ MINT FREEZE_AUTHORITY_ ACTIVE MINT_AUTHORITY_ ACTIVE - Token-2022every extension on the mint
TRANSFER_HOOK PERMANENT_DELEGATE NON_TRANSFERABLE DEFAULT_FROZEN PAUSABLETRANSFER_FEE - MarketDexScreener pairs on Solana
LOW_LIQUIDITY - Holderstop accounts, pools left out
HOLDER_CONCENTRATION - Sell simJupiter route, simulated on mainnet
NO_SELL_ ROUTE SELL_PATH_ BLOCKED - Verdictthe worst reason wins
stopwarnok
The sell sim takes the largest holder outside the pools with an unfrozen account. It asks Jupiter for a sale of 1% of that balance into SOL, builds the real swap for that holder, and runs simulateTransaction with signature checks off. If the holder only lacks SOL for fees, or slippage trips, the sim is listed as skipped. Any other failure is SELL_PATH_BLOCKED.
If the RPC refuses the holders call, both holders and sell-sim show up in skipped, because the sim needs a real holder.
GET/v1/token/{mint}
One verdict for one mint.
| Param | In | What |
|---|---|---|
| mint | path | Base58 address, 32 to 44 characters. Anything else is a 404. |
| fresh | query | fresh=1 skips the 45 s cache. Works with a key only. |
curl -s "$BSOAD/v1/token/EKpQGSJtjMFqKZ9KQanSqYXRcF8fBopzLHYxdM65zcjm" # with a key: a fresh check, no cache curl -s -H "x-api-key: $BSOAD_KEY" "$BSOAD/v1/token/EKpQGSJtjMFqKZ9KQanSqYXRcF8fBopzLHYxdM65zcjm?fresh=1"
const v = await bsoad<Verdict>(`/v1/token/${mint}`);
if (v.verdict === 'stop') {
showBlueScreen(v.stopCode, v.reasons.map((r) => r.text));
}
if (v.skipped.includes('sell-sim')) {
// the sell was not simulated: say so, do not call it safe
}
200, a real reply
{
"mint": "EKpQGSJtjMFqKZ9KQanSqYXRcF8fBopzLHYxdM65zcjm",
"verdict": "ok",
"code": "CLEAN",
"stopCode": "STOP: 0x0000B50AD CLEAN",
"reasons": [],
"skipped": ["holders", "sell-sim"],
"meta": {
"program": "spl-token", "decimals": 6, "supply": "998836642292950",
"mintAuthority": null, "freezeAuthority": null, "known": false,
"symbol": "$WIF", "name": "dogwifhat", "image": "https://cdn.dexscreener.com/…",
"priceUsd": 0.1708, "liquidityUsd": 5216990,
"dex": ["raydium", "orca", "meteora", "fluxbeam"]
},
"checkedAt": "2026-11-03T11:27:56.045Z"
}
A stop reason looks like this
"verdict": "stop",
"code": "FREEZE_AUTHORITY_ACTIVE",
"stopCode": "STOP: 0x0000B50AD FREEZE_AUTHORITY_ACTIVE",
"reasons": [
{ "level": "stop", "code": "FREEZE_AUTHORITY_ACTIVE", "text": "the issuer can freeze your tokens so you can never sell" }
]
POST/v1/tokens
Up to 25 mints in one call. They run in parallel. One failed mint does not fail the batch: it comes back as { mint, error }.
curl -s -X POST "$BSOAD/v1/tokens" \
-H 'content-type: application/json' \
-d '{"mints":["EKpQGSJtjMFqKZ9KQanSqYXRcF8fBopzLHYxdM65zcjm","DezXAZ8z7PnrnRJjz3wXBoRgixCa6xjnB7YaB1pPB263"]}'
type BatchItem = Verdict | { mint: string; error: string };
const { results } = await bsoad<{ results: BatchItem[] }>('/v1/tokens', { mints });
for (const r of results) {
if ('error' in r) console.warn(r.mint, r.error);
else if (r.verdict !== 'ok') console.log(r.mint, r.verdict, r.code);
}
200
{ "results": [ Verdict | { "mint": "…", "error": "rpc unavailable: …" }, … ] }
400 {"error":"mints: 1-25 addresses"} when the list is empty, too long, or has a bad address.
POST/v1/simulate
Send the transaction a wallet is about to sign. bsoad finds the mints it touches, checks each one, and simulates the transaction itself. This is the call the extension makes before Phantom opens.
| Field | Type | What |
|---|---|---|
| tx | string | The serialized transaction, base64. Legacy or v0. Unsigned is fine. |
| mints | string[] | Optional. Mints to check as well. Pass the output mint when the route uses lookup tables: their accounts are not resolved. |
# TX = base64 of the serialized transaction
curl -s -X POST "$BSOAD/v1/simulate" \
-H 'content-type: application/json' \
-d "{\"tx\":\"$TX\",\"mints\":[\"EKpQGSJtjMFqKZ9KQanSqYXRcF8fBopzLHYxdM65zcjm\"]}"
import { VersionedTransaction } from '@solana/web3.js';
// tx: the swap your terminal built, before the wallet signs it
async function preflight(tx: VersionedTransaction, outputMint: string) {
const sim = await bsoad<Simulation>('/v1/simulate', {
tx: Buffer.from(tx.serialize()).toString('base64'),
mints: [outputMint],
});
if (sim.verdict === 'stop') throw new Error(sim.stopCode);
if (sim.tx.fails) console.warn('this transaction fails:', sim.tx.error);
return sim;
}
200, a real reply: 0.01 SOL into WIF through Jupiter, unsigned
{
"verdict": "ok",
"code": "CLEAN",
"stopCode": "STOP: 0x0000B50AD CLEAN", // built from the worst token's code
"tx": {
"fails": false,
"error": null, // the simulation error as JSON when fails is true
"logs": ["…", "Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA success"] // last 12 lines
},
"tokens": [ { "mint": "EKpQGSJtjMFqKZ9KQanSqYXRcF8fBopzLHYxdM65zcjm", "verdict": "ok", "code": "CLEAN", "skipped": ["holders", "sell-sim"], … } ]
}
When the transaction fails and no token is worse than ok, verdict is warn and code is TX_FAILS.
- Up to 64 account keys are read. Up to 8 mints are checked.
- The simulation runs with signature checks off and a fresh blockhash.
400whentxis missing or too short, or the bytes are not a transaction.
GET/v1/wallet/{addr}
Every token in a wallet now, plus tokens it held recently, each with a verdict. Up to 40 rows, held first.
| Param | In | What |
|---|---|---|
| addr | path | The wallet (owner) address. |
| history | query | history=0 skips recent history. Much faster: token accounts only. |
status: "held"means a balance above zero."sold"means an empty token account, or a mint seen in recent history only.- History reads the last 100 transactions through Helius when the server has a key, or the last 40 signatures over RPC.
countscovers held rows only. A frozen token account turns its row intostopwithYOUR_ACCOUNT_FROZEN.- A row whose check failed has
v: nulland anerror.
curl -s "$BSOAD/v1/wallet/J6hqWW8EqNMaGyv7ThZYCwoMM38FRotgKfpDp3FtUqB8?history=0"
const scan = await bsoad<WalletScan>(`/v1/wallet/${owner}`);
const traps = scan.tokens.filter((t) => t.status === 'held' && t.v?.verdict === 'stop');
console.log(`${traps.length} tokens in this wallet can trap you`);
200, a real reply (one row)
{
"owner": "J6hqWW8EqNMaGyv7ThZYCwoMM38FRotgKfpDp3FtUqB8",
"scannedAt": "2026-11-03T11:34:38.513Z",
"tokens": [
{
"mint": "WXMRyRZhsa19ety5erZhHg4N3xj3EVN92u94422teJp",
"amount": 1, "ui": 1e-12, "frozen": false, "emptyAccount": false,
"status": "held",
"v": { "verdict": "warn", "code": "MINT_AUTHORITY_ACTIVE", "reasons": [ … ], "skipped": ["holders", "sell-sim"], "meta": { "symbol": "XMR", … } }
}
],
"counts": { "held": 3, "stop": 0, "warn": 1 }
}
GET/v1/stream/wallet/{addr}
Server-sent events. The server scans the wallet, sends the full state, waits 30 s after each scan, and scans again. When a verdict flips, you get a change event right after that scan.
| Event | Data | When |
|---|---|---|
| scan | WalletScan | First on connect, with history. Then every 30 s after the last scan ends, current token accounts only. |
| change | { mint, from, to, code } | A token's verdict differs from the last scan on this connection. |
| error | { error } | The scan failed. The next try is 30 s later. |
| : ping | comment | Every 15 s, to keep proxies open. |
curl -N "$BSOAD/v1/stream/wallet/J6hqWW8EqNMaGyv7ThZYCwoMM38FRotgKfpDp3FtUqB8"
// Browser. EventSource cannot send headers, so the stream connects without a key.
const es = new EventSource(`${API}/v1/stream/wallet/${owner}`);
es.addEventListener('scan', (e) => {
const scan: WalletScan = JSON.parse((e as MessageEvent).data);
render(scan.tokens);
});
es.addEventListener('change', (e) => {
const c: Change = JSON.parse((e as MessageEvent).data);
if (c.to === 'stop') notify(`${c.mint} is now ${c.code}`);
});
es.addEventListener('error', (e) => {
const data = (e as MessageEvent).data; // set: the scan failed; unset: the connection dropped
if (data) console.warn('scan failed:', JSON.parse(data).error);
});
- The first scan reads wallet history and can take a minute. Call
GET /v1/wallet/{addr}?history=0at the same time to paint held tokens sooner. - Later scans list current token accounts only. Keep sold rows from the first scan on your side.
- One connection counts as one request against your limit.
POST/v1/keys
Get a free key: 120 requests a minute. The server keeps only a SHA-256 hash, so the key is shown once.
curl -s -X POST "$BSOAD/v1/keys" -H 'content-type: application/json' -d '{"label":"my bot"}'
# then send it on every call
curl -s -H "x-api-key: $BSOAD_KEY" "$BSOAD/v1/wallet/J6hqWW8EqNMaGyv7ThZYCwoMM38FRotgKfpDp3FtUqB8"
const { key } = await bsoad<{ key: string; tier: 'free'; limitPerMinute: number; note: string }>(
'/v1/keys',
{ label: 'my bot' }, // optional, up to 40 characters
);
// store it now: it cannot be shown again
201
{ "key": "bsoad_<24 characters>", "tier": "free", "limitPerMinute": 120, "note": "shown once, store it" }
The header is x-api-key. A ?key= query works too, but it ends up in logs. An unknown key is treated as no key.
GET/v1/health
Is the API up, and which RPC is it using. benched means that RPC answered 429 or 5xx and is skipped for now: 60 s for Helius, 4 s for others.
curl -s "$BSOAD/v1/health"
const h = await bsoad<{ ok: boolean; rpc: { rpc: string; benched: boolean }[] }>('/v1/health');
const live = h.rpc.filter((r) => !r.benched).length;
200, a real reply
{ "ok": true, "rpc": [
{ "rpc": "https://mainnet.helius-rpc.com/?api-key=…", "benched": false },
{ "rpc": "https://api.mainnet-beta.solana.com", "benched": false }
] }
Rate limits
| Who | Limit | Counted by |
|---|---|---|
| No key | 20 / min | IP address (first x-forwarded-for entry) |
| Key | 120 / min | The key, from x-api-key |
- The window is 60 s and starts with your first request.
- Token, tokens, simulate, wallet and 429 replies carry
x-ratelimit-limit,x-ratelimit-remainingandx-ratelimit-reset(seconds left). - Health, keys and stream calls count too, but carry no headers. Health is never blocked.
- Browsers on other origins cannot read these headers. Count your own calls there.
curl -s -D - -o /dev/null "$BSOAD/v1/token/EKpQGSJtjMFqKZ9KQanSqYXRcF8fBopzLHYxdM65zcjm" | grep -i ratelimit # x-ratelimit-limit: 20 # x-ratelimit-remaining: 18 # x-ratelimit-reset: 52
async function withRetry(url: string): Promise<Response> {
const res = await fetch(url);
if (res.status !== 429) return res;
const { retryIn } = await res.json(); // seconds until the window resets
await new Promise((r) => setTimeout(r, retryIn * 1000));
return fetch(url);
}
429
{ "error": "rate limit", "retryIn": 42 }
Errors
Every error is JSON: { "error": "…" }.
| Status | When |
|---|---|
| 400 | A bad body: mints: 1-25 addresses, tx: base64 serialized transaction, cannot read transaction. |
| 404 | Unknown route, or an address that is not base58 with 32 to 44 characters. |
| 429 | Over your limit. retryIn is in seconds. |
| 500 | Body is not valid JSON (bad json), or a wallet scan could not read its accounts. |
| 503 | rpc unavailable: …. The mint account could not be read from any RPC. |
Types
type Level = 'ok' | 'warn' | 'stop';
interface Reason { level: Level; code: string; text: string }
interface Verdict {
mint: string;
verdict: Level;
code: string; // the worst reason's code, or 'CLEAN'
stopCode: string; // 'STOP: 0x0000B50AD ' + code
reasons: Reason[]; // worst first
skipped: ('holders' | 'sell-sim')[];
meta: TokenMeta | null; // null when the address is not a mint
checkedAt: string; // ISO time
cached?: true; // served from the 45 s cache
}
interface TokenMeta {
program: 'spl-token' | 'token-2022';
decimals: number;
supply: string; // raw units
mintAuthority: string | null;
freezeAuthority: string | null;
known: boolean; // a major: authority, liquidity, holder and sell checks are off
symbol: string | null;
name: string | null;
image: string | null;
priceUsd: number | null;
liquidityUsd: number | null; // sum over Solana pairs on DexScreener; null: no pair
dex: string[];
top10Pct?: number; // top 10 holders outside pools, % of supply
sellSim?: { holder: string; route: string[]; ok?: true; error?: string };
}
interface WalletRow {
mint: string;
amount: number; // raw units over all of the wallet's accounts for this mint
ui: number;
frozen: boolean;
emptyAccount: boolean;
status: 'held' | 'sold';
lastSeen?: number; // ms, rows found in history only
v: Verdict | null;
error?: string;
}
interface WalletScan {
owner: string;
scannedAt: string;
tokens: WalletRow[]; // up to 40
counts: { held: number; stop: number; warn: number };
}
interface Simulation {
verdict: Level;
code: string;
stopCode: string;
tx: { fails: boolean; error: string | null; logs: string[] };
tokens: (Verdict | (Partial<Verdict> & { mint: string; error: string }))[];
}
interface Change { mint: string; from: Level; to: Level | undefined; code: string | undefined }
Chrome extension
Before you sign
The extension wraps the sign calls of Phantom, Backpack, Solflare and Wallet Standard wallets in the page. Each transaction goes to POST /v1/simulate before the wallet opens. On stop, a blue screen shows the stop code: Cancel, or Sign anyway.
If the API takes over 1.5 s or is down, the sign goes through and a toast says it was not checked.
On screeners
A :) :| :( badge next to each coin on Axiom, GMGN, pump.fun, DexScreener, Photon and BullX. Hover shows the reasons. Badges use POST /v1/tokens, 25 mints a call.
- Unzip, open
chrome://extensions, turn on Developer mode, click Load unpacked, pick the folder. - Paste your API key in the popup to get 120 requests a minute.
Telegram bot
@bsoadbot. Send a CA or a pump.fun or DexScreener link and get the verdict card. /watch <wallet> checks the coins it holds every 2 minutes and writes when one gets worse, up to 5 wallets a chat. /unwatch and /list manage them. In a group, /auto on checks every CA posted. Inline: type @bsoadbot <CA> in any chat.
— end of dump —