Back to Blog
Tutorial

Twitter Follower Audit: Analyze Any Account's Audience Quality

April 6, 2026
14 min read
S
By SociaVault Team
TwitterXFollower AuditAudience QualityFake FollowersBot DetectionAPI

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:

SignalHealthySuspicious
Profile pictureCustom photoDefault/egg avatar
BioHas textEmpty
Tweet count10+ tweets0 tweets
Account age6+ monthsCreated in last 30 days
Follower/following ratioReasonable (0.1x–10x)Follows 5,000+, has < 50 followers
VerifiedAnyN/A (not a signal by itself)
Recent activityTweeted in last 90 daysNo 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 depthFollowers sampledAPI callsCredits
Quick check100~5 pages + 1 profile~6
Standard500~15 pages + 1 profile~16
Deep audit1,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


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.