+ 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

  1. Get a free API key (no credit card)
  2. 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:

TweepyFetchLayer
X account requiredYes (developer account)No
OAuth setupYes (API key, secret, access token)No
Rate limitsVary by endpoint, strict capsHandled by API
Pricing$100/mo Basic, $5K/mo ProFree tier + pay-per-use
LanguagePython onlyAny (REST API)
MCP supportNoYes
Authentication4 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