Twitter (X) API Error Handling — Production Best Practices

By Michael Park•10 min read
TL;DR
  • twitterapi.io: HTTP status first, then status: "error" in the HTTP 200 body — there is no errors array
  • 429 on twitterapi.io carries no rate-limit headers: back off about 1 s with jitter and an exponential cap; x-rate-limit-reset exists 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.

Which failures cost credits

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

01 — Section

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-226 reference for account-safety mitigations)
02 — Section

HTTP status decision tree

HTTP statustwitterapi.ioOfficial X APIRetry?
200, no error in bodysuccesssuccessno
200 + status: "error"the lookup failed; reason in msg or message; billednot usedno — cache the answer
200 + errors arraynot usedpartial failure — parse errors[], act per codedepends on code
400missing or invalid parameter, e.g. {"detail":"userName is required"}your query is malformedNO (fix the request)
401well-formed key that is unknown or revoked: API key is invalidauth invalid/expiredtwitterapi.io: no, replace the key. X: refresh token then retry once
402out of credits—no — recharge first
403no API key, or a malformed oneforbidden (policy/permission/private) — check body code, 226 no retryno
404not used for missing users or tweets (see below)not found — wrong URL or path; see docs.x.com for missing resourcesno
422wrong parameter type (rare); detail is a list—NO (fix the request)
429per-key requests-per-second limit exceeded; no rate-limit headersrate limit exceeded — see x-rate-limit-resetyes, with backoff
500 / 502 / 504server crash or upstream failure; 502/504 come from the gateway on timeouts; body may be non-JSON5xx server erroryes, exponential backoff + circuit breaker
network timeoutinfrastructureinfrastructureyes, with backoff

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.

03 — Section

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.

python
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"])
04 — Section

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.

Response from twitterapi.ioBilled?Retry?
400, 401, 402, 403, 422nono — fix the request, the key or the balance
429noyes — exponential backoff + jitter
500 / 502 / 503 / 504, network timeoutnoyes — exponential backoff + jitter, circuit breaker
HTTP 200 + status: "error" (user not found, login denied)yes — normal endpoint price, 15-credit minimumno — it is an answer, not a glitch
HTTP 200, successful but empty resultyes — the 15-credit minimumno

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.

05 — Section

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 window
  • x-rate-limit-remaining — requests left in this window
  • x-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.

06 — Section

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:

  1. Human-like pacing (a few seconds per action plus jitter, spread across business hours)
  2. Vary user-agent + accept-language per your app's realistic profile
  3. Multi-account distribution for high-volume workflows (don't stack every action on one account)
  4. 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.

07 — Section

Comparison — twitterapi.io vs official X API error handling

Dimensiontwitterapi.ioOfficial X API
Error inside an HTTP 200 bodystatus: "error" with msg or messageerrors array with numeric code
Body error codes (88, 226, ...)not usedyes, per docs.x.com/x-api/fundamentals/response-codes-and-errors
Bad or missing key403 (none or malformed), 401 (unknown or revoked)401, or body codes 32 / 89 / 215
Out of credits402—
Rate-limit signal429, limit quoted in message429 and body code 88
Rate-limit headersnonex-rate-limit-limit, -remaining, -reset
How long to wait on 429own backoff: about 1 s + jitter, exponential capuntil x-rate-limit-reset
Missing user or tweetHTTP 200 (status: "error", or the tweet left out of tweets)body codes 34 / 50
Cost of a failed callnon-200 free; HTTP 200 + status: "error" billedsee docs.x.com/x-api/getting-started/pricing

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.

python
# 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.

08 — Questions

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.

09 — Further reading

Continue

Sources & further reading
More from this series

Writing about something you built with TwitterAPI.io? Our community rewards program pays API credits for honest tutorials, build logs and videos.

Build it

Stop reading. Start building.

Starter credits cover real testing on real data. Google sign-in, no card, no application queue.

Get an API key

Already building with TwitterAPI.io? Publish an honest tutorial or build log about it and earn up to $200 a month in API credits.