X (Twitter) Scraper – Profile Posts & Stats
Pricing
$0.50 / 1,000 profile reads
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 Actor
Maintained by CommunityActor stats
0
Bookmarked
1
Total users
1
Monthly active users
2 days ago
Last modified
Categories
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.colinks 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 URLs —
x.com,twitter.com, a link to a single post, or a bare handle. - Incremental polling —
onlyPostsNewerThanrefuses 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 }}
| Field | Type | Default | What it does |
|---|---|---|---|
startUrls | array | — | Accounts as links. A profile URL, a twitter.com URL or a link to one of their posts all work. |
handles | array | — | Accounts as bare handles: espn or @natgeo. Read together with startUrls. |
outputFormat | string | posts | posts gives one row per post; profile gives one row per account with its posts nested. Same price. |
maxPosts | integer | 5 | Latest posts per account. 5 is X's ceiling for a logged-out reader. |
onlyPostsNewerThan | string | — | Keep only posts after a window or ISO date. |
includeRaw | boolean | false | Attach X's untouched post object under raw. Makes rows much larger. |
proxy | object | Apify Proxy | Apify 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:
| Field | Example | For |
|---|---|---|
createdAt | Fri Aug 21 19:05:34 +0000 2026 | X's own format, for existing parsers |
createdAtIso | 2026-08-21T19:05:34.000Z | Sorting, filtering, spreadsheets |
timestamp | 1787339134000 | Arithmetic, 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.
Link preview cards
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.
Links, mentions and hashtags
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
- Open the Actor and click Try for free / Start.
- 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.
- 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.
- 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.
- 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.actor~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.actor/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 oldconsole.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 read | Works 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:
error | Meaning | Run status |
|---|---|---|
not_found | No such account | Succeeds |
not_available | Suspended, withheld in this region, or otherwise restricted | Succeeds |
protected | Posts are visible only to approved followers, so a login would be needed | Succeeds |
bad_input | The entry was never a usable handle or profile URL | Succeeds |
window_too_wide | onlyPostsNewerThan reaches further back than this run can see — see below | Succeeds |
read_failed | X did not serve a readable page after several attempts | Fails only if every account did |
A missing, suspended, protected or mistyped account does not fail the run. Those are answers about the account you asked for, not a fault, and a run reporting them finishes as SUCCEEDED with the reason in a row. The run's status message says how many accounts were read and how many answered with an error.
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. The one case that does fail a run is every account
in it coming back read_failed, which means X or the proxy could not be reached
at all rather than anything about your accounts.
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. A suspended, protected, nonexistent or never-valid handle is an
answer about that account, not a fault, so each is a row on a green run. Treat
rows with an error field as the failure signal rather than the run status.
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.