Twitter Follower Audit: Analyze Any Account's Audience Quality
A Twitter account with 200K followers can be worth less than one with 20K — if 180K of those followers are bots, inactive accounts, or purchased bulk follows.
Follower counts mean nothing without audience quality. Brands learn this the hard way when they pay an influencer $5,000 for a sponsored tweet that gets 12 likes. Creators learn it when their "growing" audience doesn't translate into engagement, replies, or sales.
This guide builds a complete follower audit system. Pull a sample of any account's followers, score each one, and produce a quality report that tells you what percentage of the audience is real, active, and worth reaching.
What Makes a Follower "Real"?
Before writing code, define what you're measuring. A real, valuable follower typically has:
| Signal | Healthy | Suspicious |
|---|---|---|
| Profile picture | Custom photo | Default/egg avatar |
| Bio | Has text | Empty |
| Tweet count | 10+ tweets | 0 tweets |
| Account age | 6+ months | Created in last 30 days |
| Follower/following ratio | Reasonable (0.1x–10x) | Follows 5,000+, has < 50 followers |
| Verified | Any | N/A (not a signal by itself) |
| Recent activity | Tweeted in last 90 days | No tweets in 6+ months |
No single signal is definitive. A new account with no bio might be a real person who just signed up. But when an account has no bio, no profile picture, zero tweets, and follows 10,000 people — that's a bot.
The scoring system below combines multiple signals into a confidence score.
Step 1: Get the Account's Profile
You need the numeric user_id (rest_id) to pull followers. Start with the profile endpoint:
const axios = require('axios');
const API_KEY = process.env.SOCIAVAULT_API_KEY;
const BASE_URL = 'https://api.sociavault.com';
async function getProfile(handle) {
const response = await axios.get(`${BASE_URL}/v1/scrape/twitter/profile`, {
params: { handle },
headers: { 'X-API-Key': API_KEY }
});
const profile = response.data.data || response.data;
return {
userId: profile.rest_id || profile.id_str || profile.id,
handle: profile.screen_name || handle,
name: profile.name,
followers: profile.followers_count || profile.followersCount || 0,
following: profile.friends_count || profile.followingCount || 0,
tweets: profile.statuses_count || profile.tweetsCount || 0,
verified: profile.is_blue_verified || profile.verified || false,
createdAt: profile.created_at,
};
}
Cost: 1 credit
This gives you the userId for follower fetching, plus the top-level numbers for a quick sanity check. If someone has 500K followers and follows 490K people, you already know something's off.
Step 2: Sample Followers
You won't audit every follower of a large account — that could be millions. A sample of 500–1,000 gives you statistically useful results.
async function sampleFollowers(userId, sampleSize = 500) {
const followers = [];
let cursor = undefined;
while (followers.length < sampleSize) {
const params = { user_id: userId };
if (cursor) params.cursor = cursor;
const response = await axios.get(`${BASE_URL}/v1/scrape/twitter/followers`, {
params,
headers: { 'X-API-Key': API_KEY }
});
const data = response.data.data || response.data;
const batch = data.users || data.followers || data || [];
if (!Array.isArray(batch) || batch.length === 0) break;
followers.push(...batch);
cursor = data.next_cursor || data.cursor;
if (!cursor) break;
// Rate limiting pause
await new Promise(r => setTimeout(r, 300));
}
return followers.slice(0, sampleSize);
}
Cost: ~1 credit per page (each page returns ~20–50 followers). Sampling 500 followers ≈ 10–25 credits.
For a quick audit, 200 followers is enough to spot problems. For a thorough audit you'd present to a client, aim for 500–1,000.
Step 3: Score Each Follower
This is the core of the audit. Each follower gets a quality score from 0 (definitely fake) to 100 (definitely real):
function scoreFollower(follower) {
let score = 50; // Start neutral
const flags = [];
// --- Profile completeness ---
const hasAvatar = follower.profile_image_url &&
!follower.profile_image_url.includes('default_profile');
if (hasAvatar) {
score += 10;
} else {
score -= 15;
flags.push('default_avatar');
}
const bio = follower.description || follower.bio || '';
if (bio.length > 10) {
score += 10;
} else if (bio.length === 0) {
score -= 10;
flags.push('no_bio');
}
// --- Activity signals ---
const tweetCount = follower.statuses_count || follower.tweetsCount || 0;
if (tweetCount === 0) {
score -= 20;
flags.push('zero_tweets');
} else if (tweetCount < 5) {
score -= 10;
flags.push('low_tweets');
} else if (tweetCount > 50) {
score += 10;
}
// --- Follower/following ratio ---
const following = follower.friends_count || follower.followingCount || 0;
const followers = follower.followers_count || follower.followersCount || 0;
if (following > 2000 && followers < 50) {
// Follows thousands, almost nobody follows back — classic bot/spam pattern
score -= 25;
flags.push('mass_following');
} else if (following > 0 && followers / following > 0.1) {
score += 5;
}
if (following > 5000) {
score -= 10;
flags.push('excessive_following');
}
// --- Account age ---
if (follower.created_at) {
const created = new Date(follower.created_at);
const ageInDays = (Date.now() - created.getTime()) / (1000 * 60 * 60 * 24);
if (ageInDays < 30) {
score -= 15;
flags.push('new_account');
} else if (ageInDays < 90) {
score -= 5;
flags.push('recent_account');
} else if (ageInDays > 365) {
score += 10;
}
}
// --- Verified status ---
if (follower.is_blue_verified || follower.verified) {
score += 15;
// Verified accounts are almost always real
}
// Clamp to 0-100
score = Math.max(0, Math.min(100, score));
return {
handle: follower.screen_name || follower.username || 'unknown',
score,
flags,
classification: classifyScore(score),
};
}
function classifyScore(score) {
if (score >= 70) return 'real';
if (score >= 40) return 'suspicious';
return 'fake';
}
The scoring weights are intentionally conservative. A follower needs multiple red flags to get classified as "fake." One missing field isn't enough — the combination is what matters.
Step 4: Generate the Audit Report
Aggregate individual scores into an account-level report:
function generateAuditReport(profile, scoredFollowers) {
const total = scoredFollowers.length;
const real = scoredFollowers.filter(f => f.classification === 'real').length;
const suspicious = scoredFollowers.filter(f => f.classification === 'suspicious').length;
const fake = scoredFollowers.filter(f => f.classification === 'fake').length;
const avgScore = scoredFollowers.reduce((sum, f) => sum + f.score, 0) / total;
// Flag frequency analysis
const flagCounts = {};
scoredFollowers.forEach(f => {
f.flags.forEach(flag => {
flagCounts[flag] = (flagCounts[flag] || 0) + 1;
});
});
const topFlags = Object.entries(flagCounts)
.sort((a, b) => b[1] - a[1])
.slice(0, 5)
.map(([flag, count]) => ({
flag,
count,
percentage: ((count / total) * 100).toFixed(1) + '%',
}));
// Estimated real audience
const realPercentage = (real / total) * 100;
const estimatedRealFollowers = Math.round(profile.followers * (realPercentage / 100));
const report = {
account: {
handle: profile.handle,
totalFollowers: profile.followers,
totalFollowing: profile.following,
followerFollowingRatio: profile.following > 0
? (profile.followers / profile.following).toFixed(2)
: 'N/A',
},
sample: {
size: total,
averageQualityScore: Math.round(avgScore),
},
breakdown: {
real: { count: real, percentage: ((real / total) * 100).toFixed(1) + '%' },
suspicious: { count: suspicious, percentage: ((suspicious / total) * 100).toFixed(1) + '%' },
fake: { count: fake, percentage: ((fake / total) * 100).toFixed(1) + '%' },
},
estimatedRealAudience: estimatedRealFollowers.toLocaleString(),
topFlags,
verdict: getVerdict(realPercentage, avgScore),
};
return report;
}
function getVerdict(realPercentage, avgScore) {
if (realPercentage >= 75 && avgScore >= 65) {
return {
label: 'Healthy',
summary: 'This account has a predominantly real, active audience. Engagement metrics from this account are likely trustworthy.',
};
}
if (realPercentage >= 50 && avgScore >= 45) {
return {
label: 'Mixed',
summary: 'This account has a mix of real and low-quality followers. Some audience inflation may be present, but a meaningful portion of the audience appears genuine.',
};
}
return {
label: 'Poor',
summary: 'A significant portion of this account\'s followers show patterns inconsistent with organic growth. Engagement metrics may not reflect real audience interest.',
};
}
Step 5: Put It All Together
async function auditAccount(handle, sampleSize = 500) {
console.log(`\n🔍 Auditing @${handle}...\n`);
// Step 1: Get profile
const profile = await getProfile(handle);
console.log(`Account: @${profile.handle}`);
console.log(`Followers: ${profile.followers.toLocaleString()}`);
console.log(`Following: ${profile.following.toLocaleString()}`);
console.log(`Tweets: ${profile.tweets.toLocaleString()}\n`);
// Quick ratio check
if (profile.following > 0) {
const ratio = profile.followers / profile.following;
if (ratio < 0.1) {
console.log(`⚠️ Follower/following ratio is ${ratio.toFixed(2)} — unusually low\n`);
}
}
// Step 2: Sample followers
console.log(`Sampling ${sampleSize} followers...`);
const followers = await sampleFollowers(profile.userId, sampleSize);
console.log(`Retrieved ${followers.length} followers\n`);
if (followers.length === 0) {
console.log('No followers retrieved. Account may be private or restricted.');
return null;
}
// Step 3: Score each follower
const scored = followers.map(scoreFollower);
// Step 4: Generate report
const report = generateAuditReport(profile, scored);
// Print report
console.log('━'.repeat(50));
console.log(`FOLLOWER AUDIT REPORT: @${report.account.handle}`);
console.log('━'.repeat(50));
console.log(`\nTotal Followers: ${report.account.totalFollowers.toLocaleString()}`);
console.log(`Sample Size: ${report.sample.size}`);
console.log(`Average Quality Score: ${report.sample.averageQualityScore}/100\n`);
console.log('BREAKDOWN:');
console.log(` ✅ Real: ${report.breakdown.real.percentage} (${report.breakdown.real.count})`);
console.log(` ⚠️ Suspicious: ${report.breakdown.suspicious.percentage} (${report.breakdown.suspicious.count})`);
console.log(` ❌ Fake/Bot: ${report.breakdown.fake.percentage} (${report.breakdown.fake.count})`);
console.log(`\nEstimated Real Audience: ~${report.estimatedRealAudience}`);
console.log('\nTOP FLAGS:');
report.topFlags.forEach(f => {
console.log(` ${f.flag}: ${f.percentage} of sampled followers`);
});
console.log(`\nVERDICT: ${report.verdict.label}`);
console.log(report.verdict.summary);
console.log('━'.repeat(50));
return report;
}
// Run it
auditAccount('target_account', 500);
Example output:
🔍 Auditing @target_account...
Account: @target_account
Followers: 187,400
Following: 342
Tweets: 4,891
Sampling 500 followers...
Retrieved 500 followers
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
FOLLOWER AUDIT REPORT: @target_account
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Total Followers: 187,400
Sample Size: 500
Average Quality Score: 62/100
BREAKDOWN:
✅ Real: 58.2% (291)
⚠️ Suspicious: 28.4% (142)
❌ Fake/Bot: 13.4% (67)
Estimated Real Audience: ~109,087
TOP FLAGS:
no_bio: 31.2% of sampled followers
low_tweets: 22.6% of sampled followers
default_avatar: 14.8% of sampled followers
mass_following: 11.2% of sampled followers
new_account: 8.4% of sampled followers
VERDICT: Mixed
This account has a mix of real and low-quality followers.
Some audience inflation may be present, but a meaningful
portion of the audience appears genuine.
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Comparing Two Accounts
When choosing between influencers for a campaign, audit both and compare side by side:
async function compareAudiences(handles) {
const results = [];
for (const handle of handles) {
const report = await auditAccount(handle, 300);
if (report) results.push(report);
await new Promise(r => setTimeout(r, 1000));
}
if (results.length < 2) return;
console.log('\n' + '='.repeat(60));
console.log('SIDE-BY-SIDE COMPARISON');
console.log('='.repeat(60));
const headers = ['Metric', ...results.map(r => `@${r.account.handle}`)];
const rows = [
['Followers', ...results.map(r => r.account.totalFollowers.toLocaleString())],
['Quality Score', ...results.map(r => `${r.sample.averageQualityScore}/100`)],
['Real %', ...results.map(r => r.breakdown.real.percentage)],
['Suspicious %', ...results.map(r => r.breakdown.suspicious.percentage)],
['Fake %', ...results.map(r => r.breakdown.fake.percentage)],
['Est. Real Audience', ...results.map(r => r.estimatedRealAudience)],
['Verdict', ...results.map(r => r.verdict.label)],
];
// Simple table output
const colWidth = 20;
console.log(headers.map(h => h.padEnd(colWidth)).join(''));
console.log('-'.repeat(colWidth * headers.length));
rows.forEach(row => {
console.log(row.map(cell => String(cell).padEnd(colWidth)).join(''));
});
}
// Compare two creators
compareAudiences(['creator_a', 'creator_b']);
This is particularly useful when a brand is deciding between two influencers at similar price points. The creator with fewer followers but a higher quality score often delivers better campaign results.
Python Version
import requests
import time
from collections import Counter
API_KEY = "your_sociavault_api_key"
BASE_URL = "https://api.sociavault.com"
HEADERS = {"X-API-Key": API_KEY}
def get_profile(handle):
resp = requests.get(
f"{BASE_URL}/v1/scrape/twitter/profile",
params={"handle": handle},
headers=HEADERS
)
data = resp.json().get("data", resp.json())
return {
"user_id": data.get("rest_id") or data.get("id_str"),
"handle": data.get("screen_name", handle),
"followers": data.get("followers_count", 0),
"following": data.get("friends_count", 0),
"tweets": data.get("statuses_count", 0),
}
def sample_followers(user_id, sample_size=500):
followers = []
cursor = None
while len(followers) < sample_size:
params = {"user_id": user_id}
if cursor:
params["cursor"] = cursor
resp = requests.get(
f"{BASE_URL}/v1/scrape/twitter/followers",
params=params,
headers=HEADERS
)
data = resp.json().get("data", resp.json())
batch = data.get("users") or data.get("followers") or []
if not batch:
break
followers.extend(batch)
cursor = data.get("next_cursor") or data.get("cursor")
if not cursor:
break
time.sleep(0.3)
return followers[:sample_size]
def score_follower(f):
score = 50
flags = []
# Avatar
img = f.get("profile_image_url", "")
if "default_profile" in img or not img:
score -= 15
flags.append("default_avatar")
else:
score += 10
# Bio
bio = f.get("description", "") or ""
if len(bio) > 10:
score += 10
elif len(bio) == 0:
score -= 10
flags.append("no_bio")
# Tweet count
tweets = f.get("statuses_count", 0)
if tweets == 0:
score -= 20
flags.append("zero_tweets")
elif tweets < 5:
score -= 10
flags.append("low_tweets")
elif tweets > 50:
score += 10
# Following ratio
following = f.get("friends_count", 0)
followers = f.get("followers_count", 0)
if following > 2000 and followers < 50:
score -= 25
flags.append("mass_following")
if following > 5000:
score -= 10
flags.append("excessive_following")
score = max(0, min(100, score))
classification = "real" if score >= 70 else ("suspicious" if score >= 40 else "fake")
return {"handle": f.get("screen_name", "?"), "score": score, "flags": flags, "classification": classification}
def audit_account(handle, sample_size=500):
profile = get_profile(handle)
print(f"\nAuditing @{profile['handle']} ({profile['followers']:,} followers)")
followers = sample_followers(profile["user_id"], sample_size)
print(f"Sampled {len(followers)} followers")
scored = [score_follower(f) for f in followers]
total = len(scored)
real = sum(1 for s in scored if s["classification"] == "real")
suspicious = sum(1 for s in scored if s["classification"] == "suspicious")
fake = sum(1 for s in scored if s["classification"] == "fake")
avg = sum(s["score"] for s in scored) / total
real_pct = real / total * 100
est_real = round(profile["followers"] * (real_pct / 100))
all_flags = [flag for s in scored for flag in s["flags"]]
top_flags = Counter(all_flags).most_common(5)
print(f"\nQuality Score: {avg:.0f}/100")
print(f"Real: {real_pct:.1f}% | Suspicious: {suspicious/total*100:.1f}% | Fake: {fake/total*100:.1f}%")
print(f"Estimated real audience: ~{est_real:,}")
print(f"\nTop flags: {', '.join(f'{flag}: {count}' for flag, count in top_flags)}")
return {"profile": profile, "scored": scored, "avg_score": avg, "real_pct": real_pct}
audit_account("target_account")
Cost Breakdown
How many credits does an audit cost?
| Audit depth | Followers sampled | API calls | Credits |
|---|---|---|---|
| Quick check | 100 | ~5 pages + 1 profile | ~6 |
| Standard | 500 | ~15 pages + 1 profile | ~16 |
| Deep audit | 1,000 | ~30 pages + 1 profile | ~31 |
| Comparison (2 accounts) | 300 each | ~20 pages + 2 profiles | ~22 |
For context, auditing an account with 500 sampled followers tells you roughly the same thing as sampling 5,000 — the distribution stabilizes quickly. Don't over-sample.
What to Watch For
Common patterns the audit reveals:
Purchased followers: High percentage of accounts with zero tweets, no bio, default avatars, and following thousands of accounts. These are bulk-bought bot follows.
Follow-for-follow growth: Large numbers of followers who also follow thousands of accounts but have low individual follower counts. The creator grew by follow/unfollow tactics rather than organic content.
Engagement pods: If followers have unusually high engagement on the creator's tweets but almost no followers of their own, they may be part of a fake engagement ring.
Dormant audience: Followers that scored as "real" but haven't tweeted in 6+ months. The audience was real at some point but has gone inactive. Common with accounts that grew early and stopped being interesting.
Geographic anomalies: If an English-speaking creator in the US has 40% of followers with bios in a language that doesn't match their content market, that's worth flagging.
Engagement vs. Audience Quality
The audit is most useful when combined with engagement analysis. Pull recent tweets and compare:
async function engagementVsQuality(handle) {
const profile = await getProfile(handle);
// Get recent tweets
const tweetsRes = await axios.get(`${BASE_URL}/v1/scrape/twitter/user-tweets`, {
params: { handle },
headers: { 'X-API-Key': API_KEY },
});
const tweets = tweetsRes.data.data || tweetsRes.data || [];
const recentTweets = tweets.slice(0, 20);
// Calculate avg engagement rate
let totalEngagement = 0;
recentTweets.forEach(tweet => {
const stats = tweet.legacy || tweet.statistics || tweet;
const likes = stats.favorite_count || stats.like_count || 0;
const retweets = stats.retweet_count || 0;
const replies = stats.reply_count || 0;
totalEngagement += likes + retweets + replies;
});
const avgEngagement = recentTweets.length > 0
? totalEngagement / recentTweets.length
: 0;
const engagementRate = profile.followers > 0
? ((avgEngagement / profile.followers) * 100).toFixed(3)
: 0;
// Run follower audit
const followers = await sampleFollowers(profile.userId, 300);
const scored = followers.map(scoreFollower);
const realPct = (scored.filter(f => f.classification === 'real').length / scored.length * 100).toFixed(1);
console.log(`\n@${handle}`);
console.log(`Engagement rate: ${engagementRate}%`);
console.log(`Real followers: ${realPct}%`);
// The key insight
const adjustedFollowers = Math.round(profile.followers * (parseFloat(realPct) / 100));
const adjustedRate = adjustedFollowers > 0
? ((avgEngagement / adjustedFollowers) * 100).toFixed(3)
: 0;
console.log(`\nReported engagement rate: ${engagementRate}% (based on ${profile.followers.toLocaleString()} followers)`);
console.log(`Adjusted engagement rate: ${adjustedRate}% (based on ~${adjustedFollowers.toLocaleString()} real followers)`);
}
The adjusted engagement rate is often the number that matters. A creator with 2% engagement and 80% real followers is performing very differently from one with 2% engagement and 40% real followers — the second creator's real audience engagement is actually much higher per real follower, but the inflated follower count masks it.
Get Started
Sign up free — get API credits and start auditing Twitter followers in minutes.
Full Twitter/X API docs: docs.sociavault.com/api-reference/twitter/followers
Related Reading
Found this helpful?
Share it with others who might benefit
Ready to Try SociaVault?
Start extracting social media data with our powerful API. No credit card required.