X (Twitter) Scraper – Profile Posts & Stats avatar

X (Twitter) Scraper – Profile Posts & Stats

Pricing

$0.50 / 1,000 profile reads

Go to Apify Store
X (Twitter) Scraper – Profile Posts & Stats

X (Twitter) Scraper – Profile Posts & Stats

Scrape any public X (Twitter) profile for its latest posts — full text, permanent link, publish date, likes, reposts, views, photos and video — plus a 33-field author record. No login, no cookies, no API key. Built for polling many accounts often: 5 latest posts per profile. Unofficial.

Pricing

$0.50 / 1,000 profile reads

Rating

0.0

(0)

Developer

Simple Actors

Simple Actors

Maintained by Community

Actor stats

0

Bookmarked

9

Total users

6

Monthly active users

7 days ago

Last modified

Share

Scrape any public X (Twitter) profile for what it has posted lately — full text, permanent link, publish date, likes, reposts, views, photos and video, newest first. 49 fields per post plus a 33-field author record. Built for social media monitoring, breaking-news alerting, engagement tracking and competitor research across many accounts at once.

No login, no cookies, no API key, no account of yours involved. It reads what X already renders for logged-out visitors, so there is no session to keep alive, no account to get suspended, and nothing to re-authenticate when it breaks — because there is nothing to break. A run is one request per profile and finishes in about five seconds.

$0.50 per 1,000 profiles read — Apify platform usage included, nothing else to pay. The charge is per profile and does not depend on how many posts come back, so a full read and a check that finds one new post cost the same. A profile that cannot be read — suspended, protected, or no such account — is never charged, and does not fail the run: it comes back as a row saying why.

Read this before you start: 5 posts per profile. That is X's limit for a logged-out reader, not a setting, and no option raises it. This is built to check many accounts often — if you need one account's deep history, this is the wrong tool and you will be disappointed.

Pick your row shape up front. The default is one row per post, with the account record repeated on each — right for collecting posts, wasteful for looking up accounts. Set outputFormat: "profile" and each account is a single row instead — its profile, with the latest posts attached — at the same price.

Unofficial. Not affiliated with, endorsed by, or sponsored by X Corp.

Features

  • Latest posts per public profile — 5 per account, newest first, 49 fields each.
  • Full author record — 33 fields including followers, following, post count, bio, location, verification and join date.
  • Every engagement metric X shows — likes, replies, reposts, quotes, bookmarks and views.
  • Long posts in full — no truncation at the old character limit.
  • Quote posts carry the quoted post with its own author and engagement.
  • Media with detail — photos and videos with direct URLs, dimensions and alt text.
  • Link preview cards — title, description and image where a post has one.
  • Entities parsed — links, mentions and hashtags as their own fields, with t.co links resolved to the real destination.
  • Pinned posts flagged, so an old pin never passes for the newest post.
  • Two output shapes at the same price — one row per post, or one row per account with its posts nested.
  • Handles or URLsx.com, twitter.com, a link to a single post, or a bare handle.
  • Incremental pollingonlyPostsNewerThan refuses rather than misleads if the window reaches past what one read can see.
  • Runs stay green. A suspended, protected or nonexistent account is a row.

Input

{
"startUrls": [{ "url": "https://x.com/NASA" }], // profile, twitter.com or post URLs
"handles": ["espn", "@natgeo"], // or bare handles — both lists are read as one
"outputFormat": "posts", // "posts" = row per post, "profile" = row per account
"maxPosts": 5, // latest posts per profile (5 is X's ceiling)
"onlyPostsNewerThan": "3 days", // optional window: "20 hours", "3 days", "2026-08-01"
"includeRaw": false, // attach X's untouched post object under `raw`
"proxy": { "useApifyProxy": true }
}
FieldTypeDefaultWhat it does
startUrlsarrayAccounts as links. A profile URL, a twitter.com URL or a link to one of their posts all work.
handlesarrayAccounts as bare handles: espn or @natgeo. Read together with startUrls.
outputFormatstringpostsposts gives one row per post; profile gives one row per account with its posts nested. Same price.
maxPostsinteger5Latest posts per account. 5 is X's ceiling for a logged-out reader.
onlyPostsNewerThanstringKeep only posts after a window or ISO date.
includeRawbooleanfalseAttach X's untouched post object under raw. Makes rows much larger.
proxyobjectApify ProxyApify Proxy settings.

Paste one or more accounts:

{
"startUrls": [{ "url": "https://x.com/NASA" }]
}

A profile URL, a twitter.com URL or a link to one of their posts all work — the account is taken from whichever you give.

If what you have is handles rather than links, put them in handles instead. Both lists are read as one, so a mixed source is fine:

{
"startUrls": [
{ "url": "https://x.com/NASA" },
{ "url": "https://twitter.com/WHO" }
],
"handles": ["espn", "@natgeo"]
}

They are two inputs rather than one because Apify checks the URL list is really URLs before the run starts, so a bare handle cannot travel in it.

Output

One dataset item per post, newest first — or one per account, if you would rather have the profile stated once with its posts attached (both shapes). A shortened example of the default:

{
"type": "tweet",
"id": "2090877991228264814",
"url": "https://x.com/NASA/status/2090877991228264814",
"text": "Launching soon, our newest space telescope @NASARoman is equipped with tools to help it spot and study exoplanets orbiting distant stars.\n\nLearn about these tools and how they work in our newest NASA's Curious Universe podcast on Roman: https://t.co/nCZCpF1MIO",
"likeCount": 878,
"replyCount": 65,
"retweetCount": 138,
"quoteCount": 6,
"bookmarkCount": 39,
"viewCount": 411170,
"createdAt": "Fri Aug 21 19:05:34 +0000 2026",
"createdAtIso": "2026-08-21T19:05:34.000Z",
"timestamp": 1787339134000,
"isReply": false,
"isQuote": false,
"isPinned": false,
"isLongForm": false,
"mentions": ["NASARoman"],
"urls": ["https://go.nasa.gov/45FxOK8"],
"media": [
{
"type": "video",
"mediaUrl": "https://pbs.twimg.com/media/HQRKc2lWgAAtNGa.png",
"videoUrl": "https://video.twimg.com/amplify_video/.../7INz5crAEofwRbWp.mp4",
"durationMillis": 28361,
"width": 720,
"height": 406
}
],
"quote": null,
"author": {
"userName": "NASA",
"name": "NASA",
"id": "11348282",
"followers": 92346035,
"following": 118,
"isBlueVerified": true,
"verifiedType": "Government",
"description": "Making the seemingly impossible, possible. ✨",
"location": "Pale Blue Dot",
"profilePicture": "https://pbs.twimg.com/profile_images/...jpg"
},
"profileUrl": "https://x.com/NASA",
"scrapedAt": "2026-08-22T04:17:01.155Z"
}

The field names are the ones already used by widely-used X post datasets, so code written against those reads this output unchanged.

Two shapes: one row per post, or one row per account

The item above is the default: one row per post, with the account record on each of them. It is the right shape for collecting posts.

If what you want is the account — its picture, bio, follower count, website — set outputFormat to profile. Each account becomes a single row: the profile at the top level, its latest posts in short form under posts.

{
"type": "user",
"userName": "NASA",
"name": "NASA",
"url": "https://x.com/NASA",
"id": "11348282",
"description": "Making the seemingly impossible, possible.",
"location": "Pale Blue Dot",
"website": "https://www.nasa.gov",
"followers": 92345916,
"following": 190,
"statusesCount": 76211,
"profilePicture": "https://pbs.twimg.com/profile_images/…_normal.jpg",
"profilePictureFull": "https://pbs.twimg.com/profile_images/….jpg",
"coverPicture": "https://pbs.twimg.com/profile_banners/…",
"isVerified": false,
"isBlueVerified": true,
"verifiedType": "Government",
"isProtected": false,
"createdAtIso": "2007-12-19T20:20:32.000Z",
"postsReturned": 5,
"posts": [
{
"id": "2090877991228264814",
"type": "tweet",
"url": "https://x.com/NASA/status/2090877991228264814",
"twitterUrl": "https://twitter.com/NASA/status/2090877991228264814",
"text": "Launching soon, our newest space telescope…",
"createdAtIso": "2026-08-21T19:05:34.000Z",
"timestamp": 1787339134000,
"likeCount": 878,
"retweetCount": 138,
"replyCount": 65,
"quoteCount": 6,
"bookmarkCount": 39,
"viewCount": 411170,
"isReply": false,
"isRetweet": false,
"isQuote": false,
"isPinned": false,
"isSensitive": false,
"lang": "en",
"hashtags": [],
"mentions": ["NASARoman"],
"urls": ["https://go.nasa.gov/45FxOK8"],
"media": [{ "type": "video", "mediaUrl": "https://pbs.twimg.com/media/…png", "videoUrl": "https://video.twimg.com/…mp4" }],
"imageCount": 0,
"videoCount": 1,
"authorUserName": "NASA"
}
]
}

That is the complete post shape in this format — all 26 fields, not an excerpt. media is among them, so the pictures and video files are here too and you do not need the row-per-post format to get them. authorUserName is kept because a timeline carries reposts and quotes written by other people. What the short form drops is the rest of the post record: reply and quote threading (inReplyToId, quote), edit history, community notes, link preview card, place, source, whoCanReply and the full nested author. If you need any of those, use the default format.

Same charge either way — it is one account read. What it saves is size: the account record is stated once instead of on all five posts.

Dates

Every post carries the same instant three ways, because different tools want different things:

FieldExampleFor
createdAtFri Aug 21 19:05:34 +0000 2026X's own format, for existing parsers
createdAtIso2026-08-21T19:05:34.000ZSorting, filtering, spreadsheets
timestamp1787339134000Arithmetic, milliseconds since the epoch

Long posts are returned in full

A post past the classic length limit is stored by X twice: a visible copy cut off mid-sentence, and the whole thing separately. This returns the whole thing and sets isLongForm so you know it happened.

Quote posts

When a post quotes another, quote carries the quoted post in the same shape — its own text, author and engagement counts. The quoting post's own counts stay on the top level, so the two are never mixed up. Quoting stops one level deep.

One count X does not give out

If the author restricted who may reply, X does not disclose that post's reply count to a logged-out reader — it reports zero. Passing that on would be wrong, so replyCount is null on those posts and whoCanReply says why ("Community" or "ByInvitation"). Every other count is real.

This is worth knowing if you compare a result against the site while logged in: X shows you the reply count there because you are signed in, and this cannot see it. Likes, reposts, quotes, bookmarks and views are unaffected.

Also on every post

whoCanReply when the author narrowed replies, isEdited with the full editHistoryIds (every version the post has had, oldest first), communityNote when one is attached, socialContext for why X surfaced the post, inReplyToId, displayTextRange, and isArticle.

The author record includes both label systems X uses — affiliateLabel for the organisation an account belongs to, and identityLabel with its badge artwork and link for business and government accounts — plus a full-size avatar URL alongside the thumbnail.

Pinned posts

A pinned post is included and flagged isPinned. X returns it first regardless of its age, and it can be years older than the rest — so items are sorted by publication date, not by the order X sends them.

When a post links somewhere, card carries the preview X built for it: title, description, domain, and a preview image with dimensions. Everything X supplied is kept under card.bindings, so card types this does not name — live broadcasts, polls — are still readable.

Media

media holds photos and videos with dimensions, duration, alt text and any title or description. videoUrl is the best-quality MP4, videoVariants lists every rendition with its bitrate if you want a smaller file, and streamUrl is the HLS playlist for adaptive playback in a player.

The flat urls, mentions, hashtags and cashtags arrays cover the common case. entities carries the same things in full: for each link, the t.co as it appears in the post text, the address it expands to, and the short form X displays — plus the character positions, so you can substitute links back into the text. Mentions carry the account ID as well as the handle.

On a long post these come from the same copy of the text you get in text, so the positions always line up and nothing in the tail is missed.

Media URLs are signed by X and expire within hours. Download them promptly; do not store them as long-term links. The post's url is the stable one.

How to use

From Apify Console

  1. Open the Actor and click Try for free / Start.
  2. Paste profile links into X profiles, or a column of bare handles into X handles — the two lists are read as one, so a mixed source is fine.
  3. Decide the row shape in What a row is: one row per post for collecting posts, one row per account for looking accounts up. Same price either way.
  4. To poll for what is new, set Only posts newer than to a window shorter than the gap between your runs, then schedule the run.
  5. Click Start, then open the Dataset tab and export as JSON, CSV or Excel.

Treat rows with an error field as the failure signal — the run stays green even when an account could not be read.

From the API

curl -s "https://api.apify.com/v2/acts/simple.actors~x-profile-posts/run-sync-get-dataset-items?token=$APIFY_TOKEN" \
-H 'Content-Type: application/json' \
-d '{"handles": ["NASA"]}'

Watching a list of accounts for breaking posts, with the JavaScript client:

import { ApifyClient } from 'apify-client';
const client = new ApifyClient({ token: process.env.APIFY_TOKEN });
const run = await client.actor('simple.actors/x-profile-posts').call({
handles: ['NASA', 'WHO', 'espn'],
onlyPostsNewerThan: '30 minutes', // shorter than the gap between runs
});
const { items } = await client.dataset(run.defaultDatasetId).listItems();
for (const row of items) {
if (row.error) { console.warn(row.handle, row.error); continue; }
if (row.isPinned) continue; // a pin can be years old
console.log(row.authorHandle, row.createdAt, row.text);
}

Use cases

  • Watch accounts for new posts — run it on a schedule across as many profiles as you like and diff on id. This is what it is for.
  • Follow announcements — agencies, transit operators, emergency services, sports teams and newsrooms post breaking updates to X first.
  • Track engagement — likes, replies, reposts, quotes, bookmarks and views on every post, so you can see which ones travelled.
  • Collect media — photos and videos with direct URLs, dimensions and alt text.
  • Feed a dashboard or an alert — full post text and links, ready to route.

Usage notes

What it costs

$0.50 per 1,000 profiles read. One profile read is one charge, so asking for five profiles in one run costs five. There is no per-post fee and no per-run fee.

Because the charge is per profile, what you pay per post depends on how many posts a read returns:

Posts returned by a readWorks out at
5 — a full read$0.10 per 1,000 posts
3$0.17 per 1,000 posts
1 — a poll that found one new post$0.50 per 1,000 posts

So the price rewards full reads and costs the same on a quiet one. If you are polling with onlyPostsNewerThan and most checks come back empty, budget by profiles checked rather than by posts collected — that is the number you are actually billed on.

Two more consequences worth knowing:

  • Take all five posts. Fewer posts cost you no less, because the price is for reading the profile, not for what comes back.
  • Batch your profiles. Sending twenty profiles in one run costs exactly the same as twenty separate runs, but finishes far faster and in a single call.

A profile that cannot be read is never charged, so a list containing a few dead accounts costs only for the live ones.

What an empty result means

An empty dataset means the account was read and has posted nothing in the window you asked for. It never means "we could not look".

An account that cannot be read is reported as its own row carrying error and errorDescription, so one bad account in a batch never costs you the rest:

errorMeaningRun status
not_foundNo such accountSucceeds
not_availableSuspended, withheld in this region, or otherwise restrictedSucceeds
protectedPosts are visible only to approved followers, so a login would be neededSucceeds
bad_inputThe entry was never a usable handle or profile URLSucceeds
window_too_wideonlyPostsNewerThan reaches further back than this run can see — see belowSucceeds
read_failedX did not serve a readable page after several attemptsSucceeds

No unreadable account fails the run — not one of them, and not all of them at once. Whether it is an answer about the account (missing, suspended, protected, mistyped) or a read that did not happen (X did not serve the page), the run finishes as SUCCEEDED with the reason in a row. The run's status message counts both kinds: how many accounts were read, how many answered with an error row, and how many could not be reached.

So check the rows, not the run status. If you are scheduling this, treat any row with an error field as the failure signal — a green run can still contain accounts that were not read, and a run status of SUCCEEDED does not by itself mean every account came back.

An empty dataset still means exactly one thing — read, nothing new — because every failure leaves a row.

onlyPostsNewerThan will refuse rather than mislead

A run sees an account's latest posts and no further back. If your window reaches past them, some posts inside it were never fetched — and returning what was found would read as "this is everything since then".

So that account gets a window_too_wide row instead, telling you how far back it could actually see. That is what lets an empty result mean "nothing new" and nothing else. A pinned post does not count towards that reach, since it can be years old and would otherwise make any window look covered.

If you hit this, use a shorter window or run more often.

Limits

Five posts per account, per run. This is X's limit, not a setting we chose. X renders five posts to a logged-out reader, then ends the timeline and withholds the cursor that would ask for the next page. maxPosts is capped at 5 and values above it are rejected at input validation rather than silently under-delivered.

If you need an account's deep history, this is the wrong tool — it is built for freshness, not depth. For monitoring, five posts per run is normally plenty: run it more often rather than asking for more.

Original posts only. The logged-out profile timeline carries an account's own posts, including its replies and quote posts, but not its reposts of others.

No search, and no other tabs. X serves nothing to a logged-out reader for search results, the Media tab or the Highlights tab, so this reads profiles only.

Protected accounts cannot be read. They need an approved follower's login, which this deliberately does not have.

lang and source are always null. X does not include them in what it renders to logged-out readers. The fields are present so existing code does not break on their absence.

FAQ

Is scraping X (Twitter) legal? This Actor reads only what X already renders for logged-out visitors — it does not log in, use cookies, or reach protected accounts. X's Terms of Service restrict automated collection, so check the platform's ToS and your own obligations, particularly around personal data, before using it.

Do I need an X API key or a developer account? No. There is no key, no login, no session and no account of yours involved — nothing to get rate-limited or suspended.

Can I get more than 5 posts per profile? No. Five is what X renders to a logged-out reader, not a setting, and no option raises it. This Actor is built to check many accounts often; for one account's deep history it is the wrong tool.

Does it support pagination? There is no next page to request for a logged-out reader, so no. Run the Actor on a schedule with onlyPostsNewerThan to follow accounts over time.

Can it read protected (private) accounts? No. A protected account comes back as a row saying so, and the run still succeeds.

Why is the first row not the account's newest post? Pinned posts are the trap — X shows them first whatever their age. They are flagged isPinned, and a pin is excluded from the reach calculation for onlyPostsNewerThan so an old pin cannot make any window look covered.

Why did I get a window_too_wide row instead of posts? Because the window reached further back than the five posts a run can see, so returning what was found would have read as "this is everything since then". Use a shorter window, or run more often.

Why did my run succeed when an account was not read? By design. Every account that cannot be read comes back as a row with an error field — whether that is an answer about the account (suspended, protected, nonexistent, never a valid handle) or X not serving the page on the day — and none of them turns the run red. Treat rows with an error field as the failure signal rather than the run status, and read the run's status message for the counts.

Do I save money by asking for fewer posts? No. The charge is per profile, so a profile costs the same whether five posts come back or one.

Note

This reads publicly visible posts only — the same ones anyone can see without logging in. It does not log in, does not use cookies, and cannot reach protected accounts, direct messages, or anything else behind a login.