# Substack Newsletter Posts, Dates and Engagement (`usta/substack-posts`) Actor

Substack scraper, Substack posts scraper, newsletter monitoring: give a list of Substack publications and get one row per public post with title, date, free-or-paid flag, reactions, comments and word count.

- **URL**: https://apify.com/usta/substack-posts.md
- **Developed by:** [US Tech Automations](https://apify.com/usta) (community)
- **Categories:** News, Social media
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

$2.00 / 1,000 result rows

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?

An Actor is a serverless cloud program that runs on the Apify platform. It has two run modes.
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.

Apify vocabulary and the platform model are defined once, in the agent quickstart at https://apify.com/agents.md.

## 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.

Do not guess an integration path. Every one of them is in the agent quickstart at https://apify.com/agents.md: the Apify MCP server, Agent Skills with the Apify CLI, the JavaScript and Python clients, the REST API, and the account-free path for an agent with no human to sign in. It also carries the rule on stating cost before the first paid run.

For examples already wired to this Actor's own input schema, see the [API](#api) section below.

Each client library has reference documentation the quickstart does not restate: [JavaScript/TypeScript](https://docs.apify.com/api/client/js/docs.md) (`npm install apify-client`) and [Python](https://docs.apify.com/api/client/python/docs.md) (`pip install apify-client`).

# README

## Substack Newsletter Posts, Dates and Engagement

**Substack scraper, Substack posts scraper, newsletter monitoring:** list the
Substack publications you track and get one row per public post -- title,
date, free-or-paid status, reactions, comments, and word count -- without
opening each newsletter by hand.

### Introduction

Give this Actor one or more Substack addresses. For each one it reads the
publication's own public post-archive list (the same JSON the publication's
`/archive` page loads), and returns one row per post: what it's called, when
it went out, whether it's free or paid, how many reaction hearts and
comments it has, its word count, and its byline. Nothing is read from
behind a login. A publication this run cannot read -- because it does not
exist, or blocks this run -- gets exactly one row saying so, with a real
reason, never a silent gap.

### Tutorial

1. Set **Publications** to one or more Substack addresses:
   `name.substack.com`, a bare `name` (short for `name.substack.com`), or a
   custom domain such as `lennysnewsletter.com`.
2. Optionally set **Since date** to only include posts on or after that day.
3. Optionally set **Maximum posts per publication** and **Maximum rows** --
   both control cost; every row bills, so these are your spend caps.
4. Optionally turn on **Include free post body text** to also copy the
   plain text of each free post. Paid and founding-only posts always get
   `body_text: null` -- see "What this does not do."
5. Optionally paste **Previously seen post IDs** from a prior run's
   `post_id` field to get `is_new: true` only on posts you have not seen.
6. Click **Start**. Results land in the dataset.

### Pricing

**$0.002 per row. No start fee.**

A run that reads 500 qualifying post rows costs $1.00. A publication that
does not exist, or that this run could not read, still produces one row
(so you can see what happened) and that one row still bills at $0.002.

**Maximum rows** is the spend cap: the run cannot cost more than that
number multiplied by $0.002, whatever the publications hold.

### Input and output examples

Input:

```json
{"publications": ["importai", "lennysnewsletter.com"], "maxPostsPerPublication": 5, "maxItems": 10, "includeBodyText": true}
```

Output (one row, from a live run on 2026-09-21):

```json
{
  "publication": "importai",
  "publication_host": "importai.substack.com",
  "post_id": 216673688,
  "title": "Import AI 473: The US's superintelligence strategy; human brains vs LLMs at Othello; and why loving your work might be the best moat.",
  "subtitle": "What matters in AI for 21 Sep 2026",
  "post_url": "https://importai.substack.com/p/import-ai-473-the-uss-superintelligence",
  "post_date": "2026-09-17T11:30:37.435Z",
  "audience": "everyone",
  "is_paid": false,
  "type": "newsletter",
  "reaction_count": 24,
  "comment_count": 5,
  "wordcount": 3876,
  "cover_image": "https://substackcdn.com/image/fetch/...",
  "authors": ["Jack Clark"],
  "body_text": "...24,931 characters of plain text...",
  "is_new": true,
  "source": "archive",
  "fetched_at": "2026-09-21T16:40:12Z",
  "error": false,
  "error_type": null,
  "message": null
}
```

A publication this run could not read (from the same run family, made-up
address):

```json
{
  "publication": "this-publication-does-not-exist-zz999.substack.com",
  "publication_host": "thispublicationdoesnotexistzz999.substack.com",
  "post_id": null, "title": null, "subtitle": null, "post_url": null,
  "post_date": null, "audience": null, "is_paid": null, "type": null,
  "reaction_count": null, "comment_count": null, "wordcount": null,
  "cover_image": null, "authors": null, "body_text": null, "is_new": null,
  "source": null,
  "fetched_at": "2026-09-21T16:40:12Z",
  "error": true,
  "error_type": "NOT_FOUND",
  "message": "Archive unreadable (... HTTP 404); feed fallback also answered HTTP 404."
}
```

Every field in a row:

| Field | What it holds |
| --- | --- |
| `publication` | The address you gave, exactly as given |
| `publication_host` | The resolved host this run actually read |
| `post_id` | Substack's own numeric post id (null on the RSS-fallback path -- RSS does not carry it) |
| `title` / `subtitle` | The post's headline and dek |
| `post_url` | The post's canonical public URL |
| `post_date` | Publish date/time, ISO 8601 |
| `audience` | `everyone`, `only_paid`, or `founding`, as Substack's own archive reports it |
| `is_paid` | `true` unless `audience` is `everyone` |
| `type` | `newsletter`, `podcast`, or `thread` |
| `reaction_count` | Sum of all reaction-emoji counts Substack shows on the post |
| `comment_count` | Public comment count (comment text is never read) |
| `wordcount` | Substack's own word count for the post |
| `cover_image` | Cover image URL, when the post has one |
| `authors` | Public byline display name(s) only -- no handle, bio, or photo |
| `body_text` | Plain text of the post, **free posts only**, only when **Include free post body text** is on; otherwise `null` |
| `is_new` | `true` when `post_id` is not in **Previously seen post IDs**; `null` when unknown (RSS-fallback rows) |
| `source` | `archive`, `feed_fallback`, or `null` on an error row -- which endpoint this row came from |
| `fetched_at` | When this run read the source, in UTC |
| `error` | `true` for the one row a publication gets when it could not be read |
| `error_type` / `message` | Present only when `error` is `true`: a short code and the real reason |

### Recommendations

Run weekly or monthly per publication list, and set **Previously seen post
IDs** from the last run's `post_id` values to see only what changed.

### Limitations

**`body_text` only exists for free posts, and only from the RSS feed's
recent window.** Substack's archive JSON (the main source for every other
field) never carries full post text -- only a short preview. Free-post text
comes from the publication's public `/feed`, which typically holds the
\~20 most recent items across all post types (including separate podcast
episodes). An older free post can fall outside that window; it gets
`body_text: null`, not a guess.

**Paid text is never read, full stop.** A post the archive itself marks
`only_paid` or `founding` always gets `body_text: null`, even though
Substack's own RSS feed happens to also carry a truncated preview of paid
posts. This tool does not read it.

**A blocked or unreadable archive falls back to the RSS feed**, per the
contract. On that fallback path, `post_id`, `audience`, `is_paid`,
`reaction_count`, `comment_count`, and `wordcount` are all `null` -- RSS
does not carry them, and this tool does not invent them. `source` is set to
`feed_fallback` on every row from that path so it's never mistaken for a
full archive row.

**No reader, subscriber, or comment content.** Only the public post-level
counts and public byline names are read. Comment text, reader names, and
email addresses are never requested.

**A source outage or a blocked host returns one honest row, not guesses.**
If neither the archive nor the feed can be read for a publication, that
publication gets one row with `error: true` and a real reason -- the run
still bills for that one row, and produces no invented posts.

### FAQ

**Where does the data come from?**
Each publication's own public post-archive JSON (`/api/v1/archive`) and, as
a fallback or for free-post text, its own public RSS feed (`/feed`). Both
are read live on every run, never from a stored copy.

**Does it need an API key or a login?**
No. Both endpoints are served to a signed-out visitor.

**Why did I get fewer rows than Maximum rows?**
Either the publications held that many posts and no more, or the run hit
**Maximum rows** first, or a publication returned one error row instead of
its posts.

**What happens if a publication is blocked or does not exist?**
That publication gets exactly one row with `error: true` and a real reason
in `error_type` / `message`. It never gets invented posts.

**Is there support?**
Yes. Raise it through the Apify Store issues tab for this actor, or email
operations@ustechautomations.com. Replies within 2 business days.

### Refunds and support

Refunds: if a run returns only error rows for a publication you confirmed
is real and public, email operations@ustechautomations.com within 7 days
and we refund that run. Support: same address, replies within 2 business
days.

# Actor input Schema

## `publications` (type: `array`):

One or more Substack addresses: name.substack.com, a bare name (short for name.substack.com), or a custom domain such as lennysnewsletter.com.

## `sinceDate` (type: `string`):

Optional. Only include posts published on or after this date, written as YYYY-MM-DD. Leave blank for no date floor.

## `maxPostsPerPublication` (type: `integer`):

Hard ceiling on rows read from any one publication.

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

Hard ceiling on rows returned across all publications combined, and therefore on the cost of the run. Every row bills at the listed price from row one; there is no start fee. A publication that does not exist, or that this run could not read, produces one error row for that publication.

## `includeBodyText` (type: `boolean`):

When true, add the plain text of each FREE post (audience: everyone) read from the publication's public RSS feed. Paid and founding-only posts always get body\_text: null -- this tool never attempts to read paid text.

## `previousIds` (type: `array`):

Optional. Post IDs (from a prior run's post\_id field) already on file. Any post whose id is not in this list gets is\_new: true.

## `proxyConfiguration` (type: `object`):

Optional Apify proxy. Leave off for a local run. Every request here is an ordinary public HTTPS GET.

## Actor input object example

```json
{
  "publications": [
    "importai.substack.com"
  ],
  "maxPostsPerPublication": 50,
  "maxItems": 200,
  "includeBodyText": false,
  "proxyConfiguration": {
    "useApifyProxy": false
  }
}
```

# Actor output Schema

## `posts` (type: `string`):

No description

## `csv` (type: `string`):

No description

## `report` (type: `string`):

No description

# 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 = {
    "publications": [
        "importai.substack.com"
    ],
    "maxPostsPerPublication": 50,
    "maxItems": 200
};

// Run the Actor and wait for it to finish
const run = await client.actor("usta/substack-posts").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 = {
    "publications": ["importai.substack.com"],
    "maxPostsPerPublication": 50,
    "maxItems": 200,
}

# Run the Actor and wait for it to finish
run = client.actor("usta/substack-posts").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 '{
  "publications": [
    "importai.substack.com"
  ],
  "maxPostsPerPublication": 50,
  "maxItems": 200
}' |
apify call usta/substack-posts --silent --output-dataset

```

## MCP server setup

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

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/irqxd39fCU0wigS0c/builds/3N0IaJhHerSPaiKGy/openapi.json
