Twitter analytics API: Retrieve metrics and build a report
Export current public post counts to CSV with Python. See when to use account analytics and how to handle missing metrics.
Before building a Twitter analytics report, decide whose data you need. Public post counters, your own account analytics, and advertising results require different access:
| Question | Data to request | Credentials |
|---|---|---|
| How are public posts performing now? | Public post counters such as likes, reposts, replies, quotes, bookmarks, and available views | TweetAPI X-API-Key |
| What does my own account analytics show? | Account analytics time series returned by user-analytics | TweetAPI key and that account's authToken |
| How did an ad campaign perform? | Advertising analytics and campaign reporting | Use the advertising product and its required authorization; public post counters are not an ads report |
What the public-post report shows
The Python report looks up one public account, fetches up to the number of timeline pages you choose, and writes one CSV row for each matching post it retrieves. It uses the profile lookup and user posts endpoints. This script covers original posts, not replies; replies have a separate endpoint.
This row is synthetic. The blank views cell means the count was unavailable, not zero.
post_id,created_at,collected_at,text,likes,reposts,replies,quotes,bookmarks,views,interaction_total
1234567890123456789,2026-09-03T12:00:00+00:00,2026-09-25T09:00:00+00:00,Sample post,12,2,1,0,3,,18
created_at selects posts published in the requested window. collected_at records when the script fetched their counters. Likes and other counts can keep changing after publication, so the CSV does not measure interactions earned only during that window.
interaction_total adds likes, reposts, replies, quotes, and bookmarks when all five counts are available. It stays blank if any count is missing. This total is not X's dashboard engagement rate. The script also leaves views blank when viewCount is null or missing. Do not assume TweetAPI's viewCount matches official X impressions. One profile snapshot cannot show historical follower growth.
Official X defines public, private, organic, and promoted fields in its metrics documentation. Its Post Analytics endpoint has separate authorization and product requirements. Check access for your account before comparing those fields with TweetAPI's public counters.
Last checked September 25, 2026. The example was tested with simulated responses, not live account data. TweetAPI is a third-party service. Not affiliated with X Corp.
Download and run the report
Download post_metrics_report.py and save it in your working directory. Use Python 3.10 or newer and the maintained TweetAPI Python SDK. Set your key in the environment, replace sample_user_00001 with a real public username, and start with two pages:
python3 -m venv .venv
source .venv/bin/activate
python -m pip install tweetapi==2.4.0
export TWEETAPI_KEY="your_api_key"
python post_metrics_report.py sample_user_00001 --since 2026-09-01 --until 2026-09-08 --max-pages 2 --output post_metrics.csv
On Windows, activate the virtual environment with .\.venv\Scripts\Activate.ps1 and set the key with $env:TWEETAPI_KEY = "your_api_key". Replace the dates as needed. --until is exclusive. Keep the key out of source control.
Complete Python program
The download contains exactly this program:
import argparse
import csv
import os
import tempfile
from datetime import date, datetime, timezone
from pathlib import Path
from typing import Any
from tweetapi import TweetAPI, TweetAPIError
FIELDS = [
"post_id", "created_at", "collected_at", "text", "likes", "reposts",
"replies", "quotes", "bookmarks", "views", "interaction_total",
]
FORMULA_PREFIXES = ("=", "+", "-", "@", "\t", "\r", "\n", "=", "+", "-", "@")
METRICS = {
"likes": "likeCount", "reposts": "retweetCount", "replies": "replyCount",
"quotes": "quoteCount", "bookmarks": "bookmarkCount", "views": "viewCount",
}
def csv_text(value: Any) -> str:
text = "" if value is None else str(value).replace("\x00", "")
return "'" + text if text.startswith(FORMULA_PREFIXES) else text
def metric(post: dict[str, Any], field: str) -> int | None:
value = post.get(field)
return value if type(value) is int and value >= 0 else None
def post_time(post: dict[str, Any]) -> datetime | None:
value = post.get("createdAt")
if not isinstance(value, str):
return None
try:
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
return parsed.astimezone(timezone.utc) if parsed.tzinfo else None
except ValueError:
return None
def collect(client: TweetAPI, username: str, since: date, until: date,
max_pages: int) -> tuple[list[dict[str, Any]], bool, int]:
profile = client.user.get_by_username(username=username)["data"]
user_id = str(profile["id"])
rows: list[dict[str, Any]] = []
seen_ids: set[str] = set()
missing_dates = 0
cursor = None
seen_cursors: set[str] = set()
for _ in range(max_pages):
page = client.user.get_tweets(user_id=user_id, cursor=cursor)
collected_at = datetime.now(timezone.utc).isoformat()
for post in page.get("data", []):
if not isinstance(post, dict):
continue
post_id = str(post.get("id") or "")
if not post_id or post_id in seen_ids:
continue
seen_ids.add(post_id)
created_at = post_time(post)
if created_at is None:
missing_dates += 1
continue
if not since <= created_at.date() < until:
continue
values = {column: metric(post, field) for column, field in METRICS.items()}
counts = [values[key] for key in ("likes", "reposts", "replies", "quotes", "bookmarks")]
rows.append({
"post_id": post_id,
"created_at": created_at.isoformat(),
"collected_at": collected_at,
"text": csv_text(post.get("text")),
**values,
"interaction_total": sum(counts) if all(count is not None for count in counts) else None,
})
cursor = page.get("pagination", {}).get("nextCursor")
if not cursor:
return rows, True, missing_dates
if cursor in seen_cursors:
raise ValueError("Pagination cursor repeated")
seen_cursors.add(cursor)
return rows, False, missing_dates
def write_csv(output: Path, rows: list[dict[str, Any]]) -> None:
output.parent.mkdir(parents=True, exist_ok=True)
temporary_path = None
try:
with tempfile.NamedTemporaryFile(
mode="w", encoding="utf-8", newline="", dir=output.parent,
prefix=f".{output.name}.", suffix=".tmp", delete=False,
) as handle:
temporary_path = Path(handle.name)
writer = csv.DictWriter(handle, fieldnames=FIELDS)
writer.writeheader()
writer.writerows(rows)
os.replace(temporary_path, output)
finally:
if temporary_path is not None:
temporary_path.unlink(missing_ok=True)
def main() -> None:
parser = argparse.ArgumentParser(description="Export current public post metrics for a post-date window.")
parser.add_argument("username", help="Public username, with or without @")
parser.add_argument("--since", type=date.fromisoformat, required=True, help="First post date, UTC (YYYY-MM-DD)")
parser.add_argument("--until", type=date.fromisoformat, required=True, help="Exclusive end date, UTC (YYYY-MM-DD)")
parser.add_argument("--max-pages", type=int, default=5, help="Maximum timeline pages (default: 5)")
parser.add_argument("--output", type=Path, default=Path("post_metrics.csv"))
args = parser.parse_args()
username = args.username.lstrip("@").strip()
if not username or args.since >= args.until or args.max_pages < 1:
parser.error("supply a username, an increasing date window, and --max-pages >= 1")
key = os.environ.get("TWEETAPI_KEY", "").strip()
if not key:
parser.error("set TWEETAPI_KEY in your environment")
try:
rows, complete, missing_dates = collect(
TweetAPI(api_key=key), username, args.since, args.until, args.max_pages
)
write_csv(args.output, rows)
except (TweetAPIError, OSError, KeyError, TypeError, ValueError) as error:
raise SystemExit(f"Report failed without writing a new CSV: {error}")
print(f"Wrote {len(rows)} posts to {args.output}")
if not complete:
print("More pages exist. This CSV covers only the pages fetched; raise --max-pages to extend it.")
if missing_dates:
print(f"Skipped {missing_dates} posts without a usable createdAt date.")
if __name__ == "__main__":
main()
The script looks up the user ID once, follows pagination.nextCursor, and removes duplicate post IDs across pages. A failed request leaves the existing CSV untouched. If more pages remain after --max-pages, the script tells you that the file covers only the pages fetched. It skips posts without a usable createdAt date and reports how many it skipped.
Post text that starts with a spreadsheet formula character gets a leading apostrophe in the CSV. Keep long post IDs as text when importing the file into a spreadsheet, or the spreadsheet may round them.
One profile lookup plus N fetched timeline pages means 1 + N HTTP requests before any SDK retries. Most metered calls cost one unit, but retries and billable error responses can affect consumption. See the pricing and metering guide before scaling the page limit.
Request your own account analytics
For a time series from your own account, GET /tw-v2/interaction/user-analytics requires a TweetAPI key and that X account's authToken. The token selects the account; this endpoint does not accept a target username or user ID. You can set fromTime, toTime, and granularity (Daily, Weekly, or Monthly); the endpoint documentation lists the other options.
The response can contain organic_metrics_time_series entries with timestamps and metric_values. Check for metric_value before charting: the documented sample has metric types without values. Keep the account token private and use only a token you are authorized to access. Available metrics can vary by account.
Create a TweetAPI account to run the public-post report. Start with two pages and inspect the CSV before increasing --max-pages. The public API access guide covers credentials for other tasks.