Twitter (X) API Error Handling — Production Best Practices
- twitterapi.io: HTTP status first, then
status: "error"in the HTTP 200 body — there is noerrorsarray - 429 on twitterapi.io carries no rate-limit headers: back off about 1 s with jitter and an exponential cap;
x-rate-limit-resetexists only on the official X API - Non-200 responses are not billed; HTTP 200 with
status: "error"is billed at the normal price (15-credit minimum) - Retry 429, 5xx and timeouts; never retry 400/401/402/403/422 or a
status: "error"answer — cache negative results instead
Twitter (X) API error handling looks trivial in a hello-world tutorial and turns into days of debugging once an integration starts dropping data or paying for the same failed lookup again and again. Most of the confusion comes from mixing up two different error formats: the one the official X API uses and the one twitterapi.io uses.
This guide puts the two side by side. It lists the status codes and failure bodies twitterapi.io returns, shows where the official X API differs (an errors array with numeric codes, and rate-limit headers), explains which failures are billed and which are worth retrying, and gives runnable Python for one handling pattern that fits both.
On twitterapi.io any non-200 response (400, 401, 402, 403, 422, 429, 5xx) is not billed, while HTTP 200 with status "error" is billed at the endpoint's normal price with a 15-credit minimum — retry the first kind, cache the second
Two error formats — twitterapi.io vs the official X API
Most API tutorials teach 'check HTTP status, retry on 5xx'. That is half of it on either API, because both can report a failure inside an HTTP 200 body. They do it in different ways, and code written for one format silently misses errors in the other.
twitterapi.io: when the request reached the endpoint but the lookup failed, the response is HTTP 200 with status: "error". Read endpoints put the reason in msg (some also include data: null or a code); login, /twitter/user/check_follow_relationship and the v2 write endpoints put it in message. Looking up a handle that does not exist on /twitter/user/info returns HTTP 200 with {"status":"error","msg":"user not found","data":null}. There is never an X-style errors array.
Where the text lives: msg on read endpoints, message on the key, credit and rate-limit responses, detail on parameter errors. A robust reader tries all three in that order: msg, then message, then detail.
Three endpoints have no status field at all: /twitter/tweet/advanced_search, /twitter/tweet/retweeters and /twitter/user/search. For those, the HTTP status is the only signal, so do not test status on them.
Official X API: errors are reported through an errors array in the JSON body, sometimes on an HTTP 200 response, and each entry has a numeric code and a message. These codes belong to X and do not appear in twitterapi.io responses. Common X body error codes (docs.x.com/x-api/fundamentals/response-codes-and-errors reference):
- 32: could not authenticate you — bearer token invalid
- 34: page does not exist — tweet was deleted or never existed
- 50: user not found — handle was deleted / suspended / renamed
- 63: user suspended — target account is suspended by X
- 88: rate limit exceeded (also comes with HTTP 429)
- 89: invalid or expired token — refresh OAuth
- 131: internal error — treat as 5xx equivalent
- 179: not authorized to see this status (protected/blocked)
- 185: user is over daily status update limit
- 187: status is a duplicate
- 215: bad authentication data
- 226: this request looks like it might be automated (soft policy warning — see dedicated
twitter-request-looks-like-it-might-be-automated-error-226reference for account-safety mitigations)
HTTP status decision tree
twitterapi.io's user and tweet lookups do not return 404 for missing data. /twitter/user/info answers HTTP 200 with status: "error" for a handle that does not exist and data.unavailable: true for a suspended account, and /twitter/tweets answers HTTP 200 and leaves deleted or private tweets out of tweets. /twitter/tweets takes at most 50 IDs per request; sending more returns HTTP 400, so batch larger lists into several calls.
Runnable — production-grade error handler
One wrapper for twitterapi.io that applies the rules in order: HTTP status first (retry 429 and 5xx with backoff, fail fast on other 4xx), then status in the HTTP 200 body on endpoints that return it, with a negative cache so a billed 'user not found' is never paid for twice. The same shape works for the official X API if you swap the body check for its errors array and the wait for its reset header.
import os, random, time
import requests
BASE = "https://api.twitterapi.io"
HEADERS = {"X-API-Key": os.environ["TWITTERAPI_IO_KEY"]}
RETRY_STATUS = {429, 500, 502, 503, 504} # everything else is final
BASE_DELAY = 1.0 # seconds; use 5.0 on the free tier (1 request per 5 seconds)
MAX_DELAY = 30.0
class TwitterAPIError(Exception):
def __init__(self, http_status, message, billed):
self.http_status = http_status
self.message = message
self.billed = billed # True only for HTTP 200 + status "error"
super().__init__(f"HTTP {http_status}: {message}")
def error_text(payload):
"""The error text sits in msg, message or detail, depending on the layer."""
if not isinstance(payload, dict):
return ""
return str(payload.get("msg") or payload.get("message") or payload.get("detail") or "")
def backoff(attempt):
"""Exponential backoff with jitter: about 1 s, 2 s, 4 s ... capped."""
return min(MAX_DELAY, BASE_DELAY * (2 ** attempt)) + random.uniform(0, 1)
def api_get(path, params, has_status=True, max_retries=5):
"""GET one endpoint. has_status=False for endpoints that return no status field."""
for attempt in range(max_retries + 1):
try:
r = requests.get(BASE + path, headers=HEADERS, params=params, timeout=30)
except (requests.ConnectionError, requests.Timeout) as exc:
if attempt == max_retries:
raise TwitterAPIError(0, f"network error: {exc}", billed=False)
time.sleep(backoff(attempt))
continue
# 1) HTTP status first. 429 and 5xx are not billed and are worth retrying.
# There is no rate-limit header to read, so the wait is our own backoff.
if r.status_code in RETRY_STATUS:
if attempt == max_retries:
raise TwitterAPIError(r.status_code, r.text[:200], billed=False)
time.sleep(backoff(attempt))
continue
try:
payload = r.json()
except ValueError:
payload = None
# 2) 400 / 401 / 402 / 403 / 422: not billed, never retried. Fix the cause.
if r.status_code != 200:
raise TwitterAPIError(r.status_code, error_text(payload) or r.text[:200], billed=False)
if not isinstance(payload, dict):
raise TwitterAPIError(200, "response body is not a JSON object", billed=False)
# 3) HTTP 200 with status "error" is an answer, and it was billed. Do not retry.
if has_status and payload.get("status") == "error":
raise TwitterAPIError(200, error_text(payload), billed=True)
return payload
# Negative cache: a handle that does not exist is charged on every lookup,
# so remember the answer instead of asking again.
missing_users = {}
def get_user(user_name):
key = user_name.lower()
if key in missing_users:
return None
try:
info = api_get("/twitter/user/info", {"userName": user_name})
except TwitterAPIError as err:
if err.billed: # e.g. "user not found"
missing_users[key] = err.message
return None
raise # 400 / 401 / 402 / 403: stop and fix
return info["data"]
def search_page(query, cursor=None):
params = {"query": query, "queryType": "Latest"}
if cursor:
params["cursor"] = cursor
# advanced_search has no status field: the HTTP status is the only signal.
return api_get("/twitter/tweet/advanced_search", params, has_status=False)
if __name__ == "__main__":
user = get_user("elonmusk")
print("found" if user else "missing", user and user["userName"])
print(get_user("qzxw_no_such_user_8812")) # None, billed once
print(get_user("qzxw_no_such_user_8812")) # None, served from the cache
page = search_page("openai lang:en")
print(len(page["tweets"]), "tweets, more:", page["has_next_page"])What to retry, and which failures are billed
On twitterapi.io the retry decision is also a cost decision, because one class of failure is charged. Credits convert at $1 = 100,000 credits.
Examples: a 'user not found' on /twitter/user/info is charged 18 credits, and a failed /twitter/user_login_v2 is charged the full $0.10. Retrying either one returns the same answer and charges it again.
The mistake to avoid: a retry loop that treats HTTP 200 with status: "error" as a transient fault. It usually shows up as repeated lookups of deleted or renamed handles. Keep a negative cache (handle or tweet ID mapped to the error text) and skip known misses.
Patterns that fit: exponential backoff + jitter as the default for 429 and 5xx; a client-side token bucket so a high-throughput job stays under its requests-per-second limit instead of discovering it through 429s; a dead-letter queue for final errors (twitterapi.io 4xx and status: "error", X body codes such as 226, 63, 179) so they can be reviewed without blocking the run; and a circuit breaker per host for runs of 5xx.
Rate limits — no headers on twitterapi.io, headers on X
twitterapi.io limits each API key over a sliding 1-second window. A brand-new account that has never paid gets 1 request per 5 seconds; any account that has paid at least once gets 20 requests per second, Scale 50 and Business 500, with more on request (twitterapi.io/qps-limits). Going over returns HTTP 429 with the limit in message, for example Too Many Requests.your qps is N requests per second. See twitterapi.io/qps-limits for the current figures.
There are no rate-limit headers on twitterapi.io: no Retry-After and no x-rate-limit-*. Code that waits until a reset timestamp has nothing to read. On a 429, back off and retry with jitter, starting at about 1 second (5 seconds on the free tier), doubling up to a cap. Because the window is one second long, recovery is fast, and a 429 is not billed.
Better than reacting: pace requests on your side. A token bucket or a simple sleep between calls that keeps you under your requests-per-second limit means you rarely see a 429 at all.
Official X API — response headers to inspect (per docs.x.com/x-api/fundamentals/rate-limits):
x-rate-limit-limit— total allowed requests in the current windowx-rate-limit-remaining— requests left in this windowx-rate-limit-reset— Unix epoch when the window resets
On X: after every request, log x-rate-limit-remaining and slow down before it reaches zero; after a 429, wait until the time in x-rate-limit-reset rather than retrying early. Limits vary by endpoint and access tier, so check docs.x.com/x-api/fundamentals/rate-limits before designing bulk workflows.
Account-safety heuristics (avoid 226 + suspension)
This section is about actions taken with a logged-in X account (posting, following, liking). It does not apply to read-only calls made with an API key.
226 'automated request' code: X's soft warning that your traffic pattern looks bot-like. Continued triggering escalates to account suspension. 226 is an official X body code; on twitterapi.io a refused login or write arrives as HTTP 200 with status: "error" and the reason in message, and it is billed, so do not loop on it.
Triggers: burst posting, unnaturally consistent timing (exactly every N seconds), high-volume follows in a short window, missing normal-user headers.
Mitigations:
- Human-like pacing (a few seconds per action plus jitter, spread across business hours)
- Vary user-agent + accept-language per your app's realistic profile
- Multi-account distribution for high-volume workflows (don't stack every action on one account)
- Never combine write + follow + like automation on the same account in the same hour — spread across accounts + time-of-day
See /blog/twitter-request-looks-like-it-might-be-automated-error-226 for the full mitigation playbook.
Comparison — twitterapi.io vs official X API error handling
The handling pattern is the same on both sides: classify by HTTP status, read the body for the API's own error format, retry only what is transient, and record everything else as a final answer.
# Bulk pull with paging, backoff on 429/5xx, fail-fast on 4xx and a circuit breaker.
import json, logging, os, random, time
from collections import deque
import requests
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger(__name__)
HEADERS = {"X-API-Key": os.environ["TWITTERAPI_IO_KEY"]}
SEARCH_URL = "https://api.twitterapi.io/twitter/tweet/advanced_search"
RETRY_STATUS = {429, 500, 502, 503, 504}
BASE_DELAY = 1.0 # seconds; use 5.0 on the free tier (1 request per 5 seconds)
MAX_DELAY = 30.0
class FatalAPIError(Exception):
"""400 / 401 / 402 / 403 / 422: not billed, and retrying cannot help."""
class CircuitBreaker:
def __init__(self, max_failures=5, window_sec=60, cool_off_sec=300):
self.window_sec = window_sec
self.cool_off_sec = cool_off_sec
self.failures = deque(maxlen=max_failures)
self.open_until = 0.0
def is_open(self):
return time.time() < self.open_until
def record_failure(self):
now = time.time()
self.failures.append(now)
full = len(self.failures) == self.failures.maxlen
if full and now - self.failures[0] < self.window_sec:
self.open_until = now + self.cool_off_sec
log.warning("circuit breaker open for %ss", self.cool_off_sec)
def record_success(self):
self.failures.clear()
CB = CircuitBreaker()
def error_text(payload):
if not isinstance(payload, dict):
return ""
return str(payload.get("msg") or payload.get("message") or payload.get("detail") or "")
def fetch_page(query, cursor=None, max_retries=5):
params = {"query": query, "queryType": "Latest"}
if cursor:
params["cursor"] = cursor
for attempt in range(max_retries + 1):
if CB.is_open():
raise RuntimeError("circuit breaker open, try again later")
wait = min(MAX_DELAY, BASE_DELAY * (2 ** attempt)) + random.uniform(0, 1)
try:
r = requests.get(SEARCH_URL, headers=HEADERS, params=params, timeout=30)
except (requests.ConnectionError, requests.Timeout) as exc:
CB.record_failure()
log.warning("network error %s (attempt %s), sleeping %.1fs", exc, attempt + 1, wait)
time.sleep(wait)
continue
if r.status_code in RETRY_STATUS:
# Not billed. No rate-limit headers exist, so back off on our own clock.
if r.status_code != 429:
CB.record_failure()
log.info("HTTP %s (attempt %s), sleeping %.1fs", r.status_code, attempt + 1, wait)
time.sleep(wait)
continue
try:
payload = r.json()
except ValueError:
payload = None
if r.status_code != 200:
raise FatalAPIError(f"HTTP {r.status_code}: {error_text(payload) or r.text[:200]}")
if not isinstance(payload, dict):
raise FatalAPIError("HTTP 200 but the body is not a JSON object")
CB.record_success()
return payload # this endpoint has no status field; HTTP 200 means success
raise RuntimeError(f"gave up after {max_retries + 1} attempts")
def bulk_pull(query, out_path, max_pages=20):
n, cursor = 0, None
with open(out_path, "w", encoding="utf-8") as f:
for _ in range(max_pages):
page = fetch_page(query, cursor)
for tweet in page["tweets"]:
f.write(json.dumps(tweet, ensure_ascii=False) + "\n")
n += 1
if not page["has_next_page"] or not page["next_cursor"]:
break
cursor = page["next_cursor"]
log.info("pull complete: %s tweets", n)
return n
if __name__ == "__main__":
bulk_pull("anthropic lang:en min_faves:10", "anthropic_signal.jsonl")
# Billing: failed attempts (429, 5xx, 4xx) cost nothing. Every HTTP 200 page is billed,
# and an empty page still costs the 15-credit minimum, so stop when has_next_page is false.Researching this?Free starter credits cover end-to-end testing — Google sign-in, no card. Get a key.
Questions readers ask
Why do I get HTTP 200 with an error in the body?
Both APIs can do it, in different formats. twitterapi.io returns HTTP 200 with status: "error" and a msg or message when the request reached the endpoint but the lookup failed, for example 'user not found'. The official X API can return HTTP 200 with an errors array for per-item and partial failures. Check the format that belongs to the API you are calling; twitterapi.io never sends an errors array.
What's the difference between HTTP 429 and body code 88?
Body code 88 is an official X API code for 'rate limit exceeded' and accompanies X's HTTP 429, which also carries the x-rate-limit-reset header. twitterapi.io does not use code 88 or any rate-limit header: its only rate-limit signal is HTTP 429 with the limit written in message. Back off about 1 second with jitter and retry.
How long should I back off on 429 and 5xx?
Use exponential backoff with jitter: start at about 1 second (5 seconds on the twitterapi.io free tier), double on each attempt, and cap the wait. After several consecutive 5xx responses, open a circuit breaker for a few minutes rather than continuing to hammer the API, and log the pattern for a post-mortem. On twitterapi.io these failed attempts are not billed.
Should I retry on 401 unauthorized?
On twitterapi.io, no. 401 means the key is well-formed but unknown or revoked, and 403 means the key is missing or malformed; retrying returns the same response until the key is fixed. On the official X API, 401 usually means the bearer or OAuth token expired: refresh once and retry once, and if it is still 401 the token must be regenerated.
What about network timeouts — same retry logic?
Yes. Timeouts are usually transient, so retry with the same exponential backoff and jitter you use for 5xx, and count them toward the circuit breaker. twitterapi.io can also answer 502 or 504 from its gateway when an upstream call times out; treat those the same way.
Am I charged for failed requests on twitterapi.io?
Not for any non-200 response: 400, 401, 402, 403, 422, 429 and 5xx are free. HTTP 200 with status: "error" is billed at the endpoint's normal price with a 15-credit minimum, so a 'user not found' on /twitter/user/info costs 18 credits and a failed /twitter/user_login_v2 costs the full $0.10. Cache those answers instead of retrying.
How do I get 226 to stop triggering?
226 is an official X body code, a soft policy warning about the traffic pattern of a logged-in account. Slow down, add jitter, spread across time-of-day + accounts. Repeated 226 escalates to hard suspension. See /blog/twitter-request-looks-like-it-might-be-automated-error-226 for the full playbook.
Is the error handling the same between twitterapi.io and X official?
No. The official X API uses an errors array with numeric codes plus x-rate-limit-* headers. twitterapi.io uses plain HTTP status codes (400, 401, 402, 403, 422, 429, 5xx) and, on HTTP 200, a status: "error" field with the text in msg or message; it has no errors array and no rate-limit headers. The retry policy is shared: retry 429, 5xx and timeouts, and treat everything else as final.
Continue
- twitterapi.io — pricing
- twitterapi.io — API documentation
- twitterapi.io — QPS limits
- X — API errors reference (body error codes)
- X — rate limits (429 handling headers)
- X API — pricing
- Twitter (X) API — cluster hub
- Twitter (X) rate-limit exceeded — dedicated 429 guide
- Error 226 — automated request soft-warning mitigations
- Twitter (X) API Python — production-grade guide
- Twitter (X) API pricing breakdown
- twitterapi.io pricing
- 01Pipedream Pricing in 2026: Connect Plans, Credits and the Workflows Shutdown
- 02Cloudflare Pay per Crawl: How the HTTP 402 Handshake Works, and What It Means for Twitter (X) Data
- 03ScrapingBee Pricing in 2026: Plans, Credit Costs and Alternatives
- 04Firecrawl Pricing: Plans, Credit Costs and What an X (Twitter) URL Costs
- 05LunarCrush API: What It Returns, What It Costs, and When Raw Twitter (X) Data Is the Better Input
- 06Twitter "This Request Looks Like It Might Be Automated" — Error 226, and How to Fix It
- 07Best Claude Model for Coding in 2026: Opus vs Sonnet vs Haiku vs Fable, Priced on Real Twitter (X) Data
- 08Best Time to Post on Twitter (X): What 1,672 Brand Posts Show
Writing about something you built with TwitterAPI.io? Our community rewards program pays API credits for honest tutorials, build logs and videos.
Stop reading. Start building.
Starter credits cover real testing on real data. Google sign-in, no card, no application queue.
Get an API keyAlready building with TwitterAPI.io? Publish an honest tutorial or build log about it and earn up to $200 a month in API credits.