How to Make a Twitter (X) Thread via API — Developer Tutorial

By Michael Park•6 min read

Twitter (X) threads — a series of replies posted by the same author, forming a connected narrative — are a common content format for long-form posts that don't fit in a single tweet. Posting a thread programmatically lets you templatize a multi-tweet post (newsletter cross-post, long-form analysis, automated thread builder) and run it as part of a workflow.

This guide walks the API workflow with runnable Python: post the root tweet, chain replies via in_reply_to_tweet_id, handle rate limits. It covers two routes: X official's POST /2/tweets, and twitterapi.io's create_tweet_v2, which runs through a logged-in session of your own account.

01 — Section

How threads actually work on the wire

A thread is a sequence of tweets where each subsequent tweet is a reply to the previous one — same author, chained via in_reply_to_tweet_id. There's no special 'thread' type at the API layer; it's just a sequence of regular POST /2/tweets calls with the reply field set.

Key fields:

in_reply_to_tweet_id — the previous tweet's ID; chains the new tweet to the thread

reply.in_reply_to_tweet_id (v2 schema) — same field, nested under reply per the newer endpoint shape

for_super_followers_only — gate the thread to subscribers (if you have Super Follows enabled)

The thread renders in the X UI as a connected stack of tweets when the author is the only replier; if other users reply mid-stream, the X UI shows them as branches off the main chain.

02 — Section

The thread-posting pattern

Post tweet 1 (root). Capture its ID. Post tweet 2 with in_reply_to_tweet_id=tweet1_id. Capture tweet 2's ID. Post tweet 3 with in_reply_to_tweet_id=tweet2_id. Repeat.

Each new tweet's reply field references the previous tweet, not the root — this is what threads the posts together visually in the X UI.

python
# pip install tweepy
import tweepy, time

client = tweepy.Client(
    consumer_key="YOUR_KEY",
    consumer_secret="YOUR_SECRET",
    access_token="USER_TOKEN",
    access_token_secret="USER_TOKEN_SECRET",
)

def post_thread(tweets: list[str]) -> list[str]:
    """Post a thread; return list of tweet IDs in order."""
    if not tweets:
        return []
    ids = []
    # Post root tweet
    root = client.create_tweet(text=tweets[0])
    ids.append(root.data["id"])
    prev_id = root.data["id"]
    # Chain replies
    for body in tweets[1:]:
        resp = client.create_tweet(text=body, in_reply_to_tweet_id=prev_id)
        ids.append(resp.data["id"])
        prev_id = resp.data["id"]
        time.sleep(1.5)  # safe pacing
    return ids

thread = [
    "Thread on Twitter API rate limits 1/4 — what 429 actually means and why X tightened limits in 2024-2026.",
    "2/4 — read the rate-limit headers: x-rate-limit-limit, x-rate-limit-remaining, x-rate-limit-reset (Unix timestamp).",
    "3/4 — handle 429 with exponential backoff: sleep until reset, retry with jitter. Don't burst.",
    "4/4 — for read-heavy workloads, the per-call cost ratio (33×+) at a third-party API can be a structural fix vs upgrading the X tier.",
]

ids = post_thread(thread)
print(f"posted thread: {ids}")
03 — Section

Second route — twitterapi.io /twitter/create_tweet_v2 with reply_to_tweet_id

Write actions on twitterapi.io run through an authenticated session: you log the account in once with POST /twitter/user_login_v2 ($0.10 per login), passing your X username, email, password and a proxy you supply (a residential or ISP proxy works best), and it returns login_cookies. Store the returned login_cookies and reuse them until the session ends; do not log in on every run. Each post, delete, like, retweet, follow or unfollow is then one call at $0.0006 that takes login_cookies and the same proxy. The call acts as that X account, so X's rules apply to it and X can rate-limit, lock or suspend the account; keep a human pace. The session can stop working and require a new login. The proxy is required on every write call. Billing is per call that returns HTTP 200, and that includes some refusals from X, which come back as HTTP 200 with status: error. Reads are different: they need only an API key and no X account at all.

POST /twitter/create_tweet_v2 takes a JSON body with three required fields: login_cookies, tweet_text and proxy, plus your key in the X-API-Key header. The optional reply_to_tweet_id field is what chains a thread: set it to the ID of the post you just created. The API reference lists tweet_id in the response next to status (success or error); the script reads the new ID from there and stops the chain if it is missing, so a failed post never leaves later posts attached to the wrong parent.

Price, side by side: $0.015 per post on the official API (Post: Create) vs $0.0006 here (0.015 / 0.0006 = 25x), plus the $0.10 login, which is one per session and not one per post. A 10-post thread is 10 × $0.015 = $0.15 official vs 10 × $0.0006 + $0.10 = $0.106 here on a fresh login, or $0.006 with a session you already hold. A short thread on a fresh login is cheaper on the official side: 5 posts cost 5 × $0.015 = $0.075 there and 5 × $0.0006 + $0.10 = $0.103 here. The two totals cross at 7 posts per login. A post that contains a URL is $0.200 on the official rate card.

python
import os, time, random, requests

BASE = "https://api.twitterapi.io"
HEADERS = {"X-API-Key": os.environ["TWITTERAPI_IO_KEY"]}
PROXY = os.environ["X_PROXY"]  # a proxy you supply: http://username:password@ip:port

def login() -> str:
    """Log the account in once ($0.10) and return login_cookies. Store and reuse it."""
    body = {
        "user_name": os.environ["X_USERNAME"],
        "email": os.environ["X_EMAIL"],
        "password": os.environ["X_PASSWORD"],
        "proxy": PROXY,
    }
    if os.environ.get("X_TOTP_SECRET"):
        body["totp_secret"] = os.environ["X_TOTP_SECRET"]
    r = requests.post(f"{BASE}/twitter/user_login_v2",
                      headers=HEADERS, json=body, timeout=120)
    r.raise_for_status()
    data = r.json()
    if data.get("status") != "success":
        raise RuntimeError(f"login failed: {data.get('message')}")
    return data["login_cookies"]

def create_post(login_cookies: str, text: str, reply_to: str | None = None) -> str:
    """Create one post and return its ID. Raises if the post was not created."""
    body = {"login_cookies": login_cookies, "tweet_text": text, "proxy": PROXY}
    if reply_to:
        body["reply_to_tweet_id"] = reply_to
    r = requests.post(f"{BASE}/twitter/create_tweet_v2",
                      headers=HEADERS, json=body, timeout=60)
    r.raise_for_status()
    data = r.json()
    if data.get("status") != "success" or not data.get("tweet_id"):
        raise RuntimeError(f"post not created: {data.get('message')}")
    return data["tweet_id"]

def post_thread_v2(login_cookies: str, posts: list[str]) -> list[str]:
    """Post a thread; each post replies to the one before it."""
    ids, prev_id = [], None
    for text in posts:
        if len(text) > 280:
            raise ValueError(f"post too long: {len(text)} > 280")
    for text in posts:
        prev_id = create_post(login_cookies, text, reply_to=prev_id)
        ids.append(prev_id)
        print(f"posted {prev_id}")
        time.sleep(1.5 + random.uniform(0.1, 0.5))  # same pacing as the official route
    return ids

cookies = os.environ.get("X_LOGIN_COOKIES") or login()
thread = [
    "1/3 Notes on posting a thread through an API.",
    "2/3 Each post replies to the one before it, not to the first post.",
    "3/3 Log every ID so a broken chain can be continued by hand.",
]
print(post_thread_v2(cookies, thread))
04 — Section

Rate limits + safe pacing

X publishes per-endpoint rate limits at docs.x.com. The tweet-create endpoint has its own per-15-minute cap; consult the current docs.

Safe pacing: 1.5-2 seconds between thread posts is the operational baseline. Faster bursting risks 429s; slower is fine.

Backoff on 429: when a tweet-create returns 429, sleep until the reset header (or 60s if header missing) plus jitter, then retry.

Mid-thread failure: if the chain breaks mid-thread (network error, 429, content rejected), you have a partial thread. Decide your policy: continue from the last successful tweet, retry the failed one, or abort. Log every tweet ID for audit.

05 — Section

Side-by-side comparison — 4 paths to post a thread

PathAuthPer-tweet costProgrammaticBest for
X official POST /2/tweets + chainOAuth user-context$0.015 (docs.x.com pricing)yesdev-built thread workflows
Tweepy wrapper (same X surface)OAuth user-context$0.015 (same)yes (ergonomic)quick-start, smaller workloads
twitterapi.io POST /twitter/create_tweet_v2 + reply_to_tweet_idAPI key + your X account session (login_cookies) + a proxy you supply$0.0006, plus one $0.10 login per sessionyesthread workflows without an X developer app
Third-party thread tools (Typefully, Threads-builder UIs)tool's UI authbundled in tierUI + scheduled-postnon-dev users, content-marketer workflows

Two patterns: (a) there are two dev paths, X official with OAuth and twitterapi.io's create_tweet_v2 through your own account session; (b) for non-dev users a thread-tool's UI is faster than building from scratch; for dev-built workflows the two API paths are the programmatic options.

06 — Section

Content patterns — what makes a good thread

Numbering — '1/N' convention helps readers understand thread length; X UI shows the chain but explicit numbering reduces cognitive load.

Hook in tweet 1 — the root tweet drives whether readers expand the thread. Lead with the strongest claim or question.

One idea per tweet — Twitter UI presents each tweet as a discrete unit; trying to cram multiple ideas across tweet boundaries loses the rhythm.

Closing tweet with CTA — if the thread is promotional or links somewhere, the closing tweet is where most engagement happens. Don't bury the call-to-action mid-thread.

07 — Section

Cost framing + scaling

Math from docs.x.com pricing at $0.015 per tweet created:

10-tweet thread = $0.15

Daily thread post over a month = ~$4.50

Bulk programmatic thread workflow (100 threads/mo at 8 tweets each) = ~$12

The same volumes through twitterapi.io at $0.0006 per post: a 10-tweet thread = $0.006, a daily 10-tweet thread over a month = $0.18, and 100 threads at 8 tweets each = $0.48, plus $0.10 for each login.

Plus rate-limit overhead — at safe-pacing the bottleneck is throughput, not cost. For most thread-poster workflows the bill is single-digit dollars per month.

python
# Practical example: production-ready thread poster with retry + audit log.
import os, time, json, random
import tweepy
from datetime import datetime, timezone

client = tweepy.Client(
    consumer_key=os.environ["X_CONSUMER_KEY"],
    consumer_secret=os.environ["X_CONSUMER_SECRET"],
    access_token=os.environ["X_USER_TOKEN"],
    access_token_secret=os.environ["X_USER_SECRET"],
)

def post_thread_safe(tweets: list[str], log_path: str = None) -> dict:
    log_path = log_path or f"thread_log_{datetime.now(timezone.utc).strftime('%Y%m%d_%H%M%S')}.jsonl"
    ids = []
    prev_id = None
    with open(log_path, "a") as log:
        for i, body in enumerate(tweets):
            entry = {"index": i, "body": body, "at": datetime.now(timezone.utc).isoformat()}
            try:
                kwargs = {"text": body}
                if prev_id is not None:
                    kwargs["in_reply_to_tweet_id"] = prev_id
                resp = client.create_tweet(**kwargs)
                tid = resp.data["id"]
                ids.append(tid)
                prev_id = tid
                entry["tweet_id"] = tid
                entry["status"] = "ok"
            except tweepy.TooManyRequests:
                entry["status"] = "rate_limited"
                log.write(json.dumps(entry) + "\n")
                time.sleep(60 + random.uniform(0, 5))
                # retry once
                try:
                    resp = client.create_tweet(**kwargs)
                    tid = resp.data["id"]
                    ids.append(tid)
                    prev_id = tid
                    entry["tweet_id"] = tid
                    entry["status"] = "ok_after_retry"
                except Exception as e:
                    entry["status"] = f"failed_after_retry: {e}"
            except Exception as e:
                entry["status"] = f"error: {e}"
            log.write(json.dumps(entry) + "\n")
            time.sleep(1.5 + random.uniform(0.1, 0.5))
    return {"ids": ids, "log": log_path}

result = post_thread_safe([
    "1/3 — Always log every tweet ID for audit + recovery.",
    "2/3 — Retry once on 429 with jitter, then give up to avoid spammy retries.",
    "3/3 — Pace 1.5-2s between posts as the safe baseline.",
])
print(result)

# Cost framing (math from cited pricing):
#   3-tweet thread = 3 × $0.015 = $0.045
#   Daily thread post for a month = $1.35
#   Bulk thread workflow (100 threads × 8 tweets) = $12/mo
08 — Questions

Questions readers ask

Can I post a thread via twitterapi.io?

Yes. Call POST /twitter/create_tweet_v2 once per post and set reply_to_tweet_id to the ID of the previous post. Each call is $0.0006 against $0.015 per post on the official API (Post: Create), plus one $0.10 POST /twitter/user_login_v2 login per session. The posts are made as your own X account through a proxy you supply, so the conditions in the twitterapi.io section above apply; for fewer than 7 posts on a fresh login the official route costs less in total.

How long can each tweet in a thread be?

Standard tweet length applies — 280 characters per tweet on the basic surface. X premium accounts have longer post limits (4,000+ chars) but each individual tweet in the thread is still subject to the underlying character cap per tweet.

Can other users insert tweets into my thread?

Yes — any user can reply to any tweet, including a tweet in your thread. Their reply appears as a branch off your thread in the X UI but doesn't break your main chain. Your thread renders normally for any reader navigating only your tweet IDs.

What if a mid-thread tweet fails to post?

You have a partial thread. Your audit log should capture every successful tweet ID and the failed one. Decide your policy: retry the failed one (and continue from there with the chain), abandon the thread (publish the partial and notify), or delete and retry from scratch.

Does the order matter in the in_reply_to chain?

Yes — each new tweet should reply to the IMMEDIATELY PREVIOUS tweet in the thread, not to the root tweet. If you accidentally chain all tweets to the root, the X UI will render them as separate replies to the root, not a connected thread.

Can I schedule a thread in advance via API?

X's POST tweet endpoint doesn't have a built-in schedule param. For scheduled threads, run your own scheduler (cron job, queue worker) that calls the API at the scheduled time. Third-party thread tools (Typefully, etc.) offer UI-based scheduling that wraps this same pattern.

09 — Further reading

Continue

Sources & further reading
More from this series
Build it

Stop reading. Start building.

Starter credits cover real testing on real data. Google sign-in, no card, no application queue.

Get an API key

Already building with TwitterAPI.io? Publish an honest tutorial or build log about it and earn up to $200 a month in API credits.