Substack
Retrieve posts from any Substack newsletter via its internal API, with full authenticated access to paid/subscriber-only content.
Built using the website-to-api pattern. If this skill breaks, use the website-to-api meta-skill to re-discover the API.
If This Breaks
This skill uses Substack's internal, undocumented API. If commands fail:
- Read the error — 401/403 likely means expired cookie or renamed cookie.
- Re-discover using the
website-to-apimeta-skill. - Update this skill — fix the script and SKILL.md with the new API shape.
Known past changes: the session cookie was renamed from connect.sid to substack.sid. The COOKIE_NAME constant in the script controls this.
Prerequisites
SUBSTACK_SIDenvironment variable set to yoursubstack.sidcookie value- Python 3.9+, runs via
uv run
Getting the Cookie
- Open Chrome DevTools (Cmd+Option+I) on any
substack.compage - Go to Application > Cookies >
https://substack.com - Find
substack.sid(httpOnly, Secure) and copy its value export SUBSTACK_SID="<value>"
The cookie lasts ~3 months and survives MFA unless you sign out.
Verifying auth via Chrome extension
// Run via javascript_tool on a newsletter tab
fetch('/api/v1/archive?sort=new&limit=1', { credentials: 'include' })
.then(r => r.json())
.then(data => { document.title = JSON.stringify({ ok: true, title: data[0]?.title }); })
.catch(e => { document.title = 'ERROR: ' + e.message; });
Script Location
skills/substack/scripts/substack.py (relative to plugin root)
Commands
list-posts
uv run skills/substack/scripts/substack.py list-posts https://www.lennysnewsletter.com
uv run skills/substack/scripts/substack.py list-posts https://www.lennysnewsletter.com --limit 30 --output json
uv run skills/substack/scripts/substack.py list-posts https://www.lennysnewsletter.com --sort top --limit 10
uv run skills/substack/scripts/substack.py list-posts https://www.lennysnewsletter.com --search "AI"
uv run skills/substack/scripts/substack.py list-posts lenny # bare subdomain
| Option | Default | Description |
|---|---|---|
--limit |
12 | Number of posts (auto-paginates >50) |
--offset |
0 | Pagination offset |
--sort |
new | new or top |
--search |
(none) | Full-text search query |
--output |
table | table or json |
--sid |
(none) | Cookie value (overrides env var) |
get-post
uv run skills/substack/scripts/substack.py get-post https://www.lennysnewsletter.com my-post-slug
uv run skills/substack/scripts/substack.py get-post https://www.lennysnewsletter.com my-post-slug --output html
uv run skills/substack/scripts/substack.py get-post https://www.lennysnewsletter.com my-post-slug --output json
| Option | Default | Description |
|---|---|---|
--output |
summary | summary, html, or json |
get-text
Fetch a post and save as Markdown with metadata header.
uv run skills/substack/scripts/substack.py get-text https://www.lennysnewsletter.com my-post-slug
uv run skills/substack/scripts/substack.py get-text https://www.lennysnewsletter.com my-post-slug --out ./article.md
| Option | Default | Description |
|---|---|---|
--out / -o |
/tmp/.md | Output file path |
Newsletter argument accepts full URLs or bare subdomains (e.g. lenny → https://lenny.substack.com).
API Reference
| Endpoint | Method | Description |
|---|---|---|
/api/v1/archive |
GET | List/search posts |
/api/v1/posts/<slug> |
GET | Get a single post |
Archive parameters
| Param | Values |
|---|---|
sort |
new, top |
search |
any string |
offset |
integer |
limit |
integer (max 50) |
Auth vs No-Auth
For audience: "only_paid" posts:
- Without auth:
body_htmltruncated at paywall - With auth (paid subscriber): full content returned