Key takeaway
To migrate off the official Twitter/X API, you need to do four things: replace OAuth with a single API key, update the base URL and endpoint paths, flatten your response parsers (no more data, includes, meta), and rewrite pagination to use a single cursor field. Most teams complete the migration in 1 to 3 days for a moderate codebase, and cut their monthly bill by 90 percent or more.
A quick word on what this guide is and isn’t. It isn’t another "choose your alternative" listicle. If you are still deciding between providers, read our comparison of Twitter API alternatives first. This guide assumes you have already decided to leave the official X API and want a step-by-step technical migration: authentication, endpoint mapping, response-format changes, pagination, error handling, and working code in curl, Python, and JavaScript.
I have done this migration enough times to have a checklist memorized. Below, I’ll walk through it the way I would walk a client through it on day one of a project. We use Sorsa API as the target throughout, because that is the system I know inside-out and because the mapping is one of the cleanest from the official v2 endpoints. The principles transfer to any read-only REST provider, but the field names and the example code are Sorsa-specific.
Table of Contents
- Why developers are leaving the official X API in 2026
- Where they’re migrating to
- What migration actually involves
- Step 1: Authentication
- Step 2: Base URL and endpoint mapping
- Step 3: Response format changes
- Step 4: Pagination
- Step 5: HTTP method changes
- Step 6: Code migration examples
- Step 7: Search query syntax
- Step 8: Error handling
- Step 9: Migration checklist
- What you gain that the official API doesn’t offer
- Case study: a 2-day fintech migration
- FAQ
- Getting started
Why developers are leaving the official X API in 2026
The reason most teams migrate isn’t one thing. It’s an accumulation of small frictions that adds up to "we’re burning engineering time on the platform, not on our product."
Pricing changed (again). In January 2026, X moved the entire API to a pay-per-use credit model. The old fixed tiers (Basic at $200/mo, Pro at $5,000/mo) are no longer available for new signups. Every request now deducts credits, and the per-tweet cost has actually gone up for typical read workloads. For the full breakdown of what each endpoint costs today, see our Twitter API pricing in 2026 guide.
Authentication is heavier than it needs to be. For read-only workloads, OAuth 2.0 Bearer is workable. The moment you need user-context endpoints, you’re back in OAuth 1.0a territory: consumer keys, access tokens, HMAC-SHA1 signatures, nonce, timestamp, and a base string you have to construct correctly per request. Most teams I’ve worked with end up wrapping it in a library, then debugging the wrapper. That’s engineering time we no longer spend.
Field selection is verbose. Every v2 endpoint returns the bare minimum by default. You ask for what you want via tweet.fields, user.fields, media.fields, plus expansions to join user data to a tweet. After your first dozen endpoints, you have a small DSL of field selection strings scattered through the codebase. None of it is hard, but it’s overhead on every request.
Rate limits are per-endpoint, per-15-minute-window. That structure works fine if you have one cron job. It works less well when you have a dashboard, three background workers, and a webhook consumer all pulling from the same key.
Hard caps on historical data. The user timeline endpoint will return at most the last 3,200 tweets. For competitive intelligence, sentiment analysis, or training data, that ceiling is a real blocker. For details on this and other limits, see our X API rate limits breakdown.
None of this means the official API is broken. For posting tweets, sending DMs, or anything that writes to X on behalf of a user, it’s the only option and you have to use it. But if your workload is reading public X data, every one of the frictions above is a tax you don’t need to pay.
Where they’re migrating to
When I help a client plan a migration, the first thing we map is which alternative actually fits their workload. The market falls into three rough categories.
Dedicated REST API providers are the closest drop-in replacement: you swap your auth and endpoint paths, the rest of your code stays roughly the same. They handle X’s anti-bot infrastructure on their side and expose clean JSON. Sorsa API falls in this category, alongside a handful of others.
Scraping platforms (Apify-style "actors", custom scrapers) work well when you need very long-tail data or one-off jobs. They are not great for low-latency production pipelines: results come back as files, latency is in minutes not seconds, and you’re still on the hook for parsing.
Data aggregators sell pre-collected datasets, typically monthly snapshots. Useful for academic research and historical analysis. Not useful for real-time monitoring.
For 95 percent of the production migrations I’ve done, the right target is a dedicated REST API. The migration is technical (not architectural), the code changes are bounded, and you keep the same mental model you had with the official API. We built Sorsa API around exactly that workflow: 38 endpoints across 8 categories, flat-rate monthly pricing, one API key, no OAuth, no rate-limit-per-endpoint logic. The mapping from v2 endpoints is straightforward enough that we’ll walk through every one of them below. If you want a side-by-side comparison of providers before committing, our Twitter API alternatives guide covers the trade-offs across the market.
Disclosure: Sorsa API is our product. The migration steps below are technically accurate regardless of which dedicated REST provider you pick, but the endpoint names and response examples are Sorsa-specific. We recommend testing any solution against your actual workload before committing.
What migration actually involves
Before we dive in, here is the changeset at a glance. If you read nothing else, read this:
- Replace
Authorization: Bearer ...withApiKey: .... - Replace
https://api.x.com/2withhttps://api.sorsa.io/v3. - Replace per-endpoint paths (see the mapping tables in Step 2).
- Switch GET to POST for tweet endpoints (see Step 5).
- Drop
tweet.fields,user.fields,expansions: everything comes back by default. - Flatten parsers: remove the
data/includes/metaenvelopes. - Rename fields:
namebecomesdisplay_name,textbecomesfull_text, metrics are top-level instead of nested underpublic_metrics. - Replace
pagination_token/next_tokenwithnext_cursor. - Simplify error handling: errors come back as
{ "message": "..." }.
Now let’s walk each step.
Step 1: Authentication
Before: OAuth on the official API
The official X API uses OAuth 2.0 Bearer tokens for app-only requests, and OAuth 1.0a User Context for anything that needs a user’s permissions (read more in the official auth docs).
# OAuth 2.0 App-Only
curl -X GET "https://api.x.com/2/users/by/username/elonmusk" \
-H "Authorization: Bearer AAAAAAAAAAAAAAAAAAAAA..."
For OAuth 1.0a, you also need a consumer key, consumer secret, access token, access token secret, plus a signature you generate per request. That’s usually delegated to a library (tweepy, twitter-api-client, etc.), but you still need to register the app, complete the dev-portal flow, and rotate tokens periodically.
After: a single API key
curl -X GET "https://api.sorsa.io/v3/info?username=elonmusk" \
-H "ApiKey: YOUR_API_KEY"
That’s the whole auth story. No token refresh, no signature generation, no callback URLs. Generate a key in the dashboard, drop it in an environment variable, ship.
# Python
import os
import requests
API_KEY = os.environ["SORSA_API_KEY"]
response = requests.get(
"https://api.sorsa.io/v3/info",
params={"username": "elonmusk"},
headers={"ApiKey": API_KEY},
)
user = response.json()
// JavaScript (Node 18+ or modern browser)
const response = await fetch("https://api.sorsa.io/v3/info?username=elonmusk", {
headers: { ApiKey: process.env.SORSA_API_KEY },
});
const user = await response.json();
For the full auth reference, see the Sorsa authentication docs.
Step 2: Base URL and endpoint mapping
The base URL is now https://api.sorsa.io/v3. Everything below is relative to that.
Users
| Action | Twitter/X API v2 | Sorsa API v3 |
|---|---|---|
| Get user by username | GET /2/users/by/username/:username | GET /info?username=:username |
| Get user by ID | GET /2/users/:id | GET /info?user_id=:id |
| Get multiple users | GET /2/users?ids=... | GET /info-batch?user_ids=...&user_ids=... |
| Get followers | GET /2/users/:id/followers | GET /followers?user_id=:id |
| Get following | GET /2/users/:id/following | GET /follows?user_id=:id |
| Get verified followers | Not available | GET /verified-followers?user_id=:id |
| Account "About" metadata | Not available | GET /about?username=:username |
Things to know:
GET /info-batchtakes up to 100 usernames or IDs per call. Repeat the param:?usernames=elonmusk&usernames=jack.GET /followersandGET /followsreturn up to 200 profiles per request, with full profile data (followers count, bio, verified, etc.). On v2 you get IDs and need a second call to hydrate.GET /verified-followersfilters server-side. No equivalent on the official API.
Tweets
| Action | Twitter/X API v2 | Sorsa API v3 |
|---|---|---|
| Get a single tweet | GET /2/tweets/:id | POST /tweet-info body: { "tweet_link": ":id" } |
| Get multiple tweets | GET /2/tweets?ids=... | POST /tweet-info-bulk body: { "tweet_links": [...] } |
| User timeline | GET /2/users/:id/tweets | POST /user-tweets body: { "user_id": ":id" } |
| Quote tweets | GET /2/tweets/:id/quote_tweets | POST /quotes body: { "tweet_link": ":id" } |
| Retweeters | GET /2/tweets/:id/retweeted_by | POST /retweeters body: { "tweet_link": ":id" } |
| Replies (comments) | Not a dedicated endpoint | POST /comments body: { "tweet_link": ":id" } |
| Long-form X Article | Not available | POST /article body: { "tweet_link": ":id" } |
Things to know:
tweet_linkaccepts either a full URL (https://x.com/user/status/123) or just the numeric ID ("123"). Both work.POST /tweet-info-bulkreturns up to 100 tweets in a single call. This is one of the biggest cost-saving wins in the migration: looping/tweet-info100 times costs 100 requests, the bulk call costs 1. See our API usage optimization guide for more patterns.POST /user-tweetshas no 3,200-tweet ceiling. Paginate vianext_cursoruntil you hit the user’s very first tweet. For historical use cases, this alone is often the migration driver. More on this in the historical data docs.
Search
| Action | Twitter/X API v2 | Sorsa API v3 |
|---|---|---|
| Search recent tweets | GET /2/tweets/search/recent?query=... | POST /search-tweets body: { "query": "..." } |
| Search full archive | GET /2/tweets/search/all?query=... | POST /search-tweets (same endpoint, historical included) |
| Search by mentions | GET .../search/recent?query=@user | POST /mentions body: { "query": "user" } |
| Search users | Not available in v2 | POST /search-users body: { "query": "..." } |
Things to know:
- Sorsa uses the same Twitter Advanced Search operators (
from:,since:,until:, hashtags, quoted phrases,OR,-). Your existing queries should transfer unchanged. POST /mentionshas filters the official API doesn’t expose:min_likes,min_replies,min_retweets,since_date,until_date. For tracking brand mentions above an engagement threshold, this saves a client-side filter pass. See the track mentions docs.
Lists
| Action | Twitter/X API v2 | Sorsa API v3 |
|---|---|---|
| List members | GET /2/lists/:id/members | GET /list-members?list_id=:id |
| List followers | GET /2/lists/:id/followers | GET /list-followers?list_link=:id |
| List tweets | GET /2/lists/:id/tweets | GET /list-tweets?list_id=:id |
Communities
The official X API does not expose Community endpoints at all. Sorsa does.
| Action | Sorsa API v3 |
|---|---|
| Community members | POST /community-members body: { "community_link": ":id" } |
| Community feed | POST /community-tweets body: { "community_id": ":id", "order": "popular" } |
| Search within a community | POST /community-search-tweets body: { "community_link": ":id", "query": "..." } |
Verification endpoints
These check membership-style questions in a single call. On the official API, you would have to fetch full follower or retweet lists and scan them client-side.
| Question | Sorsa API v3 |
|---|---|
| Does user A follow user B? | POST /check-follow |
| Did user X comment on tweet Y? | GET /check-comment?tweet_link=...&username=... |
| Did user X quote or retweet tweet Y? | POST /check-quoted |
| Did user X retweet tweet Y? | POST /check-retweet |
| Is user X in community Y? | POST /check-community-member |
These are particularly common in campaign and giveaway verification flows.
Sorsa-only analytics
These have no official-API equivalent. They are crypto-focused (the index is biased toward influencers, projects, and VC accounts), but the endpoints are general-purpose.
| Action | Sorsa API v3 |
|---|---|
| Influence score | GET /score?username=... |
| Score deltas (7d, 30d) | GET /score-changes?username=... |
| Follower breakdown by category | GET /followers-stats?username=... |
| Top 20 followers by score | GET /top-followers?username=... |
| Top 20 following by score | GET /top-following?username=... |
| New followers in last 7 days | GET /new-followers-7d?username=... |
| New following in last 7 days | GET /new-following-7d?username=... |
Utility endpoints
| Action | Sorsa API v3 |
|---|---|
| Username to numeric ID | GET /username-to-id/:handle |
| Numeric ID to username | GET /id-to-username/:id |
| Profile URL to numeric ID | GET /link-to-id?link=... |
| Check your API quota | GET /key-usage-info |
Step 3: Response format changes
This is where you’ll touch the most code. The official API splits responses into data, includes, and meta. Sorsa returns flat objects, with author data embedded directly in each tweet.
User profile
Official X API v2 (with field selection):
{
"data": {
"id": "44196397",
"name": "Elon Musk",
"username": "elonmusk",
"verified": false,
"profile_image_url": "https://pbs.twimg.com/...",
"public_metrics": {
"followers_count": 100000000,
"following_count": 500,
"tweet_count": 30000,
"listed_count": 12000
}
}
}
Sorsa API:
{
"id": "44196397",
"username": "elonmusk",
"display_name": "Elon Musk",
"description": "...",
"location": "Austin, TX",
"profile_image_url": "https://pbs.twimg.com/...",
"profile_background_image_url": "...",
"followers_count": 100000000,
"followings_count": 500,
"tweets_count": 30000,
"favourites_count": 50000,
"media_count": 1200,
"verified": false,
"protected": false,
"can_dm": true,
"possibly_sensitive": false,
"created_at": "2009-06-02T20:12:29Z",
"bio_urls": ["https://example.com"],
"pinned_tweet_ids": ["17823..."]
}
Key differences:
namebecomesdisplay_name.- No
public_metricswrapper: counts are top-level. following_countbecomesfollowings_count(note the extra "s").tweet_countbecomestweets_count.- Sorsa returns extra fields by default:
favourites_count,media_count,can_dm,bio_urls,pinned_tweet_ids,profile_background_image_url,possibly_sensitive.
Tweet
Official X API v2 (with expansions=author_id and user.fields):
{
"data": {
"id": "1234567890",
"text": "Hello world",
"created_at": "2024-01-15T12:00:00.000Z",
"author_id": "44196397",
"conversation_id": "1234567890",
"lang": "en",
"public_metrics": {
"retweet_count": 100,
"reply_count": 50,
"like_count": 500,
"quote_count": 25,
"bookmark_count": 10,
"impression_count": 50000
}
},
"includes": {
"users": [
{ "id": "44196397", "name": "Elon Musk", "username": "elonmusk" }
]
}
}
Sorsa API:
{
"id": "1234567890",
"full_text": "Hello world",
"created_at": "2024-01-15T12:00:00Z",
"lang": "en",
"conversation_id_str": "1234567890",
"likes_count": 500,
"retweet_count": 100,
"reply_count": 50,
"quote_count": 25,
"view_count": 50000,
"bookmark_count": 10,
"is_reply": false,
"is_quote_status": false,
"is_replies_limited": false,
"in_reply_to_tweet_id": null,
"in_reply_to_username": null,
"user": {
"id": "44196397",
"username": "elonmusk",
"display_name": "Elon Musk",
"followers_count": 100000000
},
"entities": [],
"quoted_status": null,
"retweeted_status": null
}
Key differences:
textbecomesfull_text.like_countbecomeslikes_count. Note the inconsistency: it’slikes_count(with s) butretweet_count,reply_count,quote_count,bookmark_countare singular. Don’t lose time debugging this; it just is what it is.impression_countbecomesview_count.- Author data is embedded directly under
user, noincludes.userslookup. - Quote and retweet relationships are nested directly in
quoted_statusandretweeted_status(full Tweet objects), not referenced by ID. - New boolean flags:
is_reply,is_quote_status,is_replies_limited. Useful for filtering without inspecting nested objects. entitieshas a different shape: an array of{ type, link, preview }for media and URLs, rather than the v2 structure ofurls,mentions,hashtags,mediasub-arrays.
Field mapping reference
| Twitter/X API v2 | Sorsa API | Notes |
|---|---|---|
id | id | Same |
username | username | Same |
name | display_name | Renamed |
public_metrics.followers_count | followers_count | Flattened |
public_metrics.following_count | followings_count | Flattened, renamed |
public_metrics.tweet_count | tweets_count | Flattened, renamed |
public_metrics.listed_count | not available | |
verified | verified | Same |
protected | protected | Same |
created_at | created_at | Same |
text | full_text | Renamed |
public_metrics.like_count | likes_count | Flattened, renamed |
public_metrics.retweet_count | retweet_count | Flattened |
public_metrics.impression_count | view_count | Flattened, renamed |
conversation_id | conversation_id_str | Renamed |
in_reply_to_user_id | in_reply_to_username | Handle, not ID |
author_id (plus includes.users[]) | user (full object inline) | Embedded |
Referenced tweets via includes | quoted_status, retweeted_status | Inline |
Step 4: Pagination
The official API uses pagination_token in query parameters and returns meta.next_token in the response. Sorsa uses next_cursor, returned at the top level of the response.
Before
curl "https://api.x.com/2/users/44196397/followers?max_results=100&pagination_token=ABC123" \
-H "Authorization: Bearer $TOKEN"
Response:
{
"data": [...],
"meta": { "next_token": "XYZ789", "result_count": 100 }
}
After
For GET endpoints, pass next_cursor as a query parameter:
curl "https://api.sorsa.io/v3/followers?username=elonmusk&next_cursor=ABC123" \
-H "ApiKey: $API_KEY"
For POST endpoints, include next_cursor in the JSON body:
curl -X POST "https://api.sorsa.io/v3/search-tweets" \
-H "ApiKey: $API_KEY" \
-H "Content-Type: application/json" \
-d '{ "query": "from:elonmusk", "next_cursor": "ABC123" }'
Either way, the response is flat:
{
"tweets": [...],
"next_cursor": "XYZ789"
}
When next_cursor is missing or null, you’ve reached the last page. See the pagination docs for the full pattern.
Step 5: HTTP method changes
A common gotcha: some endpoints that are GET on the official API are POST on Sorsa. If you’re moving from the official API, this is the change you’ll forget and then debug for ten minutes.
| Action | Official API | Sorsa API |
|---|---|---|
| Get a tweet | GET | POST |
| Search tweets | GET | POST |
| User timeline | GET | POST |
| Quote tweets | GET | POST |
| Retweeters | GET | POST |
| Replies / comments | (no direct equivalent) | POST |
| User profile | GET | GET |
| Followers / following | GET | GET |
| Lists | GET | GET |
Rule of thumb: anything that takes a tweet identifier or a search query is POST. Anything that takes a user identifier or a list ID is GET.
Step 6: Code migration examples
Three representative migrations, each in curl, Python, and JavaScript. These are the patterns you’ll repeat across your codebase.
Example 1: Get a user profile
Before (Twitter/X API):
curl "https://api.x.com/2/users/by/username/elonmusk?user.fields=description,public_metrics,profile_image_url,verified,created_at" \
-H "Authorization: Bearer $TOKEN"
import requests
response = requests.get(
"https://api.x.com/2/users/by/username/elonmusk",
params={"user.fields": "description,public_metrics,profile_image_url,verified,created_at"},
headers={"Authorization": f"Bearer {BEARER_TOKEN}"},
)
user = response.json()["data"]
followers = user["public_metrics"]["followers_count"]
name = user["name"]
const url = "https://api.x.com/2/users/by/username/elonmusk" +
"?user.fields=description,public_metrics,profile_image_url,verified,created_at";
const res = await fetch(url, {
headers: { Authorization: `Bearer ${BEARER_TOKEN}` },
});
const { data: user } = await res.json();
const followers = user.public_metrics.followers_count;
const name = user.name;
After (Sorsa API):
curl "https://api.sorsa.io/v3/info?username=elonmusk" \
-H "ApiKey: $API_KEY"
import requests
response = requests.get(
"https://api.sorsa.io/v3/info",
params={"username": "elonmusk"},
headers={"ApiKey": API_KEY},
)
user = response.json()
followers = user["followers_count"]
name = user["display_name"]
const res = await fetch("https://api.sorsa.io/v3/info?username=elonmusk", {
headers: { ApiKey: API_KEY },
});
const user = await res.json();
const followers = user.followers_count;
const name = user.display_name;
Three lines removed, no field selection needed, and the auth header is shorter.
Example 2: Search tweets
Before:
curl "https://api.x.com/2/tweets/search/recent?query=from%3Aelonmusk%20since%3A2024-01-01&tweet.fields=created_at,public_metrics,lang&expansions=author_id&user.fields=username,name&max_results=10" \
-H "Authorization: Bearer $TOKEN"
url = "https://api.x.com/2/tweets/search/recent"
params = {
"query": "from:elonmusk since:2024-01-01",
"tweet.fields": "created_at,public_metrics,lang",
"expansions": "author_id",
"user.fields": "username,name",
"max_results": 10,
}
response = requests.get(url, headers={"Authorization": f"Bearer {BEARER_TOKEN}"}, params=params)
data = response.json()
tweets = data["data"]
users = {u["id"]: u for u in data.get("includes", {}).get("users", [])}
next_token = data.get("meta", {}).get("next_token")
for tweet in tweets:
author = users.get(tweet["author_id"])
print(tweet["text"], "by", author["username"])
const params = new URLSearchParams({
query: "from:elonmusk since:2024-01-01",
"tweet.fields": "created_at,public_metrics,lang",
expansions: "author_id",
"user.fields": "username,name",
max_results: "10",
});
const res = await fetch(`https://api.x.com/2/tweets/search/recent?${params}`, {
headers: { Authorization: `Bearer ${BEARER_TOKEN}` },
});
const data = await res.json();
const tweets = data.data || [];
const users = Object.fromEntries((data.includes?.users || []).map(u => [u.id, u]));
const nextToken = data.meta?.next_token;
for (const t of tweets) {
const author = users[t.author_id];
console.log(t.text, "by", author.username);
}
After:
curl -X POST "https://api.sorsa.io/v3/search-tweets" \
-H "ApiKey: $API_KEY" \
-H "Content-Type: application/json" \
-d '{"query": "from:elonmusk since:2024-01-01"}'
response = requests.post(
"https://api.sorsa.io/v3/search-tweets",
headers={"ApiKey": API_KEY},
json={"query": "from:elonmusk since:2024-01-01"},
)
data = response.json()
tweets = data["tweets"]
next_cursor = data.get("next_cursor")
for tweet in tweets:
print(tweet["full_text"], "by", tweet["user"]["username"])
const res = await fetch("https://api.sorsa.io/v3/search-tweets", {
method: "POST",
headers: { ApiKey: API_KEY, "Content-Type": "application/json" },
body: JSON.stringify({ query: "from:elonmusk since:2024-01-01" }),
});
const data = await res.json();
for (const t of data.tweets) {
console.log(t.full_text, "by", t.user.username);
}
The user-data join disappears. Each tweet already carries its author, so the lookup table is gone. For Python-specific patterns, see our full Twitter API in Python guide.
Example 3: Paginate through all followers
Before:
def fetch_all_followers_official(user_id, token):
url = f"https://api.x.com/2/users/{user_id}/followers"
headers = {"Authorization": f"Bearer {token}"}
followers = []
pagination_token = None
while True:
params = {"max_results": 1000}
if pagination_token:
params["pagination_token"] = pagination_token
r = requests.get(url, headers=headers, params=params)
r.raise_for_status()
data = r.json()
followers.extend(data.get("data", []))
pagination_token = data.get("meta", {}).get("next_token")
if not pagination_token:
break
return followers
async function fetchAllFollowersOfficial(userId, token) {
const followers = [];
let paginationToken = null;
do {
const params = new URLSearchParams({ max_results: "1000" });
if (paginationToken) params.set("pagination_token", paginationToken);
const res = await fetch(
`https://api.x.com/2/users/${userId}/followers?${params}`,
{ headers: { Authorization: `Bearer ${token}` } }
);
const json = await res.json();
followers.push(...(json.data || []));
paginationToken = json.meta?.next_token || null;
} while (paginationToken);
return followers;
}
After:
def fetch_all_followers_sorsa(user_id, api_key):
url = "https://api.sorsa.io/v3/followers"
headers = {"ApiKey": api_key}
followers = []
next_cursor = None
while True:
params = {"user_id": user_id}
if next_cursor:
params["next_cursor"] = next_cursor
r = requests.get(url, headers=headers, params=params)
r.raise_for_status()
data = r.json()
followers.extend(data.get("users", []))
next_cursor = data.get("next_cursor")
if not next_cursor:
break
return followers
async function fetchAllFollowersSorsa(userId, apiKey) {
const followers = [];
let nextCursor = null;
do {
const params = new URLSearchParams({ user_id: userId });
if (nextCursor) params.set("next_cursor", nextCursor);
const res = await fetch(
`https://api.sorsa.io/v3/followers?${params}`,
{ headers: { ApiKey: apiKey } }
);
const json = await res.json();
followers.push(...(json.users || []));
nextCursor = json.next_cursor || null;
} while (nextCursor);
return followers;
}
# In a shell loop, with jq for cursor extraction
CURSOR=""
while :; do
RES=$(curl -s "https://api.sorsa.io/v3/followers?username=elonmusk${CURSOR:+&next_cursor=$CURSOR}" \
-H "ApiKey: $API_KEY")
echo "$RES" | jq '.users'
CURSOR=$(echo "$RES" | jq -r '.next_cursor // empty')
[ -z "$CURSOR" ] && break
done
One nuance worth flagging: each Sorsa /followers page returns up to 200 fully-hydrated profiles (with bio, followers count, verified, etc.). On the official API, the followers endpoint typically returns IDs + minimal user data, and you’d need a separate /users lookup to get the rest. The migration usually halves your request count for follower-graph use cases.
Step 7: Search query syntax
Good news: you don’t need to learn a new query language. Sorsa supports the same Twitter Advanced Search operators that work on the official tweets/search/recent endpoint. The full operator reference is maintained in Igor Brigadir’s public repository and Sorsa’s own search operators docs.
| Operator | Example | Works on Sorsa |
|---|---|---|
from: | from:elonmusk | Yes |
to: | to:elonmusk | Yes |
since: and until: | since:2024-01-01 until:2024-02-01 | Yes |
| Hashtags | #bitcoin | Yes |
| Exact phrase | "hello world" | Yes |
OR | bitcoin OR ethereum | Yes |
| Exclusion | -is:retweet | Yes |
| Combinations | from:elonmusk #bitcoin -is:retweet | Yes |
Your existing query strings can be transferred unchanged.
The /mentions endpoint also adds filters not available in the official API: min_likes, min_replies, min_retweets, since_date, until_date. This is one of the quiet wins in the migration: any client-side "filter by engagement floor" logic you wrote against the official API can move to the server.
Step 8: Error handling
The official API returns errors as a structured object:
{
"errors": [
{
"message": "Not Found",
"type": "https://api.x.com/2/problems/resource-not-found",
"title": "Not Found Error",
"detail": "Could not find tweet with id: [123].",
"status": 404
}
]
}
Sorsa returns a simple shape:
{ "message": "Tweet not found" }
Status codes are consistent: 400 (bad request), 401 (missing or invalid key), 403 (forbidden), 404 (not found), 429 (rate limit exceeded), 500 (server error).
For 429, the policy is straightforward: wait one second, retry. There is no per-endpoint window to track, no "X-Rate-Limit-Reset" header to parse. The universal limit is 20 requests per second across all endpoints and all plans, and you can request a higher limit if your workload needs it.
A defensive retry wrapper that handles both APIs:
import time, requests
def call_with_retry(method, url, max_retries=3, **kwargs):
for attempt in range(max_retries):
r = requests.request(method, url, **kwargs)
if r.status_code == 429:
time.sleep(2 ** attempt)
continue
r.raise_for_status()
return r.json()
raise RuntimeError(f"Failed after {max_retries} retries")
Step 9: Migration checklist
Print this, tape it to your monitor, and tick boxes as you go.
- Replace
Authorization: Bearer ...withApiKey: ...everywhere. - Remove OAuth 1.0a signature logic (consumer keys, access tokens, signature generation).
- Update base URL from
https://api.x.com/2tohttps://api.sorsa.io/v3. - Map every endpoint path using the Step 2 tables.
- Switch GET to POST for tweet, search, comment, quote, retweeter endpoints.
- Delete
tweet.fields,user.fields,media.fields,expansionsparameters. - Update response parsers: remove
data/includes/metaunwrapping. - Rename fields in your models (
name→display_name,text→full_text, etc.). - Flatten metric access (drop the
public_metricswrapper). - Replace
pagination_token/next_tokenwithnext_cursor. - Update error handling for the simplified
{ "message": "..." }format. - Adjust rate-limit logic: 20 req/s universal, no per-endpoint windows.
- Test critical endpoints in the API Playground before deploying.
- Set up monitoring via
GET /key-usage-infoto track your quota burn. - Keep the official API key around if you also need to write to X (post tweets, send DMs); only the read path migrates.
What you gain that the official API doesn’t offer
Migration is mostly subtractive (less code, fewer headers, fewer parsers). But there are a few additions worth knowing about.
No 3,200-tweet cap on user timelines. POST /user-tweets will paginate back to the user’s first ever tweet. For historical research, this is often the killer feature.
Single-call verification endpoints. /check-follow, /check-comment, /check-retweet, /check-quoted, /check-community-member answer "did user X do Y to tweet/account Z" in one request. Campaign verification workflows that previously involved walking entire follower or retweeter lists become a single API call.
Community endpoints. X Communities have no official API at all. Sorsa exposes the full member list, the feed, and full-text search within a community.
Influence analytics. /score, /score-changes, /followers-stats, /top-followers, /new-followers-7d. These are crypto-focused (the index leans toward influencers, projects, VCs), but they’re available with the same API key and no separate signup.
Long-form Article support. POST /article returns the full text of X Articles. The official API doesn’t expose Article content.
Verified-only follower filtering. /verified-followers filters server-side. On v2, you’d pull everyone and filter the verified flag yourself.
For a broader view of what the API covers, the use-cases overview lays out the categories with examples.
Case study: a 2-day fintech migration
A fintech client I worked with last year was running a sentiment dashboard for around 200 tracked accounts: pulling recent tweets, scoring them, surfacing market-moving posts in real time. They were on the old X API Pro plan at $5,000/month (this was before the January 2026 pay-per-use shift). Two engineers had spent the previous quarter building retry logic, OAuth refresh, and field-selection helpers around the v2 client.
The migration brief was simple: get off the $5,000 bill, keep the dashboard running, minimize risk.
Day 1, morning. Mapped every official endpoint they called to the Sorsa equivalent. Six endpoints total: user lookup, user timeline, recent search, followers (for one analyst-facing feature), tweet lookup, and a periodic batch hydrate. The mapping took about ninety minutes, including a sanity check in the playground.
Day 1, afternoon. Wrote an abstraction layer. One adapter for the official API (kept for the write path: they were still posting alerts to the company X account), one adapter for Sorsa for all reads. Both implemented the same internal interface. Existing application code didn’t change.
Day 2, morning. Re-pointed the response parsers. The flatter Sorsa shape simplified things rather than complicated them. The biggest single change was deleting the user-join logic that hydrated author_id references via includes.users: every Sorsa tweet response already carries the full author, so a 30-line helper became unused.
Day 2, afternoon. Ran the dashboard against both APIs in parallel for four hours, diffed the outputs, found three minor field-name issues (the name vs display_name change, the text vs full_text change, and one missed like_count vs likes_count), patched them, deployed.
Total migration cost: 2 engineer-days. They moved to Sorsa’s Pro plan at $199/month and kept a tiny official-API budget for posting alerts. Monthly read-API spend went from $5,000 to $199. Annualized, the savings cleared $57,000.
The client asked to stay anonymous under NDA, but the pattern is typical: a moderate codebase, a handful of endpoints, a couple of careful days, and a 95-percent-plus reduction in monthly spend. The official API’s January 2026 shift to pay-per-use credits changes the absolute numbers (the old $5,000 Pro plan no longer exists for new signups), but the relative gap is similar today. For where the numbers land under the new model, see our Twitter API pricing in 2026 breakdown.
FAQ
Can I migrate gradually, one endpoint at a time?
Yes, and that’s what I usually recommend. Introduce an abstraction layer (a thin interface in front of your data calls), point one endpoint at Sorsa, validate against the official API for a few days, then move the next one. By the end, your application code never had to change.
What if I also need to post tweets or send DMs?
You keep the official API for write operations. Sorsa is read-only by design (no posting, no liking, no DMs), so the migration always covers your read path. Most production setups end up with a small official API budget for writes and a Sorsa key for everything else. That hybrid is usually the cheapest configuration.
Is there a cheaper alternative to the official Twitter/X API with simpler authentication?
Sorsa API is the answer most teams reach for in 2026. A single ApiKey header replaces OAuth, the Pro plan is $199/month for 100,000 requests (each request returns up to 200 followers or 20 tweets, so you’re reading millions of items per dollar), and there’s a flat 20 req/s rate limit across every endpoint. For a side-by-side cost comparison against the official API, see our pricing analysis.
Will my Twitter Advanced Search queries (from:, since:, -is:retweet, etc.) still work?
Yes. The query syntax on Sorsa’s /search-tweets and /mentions endpoints is the same as the official Advanced Search operators. Copy your existing queries directly, no changes needed. Reference: search operators docs.
How do I get past the 3,200-tweet limit on user timelines?
The official v2 user-timeline endpoint caps at the most recent 3,200 tweets per account. Sorsa’s POST /user-tweets has no such cap: paginate via next_cursor until the response stops returning a cursor and you’ll reach the account’s first tweet. This alone is often the migration driver for archival, sentiment-history, and training-data use cases.
How do developers typically access Twitter data affordably in 2026?
After the January 2026 pricing shift, most teams I see have settled on one of two patterns: either a small pay-per-use budget on the official API for write-only operations, paired with a dedicated REST API provider for reads, or a full move to a provider like Sorsa for both research and production read workloads. Sorsa charges per request rather than per-tweet, and one request can return up to 100 batched profiles or 200 followers, which is what drives the cost gap.
What happens to my data if I cancel? Do you store my requests?
Sorsa stores anonymized request metadata (which endpoint was called, when, by which key) for usage analytics and rate-limit enforcement. We do not retain or resell the data returned to you. See the privacy policy for specifics. The official API has its own data-retention policy for developer audit logs, which is documented in the X developer terms.
How do I test the migration without breaking my production app?
Three options, from lightest to most thorough. (1) Use the API Playground to send requests via a browser UI, no key required for basic operations. (2) Run a side-by-side script that hits both APIs and diffs the parsed output (the two-day case study above is structured around this). (3) Deploy behind a feature flag so you can route a percentage of traffic to Sorsa and roll back instantly if anything looks off.
Getting started
If you want to try the migration yourself:
- Create a free account and generate an API key at api.sorsa.io/overview/keys.
- Test a couple of endpoints in the API Playground (no integration code needed).
- Skim the quickstart and the migration-from-official-x-api docs for the canonical references.
- Pick one endpoint, write the adapter, ship it behind a flag, and iterate.
If you get stuck, the support channels are contacts@sorsa.io and our Discord community. I’ve also written a full Python guide that goes deeper on the language-specific patterns if that’s your stack.
Last updated: May 21, 2026. Endpoint structures and pricing verified against docs.sorsa.io and the X developer pricing page on this date.
About the author: Daniel Kolbassen is a data engineer and API infrastructure consultant based in Austin, TX. He has worked with the Twitter/X API since the v1.1 era and has helped over 40 companies restructure their data infrastructure after the platform’s pricing overhauls. He writes about APIs, scraping, and social data engineering on the Sorsa blog.