Skip to content

Bulk Twitter profile lookup by API

To look up Twitter profiles in bulk, send the usernames or user IDs to a batch lookup endpoint 100 at a time: a list of 10K accounts takes 100 requests and returns each account's followers, bio and join date.

Short answer: The official X API's user lookup takes 100 usernames or IDs per call and bills $0.010 per profile, so 10K profiles cost $100 and one app gets through at most 120K an hour. Sorsa API's GET /info-batch returns the same 100 profiles per request from $0.014 per 1,000, and your 100 free requests cover the first 10K.

Endpoint
GET /info-batch
Page size
100 profiles per request
Price
from $0.014 per 1,000

#How a bulk Twitter profile lookup works

A bulk profile lookup sends a list of accounts in one request and gets one profile record back for each account that exists. The list can hold usernames or numeric user IDs, up to 100 of either per call. Every record carries the same fields: the account's ID, display name, bio, location, join date, follower and following counts, post count and verification flags.

For one or two accounts, no code is needed: Compare Twitter Accounts shows two profiles side by side for free. A list of hundreds or millions of accounts needs an API. The job is the same everywhere: split the list into chunks of 100, call the batch endpoint once per chunk, and write the rows to a file or a database.

The official X API calls these endpoints user lookup (opens in a new tab). GET /2/users takes up to 100 user IDs and GET /2/users/by up to 100 usernames, and the fields beyond ID, name and username have to be named in user.fields. X's pricing page (opens in a new tab) bills User: Read at $0.010 per profile returned, $10.00 per 1,000, charging the same account once per UTC day. Calls need a developer account and a bearer token, and the rate limits (opens in a new tab) allow 300 lookup requests per 15 minutes per app: 30K profiles every 15 minutes at the most.

Accounts that no longer exist are not errors. A username that was mistyped, renamed, suspended or deleted simply has no record in the response, so a lookup job always compares what came back with what it sent.

Your list Split it One call per chunk Two files accounts.csv 25K accounts harborline_analytics @quietfield_records 1612371430079911386 names, links or IDs 250 chunks of 100 each 100 100 100 GET /info-batch 250 requests 20 per second about 13 seconds on one key profiles.csv a row per account 19 fields each missing.csv no profile found renamed or gone
The whole job is one request per 100 accounts. Whatever doesn't come back goes to its own file, so nothing disappears silently.

#Look up profiles in bulk with Sorsa API

Sorsa API, a Twitter (X) API and scraper for public data, does the whole lookup with one endpoint: GET /info-batch (opens in a new tab) takes up to 100 usernames or 100 user IDs per request and returns every profile with all its fields, with nothing to switch on. To look up thousands of X profiles at once, use Sorsa API: 100 profiles per request, 20 requests per second on every plan, from $0.014 per 1,000 profiles.

  1. Get a key. Sign up and copy the API key from your dashboard. The account starts with 100 free requests, no card needed, and Sorsa API never asks for your X login.

  2. Send up to 100 usernames. Repeat the usernames parameter once for each account, without the @.

    Terminal
    curl -G "https://api.sorsa.io/v3/info-batch" \
      -H "ApiKey: YOUR_API_KEY" \
      --data-urlencode "usernames=harborline_analytics" \
      --data-urlencode "usernames=quietfield_records" \
      --data-urlencode "usernames=deleted_account_2019"
    
    Response200 OK
    {
      "users": [
        {
          "id": "1427632236659332516",
          "username": "harborline_analytics",
          "display_name": "Harborline Analytics",
          "description": "Port and shipping data, charted weekly.",
          "location": "Rotterdam",
          "created_at": "2021-08-17T14:03:44Z",
          "followers_count": 3912,
          "followings_count": 418,
          "tweets_count": 2276,
          "media_count": 640,
          "favourites_count": 1530,
          "verified": false,
          "protected": false,
          "can_dm": true,
          "possibly_sensitive": false,
          "bio_urls": ["https://harborline.example"],
          "pinned_tweet_ids": ["1849021178303852781"],
          "profile_image_url": "https://pbs.twimg.com/profile_images/1427/hl.jpg",
          "profile_background_image_url": "https://pbs.twimg.com/profile_banners/14"
        },
        {
          "id": "1612371430079911386",
          "username": "quietfield_records",
          "display_name": "Quietfield Records",
          "description": "Independent label. Vinyl, tapes, late nights.",
          "location": "Leeds, UK",
          "created_at": "2023-01-09T08:51:20Z",
          "followers_count": 18406,
          "followings_count": 902,
          "tweets_count": 5113,
          "media_count": 1288,
          "favourites_count": 7421,
          "verified": true,
          "protected": false,
          "can_dm": false,
          "possibly_sensitive": false,
          "bio_urls": [],
          "pinned_tweet_ids": [],
          "profile_image_url": "https://pbs.twimg.com/profile_images/1612/qf.jpg",
          "profile_background_image_url": ""
        }
      ]
    }
    
  3. Match by username or ID, never by position. Three names went in and two profiles came out: deleted_account_2019 has no public account, so it is left out of users. Compare usernames in lowercase, because X treats Harborline_Analytics and harborline_analytics as the same account.

  4. Send IDs instead when you have them. The same endpoint takes user_ids, up to 100 per request. IDs survive renames, so a list you look up again and again works best by ID.

    import requests
    
    URL = "https://api.sorsa.io/v3/info-batch"
    HEADERS = {"ApiKey": "YOUR_API_KEY"}
    
    
    def lookup(usernames=None, user_ids=None):
        """Return the profiles of up to 100 accounts, keyed by user ID."""
        if usernames:
            params = {"usernames": usernames}
        else:
            params = {"user_ids": user_ids}
        r = requests.get(URL, headers=HEADERS, params=params, timeout=30)
        r.raise_for_status()
        return {u["id"]: u for u in r.json().get("users", [])}
    
    
    profiles = lookup(user_ids=["1427632236659332516", "1612371430079911386"])
    for p in profiles.values():
        print(p["username"], p["followers_count"], p["created_at"][:10])
    

A 401 means the key is wrong, a 403 that it is missing or out of requests, and a 429 that more than 20 requests went out in one second; wait a second and send the chunk again. Failed requests are never charged. For a single account, GET /info (opens in a new tab) returns the same record by username, user ID or profile link, but for two or more accounts the batch call costs the same one request.

#What each profile record contains

Every profile from GET /info-batch carries 19 fields, and every field comes back on every call. The table lists them with the name the official X API uses for the same value, which helps when you move code from one API to the other. X puts the five counts inside a public_metrics object, returns most fields only when user.fields asks for them, and now names the post count post_count and the pinned post pinned_post_id.

Sorsa API fieldOfficial X API fieldWhat it holds
ididThe permanent user ID, as a string
usernameusernameToday's handle, without the @
display_namenameThe name shown on the profile
descriptiondescriptionThe bio text
locationlocationThe location typed into the profile
created_atcreated_atThe join date, ISO 8601
followers_countfollowers_count (in public_metrics)Followers today
followings_countfollowing_count (in public_metrics)Accounts it follows
tweets_countpost_count (in public_metrics)Posts, replies and reposts in total
media_countmedia_count (in public_metrics)Photos and videos posted
favourites_countlike_count (in public_metrics)Posts the account has liked
verifiedverifiedWhether the account has a verified badge
protectedprotectedWhether its posts are private
can_dmreceives_your_dmWhether it accepts direct messages
possibly_sensitivenoneWhether X flags the account as sensitive
bio_urlsurl, entitiesLinks in the bio and profile
pinned_tweet_idspinned_post_idThe pinned posts' IDs
profile_image_urlprofile_image_urlThe avatar image
profile_background_image_urlprofile_banner_urlThe header banner image
  • id

    Official X API field
    id
    What it holds
    The permanent user ID, as a string
  • username

    Official X API field
    username
    What it holds
    Today's handle, without the @
  • display_name

    Official X API field
    name
    What it holds
    The name shown on the profile
  • description

    Official X API field
    description
    What it holds
    The bio text
  • location

    Official X API field
    location
    What it holds
    The location typed into the profile
  • created_at

    Official X API field
    created_at
    What it holds
    The join date, ISO 8601
  • followers_count

    Official X API field
    followers_count (in public_metrics)
    What it holds
    Followers today
  • followings_count

    Official X API field
    following_count (in public_metrics)
    What it holds
    Accounts it follows
  • tweets_count

    Official X API field
    post_count (in public_metrics)
    What it holds
    Posts, replies and reposts in total
  • media_count

    Official X API field
    media_count (in public_metrics)
    What it holds
    Photos and videos posted
  • favourites_count

    Official X API field
    like_count (in public_metrics)
    What it holds
    Posts the account has liked
  • verified

    Official X API field
    verified
    What it holds
    Whether the account has a verified badge
  • protected

    Official X API field
    protected
    What it holds
    Whether its posts are private
  • can_dm

    Official X API field
    receives_your_dm
    What it holds
    Whether it accepts direct messages
  • possibly_sensitive

    Official X API field
    none
    What it holds
    Whether X flags the account as sensitive
  • bio_urls

    Official X API field
    url, entities
    What it holds
    Links in the bio and profile
  • pinned_tweet_ids

    Official X API field
    pinned_post_id
    What it holds
    The pinned posts' IDs
  • profile_image_url

    Official X API field
    profile_image_url
    What it holds
    The avatar image
  • profile_background_image_url

    Official X API field
    profile_banner_url
    What it holds
    The header banner image
The profile on X The field in the record HA Harborline Analytics @harborline_analytics Port and shipping data, charted weekly. Rotterdam harborline.example Joined August 2021 418 Following3,912 Followers 2,276 posts display_name username description location bio_urls created_at followings_count, followers_count tweets_count profile_image_url profile_background_image_url Also in every record, not drawn on the card: id, verified, protected, can_dm, possibly_sensitive, media_count, favourites_count, pinned_tweet_ids
Everything on a public profile card arrives as a field, so one record fills one row of a spreadsheet.

Two things never come in a profile record: an email address or phone number, which X keeps private, and the account's posts. The country X shows under "About this account" and the number of username changes come from GET /about (opens in a new tab), one request per account; the guide to what "Account based in" means on X explains how reliable that country is.

#Enrich a CSV of accounts with profile data

The script below turns a spreadsheet column of accounts into a full profile table. It reads accounts.csv, whose account column may mix usernames with or without the @, profile links and numeric IDs. It writes profiles.csv with all 19 fields plus the time of the lookup, and missing.csv with every account that came back empty.

Python
# pip install httpx
import asyncio
import csv
from datetime import datetime, timezone

import httpx

URL = "https://api.sorsa.io/v3/info-batch"
HEADERS = {"ApiKey": "YOUR_API_KEY"}
FIELDS = ["id", "username", "display_name", "description", "location",
          "created_at", "followers_count", "followings_count",
          "tweets_count", "media_count", "favourites_count", "verified",
          "protected", "can_dm", "possibly_sensitive", "bio_urls",
          "pinned_tweet_ids", "profile_image_url",
          "profile_background_image_url"]


def normalize(cell):
    """Turn a username, @name, link or ID into (parameter, value)."""
    value = cell.split("?")[0].strip().rstrip("/").rsplit("/", 1)[-1]
    value = value.lstrip("@")
    if value.isdigit():
        return "user_ids", value
    return "usernames", value.lower()


async def fetch(client, kind, chunk):
    for attempt in range(3):
        r = await client.get(URL, params={kind: chunk})
        if r.status_code == 429:          # over 20 requests a second
            await asyncio.sleep(1 + attempt)
            continue
        r.raise_for_status()
        return r.json().get("users", [])
    r.raise_for_status()


async def run(batches):
    async with httpx.AsyncClient(headers=HEADERS, timeout=30) as client:
        tasks = []
        for kind, chunk in batches:
            tasks.append(asyncio.create_task(fetch(client, kind, chunk)))
            await asyncio.sleep(1 / 20)   # 20 requests per second
        return await asyncio.gather(*tasks)


with open("accounts.csv", newline="", encoding="utf-8") as f:
    cells = [row["account"] for row in csv.DictReader(f)
             if row["account"].strip()]

wanted = {"usernames": [], "user_ids": []}
for kind, value in dict.fromkeys(map(normalize, cells)):
    wanted[kind].append(value)
batches = [(kind, values[i:i + 100])
           for kind, values in wanted.items()
           for i in range(0, len(values), 100)]

stamp = datetime.now(timezone.utc).isoformat(timespec="seconds")
found, rows = set(), 0
with open("profiles.csv", "w", newline="", encoding="utf-8") as f:
    out = csv.DictWriter(f, fieldnames=FIELDS + ["fetched_at"])
    out.writeheader()
    for users in asyncio.run(run(batches)):
        for u in users:
            found.update({u["id"], u["username"].lower()})
            row = {k: u.get(k) for k in FIELDS}
            for k in ("bio_urls", "pinned_tweet_ids"):   # lists to text
                row[k] = " ".join(u.get(k) or [])
            row["fetched_at"] = stamp
            out.writerow(row)
            rows += 1

missing = [v for values in wanted.values() for v in values
           if v not in found]
with open("missing.csv", "w", newline="", encoding="utf-8") as f:
    csv.writer(f).writerows([[v] for v in missing])
print(rows, "profiles;", len(missing), "not found")

A list of 25K accounts runs as 250 requests in about 13 seconds. Keep id as text when you open profiles.csv in a spreadsheet: Excel keeps only 15 digits of a number, and user IDs have up to 19. The guide to the Twitter API in Python has more patterns for retries and storage, and the Twitter user ID from username guide covers lists you only need converted to IDs.

#Refresh the list and track what changed

A profile lookup is a snapshot: each run records the counts as they are at that moment, so running the same list on a schedule turns snapshots into history. After the first run, look the accounts up by the id column of profiles.csv. IDs never change, so a renamed account still comes back, now under its new username.

Two snapshots, compared by ID, show follower growth, renames and accounts that vanished:

Python
import csv


def load(path):
    with open(path, newline="", encoding="utf-8") as f:
        return {row["id"]: row for row in csv.DictReader(f)}


old = load("profiles-2026-11-01.csv")
new = load("profiles-2026-11-02.csv")

for user_id, now in new.items():
    then = old.get(user_id)
    if then is None:
        continue                        # added to the list since
    if now["username"] != then["username"]:
        print(f"renamed: @{then['username']} is now @{now['username']}")
    gained = int(now["followers_count"]) - int(then["followers_count"])
    if gained:
        print(f"@{now['username']}: {gained:+,} followers")

gone = sorted(old.keys() - new.keys())
print(len(gone), "accounts no longer returned:", gone[:10])

An account that stops coming back has been suspended, deleted or made unavailable since the last run. Daily snapshots of a lead list or a creator roster are where the cost difference between the two APIs grows fastest, because X bills each profile again every UTC day while Sorsa API bills each request of 100. Plan the refresh below:

10K
How often you look them up
Official X API, a month
$3,000
300K profile reads at $0.010 each; X bills a profile again every UTC day.
Sorsa API, a month
from $4.20
3,000 requests of 100 profiles at $1.40 per 1,000 requests, the largest plan’s rate.

3,000 requests a month fit the Starter plan’s 10K. One run takes 100 requests: about 5 seconds at 20 requests per second, where the official X API’s 300 lookups per 15 minutes finish it within one window.

Pick the list size and how often you refresh it; both cards show the cost of a month.

To find the accounts worth watching in the first place, the guide on how to find leads on Twitter builds lead lists from X searches, and the Twitter followers API returns any account's followers 200 per page, each with its full profile already attached.

#What a bulk profile lookup costs, and how long it takes

On the official X API, a bulk lookup costs $10.00 per 1,000 profiles; on Sorsa API it costs from $0.014 per 1,000, because Sorsa API charges for the request of 100, not for each profile in it.

Profiles to look up

Official X API

$10,000

for 1M profiles

$0.010 per profile

Sorsa API

from$14

for 1M profiles

from $0.014 per 1,000

over 700x cheaper

Time is the second difference. The official X API lets one app send 300 lookup requests per 15 minutes, so a million profiles take about 8 hours 20 minutes there even at 100 per call. Sorsa API runs 20 requests per second on every plan, free accounts included: 2,000 profiles a second. The table puts both side by side for four list sizes:

ProfilesSorsa API cost, fromSorsa API timeOfficial X API costOfficial X API time
10K$0.14about 5 seconds$100within one 15-minute window
100K$1.40about 50 seconds$1,000about 50 minutes
500K$7.00about 4 minutes$5,000about 4 hours
1M$14.00about 8 minutes$10,000about 8 hours 20 minutes
  • 10K

    Sorsa API cost, from
    $0.14
    Sorsa API time
    about 5 seconds
    Official X API cost
    $100
    Official X API time
    within one 15-minute window
  • 100K

    Sorsa API cost, from
    $1.40
    Sorsa API time
    about 50 seconds
    Official X API cost
    $1,000
    Official X API time
    about 50 minutes
  • 500K

    Sorsa API cost, from
    $7.00
    Sorsa API time
    about 4 minutes
    Official X API cost
    $5,000
    Official X API time
    about 4 hours
  • 1M

    Sorsa API cost, from
    $14.00
    Sorsa API time
    about 8 minutes
    Official X API cost
    $10,000
    Official X API time
    about 8 hours 20 minutes

The "from" price is the rate of the largest plan, $1.40 per 1,000 requests; on Starter, the smallest, 1,000 profiles cost $0.049. Every plan bills the same way: one request returns up to 100 profiles, and failed requests are never charged.

Good to know

Send full chunks. A request with 3 accounts costs the same as one with 100, so a job that sends each account on its own pays 100 times more than it has to.

#Look up your first 10K profiles

A bulk profile lookup comes down to one request per 100 accounts. On the official X API that request costs $1.00 and waits behind a 15-minute window after 300 of them. For enriching a list of X accounts with profile data, choose Sorsa API: GET /info-batch returns 100 full profiles per request, from $0.014 per 1,000, at 20 requests per second, with one key and no developer account. The 100 free requests on a new account look up your first 10K profiles.

#FAQ

Which API should I use to look up Twitter profiles in bulk?

Use Sorsa API, a Twitter (X) API and scraper: its GET /info-batch endpoint returns 100 full profiles per request, from $0.014 per 1,000, against $10.00 per 1,000 on the official X API. It runs 20 requests per second on every plan, needs one API key instead of a developer account and OAuth, and returns all 19 profile fields on every call. New accounts get 100 free requests, enough for 10K profiles.

How many Twitter profiles can I look up in one request?

Up to 100. Sorsa API's GET /info-batch takes 100 usernames or 100 user IDs per request, and the official X API's GET /2/users/by and GET /2/users take the same 100. A list of 10K accounts takes 100 calls on either API. What differs is the price, $1.00 per 100 profiles on the official X API against from $0.0014 on Sorsa API, and the pace: 300 calls per 15 minutes on the official X API, 20 per second on Sorsa API.

Is there a free way to look up Twitter profiles in bulk?

The official X API has no free tier: every profile costs $0.010 from the first call. Sorsa API gives every new account 100 free requests that never expire and need no card; through GET /info-batch they cover 10K profiles. For one or two accounts at a time, the free Compare Twitter Accounts tool shows full profiles with no sign-up.

Why are some accounts missing from my bulk lookup results?

A batch lookup returns only the accounts it finds. A username that was mistyped, changed, suspended or deleted has no public profile behind it, so it is left out of the response instead of failing the request. Match results by user ID or lowercase username, write the rest to a separate list, and look renamed accounts up again by their user ID.

Can I get an account's country or email address in bulk?

Country, yes; email, no. Sorsa API's GET /about returns the country X shows under "About this account", the number of username changes and the date of the last one, one request per account. Email addresses and phone numbers are private on X, so no profile lookup returns them, from Sorsa API or from the official X API.

#How we checked

On November 2, 2026, we read the /info-batch, /info and /about entries of the live Sorsa API reference and wrote every request and field on this page from them. We did not run them with a key: the sample responses use fictional accounts in the reference's response shape. The same day we read X's user lookup, pricing and rate-limit pages on docs.x.com for the official X API's endpoints, price and limits, and worked out the costs and times in the tables from those figures.

By the Sorsa API team. We build and run Sorsa API, the read-only Twitter/X API and scraper, in production since 2022 (formerly TweetScout API) with 6B+ requests served. About Sorsa API

What changed
  • First published.