Get Tweets by Username in Python: X (Twitter) Timeline API and Cursor Pagination
Collecting posts from a public X account involves more than requesting a username. Handles change, IDs exceed JavaScript's safe integer range, and successive pages can overlap. A reliable Twitter user timeline API Python cursor pagination workflow resolves the account, checks business status, keeps its selection fixed and records what was actually collected.
This guide uses an independent, read-only X (Twitter) Public Data API, not the official X API or Tweepy. Official tutorials explain the general pagination problem; their authentication, parameters, quotas and history limits do not apply to this product. No complete archive or real-time stream is promised.
Choose the right endpoint
| Goal | Operation | Required query input |
|---|---|---|
| Resolve a handle and public profile | user-info | screenname |
| Collect recent account posts | user-timeline | screenname |
| Find keyword or hashtag matches | search | query |
| Inspect a specific public post | tweet-info | id |
| Collect available follower profiles | followers | screenname |
| Index public media posts | users-media | screenname |
| Collect accounts a user follows | following | screenname |
These seven reads have demonstrated response examples. Lists, Communities, relationship checks, trends, broadcast status, Spaces and jobs are additional catalog capabilities, but require independent response validation before publishing field-level examples.
Start with a public handle
Get an authorized platform key from your dashboard. The safe starting example is NASA, without @; it is a public identifier, not a promise of future data. Do not supply an X password, cookie or session. Keep your platform key server-side: query URLs can expose it through logs, browser history and shared links.
curl --get 'https://api.opendata-api.com/api/project/twitter/user-info?key=YOUR_API_KEY' \
--data-urlencode 'screenname=NASA'
curl --get 'https://api.opendata-api.com/api/project/twitter/user-timeline?key=YOUR_API_KEY' \
--data-urlencode 'screenname=NASA'
Replace YOUR_API_KEY only in your local environment. Avoid copying completed requests into tickets or screenshots.
Check three layers: HTTP status, the gateway's code/message/data business envelope, then the endpoint payload in data. Gateway code=200 indicates business success; HTTP 200 alone is insufficient. user-info demonstrates status=active with rest_id, or status=notfound with null identity and counters. Stop a missing-profile workflow rather than interpreting nulls as zero followers.
Collect cursor pages in Python
Install requests and set OPENDATA_API_KEY through your local environment or secret manager. The example below deliberately runs only when you execute it. It writes an authors-only JSONL sample and stops after a local request budget; that budget is not a service result limit.
import json
import os
from datetime import datetime, timezone
import requests
BASE = "https://api.opendata-api.com/api/project/twitter"
API_KEY = os.environ["OPENDATA_API_KEY"]
HANDLE = "NASA"
PAGE_BUDGET = 5 # Local cost guard, not an API limit.
def get(operation, **params):
try:
response = requests.get(
f"{BASE}/{operation}",
params={"key": API_KEY, **params}, timeout=(10, 30),
)
except requests.RequestException:
raise RuntimeError("Request failed; retry later") from None
if not 200 <= response.status_code < 300:
raise RuntimeError(f"HTTP {response.status_code}")
try:
envelope = response.json()
except ValueError:
raise RuntimeError("Expected JSON") from None
if not isinstance(envelope, dict) or envelope.get("code") != 200:
raise RuntimeError("Gateway business failure; inspect sanitized status")
data = envelope.get("data")
if not isinstance(data, dict):
raise RuntimeError("Unexpected endpoint payload")
return data
def utc_timestamp(value):
if not isinstance(value, str):
return None
try:
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
except ValueError:
parsed = datetime.strptime(value, "%a %b %d %H:%M:%S %z %Y")
if parsed.tzinfo is None:
raise ValueError("Timestamp needs an explicit timezone")
return parsed.astimezone(timezone.utc).isoformat()
def counter(value):
return int(value) if value is not None and str(value).isdigit() else None
profile = get("user-info", screenname=HANDLE)
if profile.get("status") == "notfound":
raise SystemExit("Public handle not found; correct it before collecting")
if profile.get("status") != "active" or not profile.get("rest_id"):
raise RuntimeError("Unresolved account status or ID")
target_id = str(profile["rest_id"])
seen_ids, seen_cursors = set(), set()
cursor, skipped = None, 0
with open("x_timeline.jsonl", "w", encoding="utf-8") as output:
for _ in range(PAGE_BUDGET):
params = {"screenname": HANDLE, "rest_id": target_id}
if cursor:
params["cursor"] = cursor
page = get("user-timeline", **params)
if page.get("status") != "ok" or not isinstance(page.get("timeline"), list):
raise RuntimeError("Unexpected timeline status or records")
collected_at = datetime.now(timezone.utc).isoformat()
for post in page["timeline"]:
if not isinstance(post, dict) or not post.get("tweet_id"):
raise RuntimeError("Invalid post record")
author = post.get("author") or {}
if not isinstance(author, dict) or str(author.get("rest_id")) != target_id:
skipped += 1
continue
tweet_id = str(post["tweet_id"])
if tweet_id in seen_ids:
continue
post["tweet_id"], author["rest_id"] = tweet_id, target_id
row = {
"collected_at": collected_at, "target_id": target_id,
"created_at_utc": utc_timestamp(post.get("created_at")),
"metrics": {k: counter(post.get(k)) for k in
("favorites", "retweets", "replies", "quotes", "bookmarks", "views")},
"post": post,
}
output.write(json.dumps(row, ensure_ascii=False) + "\n")
seen_ids.add(tweet_id)
next_cursor = page.get("next_cursor")
if next_cursor is None or next_cursor == "":
break
if not isinstance(next_cursor, str):
raise RuntimeError("Unexpected cursor type")
if not next_cursor.strip() or next_cursor in seen_cursors:
break
seen_cursors.add(next_cursor)
cursor = next_cursor
print("Saved posts:", len(seen_ids), "unassigned records:", skipped)
In user-info and user-timeline, optional rest_id overrides account selection, but does not remove schema-required screenname. The collector retains both. For handle-only selection, omit rest_id, keep screenname, and still verify returned author IDs.
Treat next_cursor as opaque: send it unchanged with the same account selection, never calculate it or increment it. Missing, empty, whitespace-only or repeated cursors stop collection. Returned page sizes and available history vary. Add bounded backoff for transient errors rather than retrying indefinitely; each deliberate request may consume credits.
Interpret the returned fields
A demonstrated timeline contains status, timeline, user, next_cursor and prev_cursor. Posts expose tweet_id, text, created_at, author, entities, media and public engagement counters. Timeline likes are favorites; tweet-info instead uses likes. views can be a numeric string. Preserve unknown or null counters rather than substituting zero, and timestamp each snapshot because counters change.
The parser tries ISO-8601 and a Twitter-style date format, rejecting unknown formats or missing timezones instead of guessing. Retain the raw created_at alongside normalized UTC. Store IDs in text columns; converting an already rounded JavaScript number back to a string cannot recover lost precision.
Reposts may contain retweeted and retweeted_tweet, and author details may be null. The example counts unassigned records separately rather than attributing them to NASA. Its authors-only export is narrower than all account activity; preserve and validate repost context separately when studying amplification. Use persistent ID deduplication and append-safe storage across scheduled runs; this demonstration overwrites its file.
Search is not an account timeline
A search request with query=NASA and demonstrated search_type=Latest also matched lowercase “nasa” from unrelated authors in other languages. Matching text does not establish account identity. For brand monitoring, retain query and capture time, check relevance and user_info.rest_id, and deduplicate posts. For one account's output, use user-timeline.
Practical uses include institutional announcement tracking, observed posting-cadence comparisons, source-linked editorial research and scheduled brand-mention review. Public counters are not private impressions or built-in sentiment analysis. Process data lawfully, respect retention obligations and publish coverage notes rather than completeness claims.
FAQ
How can I get tweets from a public X account using Python?
Resolve its handle with user-info, then deliberately run the bounded user-timeline collector above.
How do I paginate using next_cursor?
Omit cursor initially; pass the returned value unchanged on subsequent requests with fixed account selection.
How can I get a Twitter user ID from a username?
Read rest_id from an active user-info response and retain it as a string.
How do I retrieve text, media and engagement by post ID?
Use tweet-info with required string id. Inspect its business status before reading author, media and counters.
How can I collect brand or hashtag mentions?
Use search with required query; validate text and authors rather than treating all matches as relevant.
How do I export available follower and following pages?
Use followers or following with screenname, continue returned cursors and deduplicate user_id before CSV export. Reported follower totals are not page lengths.
Can a timeline retrieve complete tweet history? No completeness is guaranteed; label results with the captured window and stopping reason.
How do I avoid duplicate posts?
Deduplicate string tweet_id across pages and keep a persistent ID store across runs.
How should I handle status=notfound? Stop, correct the handle and preserve null profile fields. It is not an active account with zero activity.
How can I retrieve replies and conversation context?
latest-replies and tweet-thread require post id; validate their unverified schemas. user-replies targets replies authored by a handle instead.
How can I collect public media URLs?
Use users-media; observed photos expose media.photo entries with media_url_https and id. Links do not grant republication rights.
Does Spaces information return audio?
spaces-info describes basic metadata only; audio, recordings, transcripts and attendance are not promised.
Try the public profile and timeline forms deliberately, and review pricing before scheduled collection. For related Instagram, TikTok, Reddit, YouTube, LinkedIn, Bluesky, Threads or Facebook APIs, ask through the contact page. Private source-code deployment is also available.