# X Profile Posts Scraper - No Cookies (`renzomacar/x-profile-posts-scraper`) Actor

Scrape the public post timeline of any X (Twitter) account without cookies or an account. Per-account limits, working date filters, full engagement metrics. Same price on every Apify plan.

- **URL**: https://apify.com/renzomacar/x-profile-posts-scraper.md
- **Developed by:** [Renzo Madueno](https://apify.com/renzomacar) (community)
- **Categories:** Social media, Automation
- **Stats:** 3 total users, 1 monthly users, 85.2% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $1.20 / 1,000 post returneds

This Actor is paid per event. You are not charged for the Apify platform usage, but only a fixed price for specific events.

Learn more: https://docs.apify.com/actors/running/actors-in-store.md#pay-per-event

## What's an Apify Actor?

Actors are web data automations that power AI and operations. They run on the Apify platform to scrape websites, process data, connect APIs, and automate workflows.
In Batch mode, an Actor accepts a well-defined JSON input, performs an action which can take anything from a few seconds to a few hours,
and optionally produces a well-defined JSON output, datasets with results, or files in key-value store.
In Standby mode, an Actor provides a web server which can be used as a website, API, or an MCP server.
Actors are written with capital "A".

## How to integrate an Actor?

If asked about integration, you help developers integrate Actors into their projects.
You adapt to their stack and deliver integrations that are safe, well-documented, and production-ready.
The best way to integrate Actors is as follows.

- **AI agents and MCP clients** — the [Apify MCP server](https://docs.apify.com/integrations/mcp.md) at `https://mcp.apify.com` (remote, streamable HTTP, OAuth on first use).
- **Agentic workflows and local Actor development** — [Agent Skills](https://apify.com/.well-known/agent-skills/index.json) with the [Apify CLI](https://docs.apify.com/cli/docs.md): `npm install -g apify-cli`, then `apify login`.
- **JavaScript/TypeScript projects** — the official [JS/TS client](https://docs.apify.com/api/client/js/docs.md): `npm install apify-client`.
- **Python projects** — the official [Python client](https://docs.apify.com/api/client/python/docs.md): `pip install apify-client`.
- **Any other language** — the [REST API](https://docs.apify.com/api/v2.md).

For usage examples, see the [API](#api) section below.

For more details, see Apify documentation as [Markdown index](https://docs.apify.com/llms.txt) and [Markdown full-text](https://docs.apify.com/llms-full.txt).

# README

## X Profile Posts Scraper - No Cookies

Point it at an X (Twitter) account. Get that account's public posts as clean JSON — text, timestamps, likes, retweets, replies, quotes, bookmarks, media, links — with a real per-account limit and date filters that actually filter.

No cookies. No session tokens. No Twitter account. No browser.

### Three things competing scrapers get wrong, and what this does instead

**1. A per-account limit exists here.** `maxItemsPerTarget` caps how many posts come from each account, and it defaults to 100. Pass twenty handles and the run cannot silently balloon because one of them posts forty times a day. The developer of the largest scraper in this category has stated in writing that limiting posts per account is not supported by their actor. It is supported here, it shipped on day one, and it has a safe default.

**2. The price is identical on every Apify plan.** $0.30 per 1,000 posts on the free plan and on every paid plan. There is no free-tier multiplier, no reduced cap for free users, and no invoice surprise waiting for anyone who tried the actor before subscribing.

**3. Nothing empty is ever charged.** A suspended account, a protected account, a handle that no longer exists, a date window that excludes everything — none of these produce a dataset row and none of them produce a charge. They produce an entry in the `FAILURES` record explaining exactly what happened.

### The one honest caveat, up front

X serves two different timelines to logged-out clients, and which one you get depends on the account. This actor labels every row with `timelineMode` so you always know which you are looking at.

**`chronological`** — a real reverse-chronological timeline with pagination cursors. You can page back through history, and date filters work properly, including an early stop once the actor pages past your window. Measured on `@nasa`, `@apify` and `@X`: 14 to 20 posts per page, cursors present, strictly newest-first.

**`curated`** — for some very large accounts, X hands logged-out clients a fixed set of roughly 100 popular posts, **not in date order**, with **no pagination cursor at all**. Measured on `@elonmusk` (99 posts spanning 2018 to 2026, unsorted, no cursor) and `@jack` (100 posts spanning 2006 to 2025, unsorted, no cursor).

For a curated account there is no page two. Not at a higher price, not with more retries, not with a proxy — the cursor does not exist in the response. When this actor detects it, it logs a clear warning, marks the rows `curated`, and **stops**. It does not loop the same page to inflate a result count.

This is a restriction X imposes, not a limitation of this actor. Scrapers that appear to page deeply into these accounts are doing it with logged-in accounts. This one does not use any.

### Input

```json
{
  "handles": ["nasa", "@apify", "https://x.com/naval"],
  "maxItemsPerTarget": 50,
  "postedAfter": "30 days",
  "includeRetweets": false
}
```

| Field | Type | Default | What it does |
|---|---|---|---|
| `handles` | array | — | Handles or profile URLs. Aliases: `handle`, `usernames`, `username`, `urls`, `url`, `profileUrls`, `startUrls`. |
| `maxItemsPerTarget` | integer | `100` | Per-account ceiling. Aliases: `maxTweetsPerProfile`, `maxPostsPerProfile`, `perTargetLimit`. |
| `maxItems` | integer | none | Ceiling across the whole run. Aliases: `maxResults`, `limit`, `maxTweets`, `maxPosts`. |
| `postedAfter` | string | — | Only posts on or after this. Alias: `since`. |
| `postedBefore` | string | — | Only posts on or before this. Alias: `until`. |
| `includeReplies` | boolean | `false` | Include posts that reply to someone else. |
| `includeRetweets` | boolean | `true` | Include retweets. |

`elonmusk`, `@elonmusk` and `https://x.com/elonmusk` are all accepted. So are profile URLs with `/with_replies` or `/media` on the end. Duplicates are collapsed before anything is fetched, so the same account listed twice costs once.

Dates accept `YYYY-MM-DD`, a full ISO timestamp, or a relative window like `30 days`, `6 weeks`, `2 years`. An unreadable date raises an error naming the field and echoing what you passed.

### Output

One row per post. Fill rates below are measured, from a verification run on 2026-08-22.

| Field | Fill | Notes |
|---|---|---|
| `id`, `url`, `text`, `createdAt`, `lang` | 100% | `createdAt` is ISO 8601 UTC. |
| `likeCount`, `retweetCount`, `replyCount`, `quoteCount`, `bookmarkCount` | 100% | The complete public metric set. |
| `conversationId` | 100% | Thread root, for grouping. |
| `isReply`, `isQuote`, `isRetweet`, `isLongform` | 100% | |
| `authorHandle`, `authorName`, `authorId`, `authorUrl`, `authorFollowers`, `authorIsBlueVerified` | 100% | |
| `source` | 100% | The client the post was made from. |
| `timelineMode` | 100% | `chronological` or `curated`. Read the caveat above. |
| `queriedHandle` | 100% | What you asked for, echoed back. |
| `media` | ~21% | Array of `{type, url, width, height, expandedUrl}`. |
| `mentions` | ~21% | |
| `possiblySensitive` | ~29% | |
| `links` | ~7% | Outbound URLs, already un-shortened from `t.co`. |
| `hashtags` | varies | |
| `scrapedAt` | 100% | |

#### Long posts come back whole

X stores a long post twice: a truncated ~277-character stub in the legacy field, and the real text in a separate `note_tweet` field. A scraper that reads the obvious field hands you the stub with the rest silently missing — and long posts are exactly the ones worth reading.

This actor reads `note_tweet` and sets `isLongform: true`. Measured across real timelines on 2026-08-22: `@ylecun` has long-form on 42 of 100 sampled posts (one stub of 277 chars is really 2,434), `@balajis` 21 of 99, `@pmarca` one at 2,841 characters. If a competitor's export is full of posts that stop dead at 277 characters, this is the reason.

#### Data integrity, not just data presence

Counters are normalised before they reach you: abbreviated display values (`"1.5M"`) become integers, and **negative counts become `null`** rather than being passed through — a 32-bit overflow on a huge account arrives as something like `-576135159`, which is not a measurement and would poison any average. Dates are parsed from X's own format into ISO 8601, and an unreadable one becomes `null` instead of a 1970 epoch date that would slip past your date filter. `t.co` shortlinks are resolved into `links` and `media`; `text` stays exactly as X returns it so it still matches what you see on screen.

**View counts are not included.** X does not send them to logged-out clients on any endpoint — the `views` object arrives with a state and no number. Rather than ship a field that is permanently null, or fill it with an estimate, this actor does not have one.

### How it works

The actor makes the same two GraphQL calls x.com makes for a visitor who is not signed in:

1. `UserByScreenName` to turn a handle into a numeric user ID, and to detect suspended, renamed and protected accounts before any paging begins.
2. `UserTweets`, paged through the `Bottom` cursor, 20 posts per request.

Authentication is a guest token from `POST /1.1/guest/activate.json` — free, accountless, and refreshed automatically inside the run whenever X rotates or rejects it. Retired GraphQL query IDs are rotated automatically too, so the actor keeps working across X's periodic renames.

No browser is launched. Memory footprint is 1 GB, so platform compute is negligible next to the per-result price.

### Date filters that pull their weight

On a `chronological` account, `postedAfter` does two jobs. It drops out-of-range posts before they are stored or billed, and it **stops paging** once the actor reaches posts older than your cutoff. Asking for the last 30 days of a ten-year-old account fetches a few pages, not a decade.

On a `curated` account the filter still applies correctly to every row, but there is no paging to stop — X returned one unordered block and that is all there is.

If you pass `postedAfter` later than `postedBefore`, the run stops immediately with an error rather than grinding through your whole handle list to produce nothing.

### Errors go where you can act on them

Nothing that failed is written into the dataset. Everything lands in the `FAILURES` key-value record:

```json
{
  "actor": "X Profile Posts Scraper",
  "targetsRequested": 3,
  "resultsDelivered": 10,
  "resultsCharged": 10,
  "failureCount": 1,
  "failures": [
    { "target": "someprotectedaccount", "error": "Account is protected - X publishes nothing about it to logged-out clients" }
  ],
  "notes": [
    "X served the non-paginated \"curated\" timeline for: elonmusk. ..."
  ]
}
```

**A run that delivers nothing is marked FAILED**, not quietly succeeded over an empty dataset. You are charged nothing either way.

### Pricing

**$0.30 per 1,000 posts. Pay per result. No subscription. Same price on every Apify plan.**

| Posts | Cost |
|---|---|
| 100 | $0.03 |
| 1,000 | $0.30 |
| 10,000 | $3.00 |
| 100,000 | $30.00 |

Suspended accounts, protected accounts, unresolvable handles and filtered-out posts cost $0.00.

A realistic run: 500 competitor accounts at 100 posts each is 50,000 posts, or **$15.00**.

### Typical uses

- **Competitor content audits.** Pull the last 90 days from every account in your category, sort by engagement, and see what actually lands.
- **Brand and executive monitoring.** Re-run daily against a fixed handle list and diff for new posts.
- **Building a training corpus** in a specific voice or domain, with per-account caps so no single account dominates the mix.
- **Influencer vetting.** Post cadence, engagement relative to follower count, how much is original versus retweeted.
- **Newsroom and research monitoring** of official accounts — agencies, regulators, exchanges — which are exactly the accounts that get the `chronological` treatment and page cleanly.

### Limits worth knowing before you start

- **No keyword search.** This actor takes accounts, not queries. X blocks the search timeline for guests, and the scrapers offering it anyway are running pools of real accounts — the machinery behind most reliability complaints in this category. Not built here, not planned.
- **Protected accounts return nothing.** X publishes nothing about them publicly.
- **Curated accounts cap at roughly 100 posts.** See the caveat above. If you need deep history from `@elonmusk`, no cookie-free scraper can give it to you, and this README says so instead of taking your money to find out.
- **Replies are excluded by default.** Set `includeReplies: true` if you want them.

### Verified

Every endpoint, field, fill rate and timeline behaviour described here was tested live against X on **2026-08-22** from a datacenter IP with no cookies and no account. The `chronological` versus `curated` split is a measurement, not a guess.

### Related actors

- **Tweet Scraper by URL or ID** — individual tweets, $0.20/1k.
- **X Profile Scraper - No Login** — bulk handle-to-profile lookup, $0.25/1k.
- **Tweet Replies Scraper - No Login** — publicly visible replies, $0.30/1k.
- **X Trends Scraper by Country** — 62 countries and 400+ cities, $0.40/1k.

Same rules across all of them: one price on every plan, and nothing charged for nothing.

# Actor input Schema

## `handles` (type: `array`):

Handles or profile URLs, one per line. "elonmusk", "@elonmusk" and "https://x.com/elonmusk" all work.

## `maxItemsPerTarget` (type: `integer`):

Hard per-account ceiling, so one prolific account cannot swallow the whole run. Defaults to 100. This is the limit competing scrapers tell you is impossible.

## `maxItems` (type: `integer`):

Ceiling across every account in the run. Leave empty for no overall cap. You are only charged for posts actually delivered.

## `postedAfter` (type: `string`):

YYYY-MM-DD, a full ISO timestamp, or a relative window such as "30 days". On accounts with a real chronological timeline the actor also stops paging once it passes this date, so you do not pay to walk history you asked to exclude.

## `postedBefore` (type: `string`):

YYYY-MM-DD, a full ISO timestamp, or a relative window such as "1 day".

## `includeReplies` (type: `boolean`):

Off by default. When off, posts that reply to someone else are dropped before billing.

## `includeRetweets` (type: `boolean`):

On by default. Turn off to keep only the account's own posts.

## Actor input object example

```json
{
  "handles": [
    "nasa"
  ],
  "maxItemsPerTarget": 100,
  "postedAfter": "2025-01-01",
  "postedBefore": "2026-01-01",
  "includeReplies": false,
  "includeRetweets": true
}
```

# Actor output Schema

## `results` (type: `string`):

One row per post from the profile timeline, with long posts returned in full.

# API

You can run this Actor programmatically using our API. Below are code examples in JavaScript, Python, and CLI, as well as the OpenAPI specification and MCP server setup.

## JavaScript example

```javascript
import { ApifyClient } from 'apify-client';

// Initialize the ApifyClient with your Apify API token
// Replace the '<YOUR_API_TOKEN>' with your token
const client = new ApifyClient({
    token: '<YOUR_API_TOKEN>',
});

// Prepare Actor input
const input = {
    "handles": [
        "nasa",
        "https://x.com/apify"
    ]
};

// Run the Actor and wait for it to finish
const run = await client.actor("renzomacar/x-profile-posts-scraper").call(input);

// Fetch and print Actor results from the run's dataset (if any)
console.log('Results from dataset');
console.log(`💾 Check your data here: https://console.apify.com/storage/datasets/${run.defaultDatasetId}`);
const { items } = await client.dataset(run.defaultDatasetId).listItems();
items.forEach((item) => {
    console.dir(item);
});

// 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/js/docs

```

## Python example

```python
from apify_client import ApifyClient

# Initialize the ApifyClient with your Apify API token
# Replace '<YOUR_API_TOKEN>' with your token.
client = ApifyClient("<YOUR_API_TOKEN>")

# Prepare the Actor input
run_input = { "handles": [
        "nasa",
        "https://x.com/apify",
    ] }

# Run the Actor and wait for it to finish
run = client.actor("renzomacar/x-profile-posts-scraper").call(run_input=run_input)

# Fetch and print Actor results from the run's dataset (if there are any)
print(f"💾 Check your data here: https://console.apify.com/storage/datasets/{run.default_dataset_id}")
for item in client.dataset(run.default_dataset_id).iterate_items():
    print(item)

# 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/python/docs/quick-start

```

## CLI example

```bash
echo '{
  "handles": [
    "nasa",
    "https://x.com/apify"
  ]
}' |
apify call renzomacar/x-profile-posts-scraper --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,renzomacar/x-profile-posts-scraper"
        }
    }
}

```

The hosted server signs you in with OAuth on first connect, so no API token belongs in this config. Clients without OAuth support can send an `Authorization: Bearer <APIFY_API_TOKEN>` header instead, using a token from API & Integrations in Apify Console (https://console.apify.com/settings/integrations).

## OpenAPI specification

Download the OpenAPI definition: https://api.apify.com/v2/actors/YGhQP3ez7MbI4MY7r/builds/VYsHxj1ks4GRZHBll/openapi.json
