X Articles API: get the full text of any Twitter (X) Article by tweet ID

By TwitterAPI.io Team•13 min read
Diagram: find Article posts with search or timelines, call /twitter/article with the tweet ID, receive contents blocks, convert to Markdown or feed an LLM
From a tweet ID to the full Article: the tweet object carries a teaser, /twitter/article returns the body. Numbers from our study, collected in 2026.

This page is about X Articles, the long-form posts with a title, a cover image and a formatted body that open on x.com under /i/article/. It is not about threads: if the text you want is a chain of ordinary posts, our guide to unrolling a Twitter thread covers that case, and the general X API playground walks through every read endpoint. Here we stay on one question developers keep typing into search boxes: how do I get the content of an X Article out of the platform, as data, without opening a browser?

The short version is that you need two things. First, the ID of the tweet that shares the Article, which you can find with a search or a user timeline because those tweet objects carry an article field. Second, one call to /twitter/article, which returns the full body as structured blocks. We called that endpoint on twelve Articles from organization and public-figure accounts, read 397 recent Article posts through search and compared 28 Article posts with 383 ordinary posts from the same six accounts, so every number below comes from data we collected in 2026, not from a spec sheet.

Prices on this page use the TwitterAPI.io rate card (100,000 credits = $1) and X's own pay-per-usage table at docs.x.com/x-api/getting-started/pricing, both read this session.

01 — Section

What an X Article looks like to an API

An X Article is published from the Articles composer and then shared as a post. That post is what shows up in timelines, search and notifications. Its text is usually nothing but a t.co link: in our search sample, 69.7% of Article posts had no words outside the link, which is also why X tags most of them with the language code zxx (no linguistic content).

What makes the post useful is the article object attached to it. On TwitterAPI.io search and timeline results that object has exactly three keys in every one of the 396 Article posts we read: title, preview_text and cover_media_img_url. Titles ran a median 41 characters (longest 100), previews a median 193.5 characters (longest 250), and 87.6% had a non-empty cover image URL.

So a timeline call tells you that an Article exists, what it is called and how it opens. It does not give you the argument, the data, the list of steps or the closing call to action. For that you need the body, and the body lives behind a separate endpoint.

Where the data comes fromTitleBodyImagesEngagement
Tweet object (article field)yespreview onlycover onlyon the tweet
/twitter/articleyesevery blockcover + inline (url, width, height)likes, replies, quotes, views
Browser on x.comyesrendered pagerenderedrendered, needs a session for some views
02 — Section

GET /twitter/article: parameters, response and errors

The endpoint takes one required query parameter, tweet_id, the ID of the post that shares the Article (the number in x.com/<user>/status/<id>). Authentication is the same X-API-Key header as every other read. The docs page at docs.twitterapi.io/api-reference/endpoint/get_article states the price as 100 credits per Article; with 100,000 credits to the dollar that is $0.001 per Article, or $1 for 1,000.

We called it 12 times on Articles from six organization and public-figure accounts and got status: "success" every time. The article object came back with these keys: id (an opaque Article entity ID, not the tweet ID), author (the full user profile), title, preview_text, cover_media_img_url, createdAt, likeCount, replyCount, quoteCount, viewCount and contents. Two details differ from the published schema, so code defensively: viewCount arrived as a string in all 12 responses (the schema says integer), and the error text came back in msg, while the schema names it message.

There is no repost or bookmark count on the Article object. If you need those, read them from the sharing tweet (retweetCount, bookmarkCount) with /twitter/tweets or from the search result you used to find the post.

Not an Article, or a bad ID. We called the endpoint once with the ID of an ordinary White House post and once with the string abc. Both returned the same JSON body: article: null, status: "failed", msg: "article not found". Check status before you touch article, and do not retry these: the answer will not change.

bash
curl -s "https://api.twitterapi.io/twitter/article?tweet_id=2115083713512091917" \
  -H "X-API-Key: $TWITTERAPI_IO_KEY"

# trimmed response (real call, collected in 2026)
{
  "article": {
    "id": "QXJ0aWNsZUVudGl0eToyMTE1MDc4MDkzNDg4NzA5NjMy",
    "author": {"userName": "bhorowitz", "name": "...", "followers": 768070, "...": "..."},
    "title": "Databricks & the Future of the Firm ",
    "preview_text": "So many aspects of our lives are going to change over the next twenty years. And ...",
    "cover_media_img_url": "https://pbs.twimg.com/media/HVpF_cwa0AAqBFE.jpg",
    "createdAt": "Tue Oct 27 14:10:28 +0000 2026",
    "likeCount": 1560, "replyCount": 55, "quoteCount": 87, "viewCount": "939960",
    "contents": [
      {"type": "unstyled", "text": "So many aspects of our lives ...",
       "inlineStyleRanges": [{"offset": 0, "length": 6, "style": "Bold"}]},
      {"type": "header-one", "text": "From Spark to Agents"},
      {"type": "image", "url": "https://pbs.twimg.com/media/...", "width": 1000, "height": 1000}
    ]
  },
  "status": "success",
  "msg": null
}

# a tweet that does not share an Article, or a malformed id
{"article": null, "status": "failed", "msg": "article not found"}
03 — Section

The contents[] blocks, measured on 12 real Articles

The body is a list of blocks in reading order. Each block has a type; text blocks add text and, when the author used bold or italic, inlineStyleRanges entries of offset, length and style (we saw only Bold and Italic). Image blocks carry url, width and height instead of text. If that model looks familiar, it is the same idea as a DraftJS content state, which X's own Articles endpoints accept for drafts.

Across the 12 Articles we fetched there were 990 blocks. Plain paragraphs dominate, but structure matters for any converter:

Block typeBlocksShareWhat to emit in Markdown
unstyled84084.8%paragraph
unordered-list-item464.6%- item
header-two373.7%### heading
image262.6%![image](url)
ordered-list-item222.2%1. item
blockquote121.2%> quote
header-one60.6%## heading
divider10.1%---

The published schema also lists header-three, gif (an mp4 URL plus a previewUrl) and markdown; none appeared in our twelve, and blockquote appeared although the schema enum does not list it. Treat the type list as open: fall back to a plain paragraph for any type you do not recognize rather than dropping it.

Lengths varied by a factor of eleven. The shortest body was 549 words, the longest 6,137, the median 2,064 words or 13,577 characters. Images were sparse: 26 across all twelve, a median of 2 per Article, and 3 Articles had none beyond the cover.

Bar chart of words per Article for 12 full X Articles, from 549 to 6,137 words, median 2,064, with blocks and images per Article
Words in each of 12 Articles fetched with /twitter/article; the tweet preview carried a median 1.5% of them. Collected in 2026.
04 — Section

Python: save any X Article as Markdown

The script below does the full job: it finds an account's Article posts with a search, fetches each body with /twitter/article, applies bold and italic from inlineStyleRanges, maps each block type to Markdown and writes one .md file per Article. We ran the conversion function on all twelve responses from the study without errors; for the 6,137-word Article it produced Markdown with the Article's six section headings and six images in place.

Search first, then fetch. The query from:<handle> url:"x.com/i/article" keeps the search to that account's Article posts, and the code still checks t.get("article") so a quote of someone else's Article does not slip through. A search page that returns tweets costs 15 credits per tweet; an Article fetch costs 100 credits.

Emphasis offsets. inlineStyleRanges offsets index into the block text. The converter inserts markers from the end of the string backwards so earlier insertions do not shift later offsets, and trims spaces inside a range so you do not get ** word**.

Lists. Consecutive list-item blocks form one list; the converter closes a list with a blank line when the next block is something else, otherwise Markdown renderers fold the next paragraph into the last bullet.

Rate limits. Before your first payment your key runs at 0.2 requests per second, so a loop over dozens of Articles should sleep about five seconds between calls; paid accounts get 20 requests per second (50 on Scale, 500 on Business).

python
import os
import re
import time
import requests

BASE = "https://api.twitterapi.io"
HEADERS = {"X-API-Key": os.environ["TWITTERAPI_IO_KEY"]}


def find_article_posts(handle, max_pages=3):
    """Posts by one account that share an X Article (tweet.article is set)."""
    found, cursor = [], ""
    for _ in range(max_pages):
        r = requests.get(f"{BASE}/twitter/tweet/advanced_search", headers=HEADERS, timeout=30,
                         params={"query": f'from:{handle} url:"x.com/i/article"', "queryType": "Latest", "cursor": cursor})
        r.raise_for_status()
        body = r.json()
        found += [t for t in body.get("tweets", []) if t.get("article")]
        if not body.get("has_next_page"):
            break
        cursor = body.get("next_cursor", "")
    return found


def get_article(tweet_id):
    """Full Article body for the tweet that shares it. 100 credits per call."""
    r = requests.get(f"{BASE}/twitter/article", headers=HEADERS, params={"tweet_id": tweet_id}, timeout=30)
    r.raise_for_status()
    body = r.json()
    if body.get("status") != "success" or not body.get("article"):
        raise ValueError(f"{tweet_id}: {body.get('msg') or body.get('message')}")
    return body["article"]


MARKS = {"Bold": "**", "Italic": "*"}


def styled(text, ranges):
    """Apply inlineStyleRanges (offset/length/style) as Markdown emphasis."""
    inserts = []
    for rg in ranges or []:
        mark = MARKS.get(rg.get("style"))
        if not mark or rg.get("length", 0) <= 0:
            continue
        start, end = rg["offset"], rg["offset"] + rg["length"]
        while start < end and text[start].isspace():
            start += 1
        while end > start and text[end - 1].isspace():
            end -= 1
        if start < end:
            inserts += [(start, 1, mark), (end, 0, mark)]
    for pos, _, mark in sorted(inserts, key=lambda x: (x[0], x[1]), reverse=True):
        text = text[:pos] + mark + text[pos:]
    return text


def to_markdown(article):
    out = [f"# {article.get('title', '').strip()}", ""]
    a = article.get("author") or {}
    out += [f"By {a.get('name', '')} (@{a.get('userName', '')}), {article.get('createdAt', '')}", ""]
    if article.get("cover_media_img_url"):
        out += [f"![cover]({article['cover_media_img_url']})", ""]
    n, prev = 0, ""
    for b in article.get("contents", []):
        kind, text = b.get("type"), styled(b.get("text", ""), b.get("inlineStyleRanges"))
        n = n + 1 if kind == "ordered-list-item" else 0
        if prev.endswith("list-item") and kind != prev:
            out.append("")  # close the list before the next block
        prev = kind
        if kind == "header-one":
            out += [f"## {text}", ""]
        elif kind == "header-two":
            out += [f"### {text}", ""]
        elif kind == "header-three":
            out += [f"#### {text}", ""]
        elif kind == "unordered-list-item":
            out.append(f"- {text}")
        elif kind == "ordered-list-item":
            out.append(f"{n}. {text}")
        elif kind == "blockquote":
            out += [f"> {text}", ""]
        elif kind == "image":
            out += [f"![image]({b.get('url', '')})", ""]
        elif kind == "gif":
            out += [f"[animated image]({b.get('url', '')})", ""]
        elif kind == "divider":
            out += ["---", ""]
        else:  # unstyled, markdown and any new block type
            out += [text, ""]
    return re.sub(r"\n{3,}", "\n\n", "\n".join(out)).strip() + "\n"


if __name__ == "__main__":
    for post in find_article_posts("bhorowitz", max_pages=1)[:3]:
        art = get_article(post["id"])
        name = re.sub(r"[^a-z0-9]+", "-", art["title"].lower()).strip("-")[:60] or post["id"]
        with open(f"{name}.md", "w", encoding="utf-8") as f:
            f.write(to_markdown(art))
        words = sum(len(b.get("text", "").split()) for b in art["contents"])
        print(f"{post['id']}  {art['title']!r}  {words} words  {len(art['contents'])} blocks -> {name}.md")
        time.sleep(0.3)
05 — Section

Finding Article posts: search and timelines

You cannot fetch an Article without its tweet ID, so discovery is half the work. The most reliable filter we found is the search operator url:"x.com/i/article". On 20 pages of Latest results it returned 397 posts, and 396 of them (99.7%) carried the article object; the single exception was a repost. Combine it with from:, lang:, min_faves: or since: to narrow the stream.

The stream is large and mostly small accounts. Those 397 posts spanned about 2.2 hours, roughly 180 Article posts an hour for this query. 173 came from accounts under 1,000 followers and only 29 from accounts above 100,000; the median post had 1 like and 96 views, and 48.5% had no likes at all. 98.5% of the authors carried the blue check, which fits the common reading that Articles are a paid-tier feature, though a sample cannot prove that.

Search sample metricValue
Article posts read396 of 397 (99.7%)
Post text is only a link69.7%
Language tag en / zxx73 / 276 posts
Median likes / views1 / 96
90th percentile likes / views8 / 876.5

For a known account, /twitter/user/last_tweets is simpler: every tweet object in data.tweets[] has the same article field, so a filter on it picks out Article posts from the rest. That is the route to use when you watch specific publishers.

python
import os, requests

BASE = "https://api.twitterapi.io"
HEADERS = {"X-API-Key": os.environ["TWITTERAPI_IO_KEY"]}

r = requests.get(f"{BASE}/twitter/tweet/advanced_search", headers=HEADERS, timeout=30,
                 params={"query": 'url:"x.com/i/article" lang:en min_faves:100', "queryType": "Latest"})
body = r.json()
for t in body.get("tweets", []):
    art = t.get("article")          # None on ordinary posts
    if art:
        print(t["id"], t["author"]["userName"], art["title"], len(art.get("preview_text") or ""))
print("more:", body.get("has_next_page"), body.get("next_cursor"))
06 — Section

Do Article posts perform better? A six-account comparison

To see whether Articles are worth tracking, we pulled the latest 100 posts of six organization and public-figure accounts that publish them and compared each account's Article posts with its other original posts (reposts and replies excluded). That left 28 Article posts against 383 ordinary ones.

Taking the median of the six per-account ratios, Article posts drew 5x the views, 7.1x the bookmarks, 2.1x the reposts and 1.2x the likes. Bookmarks moved most, which fits long reads people save for later.

AccountArticle postsViews ratioBookmarks ratioLikes ratio
@bhorowitz1 of 2522.8x158.12x8.97x
@NicoleShanahan4 of 915.44x12.84x1.94x
@thedankoe18 of 1005.15x6.39x0.85x
@eglyman3 of 854.9x7.81x1.65x
@UNWatch1 of 740.59x0.24x0.25x
@WhiteHouse1 of 360.58x0.17x0.2x

The split is not universal. The two news-style accounts, @WhiteHouse and @UNWatch, each had one Article in the window and it drew 0.58x and 0.59x their usual views: for accounts whose ordinary posts are already announcements, a long-form version adds little reach. The essay writers, by contrast, saw about five to twenty-three times their usual views on Articles. @thedankoe, the only account with a large Article count (18 of 100 posts), still came out at 5.15x views and 6.39x bookmarks.

Read the single-Article rows as anecdotes. The useful conclusion for a developer is narrower: when you monitor publishers, Article posts are disproportionately the ones that get saved and shared, so fetching their bodies is often where the value is.

Dot plot of views and bookmarks ratios of Article posts vs other posts for six accounts, from 0.17x to 158x on a log scale
Median views and bookmarks of Article posts divided by each account's other original posts. 100 latest posts per account, collected in 2026.
07 — Section

What the official X API offers for Articles

X's data dictionary lists article among the optional Post fields you can request with tweet.fields, described as an object that "contains metadata for the Article present in this Tweet" with the suggested use "to get the text and entities of an Article". X's changelog dates that addition to July 2024 ("Get article metadata"). We did not call X's endpoint for this page, so we do not claim more about its payload than those two sentences.

On the writing side, X added two endpoints in June 2026: POST https://api.x.com/2/articles/draft creates a draft from a title, a DraftJS content state and optional cover media, and POST https://api.x.com/2/articles/{article_id}/publish publishes it and returns the new post ID. Both need a user access token (OAuth 1.0a or OAuth 2.0 with PKCE, scopes tweet.read, tweet.write and users.read). X's OpenAPI schema notes a markdown limit of 10,000 weighted characters per Article for code blocks and tables.

On price, X's pay-per-usage table has no separate Articles line. A post lookup bills as Posts: Read at $0.005 per resource, so 1,000 post reads cost $5. On TwitterAPI.io, 1,000 Article bodies cost $1 (100 credits each) and 1,000 ordinary tweets cost $0.15, about 33x less than X's $5 per 1,000 post reads. X's rate card also caps pay-per-usage at 3 million post reads a month (Enterprise above) and offers $20 of credits on a first card purchase plus a match of up to $50 on the first auto-recharge.

TaskOfficial X API (pay-per-usage)TwitterAPI.io
Read 1,000 posts$5.00 ($0.005 each)$0.15 (15 credits each)
Read 1,000 Article bodiesnot priced separately; post read $0.005$1.00 (100 credits each)
Auth for readsdeveloper account, Bearer tokenX-API-Key, no X account
Publish an Article/2/articles/draft + /publish, user tokennot a documented endpoint

TwitterAPI.io's write endpoints (posting, deleting, liking, following and so on through /twitter/create_tweet_v2 and its siblings) run through an authenticated session from /twitter/user_login_v2 and cost 60 credits ($0.0006) per call, but Article drafting is not among them. If you need to publish Articles programmatically, X's own endpoints are the documented path.

08 — Section

Watching publishers for new Articles

A common job is to catch a newsletter-style account's new Article within minutes and push the full text into a summarizer, a search index or a Slack channel. The loop below polls one account's latest posts every five minutes, keeps only tweets with an article object it has not seen and fetches the body once per new Article.

Polling cost. A check that finds nothing is billed at the 15-credit minimum, $0.00015. Every five minutes for a month is about $1.30 per account; every 60 seconds is $6.48. A last_tweets page that does return 20 tweets costs $0.003, and each new Article adds $0.001. Even the most prolific publisher in our sample (@thedankoe, 18 Article posts among its latest 100) would add only cents a month in Article fetches.

Fewer requests for many accounts. For a list of publishers, one search with (from:a OR from:b OR from:c) url:"x.com/i/article" replaces one timeline call per account, and an empty result still costs only the minimum. If you need push delivery instead of polling, the filter-rule webhook route described in our real-time guides can carry the same query.

What to store. Keep the tweet ID as your key (the article.id entity string is opaque), the title, createdAt, the Markdown body and the image URLs. Image URLs point at pbs.twimg.com; download them if you need a durable archive.

python
import os, time, requests

BASE = "https://api.twitterapi.io"
HEADERS = {"X-API-Key": os.environ["TWITTERAPI_IO_KEY"]}
seen = set()

def new_articles(handle):
    r = requests.get(f"{BASE}/twitter/user/last_tweets", headers=HEADERS, timeout=30,
                     params={"userName": handle})
    tweets = (r.json().get("data") or {}).get("tweets", [])
    return [t for t in tweets if t.get("article") and t["id"] not in seen]

while True:
    for t in new_articles("bhorowitz"):
        seen.add(t["id"])
        full = requests.get(f"{BASE}/twitter/article", headers=HEADERS, timeout=30,
                            params={"tweet_id": t["id"]}).json()
        if full.get("status") == "success":
            words = sum(len(b.get("text", "").split()) for b in full["article"]["contents"])
            print("new Article:", full["article"]["title"], words, "words")
    time.sleep(300)   # every 5 minutes
09 — Section

How we collected this

Question. What does an X Article look like as data, how much of it does a normal tweet object carry, and do Article posts perform differently from the same accounts' ordinary posts?

Accounts and query. Six organization and public-figure accounts that publish Articles: @WhiteHouse, @bhorowitz, @UNWatch, @eglyman, @thedankoe and @NicoleShanahan. A search for url:"x.com/i/article" (Latest, 20 pages) for the wider sample.

Window and sample. The most recent posts at collection time, collected in 2026: 397 search posts spanning about 2.2 hours, 600 timeline posts (100 per account), and 12 full Articles fetched with /twitter/article from the six accounts. In total 65 requests, about $0.17 of credits.

What the sample cannot show. Six accounts and twelve full Articles are enough to describe the data format and to show a direction, not to estimate a platform-wide lift. The search sample covers a couple of hours of one query and leans toward small accounts. View counts are X's own, read at collection time, and older posts have had longer to accumulate them.

Privacy. Aggregates only. Search results from private individuals stayed out of the published data; the de-identified sample keeps no IDs, text or handles. Only organization and public-figure accounts are named.

python
import os
import re
import time
import requests

BASE = "https://api.twitterapi.io"
HEADERS = {"X-API-Key": os.environ["TWITTERAPI_IO_KEY"]}


def find_article_posts(handle, max_pages=3):
    """Posts by one account that share an X Article (tweet.article is set)."""
    found, cursor = [], ""
    for _ in range(max_pages):
        r = requests.get(f"{BASE}/twitter/tweet/advanced_search", headers=HEADERS, timeout=30,
                         params={"query": f'from:{handle} url:"x.com/i/article"', "queryType": "Latest", "cursor": cursor})
        r.raise_for_status()
        body = r.json()
        found += [t for t in body.get("tweets", []) if t.get("article")]
        if not body.get("has_next_page"):
            break
        cursor = body.get("next_cursor", "")
    return found


def get_article(tweet_id):
    """Full Article body for the tweet that shares it. 100 credits per call."""
    r = requests.get(f"{BASE}/twitter/article", headers=HEADERS, params={"tweet_id": tweet_id}, timeout=30)
    r.raise_for_status()
    body = r.json()
    if body.get("status") != "success" or not body.get("article"):
        raise ValueError(f"{tweet_id}: {body.get('msg') or body.get('message')}")
    return body["article"]


MARKS = {"Bold": "**", "Italic": "*"}


def styled(text, ranges):
    """Apply inlineStyleRanges (offset/length/style) as Markdown emphasis."""
    inserts = []
    for rg in ranges or []:
        mark = MARKS.get(rg.get("style"))
        if not mark or rg.get("length", 0) <= 0:
            continue
        start, end = rg["offset"], rg["offset"] + rg["length"]
        while start < end and text[start].isspace():
            start += 1
        while end > start and text[end - 1].isspace():
            end -= 1
        if start < end:
            inserts += [(start, 1, mark), (end, 0, mark)]
    for pos, _, mark in sorted(inserts, key=lambda x: (x[0], x[1]), reverse=True):
        text = text[:pos] + mark + text[pos:]
    return text


def to_markdown(article):
    out = [f"# {article.get('title', '').strip()}", ""]
    a = article.get("author") or {}
    out += [f"By {a.get('name', '')} (@{a.get('userName', '')}), {article.get('createdAt', '')}", ""]
    if article.get("cover_media_img_url"):
        out += [f"![cover]({article['cover_media_img_url']})", ""]
    n, prev = 0, ""
    for b in article.get("contents", []):
        kind, text = b.get("type"), styled(b.get("text", ""), b.get("inlineStyleRanges"))
        n = n + 1 if kind == "ordered-list-item" else 0
        if prev.endswith("list-item") and kind != prev:
            out.append("")  # close the list before the next block
        prev = kind
        if kind == "header-one":
            out += [f"## {text}", ""]
        elif kind == "header-two":
            out += [f"### {text}", ""]
        elif kind == "header-three":
            out += [f"#### {text}", ""]
        elif kind == "unordered-list-item":
            out.append(f"- {text}")
        elif kind == "ordered-list-item":
            out.append(f"{n}. {text}")
        elif kind == "blockquote":
            out += [f"> {text}", ""]
        elif kind == "image":
            out += [f"![image]({b.get('url', '')})", ""]
        elif kind == "gif":
            out += [f"[animated image]({b.get('url', '')})", ""]
        elif kind == "divider":
            out += ["---", ""]
        else:  # unstyled, markdown and any new block type
            out += [text, ""]
    return re.sub(r"\n{3,}", "\n\n", "\n".join(out)).strip() + "\n"


if __name__ == "__main__":
    for post in find_article_posts("bhorowitz", max_pages=1)[:3]:
        art = get_article(post["id"])
        name = re.sub(r"[^a-z0-9]+", "-", art["title"].lower()).strip("-")[:60] or post["id"]
        with open(f"{name}.md", "w", encoding="utf-8") as f:
            f.write(to_markdown(art))
        words = sum(len(b.get("text", "").split()) for b in art["contents"])
        print(f"{post['id']}  {art['title']!r}  {words} words  {len(art['contents'])} blocks -> {name}.md")
        time.sleep(0.3)
10 — Questions

Questions readers ask

Is there an API to get the content of an X Article?

Yes. GET https://api.twitterapi.io/twitter/article?tweet_id=<id> returns the title, author, counts and every body block of the Article shared by that tweet, for 100 credits ($0.001). X's own API documents an article post field described as Article metadata; check its payload against your needs before relying on it for full bodies.

Which ID do I pass, the Article ID or the tweet ID?

The tweet ID of the post that shares the Article, the number in x.com/<user>/status/<id>. The article.id in the response is an opaque entity string and is not accepted as tweet_id.

Why does my timeline or search result only show a short preview?

Tweet objects carry a compact article object with title, preview_text and cover_media_img_url. In our sample the preview was at most 250 characters, a median 1.5% of the body. The full text needs one /twitter/article call.

What happens if the tweet is not an Article?

You get a normal JSON body with article: null, status: "failed" and msg: "article not found". We saw the same response for a malformed ID. Check status first and do not retry.

How do I convert an X Article to Markdown?

Map each contents[] block type to Markdown (headers to ##, list items to - or 1., blockquote to >, image to ![image](url), divider to ---) and apply inlineStyleRanges for bold and italic. The Python script on this page does exactly that and writes one .md file per Article.

Can I scrape X Articles without an API?

Articles render in the x.com web app, so a browser can display them, but automating a logged-in browser is slow, breaks when the page changes and acts as your account. An API call returns the same text as structured JSON without an X account.

Can I publish an X Article through an API?

Through X's official API, yes: since June 2026 POST /2/articles/draft creates a draft and POST /2/articles/{article_id}/publish publishes it, both with a user access token. TwitterAPI.io's write endpoints cover posts, replies, likes, follows and similar actions but not Article drafting.

How much does it cost to fetch 1,000 X Articles?

$1 on TwitterAPI.io (100 credits each, 100,000 credits per dollar), plus about $0.15 per 1,000 tweets for the searches that find them. New accounts that pass the automatic bot check get up to $1 of free credits (at most 100,000 credits, no card, one per person or network, expiring after 31 days).

11 — 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.