Substack Post Scraper: Newsletter Articles, Full Text & Likes avatar

Substack Post Scraper: Newsletter Articles, Full Text & Likes

Pricing

from $1.15 / 1,000 post with its bodies

Go to Apify Store
Substack Post Scraper: Newsletter Articles, Full Text & Likes

Substack Post Scraper: Newsletter Articles, Full Text & Likes

Scrape Substack posts and articles of any newsletter by URL (substack.com or a custom domain), or single post URLs: title, subtitle, date, author, full free text as HTML and text, paywall flag, likes, comments and restacks. Whole archives, several newsletters per run, no login.

Pricing

from $1.15 / 1,000 post with its bodies

Rating

0.0

(0)

Developer

Hyperbach

Hyperbach

Maintained by Community

Actor stats

0

Bookmarked

2

Total users

1

Monthly active users

2 days ago

Last modified

Share

The posts of any Substack newsletter, logged out: publication URLs (a substack.com address or a custom domain) or post URLs in, one row per post out — the title, subtitle, description, date, type, the paywall flag, the body as HTML and as plain text (the whole body of a free post, the free preview of a paid one), word count, cover image, tags, likes, comment and restack counts, the authors and the publication — several publications in one run, a cap per publication. One free publication row per input carries a completeness verdict: COMPLETE (the archive's own end), CAPPED (your limit) or PARTIAL. An unknown publication, a site that is not Substack, an unknown post: free typed rows. No login, no API key, no browser, proxies handled for you.

From $2.30 per 1,000 rows (post) on the Free plan, $1.955 on Starter; every row and add-on is priced under Pricing below. A run start is $0.005. The default input returns about 51 rows for about $0.1223. A value that names nothing is a free row that says why.

Why this Substack scraper

  • Several newsletters in one run. Give publication URLs (https://noahpinion.substack.com, a custom domain such as https://www.lennysnewsletter.com, an /archive page, a bare name such as noahpinion) or post URLs: each publication gets its own cap (maxPostsPerPublication, 100 by default), and the run's cap sits on top (maxItems). The publication's name and URL come with every row.
  • The body, as HTML and as text. Each post's body comes from the post's own public page data: the whole body of a free post; for a paid post, the free preview the newsletter shows to visitors (body_status = preview; on our test a preview held about half the words of the post). The archive's teaser of a paid post is kept too (truncated_body_text).
  • Every field a post publishes. The title, subtitle, description, publish and update dates, the type (newsletter, podcast, thread), the audience and the paywall flag, word count, cover image, tags, section, language, likes (reaction_count and the reactions by emoji), comment and reply counts, restacks, the voiceover and podcast fields, the authors with their handles, and the publication's id, name and URL.
  • Whole archives, to the end. Posts come newest first from the publication's own archive, 50 a call, to the archive's own end: on our test Astral Codex Ten's 1,498 posts back to 2020 and Lenny's Newsletter's 483 back to 2019 (2026-10-26). The free publication row says how many posts were delivered from how many pages and why the walk stopped: the archive's end (COMPLETE), your cap (CAPPED) or a refusal or the time limit (PARTIAL).
  • Honest runs. An unknown publication or post is a free not_found row (two exits agreed), a site that is not Substack a free not_substack row, a value that is not a publication a free invalid row, a repeated value or one publication under two addresses (its substack.com address and its custom domain) a free duplicate row. Every input gets a row; a post is charged once a run.
  • Fast and light. Plain HTTP on Substack's own public API: about 0.4 KB a post for the list and 11-18 KB for a post's body on the wire. 100 posts of one newsletter with their bodies took 5 s of reading on our test (103 calls, 512 MB).

Who it's for

  • Newsletter writers and media teams — a competitor's or a peer's archive with titles, dates, likes and comment counts, read again on a schedule to see what lands and how often they publish.
  • Researchers and AI teams — the free text of a list of newsletters as clean rows (HTML and plain text), with the paywall flag and the body status to keep previews apart from whole posts.
  • Data and analytics teams — typed rows keyed by post_id, a free row per publication with the status and the verdict, and a run summary to check a pipeline against.

Quick start

A newsletter's 50 newest posts and one post by URL

{
"startUrls": [
"https://www.lennysnewsletter.com",
"https://www.astralcodexten.com/p/your-book-review-the-ghost-in-my"
],
"maxPostsPerPublication": 50
}

A whole archive, metadata only (cheaper)

{
"startUrls": [
"https://www.astralcodexten.com"
],
"maxPostsPerPublication": 0,
"includeBody": false
}

Three newsletters, the 20 newest posts of each

{
"startUrls": [
"noahpinion",
"https://on.substack.com",
"https://www.lennysnewsletter.com"
],
"maxPostsPerPublication": 20
}

Output

One row per post (and one free publication row per input):

fieldmeaning
row_typepost (one post, charged) or publication (the input's row with the walk's verdict; free).
input_indexThe 1-based position in startUrls of the input this row answers.
input_valueThe input as you gave it (the URL or name).
input_kindWhat the input names: publication, post, invalid or duplicate.
statusA publication row: ok, not_found, not_substack, invalid, duplicate, failed or not_read (see the FAQ). A post row: ok.
status_detailWhy, in words, when the status is not ok.
scraped_atWhen the row was read (ISO 8601, UTC).
post_idSubstack's post id.
urlThe post's page (on the publication's own domain). A publication row of a post URL: the post's page.
slugThe post's slug (the part of its URL after /p/).
titleThe post's title.
subtitleThe post's subtitle.
descriptionThe post's description (its search and share summary).
post_dateWhen the post was published (ISO 8601, UTC).
updated_atWhen the post was last edited (ISO 8601, UTC); read with the body, so null when includeBody is off.
post_typenewsletter, podcast, thread or another type Substack names.
audienceWho may read the whole post: everyone, only_paid (paid subscribers) or founding (founding members), as Substack names it.
is_paywalledTrue when the post's whole text is for paid subscribers (audience is not everyone).
body_statusWhat the body fields hold: full (the whole body of a free post), preview (the free preview of a paid post), empty (no free text: a paid post with no preview, or a post with no text), not_asked (includeBody off), not_found or failed (the body was not served).
body_htmlThe post's body as HTML (see body_status).
body_textThe post's body as plain text, one line a paragraph (see body_status).
truncated_body_textThe archive's short teaser of the post, when the publication sets one (paid posts mostly).
wordcountThe post's word count as Substack counts it (the whole post, paid ones too).
cover_imageThe post's cover image.
tagsThe post's tags.
section_nameThe publication's section the post sits in, when it has sections.
languageThe post's language code (en …), when set.
reaction_countLikes (reactions) on the post.
reactionsThe reactions by emoji ({"❤": 145}).
comment_countComments on the post.
child_comment_countReplies among the comments.
restacksRestacks of the post (Substack's reshares).
has_voiceoverTrue when the post has an audio voiceover.
podcast_urlA podcast post's audio file, when public.
podcast_durationA podcast post's length in seconds.
authorsThe post's bylines: one object per author with id, name, handle and is_guest.
author_nameThe first byline's name.
author_handleThe first byline's Substack handle.
first_seen_atWith onlyNew: the day this memory first saw the post.
last_seen_atWith onlyNew: the day this memory last saw the post.
times_seenWith onlyNew: how many runs of this memory saw the post.
publication_idSubstack's id of the publication.
publication_nameThe publication's name (null when no byline of the run names it).
publication_urlThe publication's home (its custom domain, or its substack.com address).
posts_deliveredA publication row: the post rows delivered for this input.
pages_readA publication row: how many calls of the publication's archive were read (a post URL: 1).
skipped_seenA publication row, with onlyNew: posts an earlier run of this memory delivered, skipped free in this run.
verdictA publication row: COMPLETE (the archive's own end: every post it lists), CAPPED (your cap) or PARTIAL (a refusal or the time limit).
verdict_detailA publication row: the verdict in words — posts delivered, pages read, posts skipped, why the walk stopped.

Example record:

{
"row_type": "post",
"input_index": 3,
"input_value": "https://www.astralcodexten.com/p/your-book-review-the-ghost-in-my",
"input_kind": "post",
"status": "ok",
"status_detail": null,
"scraped_at": "2026-10-26T11:17:52Z",
"post_id": 212349428,
"url": "https://www.astralcodexten.com/p/your-book-review-the-ghost-in-my",
"slug": "your-book-review-the-ghost-in-my",
"title": "Your Book Review: The Ghost In My Brain",
"subtitle": "Finalist #13 in the Book Review Contest",
"description": "Finalist #13 in the Book Review Contest",
"post_date": "2026-10-23T23:45:42.358Z",
"updated_at": "2026-10-23T23:47:26.909Z",
"post_type": "newsletter",
"audience": "everyone",
"is_paywalled": false,
"body_status": "full",
"body_html": "<p>[<em>This is one of the finalists in the 2026 book review contest, written by an ACX reader who will remain anonymous until after voting is done. I’ll be posting about one of these a week for several months. When you’ve read them all, I’ll ask you to vote for a favorite, so remember which ones you liked</em>]</p><div><hr></div><p>In 1999, Professor Clark Elliott is rear-ended by a stranger at a stoplight. He’s shaken but figures he’s fine, and drives to work. Spoiler: Clark Elliott is not fine.</p><p>Elliott is severely concussed, and this fact will dictate the next ten years of his life. Elliott’s memoir is based on notes he took while concussed. Reading it is like watching someone try to troubleshoot their broken laptop using the broken laptop.</p> …",
"body_text": "[This is one of the finalists in the 2026 book review contest, written by an ACX reader who will remain anonymous until after voting is done. I’ll be posting about one of these a week for several months. When you’ve read them all, I’ll ask you to vote for a favorite, so remember which ones you liked]\n\nIn 1999, Professor Clark Elliott is rear-ended by a stranger at a stoplight. He’s shaken but figures he’s fine, and drives to work. Spoiler: Clark Elliott is not fine.\nElliott is severely …",
"truncated_body_text": null,
"wordcount": 4187,
"cover_image": "https://substackcdn.com/image/fetch/$s_!wVWo!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F621384df-793b-4ff7-bfc6-7b5c2ce23f2d_468x476.png",
"tags": [],
"section_name": null,
"language": "en",
"reaction_count": 146,
"reactions": {
"❤": 146
},
"comment_count": 120,
"child_comment_count": 29,
"restacks": 1,
"has_voiceover": false,
"podcast_url": null,
"podcast_duration": null,
"authors": [
{
"id": 12009663,
"name": "Scott Alexander",
"handle": "astralcodexten",
"is_guest": false
}
],
"author_name": "Scott Alexander",
"author_handle": "astralcodexten",
"first_seen_at": null,
"last_seen_at": null,
"times_seen": null,
"publication_id": 89120,
"publication_name": "Astral Codex Ten",
"publication_url": "https://www.astralcodexten.com",
"posts_delivered": null,
"pages_read": null,
"skipped_seen": null,
"verdict": null,
"verdict_detail": null
}

Pricing

Pay for the posts a run delivers, plus one run start; no platform usage is billed to you. The post price falls with your Apify plan.

eventunitFreeStarterScaleBusiness
Post with its body (post)per 1,000$2.30$1.955$1.56$1.15
Post, metadata only (post-metadata)per 1,000$1.15$0.9775$0.78$0.575
Run start (start)per run$0.005$0.005$0.005$0.005

A post with its body is $2.30 per 1,000 on the Free plan ($0.0023 a post); with includeBody off a post is $1.15 per 1,000. A post whose body was read is a post even when the newsletter shows no free preview of it (body_status = empty: some paid posts); a post whose body the source did not serve (a rare post route that answers nothing) is charged at the metadata price. Each run that delivers a charged post also pays the run start, $0.005 once; a run that delivers no charged post pays nothing at all. So 100 posts of one newsletter with their bodies cost $0.235 on the Free plan; several newsletters in one run share one start. The free publication row of every input, an unknown publication or post (not_found), a site that is not Substack (not_substack), a value that is not a publication (invalid), a repeated value (duplicate), a publication left at the cap or the time limit (not_read) and a refusal the run could not pass (failed) are never charged. A post is charged once a run. A run that stops early (a timeout, an abort) is charged for the posts it delivered. maxItems counts the charged rows; before the first request the log names the price of each row at your plan and the most the run can cost. Enterprise plans have their own rates: the Actor's Pricing tab shows the price for your plan.

Usage patterns

  • A schedule — Save a task with your publications and onlyNew (and a stateStoreName to name the memory), and schedule it: each run reads the newest posts and delivers only those no earlier run of that memory delivered; a post skipped this way is free, so a run with nothing new costs nothing. first_seen_at, last_seen_at and times_seen ride every delivered post row, and the free publication row counts the posts skipped (skipped_seen). The caps bound the depth read, so a weekly run with maxPostsPerPublication 50 reads the same newest 50 every week. Ready-made: Substack newsletter monitor: weekly, only the new posts.
  • Publications and posts — startUrls takes a publication's URL (https://noahpinion.substack.com, its custom domain such as https://www.noahpinion.blog, its /archive or any page of it that is not a post), a bare substack.com name (noahpinion) or a post URL (https://www.astralcodexten.com/p/<slug>, an open.substack.com/pub/<name>/p/<slug> share link). A substack.com profile (substack.com/@name) or a reader-app link is not a publication: give the publication's own URL.
  • What a row is — row_type = post: one post with its record; authors holds one object per byline. row_type = publication: the free row of one input value — posts_delivered, pages_read, the verdict with its verdict_detail, and the status with its reason.
  • How far a publication goes — maxPostsPerPublication = 100 (the default) reads the 100 newest posts. 0 reads every post the archive lists, back to the first one. maxItems caps the charged rows of the whole run. The publication row's verdict says where the walk stopped and why.

Input configuration

fieldtypedefaultwhat it does
startUrlsarrayWhat to read: a publication's URL (https://noahpinion.substack.com, a custom domain such as https://www.lennysnewsletter.com, its /archive page, or a bare substack.com name such as noahpinion) for its newest posts, or a post URL (https://www.astralcodexten.com/p/<slug>, an open.substack.com/pub/<name>/p/<slug> share link) for one post. Up to 1,000 values a run. A value that is not a publication gets a free invalid row, a value given twice (or one publication under two addresses) a free duplicate row.
maxPostsPerPublicationinteger100Stop each publication after its newest posts up to this count (0 = every post its archive lists, back to its first post). A post URL is one post whatever this says.
includeBodybooleantrueRead each post's body as HTML and plain text (one more request a post): the whole body of a free post, the free preview of a paid one (body_status says which). Off: the archive's fields only (title, subtitle, description, date, paywall flag, the paid post's teaser, word count, reactions, comments, authors), at the cheaper metadata price.
onlyNewbooleanfalseRemember every post delivered under this memory (stateStoreName, or a name derived from the inputs) and skip it free in the next run: a scheduled task delivers only what is new. The publication row counts the skipped (skipped_seen). A post asked by URL is always delivered.
stateStoreNamestring""The name of the onlyNew memory, so several tasks can share one or keep theirs apart. Empty: a name derived from the inputs.
maxItemsinteger0Stop the run after this many post rows across every publication (0 = no limit). Publication rows are free and not counted; a publication left unread at the cap gets a free not_read row.

Errors

A run does not fail because of your input. When an input cannot be used — a date that is not a date, an id in the wrong form, two settings that contradict each other — the run ends Succeeded, charges nothing, and says what to change:

  • the status message starts with INPUT REJECTED;
  • the dataset holds one row, {"error": true, "code": "…", "message": "…"}, and no results;
  • the key-value store record ERROR holds the same object.

From code, check error on the first row before reading results.

A publication with no posts is not an error: it gets one free publication row with posts_delivered 0 and the verdict COMPLETE, and the run goes on with the others. A run that ends Failed is a fault on our side, never your input; it charges nothing, and we are alerted.

FAQ

Can it read the whole text of paid posts?

No. A paid post's whole text is for its subscribers. The row carries what the newsletter shows to every visitor: the free preview (body_status = preview, about half the words on our test), the teaser, the word count, likes and comment counts. No login is used or needed.

Which status is which?

A publication row's status is ok (posts served), not_found (no such publication, or no such post; two exits agreed), not_substack (the site is not a Substack publication), invalid (a value that is not a publication or post URL), duplicate (the same value, or the same publication under another address: read once), failed (a refusal or an error; free) or not_read (left at maxItems, the charge limit or the run's time limit). A post row's status is ok; its body_status is full, preview, empty (the newsletter shows no free text of it: a paid post with no preview, or a post with no text), not_asked (includeBody off), not_found or failed (the body was not served: charged at the metadata price).

Does it give subscriber counts or comments?

Not yet. The row carries each post's comment count, reply count, likes and restacks; the comments themselves and the newsletter's subscriber count are not read.

Does it need a Substack account or API key?

No. Everything is read from the publication's public archive and its posts' public data, logged out.

Integration

JavaScript

import { ApifyClient } from 'apify-client';
const client = new ApifyClient({ token: 'YOUR_TOKEN' });
const run = await client.actor('hyperbach/substack-scraper').call({"startUrls": ["https://www.lennysnewsletter.com", "https://www.astralcodexten.com/p/your-book-review-the-ghost-in-my"], "maxPostsPerPublication": 50});
const { items } = await client.dataset(run.defaultDatasetId).listItems();

Python

from apify_client import ApifyClient
client = ApifyClient('YOUR_TOKEN')
run = client.actor('hyperbach/substack-scraper').call(run_input={'startUrls': ['https://www.lennysnewsletter.com', 'https://www.astralcodexten.com/p/your-book-review-the-ghost-in-my'], 'maxPostsPerPublication': 50})
items = client.dataset(run['defaultDatasetId']).list_items().items

CLI

$apify call hyperbach/substack-scraper --input '{"startUrls": ["https://www.lennysnewsletter.com", "https://www.astralcodexten.com/p/your-book-review-the-ghost-in-my"], "maxPostsPerPublication": 50}'

REST

curl -X POST "https://api.apify.com/v2/acts/hyperbach~substack-scraper/run-sync-get-dataset-items?token=YOUR_TOKEN" \
-H 'Content-Type: application/json' -d '{"startUrls": ["https://www.lennysnewsletter.com", "https://www.astralcodexten.com/p/your-book-review-the-ghost-in-my"], "maxPostsPerPublication": 50}'

AI agents and MCP

Apify's MCP server offers this Actor as a tool to Claude, Cursor, VS Code and any other MCP client. Connect https://mcp.apify.com?tools=hyperbach/substack-scraper and ask, for example: "Get the 50 newest posts of lennysnewsletter.com with their full text and likes". The agent fills the input from the table above and reads the rows from the run's dataset. The input names are the ones in the table above.

n8n, Make and Zapier

The Apify node in n8n, the Apify app in Make and the Apify integration in Zapier run this Actor on a schedule or from a trigger and read its dataset. A webhook on the run's end (Integrations on the Actor page) sends the rows on to your own endpoint. Nothing to install on our side.

Support

Disagree with a charge? Write to apify@hyperbach.com with the run ID. We will ask Apify to compensate you, and the charge comes back to your Apify account as platform credit.

Did it do the job? A short review on the Store, at any rating, helps the next buyer choose.

See also

  • Telegram Profile Scraper — Telegram channels, groups, bots and users by username or link, with the exact subscriber or member count.
  • YouTube Channel Search Scraper — the YouTube channels a keyword finds, with subscribers, the exact video and view counts and the country.
  • Threads Scraper — Threads profiles with the exact follower count, their posts and replies with view counts, and keyword and tag search, without a login.
  • Weibo Scraper — Weibo posts, profiles, search, comments and the hot-search boards, without a login.
  • Pinterest Search Scraper — the pins of a Pinterest search, with the link each pin points to, the image and the pinner.
  • Spotify Scraper — the tracks of a Spotify playlist or album and an artist's top tracks and releases, with play counts.

This page is generated from the Actor's schemas and a live sample — it cannot describe a field the Actor does not have.