A Solana rug check API call to Solana Tracker is one request, GET /tokens/{mint}: every token response carries a risk object with a score from 1 to 10 (higher means riskier), a rugged flag, the individual risk factors, and sniper, insider, bundler, developer and top-10 holder concentration. Use the score to triage, the factors to explain, and a gate you configure to decide. The score is a screening signal, not a guarantee that a token is safe.
This guide shows the response shape, a TypeScript gate driven by MAX_RISK_SCORE, how the score is built, and the mistakes that turn a useful signal into a false sense of safety. If you want the same data without code, the Rugcheck page in the Solana Tracker terminal shows it for any mint.
What the Solana rug check API returns
The risk data lives inside the standard token info response from the Solana Data API. There is no separate risk endpoint to call. In the SDK, client.getTokenInfo(mint) returns a TokenDetailResponse whose risk field looks like this (trimmed, values illustrative, field names from the SDK 0.5.0 types and the OpenAPI spec):
{
"risk": {
"score": 7,
"rugged": false,
"jupiterVerified": false,
"top10": 31.22,
"dev": { "percentage": 0, "amount": 0 },
"snipers": {
"count": 6,
"totalBalance": 124000000,
"totalPercentage": 12.4,
"wallets": [{ "wallet": "8sVT…P1iA", "balance": 41000000, "percentage": 4.1 }]
},
"insiders": { "count": 2, "totalBalance": 31000000, "totalPercentage": 3.1, "wallets": [] },
"bundlers": {
"count": 14,
"totalBalance": 48500000,
"totalPercentage": 4.85,
"totalInitialBalance": 90000000,
"totalInitialPercentage": 9,
"wallets": []
},
"fees": { "totalTrading": 21.5, "totalTips": 162.1, "total": 183.6 },
"risks": [
{
"name": "Top 10 Holders",
"description": "Top 10 holders own more than 15% of the total supply",
"level": "danger",
"score": 5000
},
{ "name": "Snipers", "description": "Snipers own 12.40% of the total supply.", "value": "12.40%", "level": "warning", "score": 3000 }
]
}
}
The fields that matter for a rug check:
| Field | Meaning |
|---|---|
score | Final risk score, normalized to 1-10. Higher is riskier. |
rugged | Separate flag: no liquidity remains. Check it on its own, not only through the score. |
risks[] | Individual factors with name, description, level (warning or danger), an internal weight score, and sometimes a value. |
snipers, insiders | Wallet count, total balance, percent of supply, and the wallet list. |
bundlers | Bundler summary. It can be omitted from this response; use GET /tokens/{mint}/bundlers for the full list. |
dev.percentage, top10 | Developer holding and top-10 concentration, both in percent of supply. |
fees | SOL paid in trading fees and tips, by platform, plus totals. |
jupiterVerified | Whether the token is Jupiter-verified. |
The SDK normalizes two legacy details: wallet entries get an address alias next to the wire field wallet, and when the wire response omits bundlers, the SDK fills an all-zero category. That second point matters for your gate and is covered under pitfalls.
Pick the right call
Risk data appears in more places than the single-token endpoint. Choose by the question you are answering:
| Need | Call | Requests | Notes |
|---|---|---|---|
| Full check on one mint | getTokenInfo(mint) → GET /tokens/{mint} | 1 | Score, factors, holder groups, pools |
| Complete bundler list | getTokenBundlers(mint) → GET /tokens/{mint}/bundlers | 1 | Up to 500 bundler wallets with initial and current holdings |
| Screen a list you did not choose | getTrendingTokens('1h') | 1 | Each row is a full token object that includes risk |
| Pre-filter discovery | searchTokens({ maxRiskScore, maxSnipers, ... }) | 1 per page | Filters server-side; see the token screener guide |
| Watch holder groups change | Datastream subscribe.snipers(), .insiders(), .bundlers() | 0 REST | WebSocket rooms; Premium plan or higher |
| Manual review | Rugcheck | none | Same signals in the UI |
For a trading bot, a common pattern is search with maxRiskScore to narrow the universe, getTokenInfo on each candidate right before acting, and a Datastream subscription while you hold a position.
Build a configurable risk gate in TypeScript
The gate below fetches one token, prints the factors, and returns one of three states: PASS, FAIL, or UNKNOWN. Missing data is never a pass. The thresholds come from the environment so you can tune them without a deploy.
Install and run (Node.js 20.18 or newer):
npm init -y && npm pkg set type=module
npm install @solana-tracker/data-api@^0.5.0
npm install --save-dev tsx typescript @types/node
node --env-file=.env --import tsx index.ts
.env.example:
# Data API key from https://www.solanatracker.io/account/data-api
ST_API_KEY=
# Mint to check
TOKEN_MINT=
# Fail above this score (1-10, higher = riskier)
MAX_RISK_SCORE=6
# Fail when snipers or insiders hold more than this percent of supply
MAX_SNIPER_PCT=20
MAX_INSIDER_PCT=20
index.ts:
import { Client, DataApiError, RateLimitError, type TokenDetailResponse } from '@solana-tracker/data-api';
type Risk = TokenDetailResponse['risk'];
type Verdict = { status: 'PASS' | 'FAIL' | 'UNKNOWN'; reasons: string[] };
type Factor = { name: string; level: string; detail: string };
function exit(message: string): never {
console.error(message);
process.exit(1);
}
function numberFromEnv(name: string, fallback: number, max: number): number {
const raw = process.env[name]?.trim();
const value = raw ? Number(raw) : fallback;
if (!Number.isFinite(value) || value < 0 || value > max) exit(`${name} must be a number from 0 to ${max}`);
return value;
}
const apiKey = process.env.ST_API_KEY?.trim();
if (!apiKey) exit('Missing ST_API_KEY (create one at https://www.solanatracker.io/account/data-api)');
const mint = process.env.TOKEN_MINT?.trim() ?? '';
if (!/^[1-9A-HJ-NP-Za-km-z]{32,44}$/.test(mint)) exit('Set TOKEN_MINT to a base58 mint address');
const policy = {
maxScore: numberFromEnv('MAX_RISK_SCORE', 6, 10),
maxSniperPct: numberFromEnv('MAX_SNIPER_PCT', 20, 100),
maxInsiderPct: numberFromEnv('MAX_INSIDER_PCT', 20, 100),
};
const client = new Client({ apiKey });
// The SDK has no timeout and no retry: add both.
async function call<T>(label: string, fn: () => Promise<T>, attempts = 3): Promise<T> {
for (let attempt = 1; ; attempt++) {
let timer: NodeJS.Timeout | undefined;
try {
const timeout = new Promise<never>((_, reject) => {
timer = setTimeout(() => reject(new Error(`${label} timed out`)), 15_000);
});
return await Promise.race([fn(), timeout]);
} catch (error) {
const retryable =
error instanceof RateLimitError ||
!(error instanceof DataApiError) ||
error.status === undefined ||
error.status >= 500;
if (!retryable || attempt >= attempts) throw error;
const retryAfterMs = error instanceof RateLimitError && error.retryAfter ? error.retryAfter * 1000 : 0;
await new Promise((resolve) => setTimeout(resolve, Math.max(retryAfterMs, 500 * 2 ** attempt)));
} finally {
clearTimeout(timer);
}
}
}
// Guides document factors as objects; the OpenAPI schema lists strings. Accept both.
function factors(risks: unknown): Factor[] {
if (!Array.isArray(risks)) return [];
return risks.map((entry: unknown) => {
if (typeof entry === 'string') return { name: entry, level: 'unknown', detail: '' };
const f = (entry ?? {}) as { name?: string; level?: string; description?: string; value?: string | number };
return { name: f.name ?? 'unnamed', level: f.level ?? 'unknown', detail: String(f.value ?? f.description ?? '') };
});
}
function evaluate(risk: Risk | undefined): Verdict {
if (!risk || typeof risk.score !== 'number' || !Number.isFinite(risk.score)) {
return { status: 'UNKNOWN', reasons: ['no risk score in the response'] };
}
const reasons: string[] = [];
if (risk.rugged) reasons.push('rugged flag is set');
if (risk.score > policy.maxScore) reasons.push(`score ${risk.score} > ${policy.maxScore}`);
if ((risk.snipers?.totalPercentage ?? 0) > policy.maxSniperPct) reasons.push(`snipers ${risk.snipers.totalPercentage.toFixed(1)}%`);
if ((risk.insiders?.totalPercentage ?? 0) > policy.maxInsiderPct) reasons.push(`insiders ${risk.insiders.totalPercentage.toFixed(1)}%`);
for (const f of factors(risk.risks)) if (f.level === 'danger') reasons.push(`danger: ${f.name}`);
return { status: reasons.length ? 'FAIL' : 'PASS', reasons };
}
try {
const info = await call('getTokenInfo', () => client.getTokenInfo(mint));
const bundlers = await call('getTokenBundlers', () => client.getTokenBundlers(mint)).catch(() => undefined);
const { risk } = info;
console.log(`${info.token?.symbol ?? '?'} score ${risk?.score ?? 'n/a'}/10 rugged: ${risk?.rugged ? 'yes' : 'no'}`);
console.log(`snipers ${risk?.snipers?.totalPercentage ?? 'n/a'}% insiders ${risk?.insiders?.totalPercentage ?? 'n/a'}%`);
console.log(`dev ${risk?.dev?.percentage ?? 'n/a'}% top10 ${risk?.top10 ?? 'n/a'}% bundlers ${bundlers ? `${bundlers.percentage}% (${bundlers.total} wallets)` : 'unavailable'}`);
for (const f of factors(risk?.risks)) console.log(` [${f.level}] ${f.name} ${f.detail}`);
const verdict = evaluate(risk);
console.log(`Risk gate (MAX_RISK_SCORE=${policy.maxScore}): ${verdict.status}`);
for (const reason of verdict.reasons) console.log(` - ${reason}`);
process.exitCode = verdict.status === 'PASS' ? 0 : 2;
} catch (error) {
const status = error instanceof DataApiError && error.status ? `HTTP ${error.status}: ` : '';
exit(`Rug check failed: ${status}${error instanceof Error ? error.message : String(error)}`);
}
The process exits with 0 on PASS, 2 on FAIL or UNKNOWN, and 1 on configuration or request errors, so a shell script or CI job can branch on the result. The companion project splits the same logic into modules, prints aligned tables, and screens the 1h trending list when TOKEN_MINT is empty.
How the score is built
Each factor carries an internal weight. The API sums the weights of the factors that apply and normalizes the total to the final 1-10 score, so the per-factor score values (for example 5000) are relative strength, not the user-facing number. The risk score reference lists every factor. A few representative ones:
| Factor | Level | Weight |
|---|---|---|
| Rugged (no liquidity remains) | danger | 20000 |
| Freeze authority enabled | danger | 7500 |
| Very low liquidity | danger | 7500 |
| Top 10 holders own more than 15% | danger | 5000 |
| LP not burned or not locked | danger | 4000 |
| Mint authority enabled | danger | 2500 |
| Snipers hold more than 10% | warning | 3000 |
| No social media links | warning | 2000 |
Sniper, insider, bundler and developer factors scale with the share of supply those groups hold; bundlers also scale with wallet count. Meteora curve tokens get a dynamic-fee factor once fees pass 5%.
The docs describe score bands roughly as 1-3 low, 4-6 medium, 7-8 high and 9-10 critical. Treat those as labels for a UI, not as a policy. Your threshold depends on what a false negative costs you: a portfolio display can show everything with a badge, while an auto-buy bot might want a low MAX_RISK_SCORE and danger factors blocking.
Why a score is a signal, not a guarantee
A risk score summarizes what the indexer observed at one moment. It cannot see intent, off-chain agreements, or a deployer's next transaction. A token with a score of 2 can still lose its liquidity tomorrow, and a token with a score of 8 can be a long-lived project whose authority settings simply have not been revoked.
Use the score to decide where to spend attention. Use the factors to explain the decision to a user. Keep rugged as its own check, because it describes the current state of liquidity rather than a probability.
Production pitfalls
Missing data is not a pass. If risk or risk.score is missing, return UNKNOWN and treat it like a fail in automated flows. The gate above never substitutes zero.
Do not trust a zero bundler count from the token response. The OpenAPI spec notes that risk.bundlers may be omitted even when the bundlers endpoint returns wallets, and the SDK then fills an all-zero category. Call getTokenBundlers when bundling matters to your decision, and show "unavailable" when that call fails.
Type the factor list defensively. The guides show risks as objects with name, level and score; the OpenAPI schema types them as strings. Normalizing both shapes costs a few lines and prevents a crash.
Do not assume an integer score. The OpenAPI schema says integer, but the token-safety guide shows a decimal example (7.4). Compare with > against a number and format for display.
Holder groups overlap. The same wallet can be counted as a sniper and an insider. Never add sniper, insider, bundler and developer percentages into one "suspicious supply" total.
Snapshots go stale. Liquidity, authorities and holdings change after your check. Re-check right before any action that moves funds, cache results for seconds rather than hours, and record the policy values and timestamp with every verdict so you can explain it later.
Handle rate limits and timeouts yourself. The SDK's REST client has no timeout and does not retry. RateLimitError.retryAfter gives seconds to wait; ValidationError (for example, a malformed mint) should never be retried.
Keep watching after the first check
A one-time check answers "is this acceptable now". To learn when it stops being acceptable, subscribe to the Datastream safety rooms for the mint: ds.subscribe.snipers(mint), ds.subscribe.insiders(mint) and ds.subscribe.bundlers(mint) push holder-group changes, and ds.subscribe.token(mint).top10() tracks concentration. Datastream is included on Premium and higher plans; manage keys in the Data API dashboard.
For liquidity pulls specifically, follow LP additions and removals with the liquidity events guide. For launch-time behavior, the Pump.fun first buyers guide shows who bought first and what they still hold; the Pump.fun API page covers the launchpad endpoints.
FAQ
Is a higher risk score better or worse?
Worse. The score runs from 1 to 10 and higher means more reported risk. Read the factor list to see why a token scored the way it did.
What does MAX_RISK_SCORE do in the example?
It is your policy threshold: any token with risk.score above it fails the gate. It is not an API parameter on the token endpoint. On the search endpoint, maxRiskScore is a real filter that applies the same idea server-side.
Does passing the gate mean a token is safe?
No. It means the observed fields passed your configured policy at the moment of the request. Liquidity, authorities and holdings can change minutes later.
Why does the bundler count differ between the token response and the bundlers endpoint?
The token response can omit the bundler summary, and the SDK replaces an omitted summary with zeros for compatibility. The dedicated GET /tokens/{mint}/bundlers endpoint returns up to 500 bundler wallets and is the source to trust.
How many requests does a rug check cost?
One request for getTokenInfo, plus one if you also call getTokenBundlers. Screening the trending list costs one request for up to the whole list, because each row already includes risk.
Can I check risk without writing code?
Yes. Open Rugcheck and enter a mint. It shows the risk score, holder distribution, liquidity locks and mint and freeze authority for any Solana token.
References
- Read Solana token risk scores and safety signals
- Risk score reference
- Get token information
- Get token bundlers
- Find early token buyers, snipers, and bundlers
- Monitor token risk signals over WebSocket
- @solana-tracker/data-api on npm
Companion project
The full example lives at solanatracker/examples/12-check-solana-token-rug-risk-api. It reads the same environment variables, adds BLOCK_DANGER_FACTORS, and screens the trending list when no mint is set.
cp .env.example .env && npm install && npm start