Substack Post Scraper: Newsletter Articles, Full Text & Likes
Pricing
from $1.15 / 1,000 post with its bodies
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
Maintained by CommunityActor stats
0
Bookmarked
2
Total users
1
Monthly active users
2 days ago
Last modified
Categories
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 ashttps://www.lennysnewsletter.com, an/archivepage, a bare name such asnoahpinion) 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_countand 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
publicationrow 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_foundrow (two exits agreed), a site that is not Substack a freenot_substackrow, a value that is not a publication a freeinvalidrow, a repeated value or one publication under two addresses (its substack.com address and its custom domain) a freeduplicaterow. 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):
| field | meaning |
|---|---|
row_type | post (one post, charged) or publication (the input's row with the walk's verdict; free). |
input_index | The 1-based position in startUrls of the input this row answers. |
input_value | The input as you gave it (the URL or name). |
input_kind | What the input names: publication, post, invalid or duplicate. |
status | A publication row: ok, not_found, not_substack, invalid, duplicate, failed or not_read (see the FAQ). A post row: ok. |
status_detail | Why, in words, when the status is not ok. |
scraped_at | When the row was read (ISO 8601, UTC). |
post_id | Substack's post id. |
url | The post's page (on the publication's own domain). A publication row of a post URL: the post's page. |
slug | The post's slug (the part of its URL after /p/). |
title | The post's title. |
subtitle | The post's subtitle. |
description | The post's description (its search and share summary). |
post_date | When the post was published (ISO 8601, UTC). |
updated_at | When the post was last edited (ISO 8601, UTC); read with the body, so null when includeBody is off. |
post_type | newsletter, podcast, thread or another type Substack names. |
audience | Who may read the whole post: everyone, only_paid (paid subscribers) or founding (founding members), as Substack names it. |
is_paywalled | True when the post's whole text is for paid subscribers (audience is not everyone). |
body_status | What 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_html | The post's body as HTML (see body_status). |
body_text | The post's body as plain text, one line a paragraph (see body_status). |
truncated_body_text | The archive's short teaser of the post, when the publication sets one (paid posts mostly). |
wordcount | The post's word count as Substack counts it (the whole post, paid ones too). |
cover_image | The post's cover image. |
tags | The post's tags. |
section_name | The publication's section the post sits in, when it has sections. |
language | The post's language code (en …), when set. |
reaction_count | Likes (reactions) on the post. |
reactions | The reactions by emoji ({"❤": 145}). |
comment_count | Comments on the post. |
child_comment_count | Replies among the comments. |
restacks | Restacks of the post (Substack's reshares). |
has_voiceover | True when the post has an audio voiceover. |
podcast_url | A podcast post's audio file, when public. |
podcast_duration | A podcast post's length in seconds. |
authors | The post's bylines: one object per author with id, name, handle and is_guest. |
author_name | The first byline's name. |
author_handle | The first byline's Substack handle. |
first_seen_at | With onlyNew: the day this memory first saw the post. |
last_seen_at | With onlyNew: the day this memory last saw the post. |
times_seen | With onlyNew: how many runs of this memory saw the post. |
publication_id | Substack's id of the publication. |
publication_name | The publication's name (null when no byline of the run names it). |
publication_url | The publication's home (its custom domain, or its substack.com address). |
posts_delivered | A publication row: the post rows delivered for this input. |
pages_read | A publication row: how many calls of the publication's archive were read (a post URL: 1). |
skipped_seen | A publication row, with onlyNew: posts an earlier run of this memory delivered, skipped free in this run. |
verdict | A publication row: COMPLETE (the archive's own end: every post it lists), CAPPED (your cap) or PARTIAL (a refusal or the time limit). |
verdict_detail | A 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.
| event | unit | Free | Starter | Scale | Business |
|---|---|---|---|---|---|
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 astateStoreNameto 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_atandtimes_seenride 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 withmaxPostsPerPublication50 reads the same newest 50 every week. Ready-made: Substack newsletter monitor: weekly, only the new posts. - Publications and posts —
startUrlstakes a publication's URL (https://noahpinion.substack.com, its custom domain such ashttps://www.noahpinion.blog, its/archiveor 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>, anopen.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;authorsholds one object per byline.row_type=publication: the free row of one input value —posts_delivered,pages_read, theverdictwith itsverdict_detail, and thestatuswith 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.maxItemscaps the charged rows of the whole run. The publication row'sverdictsays where the walk stopped and why.
Input configuration
| field | type | default | what it does |
|---|---|---|---|
startUrls | array | What 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. | |
maxPostsPerPublication | integer | 100 | Stop 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. |
includeBody | boolean | true | Read 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. |
onlyNew | boolean | false | Remember 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. |
stateStoreName | string | "" | The name of the onlyNew memory, so several tasks can share one or keep theirs apart. Empty: a name derived from the inputs. |
maxItems | integer | 0 | Stop 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
ERRORholds 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 ApifyClientclient = 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.