bsoadapi

/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.

Base
https://bsoad.xyz/v1
Format
JSON. CORS open to every origin.
Auth
None needed. An x-api-key header raises your limit.
Limits
20 requests a minute per IP. 120 a minute per key.

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

okNothing found. Never a promise of safe.
warnA real risk. You can still sell.
stopA way you may never sell.
  • verdict is the level of the worst reason. code is that reason's code, or CLEAN.
  • reasons lists every finding, worst first. Each has level, code and a plain text.
  • stopCode is STOP: 0x0000B50AD plus the code. Show it on your blue screen.
  • skipped lists 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

CodeLevelFromFires when
FREEZE_AUTHORITY_ACTIVESTOPmint accountA freeze authority is set. The issuer can freeze your tokens.
TRANSFER_HOOKSTOPToken-2022Every transfer runs a custom program that can block sells.
PERMANENT_DELEGATESTOPToken-2022A delegate can move tokens out of any wallet.
NON_TRANSFERABLESTOPToken-2022The token cannot move at all.
DEFAULT_FROZENSTOPToken-2022New holder accounts start frozen.
PAUSABLESTOPToken-2022An authority can pause all transfers.
TRANSFER_FEESTOP WARNToken-2022A fee on every move. Stop at 10% or more, warn below 10%.
SELL_PATH_BLOCKEDSTOPsell simA real holder's sell fails on chain: frozen account, hook, or other error.
YOUR_ACCOUNT_FROZENSTOPwallet scanYour own token account for this coin is frozen. Wallet and stream only.
MINT_AUTHORITY_ACTIVEWARNmint accountThe issuer can still mint more supply.
LOW_LIQUIDITYWARNDexScreenerAll Solana pairs hold under $3,000 and none is a pump.fun curve.
HOLDER_CONCENTRATIONWARNholdersThe top 10 wallets outside pools hold over 50% of supply.
NO_SELL_ROUTEWARNsell simJupiter has no route to sell into SOL.
NOT_A_MINTWARNmint accountNo account at the address, or it is not a token mint (a pool or a wallet).
TX_FAILSWARN/v1/simulateThe transaction fails in simulation and no token is worse than ok.
CHECK_FAILEDWARN/v1/simulateOne token check threw. Its error field says why.
CLEANOKallNo 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

  1. MintgetAccountInfo, parsed
    NOT_A_MINTFREEZE_AUTHORITY_ACTIVEMINT_AUTHORITY_ACTIVE
  2. Token-2022every extension on the mint
    TRANSFER_HOOKPERMANENT_DELEGATENON_TRANSFERABLEDEFAULT_FROZENPAUSABLETRANSFER_FEE
  3. MarketDexScreener pairs on Solana
    LOW_LIQUIDITY
  4. Holderstop accounts, pools left out
    HOLDER_CONCENTRATION
  5. Sell simJupiter route, simulated on mainnet
    NO_SELL_ROUTESELL_PATH_BLOCKED
  6. 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.

ParamInWhat
mintpathBase58 address, 32 to 44 characters. Anything else is a 404.
freshqueryfresh=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.

FieldTypeWhat
txstringThe serialized transaction, base64. Legacy or v0. Unsigned is fine.
mintsstring[]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.
  • 400 when tx is 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.

ParamInWhat
addrpathThe wallet (owner) address.
historyqueryhistory=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.
  • counts covers held rows only. A frozen token account turns its row into stop with YOUR_ACCOUNT_FROZEN.
  • A row whose check failed has v: null and an error.
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.

EventDataWhen
scanWalletScanFirst 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.
: pingcommentEvery 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=0 at 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

WhoLimitCounted by
No key20 / minIP address (first x-forwarded-for entry)
Key120 / minThe 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-remaining and x-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": "…" }.

StatusWhen
400A bad body: mints: 1-25 addresses, tx: base64 serialized transaction, cannot read transaction.
404Unknown route, or an address that is not base58 with 32 to 44 characters.
429Over your limit. retryIn is in seconds.
500Body is not valid JSON (bad json), or a wallet scan could not read its accounts.
503rpc 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 —