Skip to main content

How to Analyze Competitors on Twitter Using the API

This guide describes a complete workflow for analyzing competitors on X (formerly Twitter) using Sorsa API: profile benchmarking, content strategy decomposition, audience composition, public sentiment, and share of voice. Each phase maps to a specific endpoint, and the phases compose into a single weekly report you can run on a schedule. All examples use Python 3.8+ with requests. Replace YOUR_API_KEY with your actual key in every snippet. The complete consolidated script with all helper functions is at the bottom of this page.
Note: For a narrative walkthrough with strategy context and worked examples, see Twitter Competitor Analysis: A Developer’s Guide on the blog.
No-code option: For ad-hoc side-by-side comparison without writing code, use the Profile Comparison Tool. It returns followers, engagement rate, average likes and retweets per tweet, posting frequency, and account age for any two handles. The Engagement Calculator covers per-tweet engagement-rate math for a single account.

Setup

import requests
import time
import csv
from pathlib import Path
from datetime import date, datetime, timedelta

API_KEY = "YOUR_API_KEY"
BASE = "https://api.sorsa.io/v3"
HEADERS = {"ApiKey": API_KEY}
JSON_HEADERS = {**HEADERS, "Content-Type": "application/json"}
The ApiKey header authenticates every request. See Authentication for details.

Phase 1: Profile Benchmarking

Endpoints: GET /v3/info, GET /v3/info-batch Establish the baseline: follower count, tweet volume, account age, bio, verified status. Use /info-batch to fetch up to 100 profiles in a single request, which counts as one request against your quota.

Snapshot script

def get_profiles(usernames):
    """Fetch profiles for up to 100 accounts in a single API call."""
    resp = requests.get(
        f"{BASE}/info-batch",
        headers=HEADERS,
        params=[("usernames", u) for u in usernames],
        timeout=30,
    )
    resp.raise_for_status()
    return resp.json().get("users", [])


competitors = ["stripe", "wise", "revolutapp"]
profiles = get_profiles(competitors)

print(f"{'Handle':<18} {'Followers':>12} {'Tweets':>10} {'Following':>10} {'Verified':>10}")
print("-" * 64)
for p in profiles:
    print(
        f"@{p['username']:<17} "
        f"{p['followers_count']:>12,} "
        f"{p['tweets_count']:>10,} "
        f"{p['followings_count']:>10,} "
        f"{str(p.get('verified', False)):>10}"
    )

Tracking growth over time

A single snapshot has no analytical value. Log snapshots on a daily or weekly schedule (cron, GitHub Actions, etc.) and compute deltas:
def log_snapshot(profiles, output_file="snapshots.csv"):
    """Append today's snapshot to a running CSV log."""
    file_exists = Path(output_file).exists()
    today = date.today().isoformat()
    with open(output_file, "a", newline="") as f:
        writer = csv.writer(f)
        if not file_exists:
            writer.writerow(["date", "username", "followers", "tweets", "following"])
        for p in profiles:
            writer.writerow([
                today,
                p["username"],
                p["followers_count"],
                p["tweets_count"],
                p["followings_count"],
            ])


def compute_growth(csv_file, username, days=7):
    """Compute follower growth rate over the last N days."""
    with open(csv_file) as f:
        rows = [r for r in csv.DictReader(f) if r["username"] == username]
    if len(rows) < 2:
        return None
    latest = int(rows[-1]["followers"])
    earlier = int(rows[max(0, len(rows) - days - 1)]["followers"])
    if earlier == 0:
        return None
    return ((latest - earlier) / earlier) * 100


log_snapshot(profiles)
for handle in competitors:
    g = compute_growth("snapshots.csv", handle, days=7)
    if g is not None:
        print(f"@{handle}: {g:+.2f}% weekly follower growth")
The growth formula:
Growth Rate % = ((Followers Today - Followers N Days Ago) / Followers N Days Ago) * 100

Bio and positioning changes

/info returns description, location, bio_urls, and created_at. Diff these across snapshots to detect positioning shifts (bio changes, link destination changes) at zero additional cost.

Phase 2: Content Strategy

Endpoints: POST /v3/user-tweets, POST /v3/search-tweets Pull a competitor’s recent tweets and decompose their content mix: original posts vs. replies vs. quotes vs. retweets, average engagement, top-performing posts.

Fetching recent tweets

/user-tweets returns up to 20 tweets per page. Unlike the official X API, there is no 3,200-tweet historical cap, so pagination can reach the account’s first tweet.
def fetch_user_tweets(username, max_pages=10):
    """Pull a competitor's recent tweets via pagination."""
    all_tweets = []
    cursor = None

    for _ in range(max_pages):
        body = {"username": username}
        if cursor:
            body["next_cursor"] = cursor

        resp = requests.post(
            f"{BASE}/user-tweets",
            headers=JSON_HEADERS,
            json=body,
            timeout=30,
        )
        resp.raise_for_status()
        data = resp.json()

        all_tweets.extend(data.get("tweets", []))
        cursor = data.get("next_cursor")
        if not cursor:
            break
        time.sleep(0.1)

    return all_tweets

Decomposing the content mix

def analyze_content(tweets, username):
    if not tweets:
        return None

    total = len(tweets)
    likes = [t.get("likes_count", 0) for t in tweets]
    retweets = [t.get("retweet_count", 0) for t in tweets]
    replies = [t.get("reply_count", 0) for t in tweets]

    original = sum(1 for t in tweets if not t.get("is_reply") and not t.get("retweeted_status"))
    reply_count = sum(1 for t in tweets if t.get("is_reply"))
    quote_count = sum(1 for t in tweets if t.get("is_quote_status"))
    with_media = sum(1 for t in tweets if t.get("entities"))

    top_tweet = max(tweets, key=lambda t: t.get("likes_count", 0))

    return {
        "username": username,
        "sample_size": total,
        "avg_likes": sum(likes) / total,
        "avg_retweets": sum(retweets) / total,
        "avg_replies": sum(replies) / total,
        "original_pct": original / total * 100,
        "reply_pct": reply_count / total * 100,
        "quote_pct": quote_count / total * 100,
        "media_pct": with_media / total * 100,
        "top_tweet_likes": top_tweet.get("likes_count", 0),
        "top_tweet_text": top_tweet.get("full_text", "")[:200],
    }


for handle in competitors:
    tweets = fetch_user_tweets(handle, max_pages=10)
    result = analyze_content(tweets, handle)
    if result:
        print(f"\n@{result['username']} (n={result['sample_size']})")
        print(f"  Avg likes/tweet:     {result['avg_likes']:.1f}")
        print(f"  Avg retweets/tweet:  {result['avg_retweets']:.1f}")
        print(f"  Content mix: {result['original_pct']:.0f}% original / "
              f"{result['reply_pct']:.0f}% replies / {result['quote_pct']:.0f}% quotes / "
              f"{result['media_pct']:.0f}% with media")
        print(f"  Top tweet: ({result['top_tweet_likes']} likes) {result['top_tweet_text']}")

Historical comparison

To compare two time windows for the same account (e.g., Q1 vs Q4), switch from /user-tweets to /search-tweets and use the since: and until: operators. See Search Operators for the full syntax and Historical Data for backfill patterns.
def fetch_tweets_in_range(username, since_date, until_date):
    query = f"from:{username} since:{since_date} until:{until_date}"
    resp = requests.post(
        f"{BASE}/search-tweets",
        headers=JSON_HEADERS,
        json={"query": query, "order": "latest"},
        timeout=30,
    )
    resp.raise_for_status()
    return resp.json().get("tweets", [])


q1_tweets = fetch_tweets_in_range("stripe", "2026-01-01", "2026-04-01")
q4_tweets = fetch_tweets_in_range("stripe", "2025-10-01", "2026-01-01")

Phase 3: Audience Composition

Endpoints: GET /v3/followers, GET /v3/verified-followers, GET /v3/followers-stats A competitor’s follower list reveals who their audience is. There are two approaches with different cost profiles.

Verified followers (low cost)

/verified-followers returns only verified accounts following a handle. This is the highest-signal slice of any audience and is dramatically cheaper than pulling the full follower graph.
def fetch_verified_followers(username, max_pages=10):
    all_users = []
    cursor = None
    for _ in range(max_pages):
        params = {"username": username}
        if cursor:
            params["next_cursor"] = cursor
        resp = requests.get(
            f"{BASE}/verified-followers",
            headers=HEADERS,
            params=params,
            timeout=30,
        )
        resp.raise_for_status()
        data = resp.json()
        all_users.extend(data.get("users", []))
        cursor = data.get("next_cursor")
        if not cursor:
            break
        time.sleep(0.1)
    return all_users


for handle in competitors:
    verified = fetch_verified_followers(handle, max_pages=5)
    top = sorted(verified, key=lambda u: u.get("followers_count", 0), reverse=True)[:10]
    print(f"\n@{handle}: {len(verified)} verified followers fetched")
    for u in top:
        print(f"  @{u['username']:<25} {u['followers_count']:>10,} followers")
Diff verified-follower lists across snapshots to detect new high-authority followers per competitor. Journalist follow events often precede coverage by 2-4 weeks.

Full follower extraction (high cost)

/followers returns up to 200 profiles per page. For an account with 1M followers, full extraction is roughly 5,000 requests. Plan accordingly: see Pricing for plan limits and Optimizing API Usage for batch and budget patterns.
def fetch_all_followers(username, max_pages=200):
    """Pull all followers via pagination. Cost scales with account size."""
    all_users = []
    cursor = None
    for _ in range(max_pages):
        params = {"username": username}
        if cursor:
            params["next_cursor"] = cursor
        resp = requests.get(
            f"{BASE}/followers",
            headers=HEADERS,
            params=params,
            timeout=30,
        )
        resp.raise_for_status()
        data = resp.json()
        all_users.extend(data.get("users", []))
        cursor = data.get("next_cursor")
        if not cursor:
            break
        time.sleep(0.1)
    return all_users

Audience overlap

Once you have follower lists for two accounts, compute overlap with set intersection on user IDs:
followers_a = {u["id"] for u in fetch_all_followers("competitor_a")}
followers_b = {u["id"] for u in fetch_all_followers("competitor_b")}

overlap = followers_a & followers_b
only_a = followers_a - followers_b
only_b = followers_b - followers_a

print(f"Shared audience: {len(overlap):,}")
print(f"Unique to @competitor_a: {len(only_a):,}")
print(f"Unique to @competitor_b: {len(only_b):,}")

overlap_ratio = len(overlap) / min(len(followers_a), len(followers_b))
print(f"Overlap ratio: {overlap_ratio:.1%}")
For a deeper dive on follower extraction patterns, see Followers and Following.

Crypto and Web3 follower breakdown

For accounts in Sorsa’s crypto database, /followers-stats returns a categorical breakdown: influencers, projects, VCs. See Sorsa Score and Crypto Analytics for context.
def get_follower_breakdown(username):
    resp = requests.get(
        f"{BASE}/followers-stats",
        headers=HEADERS,
        params={"username": username},
        timeout=30,
    )
    resp.raise_for_status()
    return resp.json()


for handle in ["VitalikButerin", "saylor"]:
    stats = get_follower_breakdown(handle)
    print(f"\n@{handle}:")
    print(f"  Tracked followers: {stats['followers_count']}")
    print(f"  Influencers:       {stats['influencers_count']}")
    print(f"  Projects:          {stats['projects_count']}")
    print(f"  VCs:               {stats['venture_capitals_count']}")
Counts only include accounts already tracked in the Sorsa crypto database.

Phase 4: Public Sentiment and Mentions

Endpoint: POST /v3/mentions /mentions supports filtering by minimum likes, retweets, replies, and date ranges. Filtering by min_likes cuts low-signal noise (bot replies, auto-tags) from the mention stream. See Track Mentions for the full filter set.

Pulling high-engagement mentions

def fetch_mentions(handle, min_likes=10, since_date=None, until_date=None, max_pages=5):
    all_mentions = []
    cursor = None
    for _ in range(max_pages):
        body = {"query": handle, "order": "popular", "min_likes": min_likes}
        if since_date:
            body["since_date"] = since_date
        if until_date:
            body["until_date"] = until_date
        if cursor:
            body["next_cursor"] = cursor

        resp = requests.post(
            f"{BASE}/mentions",
            headers=JSON_HEADERS,
            json=body,
            timeout=30,
        )
        resp.raise_for_status()
        data = resp.json()
        all_mentions.extend(data.get("tweets", []))
        cursor = data.get("next_cursor")
        if not cursor:
            break
        time.sleep(0.1)
    return all_mentions

Sentiment classification with VADER

VADER is an open-source sentiment library tuned for social media text. It runs locally with no per-call cost and handles negation, intensifiers, and emoji reasonably well.
# pip install vaderSentiment
from vaderSentiment.vaderSentiment import SentimentIntensityAnalyzer

analyzer = SentimentIntensityAnalyzer()

def classify_sentiment(mentions):
    results = {"positive": [], "negative": [], "neutral": []}
    for m in mentions:
        score = analyzer.polarity_scores(m["full_text"])["compound"]
        if score >= 0.05:
            results["positive"].append((score, m))
        elif score <= -0.05:
            results["negative"].append((score, m))
        else:
            results["neutral"].append((score, m))
    return results


for handle in competitors:
    mentions = fetch_mentions(handle, min_likes=10, max_pages=5)
    s = classify_sentiment(mentions)
    print(f"\n@{handle}: {len(mentions)} mentions analyzed")
    print(f"  Positive: {len(s['positive'])}  Negative: {len(s['negative'])}  Neutral: {len(s['neutral'])}")
    if s["negative"]:
        worst = min(s["negative"], key=lambda x: x[0])
        text = worst[1]["full_text"][:150].replace("\n", " ")
        print(f"  Sharpest negative: {text}...")
For higher accuracy on sarcasm, technical complaints, or mixed sentiment, pipe full_text into an LLM API (OpenAI, Anthropic). A hybrid approach (VADER for filtering, LLM only on flagged or high-engagement mentions) keeps cost predictable.

Phase 5: Share of Voice

Share of voice (SOV) measures one brand’s mention volume against the category total. The formula:
SOV = (your mentions in period) / (your mentions + sum of competitor mentions in period)
Implementation:
def count_mentions(handle, days=7, min_likes=0):
    until = datetime.utcnow().date().isoformat()
    since = (datetime.utcnow() - timedelta(days=days)).date().isoformat()
    mentions = fetch_mentions(
        handle,
        min_likes=min_likes,
        since_date=since,
        until_date=until,
        max_pages=20,
    )
    return len(mentions)


brand = "your_handle"
your_mentions = count_mentions(brand, days=7, min_likes=5)
competitor_mentions = {h: count_mentions(h, days=7, min_likes=5) for h in competitors}

total = your_mentions + sum(competitor_mentions.values())
print(f"\nShare of voice, last 7 days (min 5 likes):")
print(f"  @{brand:<20} {your_mentions:>5}  ({your_mentions/total*100:.1f}%)")
for h, n in sorted(competitor_mentions.items(), key=lambda x: -x[1]):
    print(f"  @{h:<20} {n:>5}  ({n/total*100:.1f}%)")
Notes:
  • Filter by minimum engagement (min_likes=5 is a reasonable floor) to exclude bot and spam noise.
  • Track week-over-week deltas, not absolute snapshots. Category-level events skew absolute numbers in ways that obscure your own movement.
  • To compute category-keyword SOV (e.g., “embedded finance” rather than brand mentions), replace /mentions with /search-tweets and use the keyword as the denominator query.

Consolidated Weekly Report Script

This script combines all five phases. Drop it into a cron job, GitHub Actions schedule, or any task runner.
import requests
import csv
import time
from datetime import date, datetime, timedelta
from pathlib import Path
from vaderSentiment.vaderSentiment import SentimentIntensityAnalyzer

API_KEY = "YOUR_API_KEY"
BASE = "https://api.sorsa.io/v3"
HEADERS = {"ApiKey": API_KEY}
JSON_HEADERS = {**HEADERS, "Content-Type": "application/json"}

BRAND = "your_handle"
COMPETITORS = ["competitor1", "competitor2", "competitor3"]
SNAPSHOT_FILE = "snapshots.csv"

analyzer = SentimentIntensityAnalyzer()


def get_profiles(usernames):
    resp = requests.get(
        f"{BASE}/info-batch",
        headers=HEADERS,
        params=[("usernames", u) for u in usernames],
        timeout=30,
    )
    resp.raise_for_status()
    return resp.json().get("users", [])


def log_snapshot(profiles, output_file=SNAPSHOT_FILE):
    file_exists = Path(output_file).exists()
    today = date.today().isoformat()
    with open(output_file, "a", newline="") as f:
        writer = csv.writer(f)
        if not file_exists:
            writer.writerow(["date", "username", "followers", "tweets", "following"])
        for p in profiles:
            writer.writerow([
                today, p["username"], p["followers_count"],
                p["tweets_count"], p["followings_count"],
            ])


def compute_growth(csv_file, username, days=7):
    with open(csv_file) as f:
        rows = [r for r in csv.DictReader(f) if r["username"] == username]
    if len(rows) < 2:
        return None
    latest = int(rows[-1]["followers"])
    earlier = int(rows[max(0, len(rows) - days - 1)]["followers"])
    if earlier == 0:
        return None
    return ((latest - earlier) / earlier) * 100


def fetch_user_tweets(username, max_pages=5):
    all_tweets = []
    cursor = None
    for _ in range(max_pages):
        body = {"username": username}
        if cursor:
            body["next_cursor"] = cursor
        resp = requests.post(f"{BASE}/user-tweets", headers=JSON_HEADERS, json=body, timeout=30)
        resp.raise_for_status()
        data = resp.json()
        all_tweets.extend(data.get("tweets", []))
        cursor = data.get("next_cursor")
        if not cursor:
            break
        time.sleep(0.1)
    return all_tweets


def analyze_content(tweets, username):
    if not tweets:
        return None
    total = len(tweets)
    likes = [t.get("likes_count", 0) for t in tweets]
    original = sum(1 for t in tweets if not t.get("is_reply") and not t.get("retweeted_status"))
    with_media = sum(1 for t in tweets if t.get("entities"))
    return {
        "username": username,
        "sample_size": total,
        "avg_likes": sum(likes) / total,
        "original_pct": original / total * 100,
        "media_pct": with_media / total * 100,
    }


def fetch_verified_followers(username, max_pages=3):
    all_users = []
    cursor = None
    for _ in range(max_pages):
        params = {"username": username}
        if cursor:
            params["next_cursor"] = cursor
        resp = requests.get(f"{BASE}/verified-followers", headers=HEADERS, params=params, timeout=30)
        resp.raise_for_status()
        data = resp.json()
        all_users.extend(data.get("users", []))
        cursor = data.get("next_cursor")
        if not cursor:
            break
        time.sleep(0.1)
    return all_users


def fetch_mentions(handle, min_likes=10, since_date=None, until_date=None, max_pages=5):
    all_mentions = []
    cursor = None
    for _ in range(max_pages):
        body = {"query": handle, "order": "popular", "min_likes": min_likes}
        if since_date:
            body["since_date"] = since_date
        if until_date:
            body["until_date"] = until_date
        if cursor:
            body["next_cursor"] = cursor
        resp = requests.post(f"{BASE}/mentions", headers=JSON_HEADERS, json=body, timeout=30)
        resp.raise_for_status()
        data = resp.json()
        all_mentions.extend(data.get("tweets", []))
        cursor = data.get("next_cursor")
        if not cursor:
            break
        time.sleep(0.1)
    return all_mentions


def classify_sentiment(mentions):
    results = {"positive": [], "negative": [], "neutral": []}
    for m in mentions:
        score = analyzer.polarity_scores(m["full_text"])["compound"]
        if score >= 0.05:
            results["positive"].append((score, m))
        elif score <= -0.05:
            results["negative"].append((score, m))
        else:
            results["neutral"].append((score, m))
    return results


def count_mentions(handle, days=7, min_likes=0):
    until = datetime.utcnow().date().isoformat()
    since = (datetime.utcnow() - timedelta(days=days)).date().isoformat()
    return len(fetch_mentions(handle, min_likes=min_likes, since_date=since, until_date=until, max_pages=20))


def header(text):
    line = "=" * 64
    print(f"\n{line}\n{text}\n{line}")


def run_weekly_report():
    header("PHASE 1: PROFILE BENCHMARKS")
    profiles = get_profiles(COMPETITORS + [BRAND])
    print(f"{'Handle':<18} {'Followers':>12} {'Tweets':>10} {'Verified':>10}")
    for p in profiles:
        print(f"@{p['username']:<17} {p['followers_count']:>12,} "
              f"{p['tweets_count']:>10,} {str(p.get('verified', False)):>10}")
    log_snapshot(profiles)
    for h in COMPETITORS + [BRAND]:
        g = compute_growth(SNAPSHOT_FILE, h, days=7)
        if g is not None:
            print(f"  @{h}: {g:+.2f}% weekly follower growth")

    header("PHASE 2: CONTENT STRATEGY")
    for handle in COMPETITORS:
        tweets = fetch_user_tweets(handle, max_pages=5)
        result = analyze_content(tweets, handle)
        if result:
            print(f"@{result['username']}: avg {result['avg_likes']:.0f} likes/tweet, "
                  f"{result['original_pct']:.0f}% original, "
                  f"{result['media_pct']:.0f}% with media")

    header("PHASE 3: VERIFIED FOLLOWERS")
    for handle in COMPETITORS:
        verified = fetch_verified_followers(handle, max_pages=3)
        print(f"@{handle}: {len(verified)} verified followers in top pages")

    header("PHASE 4: SENTIMENT")
    for handle in COMPETITORS:
        mentions = fetch_mentions(handle, min_likes=10, max_pages=3)
        s = classify_sentiment(mentions)
        print(f"@{handle}: {len(s['positive'])} pos / {len(s['negative'])} neg "
              f"/ {len(s['neutral'])} neutral (n={len(mentions)})")

    header("PHASE 5: SHARE OF VOICE (7d)")
    your_n = count_mentions(BRAND, days=7, min_likes=5)
    comp_n = {h: count_mentions(h, days=7, min_likes=5) for h in COMPETITORS}
    total = your_n + sum(comp_n.values())
    if total:
        print(f"  @{BRAND}: {your_n} ({your_n/total*100:.1f}%)")
        for h, n in sorted(comp_n.items(), key=lambda x: -x[1]):
            print(f"  @{h}: {n} ({n/total*100:.1f}%)")


if __name__ == "__main__":
    run_weekly_report()
For three competitors with weekly cadence, total cost runs roughly 150-200 requests per execution, or under 1,000 requests per month at default depth.

Next Steps