Published May 21, 2026. Updated September 23, 2026: rewritten around the 2026 X API changes, with the legacy Basic and Pro pay-per-use cutovers, the May search changes, an X API vs Sorsa comparison, a search-operator translation table, and a parallel-test script. Official X API pay-per-use rates verified September 2026.
Key Takeaway: In 2026 the X API switched to pay-per-use billing, began moving legacy Basic and Pro plans onto it, capped post reads at 3 million a month, dropped follow, like, and quote-post writes, and changed search. Read-heavy apps can keep writes on X and move reads to a cheaper flat per-request API within days.
The X API, still called the Twitter API in most codebases, changed on almost every axis developers depend on in 2026: how it bills, what self-serve apps can write, how search behaves, and which plans still exist. If an integration broke this year, or a bill suddenly looks different, one of these Twitter API changes is the reason.
For most teams the fix sits on the read side, where the bill grows fastest. Sorsa API, a read-only Twitter/X data API, returns public posts, profiles, and follower lists as flat JSON behind a single ApiKey header. Billing is flat: one call counts as one request whatever it returns, the author profile rides along in every post, and batch endpoints return up to 100 posts or 200 followers per call. On batch endpoints that works out to about $0.02 per 1,000 tweets on the Pro plan (from $0.049 on Starter), and 100 free requests, no card and no expiry, cover a full parallel test before any spend.
This guide does two jobs. The first half lists each major change with its date, the symptom it produces, and the fix. The second half is a migration playbook for the read path: an endpoint map, a field map, a search-operator translation table, a v2-compatible shim, and a parallel-test script. Together they let you move reads to a flat per-request API while writes stay on X.
Contents
- What changed in the X API in 2026?
- What broke, and how do you fix it?
- Should you stay on pay-per-use or move your reads?
- What does your workload cost after the switch?
- Worked example: re-pricing a legacy Pro read pipeline
- How do you migrate the read path from the X API?
- How do you cut over without breaking production?
- What stays on the official X API?
- X API migration checklist
- FAQ
- Getting started
What changed in the X API in 2026?
The eight X API updates that matter most for code and budgets, in order:
| Date (2026) | What changed | Who it hits |
|---|---|---|
| Feb 6 | Pay-per-use launches: credits are bought upfront in the new Developer Console and every resource returned is billed. Basic and Pro stay available for the moment, and existing subscribers can opt in. | New apps. Legacy Free users got a $10 voucher. |
| Feb 23 | Programmatic replies through POST /2/tweets need a "summons": the post's author must mention or quote your account. | Reply bots, auto-responders |
| Apr 20 | Posting rises to $0.015 per post and $0.20 per post with a URL. Owned reads drop to $0.001. Follow, like, and quote-post endpoints leave every self-serve plan. | Growth tools, schedulers, link-sharing bots |
| May 4 | Search endpoints move to a new index. New min_likes:, min_replies:, and min_reposts: operators arrive, and keyword searches stop returning retweets. | Search dashboards, monitoring, analytics |
| May 30 | X shuts down Communities, after pushing back the original May 6 date. | Anything that tracked community members or feeds |
| After Jun 1 | Legacy Basic plans, monthly and annual, move to pay-per-use at the end of each billing cycle. | Former $200/month subscribers |
| After Sep 1 | Legacy Pro plans move to pay-per-use at each account's own cutover date (announced in August). | Former $5,000/month subscribers |
| Sep 21 | Apps can exchange stored OAuth 1.0a tokens for OAuth 2.0 tokens without re-authorizing users. | Apps still signing requests with OAuth 1.0a |
Sources: the official X API changelog, X's pay-per-use pricing page, and the developer-forum announcements for the legacy Basic and legacy Pro cutovers.
One more rule shapes every budget: pay-per-use accounts are capped at 3 million post reads per billing cycle. Past the cap, post reads stop until the next cycle unless you sign an Enterprise contract.
The 2026 changes sit on top of the ones that started the paid era. Most free read access disappeared in 2023, when paid tiers took over, and in October 2024 the Basic plan doubled to $200 a month for 15,000 post reads. The background is in why the X API got so expensive, and every line item is in the full X API rate card for 2026.
If your legacy Basic or Pro plan just converted
- Check the Developer Console first. Confirm the cutover date, the spending limit, and the auto-recharge rule. Converted monthly accounts receive default recharge settings that may not match your budget, and annual subscribers' prorated credits expire after the matching number of months.
- Export a month of usage by resource type. Posts, users, and followers bill at different rates, so the mix decides the new bill.
- Price reads and writes separately. Writes have to stay on X. Reads do not.
- Watch the 3 million cap. It blocks post reads mid-cycle instead of billing overage, so a busy month can stop a pipeline.
What broke, and how do you fix it?
Most 2026 breakages trace back to one of these changes:
| What you see | What changed | What to do |
|---|---|---|
| Follow, like, or quote-post calls fail | Removed from self-serve plans on April 20 | Drop the feature or negotiate Enterprise. No self-serve workaround exists. |
| Automated replies are rejected | Summons rule since February 23 | Reply only to posts that mention or quote your account |
| Keyword search stopped returning retweets | New search index on May 4 | Read reposts of any post with a reposters endpoint (POST /retweeters on Sorsa), or query an account's recent retweets with from:username filter:nativeretweets |
min_faves: or min_retweets: return a 400 error | The v2 names are min_likes: and min_reposts: | Rename them, or keep the web names unchanged on an Advanced Search API such as Sorsa's POST /search-tweets |
| Community members or feeds come back empty | X shut down Communities in May (TechCrunch) | Track the same people with Lists or keyword search |
| Monthly cost changed after a plan converted | Flat Basic and Pro fees became per-resource billing | Set a spending cap, cache results so the same posts are not re-read on later days (same-day re-reads are billed once), and move bulk reads to flat per-request billing |
| Post reads blocked before the month ends | 3 million post-read cap per billing cycle | Move bulk reads to a flat per-request plan, where only the request quota counts, or negotiate Enterprise |
Write-side problems have no fix outside X: a read-only API cannot follow, like, or post. Billing and search problems are different. They are exactly what a read-path migration solves, and for most data pipelines the read path is where the money goes.
Should you stay on pay-per-use or move your reads?
Pay-per-use is cheap at low volume and expensive at scale, because every post and every profile in a response is billed on its own. Flat per-request billing matches it at about 10,000 post reads a month and pulls ahead quickly after that, since each extra post, profile, or follower on a flat plan costs a fraction of a cent.
| Your situation | Best path | Why |
|---|---|---|
| A few thousand posts a month and nothing else | Pay-per-use is fine for now | A few dollars a month; revisit as volume grows |
| Over about 10,000 posts a month, or regular profile and follower pulls | Move reads to a flat per-request API | Costs match at 10,000 posts and diverge fast after that |
| You post, reply, or DM, and also read at volume | Hybrid: writes on X, reads on a flat per-request API | The X bill shrinks to writes only |
| Analytics, monitoring, research, or AI agents that only read | Move all reads | Largest savings, and you authenticate with one API key |
| Over 3 million post reads a month | Move reads | Pay-per-use blocks reads past the cap; flat plans only count requests |
| You need real-time push delivery | Keep filtered stream for that one job | Everything else can still move |
A flat per-request read API such as Sorsa bills one request per call, whether the response carries 1 post or 200 profiles. With batch endpoints, reads start from $0.02 per 1,000 tweets and from $0.01 per 1,000 profiles, which makes read-heavy workloads up to 50x cheaper than per-resource billing. Sorsa is read-only by design: it takes over the read path, which in most data pipelines is where the bill sits, while posting stays on X. For the wider market, see Twitter API alternatives compared.
Official X API vs Sorsa API at a glance
| Official X API (pay-per-use) | Sorsa API | |
|---|---|---|
| Billing unit | Every post, profile, or follower returned | Every request, whatever it returns |
| 1,000 posts | $5.00 | from $0.02 |
| 1,000 follower profiles | $10.00 | from $0.01 |
| Author data on each post | Joined from includes through expansions | Full profile embedded in every post |
| Monthly read ceiling | 3 million post reads per billing cycle | Your plan's request quota; custom plans above 500,000 requests |
| Rate limits | Per endpoint, in 15-minute windows | 20 requests per second on every plan |
| Authentication | OAuth 2.0 or OAuth 1.0a | One ApiKey header |
| User timeline depth | Latest 3,200 posts | No 3,200-post ceiling |
| Search syntax | X API v2 operators | X Advanced Search operators |
| Posting and DMs | Yes (follows, likes, quote-posts are Enterprise only) | Read-only |
| What you need to start | An X developer account and prepaid credits | An API key; 100 free requests, no card |
What does your workload cost after the switch?
Pay-per-use charges $5.00 per 1,000 posts and $10.00 per 1,000 profiles or followers. Flat per-request billing charges per call instead: from $0.02 per 1,000 tweets through the bulk lookup endpoint, about $0.10 per 1,000 tweets through paginated search on the Pro plan, and from $0.01 per 1,000 profiles through follower lists.
| Monthly read workload | Legacy flat plan | Pay-per-use today | Flat per-request billing |
|---|---|---|---|
| 15,000 posts (the legacy Basic limit) | $200 (Basic) | $75 | 750 requests, Starter plan, $49 |
| 100,000 posts | $5,000 (Pro) | $500 | 5,000 requests, Starter plan, $49 |
| 1,000,000 posts (the legacy Pro limit) | $5,000 (Pro) | $5,000 | 50,000 requests, Pro plan, $199 |
| 3,000,000 posts (the pay-per-use cap) | Enterprise only | $15,000, then blocked | 150,000 requests, Enterprise plan, $899 |
| 1,000,000 follower profiles | Varied by plan | $10,000 | 5,000 requests, Starter plan, $49 |
The flat-rate column assumes full pages: 20 posts per search or timeline request and 200 profiles per follower request. Some pages come back partly filled, so treat the request counts as a floor and leave headroom when picking a plan. The pay-per-use column ignores two discounts. Re-reading the same post within one UTC day is billed once, and once a billing cycle passes $200, X returns 10% to 20% of spend as xAI API credits, which only helps if you use xAI's API. Neither discount closes a gap of this size.
Worked example: re-pricing a legacy Pro read pipeline
Consider a brand-monitoring pipeline on legacy Pro. Each month it reads 800,000 posts through search, looks up 100,000 author profiles, and snapshots 500,000 follower records, and it publishes about 2,000 posts. On the flat Pro plan, all of that cost $5,000 a month.
After the Pro cutover, pay-per-use prices each resource:
- 800,000 posts at $0.005: $4,000
- 100,000 profiles at $0.010: $1,000
- 500,000 follower records at $0.010: $5,000
- 2,000 posts published at $0.015: $30 (or $400 if every post carries a link)
That is about $10,030 a month, double the old plan, and it uses 800,000 of the 3 million post-read cap.
Moving only the reads to Sorsa changes the math. Search at 20 posts per request is 40,000 requests, batch profile lookups at 100 per request add 1,000, and follower pages at 200 per request add 2,500. The total, 43,500 requests, fits inside the 100,000-request Pro plan at $199 a month. Publishing stays on X at $30, so the hybrid setup runs about $229 a month, with more than half of the plan's quota still free for growth. The code side is Steps 1 to 7 below: with the shim from Step 4 in place, existing parsers keep working on day one while the rest of the migration proceeds endpoint by endpoint.
Check your own numbers. Enter your monthly volumes in the X API cost calculator to see which plan fits, then get an API key and run your real queries: every new account includes 100 free requests, no card required.
How do you migrate the read path from the X API?
Seven changes cover a typical read-path migration, and most teams finish in one to three engineering days. The steps below include the parts teams usually get stuck on: search operators, a compatibility shim, and a safe cutover.
Step 1: Replace OAuth with one API key header
Authentication removes the most code. The official API uses OAuth 2.0 bearer tokens for app-only reads and OAuth 1.0a or OAuth 2.0 user tokens for user context. After the switch, your code sends one header.
# Before: X API v2 with an app-only bearer token
curl "https://api.x.com/2/users/by/username/nasa?user.fields=public_metrics,created_at" \
-H "Authorization: Bearer $X_BEARER_TOKEN"
# After: one API key header, all fields returned by default
curl "https://api.sorsa.io/v3/info?username=nasa" \
-H "ApiKey: $SORSA_API_KEY"
You stop generating OAuth 1.0a signatures, refreshing tokens, and registering callback URLs. You also don't need an X developer account, as this guide to getting X data without a developer account explains. Create a key in the dashboard, store it in an environment variable, and send it as ApiKey on every request.
Step 2: Change the base URL and remap X API v2 endpoints
The base URL moves from https://api.x.com/2 to https://api.sorsa.io/v3. Each read endpoint has a direct counterpart. The Tweepy column helps if your code calls the library instead of raw URLs.
| Data you read | X API v2 endpoint | Tweepy Client method | Sorsa API v3 |
|---|---|---|---|
| Profile by username | GET /2/users/by/username/:username | get_user(username=...) | GET /info?username= |
| Profile by ID | GET /2/users/:id | get_user(id=...) | GET /info?user_id= |
| Up to 100 profiles | GET /2/users?ids= or /2/users/by?usernames= | get_users() | GET /info-batch?usernames=a&usernames=b |
| Followers | GET /2/users/:id/followers | get_users_followers() | GET /followers?user_id= (200 per page) |
| Following | GET /2/users/:id/following | get_users_following() | GET /follows?user_id= (200 per page) |
| User search | GET /2/users/search (user context) | none | POST /search-users |
| Single post | GET /2/tweets/:id | get_tweet() | POST /tweet-info |
| Up to 100 posts | GET /2/tweets?ids= | get_tweets() | POST /tweet-info-bulk |
| X Article text | GET /2/tweets/:id with tweet.fields=article | get_tweet() | POST /article |
| User timeline | GET /2/users/:id/tweets (latest 3,200) | get_users_tweets() | POST /user-tweets (no 3,200 ceiling) |
| Mentions | GET /2/users/:id/mentions | get_users_mentions() | POST /mentions |
| Recent or full-archive search | GET /2/tweets/search/recent, /search/all | search_recent_tweets(), search_all_tweets() | POST /search-tweets |
| Replies to a post | search with conversation_id: | search_recent_tweets() | POST /comments |
| Quote posts | GET /2/tweets/:id/quote_tweets | get_quote_tweets() | POST /quotes |
| Reposters | GET /2/tweets/:id/retweeted_by | get_retweeters() | POST /retweeters |
| List members | GET /2/lists/:id/members | get_list_members() | GET /list-members?list_id= |
| List followers | GET /2/lists/:id/followers | get_list_followers() | GET /list-followers?list_link= |
| List posts | GET /2/lists/:id/tweets | get_list_tweets() | GET /list-tweets?list_id= |
| Space details | GET /2/spaces/:id | get_space() | GET /spaces?id= |
| Trends by location | GET /2/trends/by/woeid/:woeid | none | GET /trends?woeid= |
A few reads have no v2 counterpart and often replace custom code:
GET /verified-followersreturns only verified followers of an account.GET /aboutreturns the "About this account" data: country, the number of username changes and the date of the last one, and the Premium start date.POST /check-follow,POST /check-retweet,POST /check-quoted, andGET /check-commentanswer yes/no questions that would otherwise mean crawling whole follower or reposter lists.
Long-form posts get a dedicated call too: POST /article returns the full text, cover image, and metrics in one flat object, as the X Articles API guide shows.
Follower pulls change the most in cost. On pay-per-use, 1,000 followers cost $10. On a flat plan, the same 1,000 profiles take 5 requests. The followers and following lists guide covers full-graph exports.
Step 3: Move post and search calls to POST
This is the change most often missed while debugging. On the official API every read is GET. Here, most endpoints that take a post or a search query use POST with a JSON body, while profile, follower, list, Space, and trend lookups stay GET. Treat the method column in the map above as the source of truth.
Two details save time. Fields named tweet_link accept either a full post URL or the bare numeric ID. And on GET endpoints, list parameters are passed by repeating the key (?usernames=a&usernames=b).
Step 4: Flatten response parsing, or add a v2 shim
The official API splits every response into data, includes, and meta, and you join authors back to posts through author_id. Here, each post is a flat object with the full author profile embedded under user, and metrics sit at the top level.
| X API v2 field | Sorsa field | Change |
|---|---|---|
name | display_name | Renamed |
text | full_text | Renamed |
public_metrics.followers_count | followers_count | Flattened |
public_metrics.following_count | followings_count | Flattened, extra "s" |
public_metrics.tweet_count | tweets_count | Flattened, renamed |
public_metrics.listed_count | not returned | Dropped |
public_metrics.like_count | likes_count | Flattened, extra "s" |
public_metrics.retweet_count, reply_count, quote_count, bookmark_count | same names | Flattened |
public_metrics.impression_count | view_count | Renamed |
conversation_id | conversation_id_str | Renamed |
in_reply_to_user_id | in_reply_to_username | Handle instead of ID |
author_id plus includes.users[] | user on every post | Embedded |
referenced_tweets plus includes.tweets[] | quoted_status, retweeted_status | Embedded |
entities (urls, mentions, hashtags) and media | entities array of {type, link, preview} for photos, videos, and URLs | Mentions and hashtags come from full_text |
meta.next_token | next_cursor | Moved to top level |
Watch the plurals: likes_count, followings_count, and tweets_count take an "s", while retweet_count, reply_count, and quote_count do not. Profiles also carry extra fields such as favourites_count, media_count, can_dm, bio_urls, and pinned_tweet_ids, with no field-selection parameters needed.
There are two ways to absorb the new shape. Rewrite parsers to read the flat fields, or wrap calls in a shim that returns v2-shaped objects so the rest of the codebase keeps working. The shim is the faster first step, and you can retire it one endpoint at a time.
import os
import requests
SORSA = "https://api.sorsa.io/v3"
HEADERS = {"ApiKey": os.environ["SORSA_API_KEY"]}
def v2_user(u):
"""Reshape a flat profile into the X API v2 user object."""
return {
"id": u["id"],
"username": u["username"],
"name": u["display_name"],
"description": u.get("description"),
"verified": u.get("verified"),
"created_at": u.get("created_at"),
"profile_image_url": u.get("profile_image_url"),
"public_metrics": {
"followers_count": u.get("followers_count"),
"following_count": u.get("followings_count"),
"tweet_count": u.get("tweets_count"),
},
}
def v2_tweet(t):
"""Reshape a flat post into the X API v2 tweet object."""
return {
"id": t["id"],
"text": t["full_text"],
"created_at": t["created_at"],
"lang": t.get("lang"),
"author_id": t["user"]["id"],
"conversation_id": t.get("conversation_id_str"),
"public_metrics": {
"like_count": t.get("likes_count"),
"retweet_count": t.get("retweet_count"),
"reply_count": t.get("reply_count"),
"quote_count": t.get("quote_count"),
"bookmark_count": t.get("bookmark_count"),
"impression_count": t.get("view_count"),
},
}
def search_recent(query, next_token=None):
"""Drop-in for GET /2/tweets/search/recent that returns a v2-shaped page."""
body = {"query": query, "order": "latest"}
if next_token:
body["next_cursor"] = next_token
r = requests.post(f"{SORSA}/search-tweets", headers=HEADERS, json=body, timeout=30)
r.raise_for_status()
page = r.json()
tweets = page.get("tweets", [])
authors = {t["user"]["id"]: v2_user(t["user"]) for t in tweets}
meta = {"result_count": len(tweets)}
if page.get("next_cursor"): # like v2, omit next_token on the last page
meta["next_token"] = page["next_cursor"]
return {
"data": [v2_tweet(t) for t in tweets],
"includes": {"users": list(authors.values())},
"meta": meta,
}
Existing code that reads page["data"], joins includes.users on author_id, and follows meta.next_token until it disappears keeps running unchanged. The query passed in must already use Advanced Search syntax, which is Step 5.
Step 5: Translate search operators to Advanced Search syntax
Search queries do not carry over unchanged. The X API v2 has its own operator set, while many alternative read APIs, Sorsa included, use the Advanced Search syntax that powers X's web search. The two overlap on the basics and diverge on filters, engagement thresholds, and time windows. X's own operator reference notes that the web names min_faves: and min_retweets: are rejected by the v2 API with a 400 error, so each side needs its own spelling.
| X API v2 | Advanced Search syntax | Note |
|---|---|---|
from: to: @ # $ "exact phrase" OR -term ( ) | Same | No change |
lang:en, url:, conversation_id:, list: | Same | No change |
min_likes:100 | min_faves:100 | Renamed |
min_reposts:50 | min_retweets:50 | Renamed |
min_replies:10 | min_replies:10 | Same |
is:reply | filter:replies | |
is:quote | filter:quote | |
is:retweet / -is:retweet | filter:nativeretweets / -filter:nativeretweets | Reliable with from: and for recent posts |
has:media | filter:media | |
has:images | filter:images | |
has:video_link | filter:native_video | filter:videos also matches external video such as YouTube |
has:links | filter:links | |
has:mentions, has:hashtags | filter:mentions, filter:hashtags | |
is:verified | filter:verified or filter:blue_verified | Choose the badge type you mean |
quotes_of_tweet_id:ID | quoted_tweet_id:ID | |
point_radius:[lon lat 10km] | geocode:lat,lon,10km | Coordinate order flips |
start_time / end_time parameters | since:YYYY-MM-DD / until:YYYY-MM-DD, or since_time: / until_time: with Unix seconds | Time moves into the query string |
since_id / until_id parameters | since_id: / max_id: | max_id: includes that post, until_id excludes it |
sort_order=recency or relevancy | "order": "latest" or "popular" in the body | |
context:, entity:, retweets_of:, -is:nullcast | No direct equivalent | Rewrite with keywords and accounts |
A small translator handles most queries automatically:
import re
from datetime import datetime
V2_TO_WEB = [
(r"\bmin_likes:", "min_faves:"),
(r"\bmin_reposts:", "min_retweets:"),
(r"\bis:retweet\b", "filter:nativeretweets"),
(r"\bis:reply\b", "filter:replies"),
(r"\bis:quote\b", "filter:quote"),
(r"\bhas:media\b", "filter:media"),
(r"\bhas:images\b", "filter:images"),
(r"\bhas:video_link\b", "filter:native_video"),
(r"\bhas:links\b", "filter:links"),
(r"\bhas:mentions\b", "filter:mentions"),
(r"\bhas:hashtags\b", "filter:hashtags"),
(r"\bquotes_of_tweet_id:", "quoted_tweet_id:"),
]
def to_unix(iso_ts):
return int(datetime.fromisoformat(iso_ts.replace("Z", "+00:00")).timestamp())
def translate_query(query, start_time=None, end_time=None):
"""Convert an X API v2 query plus time params into Advanced Search syntax."""
for pattern, replacement in V2_TO_WEB:
query = re.sub(pattern, replacement, query)
if start_time:
query += f" since_time:{to_unix(start_time)}"
if end_time:
query += f" until_time:{to_unix(end_time)}"
return query
print(translate_query("from:nasa has:media -is:retweet min_likes:100",
start_time="2026-09-01T00:00:00Z"))
# from:nasa filter:media -filter:nativeretweets min_faves:100 since_time:1788220800
Keep queries under roughly 22 operators, the practical ceiling for Advanced Search. The mentions endpoint also takes engagement and date filters as body fields (min_likes, min_replies, min_retweets, since_date, until_date), so those do not need to live in the query string. The full operator list, with examples, is in the Twitter search operators cheat sheet.
Step 6: Replace pagination tokens with next_cursor
The official API takes pagination_token (timelines, follows, lists) or next_token (search) and returns meta.next_token. Here, one field does both jobs: send next_cursor as a query parameter on GET endpoints or inside the JSON body on POST endpoints, and read next_cursor from the top level of the response. A missing or null cursor means the last page.
from itertools import islice
def paginate(method, path, params, key):
"""Yield items from any list endpoint until next_cursor runs out."""
cursor = None
while True:
args = dict(params, next_cursor=cursor) if cursor else dict(params)
if method == "GET":
r = requests.get(f"{SORSA}/{path}", headers=HEADERS, params=args, timeout=30)
else:
r = requests.post(f"{SORSA}/{path}", headers=HEADERS, json=args, timeout=30)
r.raise_for_status()
page = r.json()
yield from page.get(key, [])
cursor = page.get("next_cursor")
if not cursor:
return
# Up to 200 full profiles per request: the first 10,000 followers take 50 requests
followers = list(islice(paginate("GET", "followers", {"username": "nasa"}, "users"), 10_000))
# 20 posts per request, paging past the 3,200-post timeline ceiling (a lazy generator)
posts = paginate("POST", "user-tweets", {"username": "nasa"}, "tweets")
The timeline difference matters for archives. The v2 user timeline stops at an account's most recent 3,200 posts, while POST /user-tweets keeps paging back as long as a cursor comes back. For date-bounded pulls, the historical Twitter data guide shows how to combine this with since: and until: search.
Step 7: Simplify error handling and rate limiting
The official API returns errors as an errors array or a problem object with type, title, and detail. Here, every error is one field, {"message": "..."}, with standard status codes: 400, 401, 403, 404, 429, and 500.
Rate limiting gets simpler too. Instead of per-endpoint 15-minute windows (the X API rate limits guide lists them all), each API key gets 20 requests per second across every endpoint and plan, and the counter resets every second. A 429 does not penalize the key: wait a moment and retry.
import time
def call(method, path, retries=4, **kwargs):
for attempt in range(retries):
r = requests.request(method, f"{SORSA}/{path}", headers=HEADERS, timeout=30, **kwargs)
if r.status_code == 429: # more than 20 requests in the current second
time.sleep(0.25 * (2 ** attempt))
continue
if r.status_code >= 400:
try:
message = r.json().get("message")
except ValueError:
message = r.text
raise RuntimeError(f"{r.status_code}: {message}")
return r.json()
raise RuntimeError("still rate limited after retries")
Before and after: one search call in JavaScript
The same pattern in Node.js, with the official call first and the migrated call second:
// Recent search only accepts a start_time within the last 7 days
const since = new Date(Date.now() - 3 * 24 * 60 * 60 * 1000);
// Before: X API v2 recent search, authors joined from includes
const params = new URLSearchParams({
query: "from:nasa has:media -is:retweet",
start_time: since.toISOString().replace(/\.\d{3}Z$/, "Z"),
max_results: "100",
"tweet.fields": "created_at,public_metrics",
expansions: "author_id",
"user.fields": "username",
});
const before = await fetch(`https://api.x.com/2/tweets/search/recent?${params}`, {
headers: { Authorization: `Bearer ${process.env.X_BEARER_TOKEN}` },
});
const v2 = await before.json();
const users = new Map((v2.includes?.users ?? []).map((u) => [u.id, u]));
for (const t of v2.data ?? []) {
console.log(users.get(t.author_id)?.username, t.public_metrics.like_count, t.text);
}
// After: POST with a JSON body, author embedded in every post
const after = await fetch("https://api.sorsa.io/v3/search-tweets", {
method: "POST",
headers: { ApiKey: process.env.SORSA_API_KEY, "Content-Type": "application/json" },
body: JSON.stringify({
query: `from:nasa filter:media -filter:nativeretweets since_time:${Math.floor(since / 1000)}`,
order: "latest",
}),
});
const flat = await after.json();
for (const t of flat.tweets) {
console.log(t.user.username, t.likes_count, t.full_text);
}
The join table disappears because the author ships inside every post. Full walkthroughs for each language are in the Twitter API in Python and Twitter API in Node.js guides.
How do you cut over without breaking production?
Migrate one endpoint at a time, prove each one against the official API, and keep a rollback path until the numbers match.
- Put one client module in front of every data call. Application code should call
get_followers()orsearch(), never a URL. The shim from Step 4 is a good start. - Shadow-read for three to seven days. Send the same inputs to both APIs and log differences without serving the new data. The official side still bills per resource, so sample a few queries instead of mirroring all traffic.
- Diff post IDs, not rankings. Compare results ordered by latest inside a fixed time window.
- Flip one endpoint behind a flag. Keep the official path callable so any endpoint can roll back on its own.
- Watch quota after each flip.
GET /key-usage-inforeturns the requests remaining on your key. - Delete the OAuth read code last, once every read endpoint has run clean for a couple of weeks.
A minimal diff for step 3, reusing translate_query() and paginate() from above:
def shadow_diff(v2_query, start_time, pages=5):
"""Run one query on both APIs and compare post IDs before cutting over.
start_time must fall inside recent search's 7-day window.
"""
r = requests.get(
"https://api.x.com/2/tweets/search/recent",
headers={"Authorization": f"Bearer {os.environ['X_BEARER_TOKEN']}"},
params={"query": v2_query, "start_time": start_time, "max_results": 100},
timeout=30,
)
r.raise_for_status() # a failed official call must not look like "0 results"
official_ids = {t["id"] for t in r.json().get("data", [])}
body = {"query": translate_query(v2_query, start_time), "order": "latest"}
new_ids = set()
for tweet in paginate("POST", "search-tweets", body, "tweets"):
new_ids.add(tweet["id"])
if len(new_ids) >= pages * 20:
break
shared = official_ids & new_ids
print(f"official={len(official_ids)} new={len(new_ids)} shared={len(shared)}")
print("only on official:", sorted(official_ids - new_ids)[:10])
return shared
Each official run reads up to 100 posts, about $0.50 at pay-per-use rates. Expect small, explainable gaps: engagement counts move between calls, the two indexes rank "popular" results differently, and X's own index stopped returning retweets in keyword search in May 2026.
Run the diff on your own queries. The 100 free requests on a new Sorsa account cover a full parallel test of search, timelines, and follower pulls. To see real responses before writing any code, send the same calls from the API playground in your browser.
What stays on the official X API?
Everything a typical data pipeline reads can move: search, timelines, profiles, followers, lists, replies, quotes, reposters, trends, and Spaces. What stays on X is the write side and data only the account owner can see, which is usually a small bill:
| Capability | Why it stays | Cost on pay-per-use |
|---|---|---|
| Posting and replies | Read-only APIs do not write | $0.015 per post, $0.20 with a URL, $0.010 per summoned reply |
| Direct messages | Private data requires the account owner's authorization | $0.015 per DM sent, $0.010 per DM event read |
| Follows, likes, quote-posts | Removed from self-serve plans on April 20, 2026 | Enterprise contract only |
| Your account's private data (bookmarks, home timeline) | Only the account owner can authorize it | $0.001 per resource |
| Filtered stream and push webhooks | Real-time push delivery | Metered under pay-per-use, see the rate card |
| Ads API and compliance programs | Official-only programs | Separate agreements |
Keep one small credit balance on X for these, with a spending limit so a busy week cannot surprise you. For alerting and dashboards, polling search on a flat plan replaces the stream for most monitoring jobs, as the real-time Twitter monitoring guide shows.
X API migration checklist
- Record your cutover date, spending limit, and auto-recharge rule in the X Developer Console.
- Export a month of usage by resource type: posts, users, followers.
- Mark every write call (posting, replies, DMs) to stay on X.
- Replace
Authorization: Bearer ...with theApiKeyheader on read calls. - Remove OAuth 1.0a signing and token refresh from the read path.
- Change the base URL to
https://api.sorsa.io/v3. - Remap every endpoint with the Step 2 table.
- Switch post and search calls from GET to POST with JSON bodies.
- Delete
tweet.fields,user.fields, andexpansionsparameters. - Flatten parsers, or route calls through the v2 shim.
- Translate search operators and move
start_timeandend_timeinto the query. - Replace
pagination_tokenandnext_tokenwithnext_cursor. - Update error handling for the single
messagefield, and throttle to 20 requests per second. - Shadow-read and diff for three to seven days, then flip endpoints one at a time.
- Track quota with
GET /key-usage-infoafter every flip.
FAQ
What changed in the Twitter/X API in 2026?
X launched pay-per-use billing on February 6, 2026, then moved legacy Basic plans onto it after June 1 and legacy Pro plans after September 1. On April 20 it removed follow, like, and quote-post writes from self-serve plans and repriced posting. On May 4 search moved to a new index with new engagement operators, and Communities shut down on May 30.
What happens when a legacy Basic or Pro plan moves to pay-per-use?
The endpoints stay the same, but billing switches from a flat monthly fee to per-resource charges: $0.005 per post read and $0.010 per user or follower read. Annual subscribers receive the prorated unused balance as credits that expire after the matching number of months. Post reads are capped at 3 million per billing cycle, so review the spending limit and auto-recharge settings right after the cutover.
Is there an alternative to X API pay-per-use for read-heavy apps?
Yes. Flat per-request APIs bill one request per call instead of one charge per post or profile. Sorsa API, for example, costs from $0.02 per 1,000 tweets and from $0.01 per 1,000 profiles, uses a single API key, allows 20 requests per second on every plan, and includes 100 free requests to test. Posting and DMs still go through the official X API.
Why did X API search stop returning retweets?
On May 4, 2026, X moved its recent and full-archive search endpoints to a new index, and keyword searches stopped returning retweets. Filtered stream was not affected. To count reposts of a specific post, use the reposted-by endpoint. To find recent retweets by a known account, query from:username filter:nativeretweets on an API that supports Advanced Search syntax.
Do X API v2 search queries work on other APIs without changes?
Keywords, from:, to:, hashtags, quoted phrases, OR, and exclusions carry over, but several operators need renaming. APIs built on X's Advanced Search syntax use min_faves: instead of min_likes:, min_retweets: instead of min_reposts:, and filter:media or filter:replies instead of has:media or is:reply. Time filters move from start_time and end_time parameters into since: and until: operators.
Can you migrate off the X API one endpoint at a time?
Yes, and it is the safer route. Put a thin client in front of your data calls, switch one endpoint, run it in parallel with the official API for a few days, compare the output, then move the next one. Each endpoint can roll back on its own, and write calls never have to move.
How do you keep posting to X if the new API is read-only?
Keep the official X API for writes and move only the read path. Posting, replies, DMs, and your own account's data stay on X under pay-per-use, while search, timelines, profiles, and follower lists run through a flat per-request read API. Most teams keep one small X credit balance, with a spending limit, for the write side.
Does moving off the official API remove the 3,200-post timeline limit?
It can. The X API v2 user timeline endpoint returns only an account's most recent 3,200 posts. Sorsa's user-tweets endpoint has no such ceiling: keep passing next_cursor until the response stops returning one to page back through older posts. For a specific date range, historical search with since: and until: operators is faster.
How long does an X API migration take?
A read-only pipeline usually takes one to three engineering days: a few hours for auth, the base URL, and endpoint mapping, about a day for response parsing and search operators, and a day of parallel testing before cutover. Apps that also write keep those calls on X, so the work stays limited to the read path.
Getting started
The fastest way to scope a migration is to run your three most expensive calls through both APIs and compare the output. Sorsa starts with 100 free requests: one-time, no card, no expiry, enough to pull up to 10,000 tweets or 20,000 profiles while you run a parallel test of search, timelines, and follower pulls. Get an API key and make a first request in minutes.
When you are ready to scale, the flat per-request pricing plans run from about $0.02 per 1,000 tweets and $0.01 per 1,000 profiles on batch endpoints, with the same 20 requests per second on every plan, and you don't need to apply for an X developer account. If the data feeds an AI agent, connect the same endpoints through the Sorsa MCP server.
Reviewed by Keksich, founder of Sorsa, marketer and X API researcher.
This guide draws on our team's hands-on work running the live Sorsa API since 2022 and moving read pipelines off the official X API. Every X API fact was checked on September 23, 2026 against X's changelog, pay-per-use pricing documentation, search operator reference, and data dictionary, and the legacy Basic and Pro cutover dates come from X's developer-forum announcements. Sorsa endpoint paths, parameters, and response fields were checked against the v3 API documentation, the Python samples were unit-tested against mocked API responses, and the JavaScript sample was syntax-checked and mock-run in Node.js 22. Sorsa is an independent service and is not affiliated with or endorsed by X Corp. Verified September 2026.