# X (Twitter) Bulk Tweet Extractor (`praise-most-high/x-twitter-bulk-tweet-extractor`) Actor

Extract complete X (Twitter) post records from a list of tweet URLs or IDs. Returns post text, author, likes, replies, hashtags, mentions, links, the parent of a reply, and direct photo and video file URLs. No login, no API key. Deleted tweets are reported, never charged.

- **URL**: https://apify.com/praise-most-high/x-twitter-bulk-tweet-extractor.md
- **Developed by:** [angel nguyen](https://apify.com/praise-most-high) (community)
- **Categories:** Social media, News, Automation
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $5.00 / 1,000 tweet extracteds

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 (Twitter) Bulk Tweet Extractor collects complete post records from a list of tweet URLs or IDs you already have. You paste the links, you get one typed row per post — text, author, likes, replies, hashtags, mentions, outbound links, the post a reply was replying to, and direct file URLs for every photo and video. There is no login, no API key and no developer account anywhere in it.

It is built for the job that starts with a list: a spreadsheet of links from a report, the citations in an article, the posts an alerting tool flagged overnight. If you already know *which* posts you care about, this turns them into data.

![X (Twitter) Bulk Tweet Extractor](https://apify-image-uploads-prod.s3.us-east-1.amazonaws.com/0ezaunoiVKF5LFUup-actor-I6J54xbt9CvfqObIs-IjKI70we10-x-twitter-bulk-tweet-extractor.png)

### Key features

- **Paste anything that identifies a post.** Full `x.com` or `twitter.com` status URLs, `i/web/status` links, mobile links, or bare numeric IDs — mixed together in one list.
- **Duplicates are removed before anything is fetched**, so the same post pasted twice is fetched once and charged once.
- **32 fields per post**, including the ones that are easy to lose: text of posts past the 280-character display limit (326 characters returned whole in testing), the parent post of a reply, and per-file media details.
- **Deleted posts are reported, not silently dropped.** A post that is gone, protected or suspended comes back as a row with `available: false` and a reason — and is never charged.
- **Direct media file URLs.** For video, the highest-bitrate MP4 is chosen; an HLS-only stream is flagged rather than passed off as a downloadable file.
- **No account, no cookies, no rate-limit dance.** The Actor reads X's own public post endpoint.

### How it works

1. You give it a list of tweet URLs or IDs in **Tweet URLs or IDs**.
2. Every entry is parsed to a numeric post ID. Anything that is not a post reference is reported in the run log and skipped — it is never guessed at.
3. The list is de-duplicated, then capped at **Maximum tweets per run** so a large paste cannot cost more than you intended.
4. Each post is fetched from X's public endpoint, paced by **Delay between requests**.
5. Each result is sorted into one of three states — available, gone, or never existed — and written to the dataset.

The three-state handling is the part worth reading twice. A scraper that treats "deleted" and "failed" as the same thing produces a dataset you cannot trust, because a missing row could mean either. Here they are separate values in `unavailableReason`, and neither of them is charged.

### Input

Every field below is optional except the list itself.

| Field | Type | Default | What it does |
|---|---|---|---|
| `tweetUrls` | array | *required* | The tweets to extract, one per line. Accepts full x.com or twitter.com status URLs, i/web/status links, and bare numeric tweet IDs — you can mix all three. Duplicates are removed before anything is fetched, so a list pasted twice is never charged twice. |
| `maxTweets` | integer | `1000` | A hard ceiling on how many tweets this run will fetch, applied after duplicates are removed. Use it to bound the cost of a large paste. Set it to the number of tweets you are willing to pay for. |
| `includeMedia` | boolean | `true` | Return direct file URLs for every photo and video attached to a tweet, with width, height, duration and bitrate. The highest-bitrate MP4 is chosen for video. Turn this off to extract text and metrics only, and the media charge never applies. |
| `includeUserProfile` | boolean | `true` | Add the author block to each record: display name, @handle, numeric account ID, profile image URL, and the legacy verified and Blue verified flags. |
| `includeParentTweet` | boolean | `true` | When a tweet is a reply, attach the post it replies to as a nested record with its text, author, likes, retweets and reply count. It lets you read a reply in context without a second lookup. |
| `includeUnavailable` | boolean | `true` | Emit a row for every requested tweet that could not be read, carrying available=false and the reason — deleted, protected, suspended, or an ID that never existed. These rows are never charged. Turn this off to receive only successful extractions. |
| `language` | string | `"en"` | The language X uses for the strings it localises, such as the explanation returned on an unavailable tweet. It does not translate tweet text. |
| `requestDelayMs` | integer | `250` | Milliseconds to wait between tweets. The default of 250 ms paces a run politely against the source. Raise it for very large lists; 0 disables the pause. |

Example input — this is the Actor's own prefill, so it is exactly what you see in the form:

```json
{
  "tweetUrls": [
    "https://x.com/jack/status/20",
    "https://x.com/Interior/status/463440424141459456",
    "1349129669258448897"
  ],
  "maxTweets": 100,
  "includeMedia": true,
  "includeUserProfile": true,
  "includeParentTweet": true,
  "includeUnavailable": true,
  "language": "en",
  "requestDelayMs": 250
}
```

### Output

One row per requested post. This is a **real row from a real run** of this Actor, not an illustration:

```json
{
  "tweetId": "2049057344013881523",
  "url": "https://x.com/MushtaqBilalPhD/status/2049057344013881523",
  "inputRef": "2049057344013881523",
  "available": true,
  "unavailableReason": null,
  "text": "&gt; be Alexandra Elbakyan\n&gt; be born in Kazakhstan in 1988\n&gt; start coding at 12\n&gt; hack your internet provider at 14\n&gt; hack MIT Press at 16 to download neuroscience books you can't afford\n&gt; get a CS degree from Satbayev University\n&gt; intern in neuroscience at Georgia Tech\n&gt; speak at https://t.co/xt74OVA2kW",
  "textLength": 326,
  "isLongForm": true,
  "lang": "en",
  "createdAt": "2026-04-28T09:25:15.000Z",
  "favoriteCount": 34176,
  "replyCount": 241,
  "isEdited": false,
  "possiblySensitive": false,
  "isReply": false,
  "inReplyToStatusId": null,
  "inReplyToScreenName": null,
  "hashtags": [],
  "mentions": [],
  "links": [],
  "hasMedia": true,
  "mediaCount": 1,
  "media": [
    {
      "type": "photo",
      "previewImageUrl": "https://pbs.twimg.com/media/HG-2y7ybIAA54vq.jpg",
      "width": 996,
      "height": 560,
      "altText": "Alexandra Elbakyan, the founder of Sci-Hub",
      "tweetPermalink": "https://x.com/MushtaqBilalPhD/status/2049057344013881523/photo/1",
      "fileUrl": "https://pbs.twimg.com/media/HG-2y7ybIAA54vq.jpg",
      "format": "jpg",
      "durationMs": null,
      "bitrate": null,
      "isPlaylist": false
    }
  ],
  "placeName": null,
  "parentTweet": null,
  "authorId": "1513235298192437262",
  "authorName": "Mushtaq Bilal, PhD",
  "authorScreenName": "MushtaqBilalPhD",
  "authorUrl": "https://x.com/MushtaqBilalPhD",
  "authorVerified": false,
  "authorBlueVerified": true,
  "authorProfileImageUrl": "https://pbs.twimg.com/profile_images/2047218284727066624/e4pD5FkY_normal.jpg"
}
```

And this is what a post that is no longer available looks like. It carries the reason, it carries no invented fields, and it was not charged:

```json
{
  "tweetId": "1445078208190291973",
  "url": "https://x.com/i/status/1445078208190291973",
  "inputRef": "https://x.com/i/web/status/1445078208190291973",
  "available": false,
  "unavailableReason": "TOMBSTONE",
  "unavailableDetail": "This Post is unavailable. Learn more",
  "text": null,
  "textLength": null,
  "hasMedia": false,
  "mediaCount": 0,
  "media": [],
  "parentTweet": null
}
```

#### The three availability states

| `unavailableReason` | What it means | Charged |
|---|---|---|
| *(null, `available: true`)* | The post was read and returned in full | Yes |
| `TOMBSTONE` | The post existed and is gone — deleted, protected, suspended or age-restricted | **No** |
| `NOT_FOUND` | No post has ever had this ID | **No** |

Set **Report unavailable tweets** to `false` if you want only the successful rows.

### Pricing

This Actor bills per delivered unit. A post it could not read costs nothing, and a run that reads nothing costs nothing.

| Event | Price | When you are charged |
|---|---|---|
| `tweet-extracted` | **$0.005** | Charged once per tweet record delivered to the dataset. A tweet that is deleted, protected, suspended, or an ID that never existed is reported with available=false and is never charged, and duplicate URLs in your input are removed before anything is fetched. |
| `media-file-resolved` | **$0.0015** | Charged once per photo or video file whose direct URL, dimensions and bitrate were resolved and returned. It applies only when Resolve media files is on and the tweet actually carries media, so a text-only tweet costs the extraction charge alone. |

What that works out to:

| Run | Extraction | Media | Total |
|---|---|---|---|
| 100 text-only posts | $0.50 | $0.00 | **$0.50** |
| 100 posts, 40 of them with one image | $0.50 | $0.06 | **$0.56** |
| 1,000 posts, 400 with one image | $5.00 | $0.60 | **$5.60** |
| 1,000 posts with media resolving switched off | $5.00 | $0.00 | **$5.00** |

The run whose output is shown above requested 6 posts, delivered 4 and resolved 3 media files, for **$0.02** — with the 2 unavailable posts costing nothing.

Turning **Resolve media files** off removes the media charge entirely, which is the cheapest way to run this at volume if you only need text and metrics.

### FAQ

**Do I need an X account, an API key or a developer application?**
No. The Actor reads X's own public post endpoint, which serves the same data an embedded post on any website shows. Nothing is logged in to.

**Can it fetch a user's timeline, search results, or the replies to a post?**
No, and it does not pretend to. It resolves posts you name. It returns the *number* of replies a post has, but not the replies themselves. If you need a timeline or a search, this is the wrong Actor and you should not buy it for that.

**Does it work on protected or deleted posts?**
It tells you they are protected or deleted. It cannot read them, and it does not charge you for finding out.

**How long can the returned text be?**
Longer than the 280-character display limit — posts of 326 characters have been returned whole in testing, and `isLongForm` marks the posts X itself flags as long-form. Very long articles have not been verified past that length, so the honest answer is that `textLength` on every row tells you exactly what you got.

**Does it return retweet counts?**
Not on the post itself — X's public endpoint does not include one. It *is* included on the parent post of a reply, where X does supply it. A field that is not served is left out rather than filled with a zero.

**Is this affiliated with X?**
No. See the legal note below.

**Why is my run smaller than my input list?**
Three reasons, all of them visible in the run log and the run summary: entries that were not post references, duplicates removed, and the **Maximum tweets per run** cap. The summary in the key-value store gives exact counts for each.

### What this Actor is not

It is a resolver, not a discovery tool. It does not search X, does not walk a timeline, does not follow a thread, and does not read anything behind a login. It turns post references you already hold into structured rows. Every one of those boundaries is stated here rather than discovered after you have paid for a run.

### Limits and troubleshooting

- **Rate.** The default 250 ms pause between posts is polite and reliable. If you are running many thousands of posts, raise it rather than lowering it.
- **Media URLs expire.** X's file URLs are stable for a useful window but are not permanent archives. Download what you need rather than storing the URL for later.
- **A run stopped early.** If your event budget is reached mid-run, the Actor stops, says so in the log, and sets `stoppedByEventBudget` in the run summary — it never returns a short list that reads as a complete one.
- **Everything came back `NOT_FOUND`.** Check the IDs. A post ID is the long number at the end of a status URL, not the numeric part of a username.

### Integrations and API

The dataset is available in JSON, CSV, Excel, XML and RSS from the run's storage, and this Actor can be called from anywhere the [Apify API](https://docs.apify.com/api/v2) reaches — including the [Apify JavaScript and Python clients](https://docs.apify.com/api/client/js/), scheduled runs, webhooks, and Make or Zapier via Apify's own connectors.

```bash
curl -X POST "https://api.apify.com/v2/acts/praise-most-high~x-twitter-bulk-tweet-extractor/runs?token=YOUR_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"tweetUrls":["https://x.com/jack/status/20"],"includeMedia":true}'
```

Because it runs on pay-per-event pricing with limited permissions, it can also be called by an AI agent through Apify's agentic payment flow.

### Legal

This Actor is **unofficial** and is **not affiliated with, endorsed by, or sponsored by X Corp.**, Twitter, or any of their subsidiaries. "X" and "Twitter" are trademarks of their respective owners and are used here only to describe what this Actor reads.

It accesses only publicly available post data — the same data any website shows in an embedded post — and it accesses no private, protected or logged-in content. You are responsible for how you use the data you collect, including compliance with the [X Terms of Service](https://help.x.com/en/rules-and-policies) and with data-protection law in your jurisdiction where the posts you collect contain personal data.

### Other actors

This Actor is one of a small set on this account that read first-party public endpoints without an API key, alongside monitors for YouTube channel uploads and Telegram channel messages. Each reads one source and does one job.

### Your feedback

Found a post this Actor handles badly, or a field you need that X serves and this does not return? Open an issue from the Actor's **Issues** tab on its Store page. Issues are read and answered, and a bug report with the post ID that triggered it is the fastest possible fix.

# Actor input Schema

## `tweetUrls` (type: `array`):

The tweets to extract, one per line. Accepts full x.com or twitter.com status URLs, i/web/status links, and bare numeric tweet IDs — you can mix all three. Duplicates are removed before anything is fetched, so a list pasted twice is never charged twice.

## `maxTweets` (type: `integer`):

A hard ceiling on how many tweets this run will fetch, applied after duplicates are removed. Use it to bound the cost of a large paste. Set it to the number of tweets you are willing to pay for.

## `includeMedia` (type: `boolean`):

Return direct file URLs for every photo and video attached to a tweet, with width, height, duration and bitrate. The highest-bitrate MP4 is chosen for video. Turn this off to extract text and metrics only, and the media charge never applies.

## `includeUserProfile` (type: `boolean`):

Add the author block to each record: display name, @handle, numeric account ID, profile image URL, and the legacy verified and Blue verified flags.

## `includeParentTweet` (type: `boolean`):

When a tweet is a reply, attach the post it replies to as a nested record with its text, author, likes, retweets and reply count. It lets you read a reply in context without a second lookup.

## `includeUnavailable` (type: `boolean`):

Emit a row for every requested tweet that could not be read, carrying available=false and the reason — deleted, protected, suspended, or an ID that never existed. These rows are never charged. Turn this off to receive only successful extractions.

## `language` (type: `string`):

The language X uses for the strings it localises, such as the explanation returned on an unavailable tweet. It does not translate tweet text.

## `requestDelayMs` (type: `integer`):

Milliseconds to wait between tweets. The default of 250 ms paces a run politely against the source. Raise it for very large lists; 0 disables the pause.

## Actor input object example

```json
{
  "tweetUrls": [
    "https://x.com/jack/status/20",
    "https://x.com/Interior/status/463440424141459456",
    "1349129669258448897"
  ],
  "maxTweets": 100,
  "includeMedia": true,
  "includeUserProfile": true,
  "includeParentTweet": true,
  "includeUnavailable": true,
  "language": "en",
  "requestDelayMs": 250
}
```

# Actor output Schema

## `tweets` (type: `string`):

One row per requested tweet. Rows with available=false carry the reason and were never charged.

## `runSummary` (type: `string`):

Counts of what was extracted, what was unavailable and what was charged.

# 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 = {
    "tweetUrls": [
        "https://x.com/jack/status/20",
        "https://x.com/Interior/status/463440424141459456",
        "1349129669258448897"
    ],
    "maxTweets": 100,
    "includeMedia": true,
    "includeUserProfile": true,
    "includeParentTweet": true,
    "includeUnavailable": true,
    "language": "en",
    "requestDelayMs": 250
};

// Run the Actor and wait for it to finish
const run = await client.actor("praise-most-high/x-twitter-bulk-tweet-extractor").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 = {
    "tweetUrls": [
        "https://x.com/jack/status/20",
        "https://x.com/Interior/status/463440424141459456",
        "1349129669258448897",
    ],
    "maxTweets": 100,
    "includeMedia": True,
    "includeUserProfile": True,
    "includeParentTweet": True,
    "includeUnavailable": True,
    "language": "en",
    "requestDelayMs": 250,
}

# Run the Actor and wait for it to finish
run = client.actor("praise-most-high/x-twitter-bulk-tweet-extractor").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 '{
  "tweetUrls": [
    "https://x.com/jack/status/20",
    "https://x.com/Interior/status/463440424141459456",
    "1349129669258448897"
  ],
  "maxTweets": 100,
  "includeMedia": true,
  "includeUserProfile": true,
  "includeParentTweet": true,
  "includeUnavailable": true,
  "language": "en",
  "requestDelayMs": 250
}' |
apify call praise-most-high/x-twitter-bulk-tweet-extractor --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,praise-most-high/x-twitter-bulk-tweet-extractor"
        }
    }
}

```

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/I6J54xbt9CvfqObIs/builds/JqPx3fpWrhIGfByS8/openapi.json
