XAnswer

Twitter API get user tweets: what X charges and how far back it goes

X's user timeline returns up to 3,200 recent posts at $0.005 each. Ours needs no X account, costs 5 credits per 20 posts and ends near 2,000 posts.

8 min read

Short answer

  • X's API gets a user's tweets from GET /2/users/{id}/tweets: 5 to 100 posts a request, "Up to 3,200 most recent Posts", at $0.005 per post read on pay-per-use, so 1,000 posts cost $5.00 (docs.x.com, read 2026-10-17).
  • ToolzerHub's /v1/x/profile/posts takes a handle and returns 20 posts a call for 5 credits, with no X developer account. On 2026-10-17 four accounts ended at 2,035 to 2,065 posts, short of X's 3,200. 1,000 posts cost about 293 credits, $0.29 to $0.56.
  • A later page sometimes came back empty with has_more false before the real end. Re-asking the same cursor continued it, and the script below does that. For posts older than the timeline reaches, search with from: and month windows.
On this page

X's API returns a user's tweets from GET /2/users/{id}/tweets, up to the 3,200 most recent, at $0.005 per post read. ToolzerHub's /v1/x/profile/posts takes a handle, needs no X developer account, and costs 5 credits per 20 posts, but on 2026-10-17 it ended at about 2,000 to 2,100 posts per account, and a later page can come back empty before the real end. We read X's documentation and ran ours that day.

How X's API gets a user's tweets

X's timelines page: "Get the most recent Posts authored by a specific user", with "Up to 3,200 most recent Posts", "Filter out replies and retweets" and "Historical access with time-based filtering". The endpoint takes the account's numeric ID, not its handle, and max_results has a "Required range: 5 <= x <= 100". You need "An approved developer account" and "A Project and App in the Developer Console".

X's pricing page lists "Posts: Read $0.005 per resource" and "User: Read $0.010 per resource", so turning a handle into an ID is a paid lookup too. Two rules lower the bill: "All resources are deduplicated within a 24-hour UTC day window", and Owned Reads of your own account are "$0.001 per resource". "Pay-per-usage plans are capped at 3 million Post reads per monthly billing cycle." The rate-limit table gives /2/users/:id/tweets 10,000 requests per 15 minutes per app.

Posts X, plus $0.01 ID lookup ToolzerHub, plan to pack price
1,000 $5.00 $0.29 to $0.56
2,000 $10.00 $0.58 to $1.11
3,200 $16.00 not reached

Get a user's posts with ToolzerHub

We make ToolzerHub. Our X API returns an account's posts by handle, with one API key. GET /v1/x/profile/posts takes handle, include_replies and cursor, returns 20 posts newest first plus the pinned post, and costs 5 credits a call (our OpenAPI file, 2026-10-17). Each post has its text, created_at, likes, replies, reposts, quotes, views and bookmarks. An unknown handle returns an empty page; a malformed one returns a 400.

This script writes each account's posts to CSV and marks each row as a post, reply, repost or quote. It leaves out the separate pinned-post field.

python
import csv
import os
import sys
import time

import requests

URL = "https://api.toolzerhub.com/v1/x/profile/posts"
HEADERS = {"x-api-key": os.environ.get("TOOLZERHUB_API_KEY", "")}
FIELDS = ["account", "id", "url", "created_at", "type", "likes", "replies", "reposts",
          "quotes", "views", "bookmarks", "text"]
calls = 0


def cell(value):
    # Spreadsheets run text starting with = + - or @ as a formula; an apostrophe keeps it text.
    if isinstance(value, str) and value[:1] in ("=", "+", "-", "@", "\t"):
        return "'" + value
    return value


def get(params, tries=4):
    # 429 and 503 are retried; the wait comes from the error body or the Retry-After header.
    global calls
    for attempt in range(tries):
        r = requests.get(URL, headers=HEADERS, params=params, timeout=90)
        calls += 1
        if r.status_code == 400:  # malformed handle: skip this account
            print("bad request:", str(r.json()["error"].get("message")).split("\n")[0])
            return None
        if r.status_code not in (429, 503) or attempt == tries - 1:
            if r.status_code >= 500:  # any 5xx, or a 503 past the retries: skip
                print(f"HTTP {r.status_code}, skipping:", r.text[:120].replace("\n", " "))
                return None
            r.raise_for_status()
            return r.json().get("data")
        try:
            wait = r.json()["error"]["details"]["retryAfter"]
        except (ValueError, KeyError, TypeError):
            wait = None
        try:
            wait = int(wait or r.headers.get("Retry-After") or 5 * (attempt + 1))
        except ValueError:
            wait = 5 * (attempt + 1)
        time.sleep(min(wait, 60))


def kind(post):
    if post.get("reposted_post"):
        return "repost"
    if post.get("is_reply"):
        return "reply"
    return "quote" if post.get("quoted_post") else "post"


def export_account(writer, account, max_pages, replies):
    params, seen, pages, empty = {"handle": account}, set(), 0, 0
    if replies:
        params["include_replies"] = "true"
    status = "page cap reached, more available"
    while pages < max_pages:
        data = get(params)
        if data is None:
            status = "stopped early: failed page" if pages else "nothing returned"
            break
        posts = data.get("posts") or []
        if not posts and pages and empty < 2:  # a later page can come back empty once: re-ask it
            empty += 1
            time.sleep(2)
            continue
        empty = 0
        if not posts:
            status = "end" if pages else "nothing returned"
            break
        pages += 1
        for p in posts:
            if not p.get("id") or p["id"] in seen:  # keep one row per post
                continue
            seen.add(p["id"])
            writer.writerow({
                "account": account, "id": p["id"], "url": p.get("url"),
                "created_at": p.get("created_at"), "type": kind(p),
                "likes": p.get("like_count"), "replies": p.get("reply_count"),
                "reposts": p.get("repost_count"), "quotes": p.get("quote_count"),
                "views": p.get("view_count"), "bookmarks": p.get("bookmark_count"),
                "text": cell((p.get("text") or "").replace("\r", " ").replace("\n", " ")),
            })
        if not (data.get("has_more") and data.get("cursor")):
            status = "end"
            break
        params["cursor"] = data["cursor"]
        time.sleep(0.3)
    print(f"{account}: {len(seen)} posts, {pages} pages ({status})")
    return len(seen)


def main(accounts, max_pages="120", replies="no", out="x_posts.csv"):
    total = 0
    with open(out, "w", newline="", encoding="utf-8-sig") as f:  # BOM: Excel reads emoji
        writer = csv.DictWriter(f, fieldnames=FIELDS)
        writer.writeheader()
        for account in [a.strip().lstrip("@") for a in accounts.split(",") if a.strip()]:
            total += export_account(writer, account, int(max_pages), replies == "yes")
    print(f"wrote {total} posts to {out} in {calls} calls")


if __name__ == "__main__":
    if len(sys.argv) < 2 or (len(sys.argv) > 3 and sys.argv[3] not in ("yes", "no")):
        sys.exit("usage: python x_user_posts.py nasa,spacex [max_pages] [yes|no replies] [file.csv]")
    if not HEADERS["x-api-key"]:
        sys.exit("set TOOLZERHUB_API_KEY first")
    main(*sys.argv[1:])

A 400 skips that account. A 429 or 503 is retried with the wait from retryAfter or Retry-After; a 503 that keeps coming back, or any other 5xx, skips the account, and a 429 that outlasts the retries, a 401 or a 402 stops the run. An empty later page is asked again twice before the account counts as finished. Text that starts with =, +, - or @ gets a leading apostrophe so spreadsheets keep it as text; the apostrophe stays visible in the CSV. We tested the 400, 429, 503, 500 and empty-page branches against a stand-in server, not live; the 401, 402 and exhausted-429 stops are untested. The default page cap is 120, which would have cut Netflix short at 121 pages, so the run below passes 150.

Run it

Run it with two accounts:

bash
export TOOLZERHUB_API_KEY=your_key
python x_user_posts.py "spacex, @duolingo ,,zzqq9x8y7w6v" 150 no x_posts.csv

Output on 2026-10-17, with a stray space, an @, an empty item and an unknown handle added:

text
spacex: 2110 posts, 124 pages (end)
duolingo: 2047 posts, 109 pages (end)
zzqq9x8y7w6v: 0 posts, 0 pages (nothing returned)
wrote 4157 posts to x_posts.csv in 244 calls

It took 387 seconds and 244 calls: 233 pages, one call for the unknown handle and 10 re-asks of empty pages. The tail is thin: SpaceX's 2,000th post is from 2024-02-25 and its last 110 stretch back to 2022-10-15; Duolingo's 2,000th is from 2016-11-17 and its last 47 go back to 2012. Posts from before mid-December 2022 have no view count: 1,155 of the 4,157 rows, none newer than 2022-12-15.

What we measured

All four accounts ended near 2,000 posts. On 2026-10-17 we paged each until has_more was false, re-asking each empty page up to three times (the script above re-asks twice).

Account Posts Oldest returned Pages
SpaceX 2,036 2023-11-11 109
NASA 2,035 2022-08-19 114
Duolingo 2,044 2012-06-15 110
Netflix 2,065 2025-11-22 121
  • The end sits near 2,000 posts, not at a date. Netflix posts often, so its 2,065 reach back eleven months. The oldest dates come from a thin tail, not full coverage. In the script run above SpaceX gave 2,110, so the count moves a little between runs.
  • The false end is real. Without the re-ask, the first runs stopped at 40 (SpaceX), 1,420 (NASA) and 1,800 (Duolingo); one SpaceX page came back empty with has_more false, and the same paging continued in the next run. Empty pages are not rare: the four-account run recovered 3, and the two-account script run needed 10 re-asks.
  • Reposts count toward the total: 148 of SpaceX's 2,110 rows and 321 of Duolingo's 2,047 in the script run. The default leaves out replies; include_replies=true adds them (4 of 20 on NASA's first page).
  • Most pages held 20 posts, and no post repeated within a run.
  • Reliability: most of the 823 calls in the 7 days to 2026-10-17 were our own tests; 820 returned 200 and none a server error. A 200 can still be an empty false-end page. Before our runs: 49 of 52, no server error.

For older posts, our Twitter search post pages from:NASA one month at a time back to 2020.

What it costs

Five credits a call of 20 posts, at $0.99 to $1.90 per 1,000 credits ($1.45 on the $29 plan). The script run cost 244 calls, 1,220 credits, for 4,157 posts: $1.21 to $2.32, or about 293 credits per 1,000 posts. The same 4,157 posts on X's pay-per-use would be $20.79, before the user lookups. That 293 includes the 10 empty re-asks and the unknown handle; with every page full it would be 250. The free 100 credits a month cover 20 calls, 340 to 400 posts. Calls that fail with an error cost nothing; an empty page is a successful call.

Where X's API is the better pick

X's timeline goes deeper, to 3,200 posts, and filters by time and by post type at the source. Reading your own account is $0.001 per post as an Owned Read, and repeats within a UTC day are free. X's API is the supported route to post, like or repost; ours reads public accounts only. For followers, see the followers list post.

We are not affiliated with X Corp.

Sources

Get this data from your own code

The X (Twitter) API returns it as JSON with one x-api-key header. 100 free credits monthly.