+ Integration Guide
· Updated August 28, 2026
Twitter/X API with Python: Complete Guide
How to scrape Twitter/X data with Python using the FetchLayer API. Search tweets, get profiles, fetch replies, and pull follower lists — no Tweepy needed.
Written by Alex P.
- Python
- twitter scraping
- X API
- Twitter API
- Tweepy alternative
- API integration
This guide shows how to use the FetchLayer Twitter/X API with Python. Unlike Tweepy, you don’t need X credentials, OAuth setup, or a Twitter developer account. Just an API key and the requests library.
Setup
- Get a free API key (no credit card)
- Install requests:
pip install requests
API Client
import os
import requests
API_KEY = os.environ["FETCHLAYER_API_KEY"]
BASE_URL = "https://api.fetchlayer.dev/twitter"
def twitter(endpoint: str, **kwargs) -> dict:
res = requests.post(
f"{BASE_URL}/{endpoint}",
headers={"Authorization": f"Bearer {API_KEY}"},
json=kwargs,
)
res.raise_for_status()
return res.json()
Search Twitter/X
data = twitter("search", query="best Python web framework", product="Latest", count=10)
for tweet in data["results"]:
print(f"[{tweet['likeCount']} likes] @{tweet['author']['handle']}: {tweet['text'][:100]}")
# Search for people/accounts
data = twitter("search", query="Python developer", product="People", count=20)
# Search with pagination
page1 = twitter("search", query="FastAPI", product="Top", count=25)
page2 = twitter("search", query="FastAPI", product="Top", count=25, cursor=page1.get("cursor"))
Get a Tweet by ID
tweet = twitter("tweet-detail", tweetId="1942939879222220800")
print(f"@{tweet['author']['handle']}: {tweet['text']}")
print(f"{tweet['likeCount']} likes · {tweet['retweetCount']} retweets · {tweet['replyCount']} replies")
Get Replies to a Tweet
replies = twitter("tweet-replies", tweetId="1942939879222220800")
for reply in replies["replies"]:
print(f" @{reply['author']['handle']}: {reply['text'][:100]}")
Get a User Profile
profile = twitter("user-profile-details", handle="openai")
print(f"{profile['displayName']} (@{profile['handle']})")
print(f"Followers: {profile['followersCount']:,}")
print(f"Following: {profile['followingCount']:,}")
print(f"Tweets: {profile['tweetsCount']:,}")
print(f"Bio: {profile['description']}")
Get a User’s Tweets
data = twitter("user-tweets", handle="rauchg", count=20)
for tweet in data.get("tweets", []):
print(f"[{tweet.get('likeCount', 0)} likes] {tweet['text'][:120]}")
Get Followers and Following
# Who a user follows
following = twitter("following", handle="openai", count=50)
for account in following.get("accounts", []):
print(f"@{account['handle']} — {account.get('followersCount', 0):,} followers")
# Who follows a user
followers = twitter("followers", handle="openai", count=50)
# Verified followers only
verified = twitter("verified-followers", handle="openai", count=20)
Tweepy vs FetchLayer
If you’re coming from Tweepy, here’s the comparison:
| Tweepy | FetchLayer | |
|---|---|---|
| X account required | Yes (developer account) | No |
| OAuth setup | Yes (API key, secret, access token) | No |
| Rate limits | Vary by endpoint, strict caps | Handled by API |
| Pricing | $100/mo Basic, $5K/mo Pro | Free tier + pay-per-use |
| Language | Python only | Any (REST API) |
| MCP support | No | Yes |
| Authentication | 4 keys (key, secret, token, token secret) | Single API key |
Tweepy equivalent vs FetchLayer:
# Tweepy (requires X developer account + OAuth)
import tweepy
client = tweepy.Client(bearer_token="...")
tweets = client.search_recent_tweets(query="python", max_results=10)
# FetchLayer (just an API key)
data = twitter("search", query="python", product="Latest", count=10)
Handling Errors and Rate Limits
The client above calls res.raise_for_status(), which is fine for a script you’re watching, but it throws away information a production job needs to act on. FetchLayer returns standard HTTP status codes: 401 for a missing or invalid API key, 400 when a required field like query or a handle is missing or malformed, and 429 once you exceed your plan’s request rate. Handle each explicitly instead of treating them all as the same failure:
import time
def twitter(endpoint: str, retries: int = 2, **kwargs) -> dict:
for attempt in range(retries + 1):
res = requests.post(
f"{BASE_URL}/{endpoint}",
headers={"Authorization": f"Bearer {API_KEY}"},
json=kwargs,
)
if res.status_code == 429:
if attempt == retries:
raise RuntimeError(f"Rate limited after {retries} retries — back off and try again later")
wait = int(res.headers.get("Retry-After", 5))
time.sleep(wait)
continue
if res.status_code == 401:
raise RuntimeError("Invalid or missing API key — check FETCHLAYER_API_KEY")
if res.status_code == 400:
raise ValueError(f"Bad request to /{endpoint}: {res.text}")
res.raise_for_status()
return res.json()
A 429 mid-run is expected behavior on the free tier, not a bug — the retry above respects Retry-After rather than hammering the endpoint again immediately. For anything higher-throughput than a script you’re watching by hand, catch RuntimeError and ValueError separately in the caller so a bad key doesn’t get silently retried the same way a transient rate limit does.
Full Example: Twitter Keyword Monitor
import os
import requests
from datetime import datetime
API_KEY = os.environ["FETCHLAYER_API_KEY"]
BASE_URL = "https://api.fetchlayer.dev/twitter"
def twitter(endpoint: str, **kwargs) -> dict:
res = requests.post(
f"{BASE_URL}/{endpoint}",
headers={"Authorization": f"Bearer {API_KEY}"},
json=kwargs,
)
res.raise_for_status()
return res.json()
def monitor_keyword(keyword: str, count: int = 25):
"""Search Twitter for a keyword and summarize results."""
data = twitter("search", query=keyword, product="Latest", count=count)
results = data.get("results", [])
print(f"\nFound {len(results)} tweets for '{keyword}'")
# Engagement summary
total_likes = sum(t.get("likeCount", 0) for t in results)
total_retweets = sum(t.get("retweetCount", 0) for t in results)
print(f"Total engagement: {total_likes:,} likes, {total_retweets:,} retweets")
# Top tweets
top = sorted(results, key=lambda t: t.get("likeCount", 0), reverse=True)[:5]
print(f"\nTop 5 tweets:")
for tweet in top:
print(f" [{tweet['likeCount']}] @{tweet['author']['handle']}: {tweet['text'][:120]}")
print(f" {tweet.get('url', '')}\n")
if __name__ == "__main__":
monitor_keyword("your-product-name")
FETCHLAYER_API_KEY=ss-your-key python monitor.py
Typed responses with dataclasses
Tweets arrive with a nested author object, which is exactly the shape that gets awkward with raw dictionary access (tweet["author"]["handle"] fails differently depending on which key is missing). Nested dataclasses make the structure explicit:
from dataclasses import dataclass
from typing import Any
@dataclass
class TwitterUser:
handle: str
display_name: str
followers_count: int = 0
following_count: int = 0
verified: bool = False
@classmethod
def from_api(cls, raw: dict[str, Any]) -> "TwitterUser":
return cls(
handle=raw.get("handle", ""),
display_name=raw.get("displayName", ""),
followers_count=raw.get("followersCount", 0),
following_count=raw.get("followingCount", 0),
verified=raw.get("verified", False),
)
@dataclass
class Tweet:
id: str
text: str
author: TwitterUser
like_count: int = 0
retweet_count: int = 0
reply_count: int = 0
created_at: str = ""
@classmethod
def from_api(cls, raw: dict[str, Any]) -> "Tweet":
return cls(
id=raw.get("id", ""),
text=raw.get("text", ""),
author=TwitterUser.from_api(raw.get("author", {})),
like_count=raw.get("likeCount", 0),
retweet_count=raw.get("retweetCount", 0),
reply_count=raw.get("replyCount", 0),
created_at=raw.get("createdAt", ""),
)
tweets = [Tweet.from_api(t) for t in twitter("search", query="rust async", product="Latest")["results"]]
Defaulting every optional field means a tweet missing retweetCount yields 0 rather than raising mid-loop — worth it in a long-running monitor.
Concurrent requests with asyncio and httpx
Twitter workloads tend to fan out across many handles — checking twenty competitor accounts, or pulling profiles for everyone who mentioned you. Serial requests calls make that slow. httpx plus asyncio runs them together:
import asyncio
import httpx
BASE = "https://api.fetchlayer.dev/twitter"
HEADERS = {
"Authorization": f"Bearer {API_KEY}",
"Content-Type": "application/json",
}
async def twitter_async(client: httpx.AsyncClient, endpoint: str, **body):
resp = await client.post(f"{BASE}/{endpoint}", headers=HEADERS, json=body)
resp.raise_for_status()
return resp.json()
async def fetch_profiles(handles: list[str], concurrency: int = 3) -> list[TwitterUser]:
sem = asyncio.Semaphore(concurrency)
async with httpx.AsyncClient(timeout=60.0) as client:
async def one(handle: str):
async with sem:
raw = await twitter_async(client, "user-profile-details", handle=handle)
return TwitterUser.from_api(raw)
results = await asyncio.gather(
*(one(h) for h in handles),
return_exceptions=True,
)
profiles = []
for item in results:
if isinstance(item, Exception):
print(f"Profile fetch failed: {item}")
continue
profiles.append(item)
return profiles
accounts = asyncio.run(fetch_profiles(["openai", "anthropicai", "googledeepmind"]))
for a in sorted(accounts, key=lambda x: x.followers_count, reverse=True):
print(f"@{a.handle}: {a.followers_count:,} followers")
return_exceptions=True matters more here than usual: a single deleted or suspended handle raises a 404, and without it that one bad handle discards the other nineteen successful lookups.
Analysis with pandas
Engagement analysis is the natural fit for a DataFrame — the questions are all ratios and groupings:
import pandas as pd
df = pd.DataFrame([
{
"handle": t.author.handle,
"followers": t.author.followers_count,
"text": t.text,
"likes": t.like_count,
"retweets": t.retweet_count,
"replies": t.reply_count,
}
for t in tweets
])
# Engagement rate normalizes for audience size — a 50-like tweet from a
# 500-follower account outperforms a 500-like tweet from a 100k account.
df["engagement"] = df["likes"] + df["retweets"] + df["replies"]
df["engagement_rate"] = df["engagement"] / df["followers"].clip(lower=1) * 100
# Ratio of replies to likes: high values indicate argument, not agreement.
df["reply_ratio"] = df["replies"] / df["likes"].clip(lower=1)
by_account = (
df.groupby("handle")
.agg(
tweets=("text", "count"),
avg_engagement=("engagement", "mean"),
avg_rate=("engagement_rate", "mean"),
)
.sort_values("avg_rate", ascending=False)
)
print(by_account.head(10))
Sorting by engagement_rate rather than raw likes surfaces smaller accounts punching above their weight — which is usually who you want to find when you’re looking for people worth engaging rather than just the largest accounts in a niche.
Export for a spreadsheet:
df.to_csv("twitter-analysis.csv", index=False, encoding="utf-8-sig")
utf-8-sig adds the BOM Excel needs to render emoji correctly — and tweets are mostly emoji.
What’s Next
- Twitter API with Node.js — JavaScript/Node version
- Twitter API with TypeScript — typed version
- Twitter API with Bun — Bun runtime version
- Twitter API with Go — Go integration
- How to Scrape Twitter/X in 2026 — all scraping methods
- FetchLayer API Reference