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.
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:
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:
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_morefalse, 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=trueadds 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
- X, Timelines introduction, https://docs.x.com/x-api/posts/timelines/introduction, read 2026-10-17
- X, Get Posts by a user (API reference), https://docs.x.com/x-api/users/get-posts, read 2026-10-17
- X, Pricing, https://docs.x.com/x-api/getting-started/pricing, read 2026-10-17
- X, Rate limits, https://docs.x.com/x-api/fundamentals/rate-limits, read 2026-10-17
- ToolzerHub OpenAPI file, https://api.toolzerhub.com/openapi.json, checked 2026-10-17
- ToolzerHub pricing, https://toolzerhub.com/pricing, checked 2026-10-17