A Substack API and a Substack MCP server you run on your own computer, in Python, on the session of your own Google Chrome. All 50 Substack tools we run in production at Cotlet: posts and drafts from Markdown, publishing and scheduling, Notes, comments, subscribers, stats, recommendations and direct messages.
Substack has no public API to write posts, publish Notes or manage subscribers, and no official MCP server. This repo gives you both:
- MCP server for Claude Code, Claude Desktop, Cursor, Codex and any MCP client, over stdio or HTTP. Setup
- REST API on
http://127.0.0.1:8787/api/v1/substack, with OpenAPI docs at/docs. Setup - Python:
call("list_posts", status="draft"), plus 14 ready scripts inexamples/.
It is for technical people who want to run it themselves, for personal use only (license). If you want it running 24/7 on a server, kept working when Substack changes its site, for a team or a business, use the hosted version at cotlet.dev.
git clone https://github.com/cotletdev/substack-api-mcp-server.git && cd substack-api-mcp-server
python3 -m venv .venv && . .venv/bin/activate && pip install -e .
substack-mcp connect # reads your Substack session from Chrome
substack-mcp call list_posts
claude mcp add substack -- "$PWD/.venv/bin/substack-mcp" mcp- How it works
- Requirements
- Install
- Connect your Chrome
- MCP server
- REST API
- Python and the example scripts
- All 50 tools
- Configuration
- Security and your Substack account
- Errors
- Self-hosted or hosted
- FAQ
- License
AI agent (MCP) / script (REST, Python) ──▶ substack-mcp on your computer ──your Substack cookies──▶ substack.com
▲
substack-mcp connect ───────┘ reads them once from your Chrome (DevTools Protocol)
- You sign in to Substack in your own Chrome, the way you always do.
substack-mcp connectattaches to that Chrome through the Chrome DevTools Protocol, reads the substack.com cookies and saves them to~/.config/substack-api-mcp/session.json, readable by your user only. Substack signs in by an emailed code or link, so there is no password to see or store.- Every tool call is a request to Substack's own web app endpoints (
substack.com/api/v1for the reader side,yourname.substack.com/api/v1for your publication's dashboard) with those cookies, sent with curl_cffi, which copies Chrome's TLS fingerprint. No headless browser runs.
Substack's session cookie (substack.sid) lasts about three months. When Substack signs you out, the server reads the cookies from Chrome again, if Chrome is open with remote debugging on.
- Python 3.11 or newer.
- Google Chrome 144 or newer, signed in to Substack. Any Chrome or Chromium works with a debugging port.
- macOS, Linux or Windows.
- A Substack account. Publisher tools (posts, subscribers, stats, comments) work on the first publication your account runs as admin, owner, editor or contributor. Reader tools (feed, search, Notes, messages) work on any account.
git clone https://github.com/cotletdev/substack-api-mcp-server.git
cd substack-api-mcp-server
python3 -m venv .venv
. .venv/bin/activate # Windows: .venv\Scripts\activate
pip install -e .
substack-mcp --versionWith uv: uv venv && uv pip install -e ., then uv run substack-mcp ....
Setting it up with an AI agent (Claude Code, Codex, Cursor)? Open it in this folder and ask it to set up the repo: AGENTS.md tells it every step, and it asks you only for the steps in Chrome.
To update: git pull && pip install -e .. We copy every change of the hosted Substack tools into this repo.
-
Open
chrome://inspect/#remote-debuggingin Chrome and switch remote debugging on. Chrome then accepts DevTools connections from programs on this computer, and asks you before each one. -
Make sure you are signed in at substack.com.
-
Run:
substack-mcp connect
-
Chrome asks whether to allow the connection. Click Allow.
Saved 19 Substack cookies to /Users/you/.config/substack-api-mcp/session.json
Signed in to Substack as Maya Chen (@mayachen)
You can switch remote debugging off again afterwards. Leave it on if you want the server to pick up a new session by itself after Substack signs you out.
connect looks for Chrome's DevToolsActivePort file in the default profile folder. For Chrome Beta, Canary or a custom profile folder, pass --profile-dir "<folder>".
Chrome does not open a debugging port on your default profile, so start a separate one, sign in to Substack there once, and connect to it:
# macOS
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" --remote-debugging-port=9222 --user-data-dir="$HOME/.chrome-substack"
# Linux
google-chrome --remote-debugging-port=9222 --user-data-dir="$HOME/.chrome-substack"
# Windows
"C:\Program Files\Google\Chrome\Application\chrome.exe" --remote-debugging-port=9222 --user-data-dir="%USERPROFILE%\.chrome-substack"
substack-mcp connect --cdp http://127.0.0.1:9222--cdp also takes a ws:// DevTools URL, for example of a Chrome on another machine reached over an SSH tunnel.
For a server with no Chrome: in Chrome open DevTools on substack.com, Application > Cookies > https://substack.com, copy the value of substack.sid, then:
substack-mcp connect --token # paste it, input is hidden
# or, without a session file:
export SUBSTACK_AUTH_TOKEN=...A pasted token is not refreshed on its own. Paste a new one when Substack signs you out.
Check the session at any time:
substack-mcp statusThe MCP server lists every tool with its input schema, a description written for agents, and hints: read tools are marked read-only, and tools that publish, message or delete are marked so your client asks before running them.
claude mcp add substack -- /path/to/substack-api-mcp-server/.venv/bin/substack-mcp mcp
# read only:
claude mcp add substack -- /path/to/substack-api-mcp-server/.venv/bin/substack-mcp mcp --read-only~/Library/Application Support/Claude/claude_desktop_config.json (macOS) or %APPDATA%\Claude\claude_desktop_config.json (Windows):
{
"mcpServers": {
"substack": {
"command": "/path/to/substack-api-mcp-server/.venv/bin/substack-mcp",
"args": ["mcp"]
}
}
}~/.cursor/mcp.json, the same block as Claude Desktop. A ready copy is in mcp.json.
~/.codex/config.toml:
[mcp_servers.substack]
command = "/path/to/substack-api-mcp-server/.venv/bin/substack-mcp"
args = ["mcp"]substack-mcp serve also serves MCP over streamable HTTP at http://127.0.0.1:8787/mcp, for clients that take a URL:
substack-mcp serve
claude mcp add --transport http substack http://127.0.0.1:8787/mcpThen ask your agent things like:
Turn essay.md into a draft for everyone and schedule it for Tuesday 8:00 New York time. Once it is out, post a Note with a link card to it.
Find comments on my last three posts that I have not answered and draft a short reply to each.
Last week's numbers: new and paid subscribers, cancellations, revenue, and opens for each post.
Where did my readers come from in the last 30 days?
substack-mcp serve # http://127.0.0.1:8787, MCP at /mcp, docs at /docsEach tool has its own route under /api/v1/substack, the same paths as the hosted API at cotlet.dev, so code written against one works with the other. Path parts and the query string are arguments, and a JSON body adds the rest.
curl 'http://127.0.0.1:8787/api/v1/substack/posts?status=scheduled'
curl 'http://127.0.0.1:8787/api/v1/substack/stats?period=30d'
curl -X POST http://127.0.0.1:8787/api/v1/substack/drafts \
-H 'Content-Type: application/json' \
-d '{"title": "Notes on pricing", "body_markdown": "## Why I raised prices\n\nThree reasons..."}'Any tool by name, body as its arguments:
curl -X POST http://127.0.0.1:8787/api/v1/substack/tools/list_comments -d '{"post_id": "148213977"}'GET /api/v1/substack/tools: every tool with its route, input schema and an example answer.GET /docs: interactive OpenAPI docs,GET /openapi.jsonthe schema, for n8n, Postman or a generated client.- Answers are JSON. Lists return
next_cursor; pass it back ascursorfor the next page.
The server listens on 127.0.0.1 only. To reach it from another device of yours, set a token, and every request then needs Authorization: Bearer <token>:
SUBSTACK_MCP_TOKEN=$(openssl rand -hex 24) substack-mcp serve --host 0.0.0.0Straight from Python, no server needed:
from substack_api_mcp import call
from substack_api_mcp.scripting import paginate, write_rows
posts = list(paginate("list_posts", {"status": "published"}, "posts"))
write_rows(posts, "posts.csv")
draft = call("create_draft", title="Notes on pricing", body_markdown="Three reasons...")
call("schedule_post", post_id=draft["post_id"], publish_at="2026-11-03T08:00:00-05:00")Or one tool from the shell: substack-mcp call get_post_stats post_id=148213977, or with a JSON object.
Ready scripts in examples/. Each prints JSON, takes --out file.csv or --out file.json where it makes sense, and --help:
| Script | What it does |
|---|---|
draft_from_markdown.py |
A .md file to a draft: the first heading is the title, headings, links and lists are kept. --publish-at schedules it. |
schedule_post.py |
Schedules a draft for a set time, or --cancel takes it back to drafts. |
publish_note.py |
A Note now or at a set time, --link-latest adds a link card to your newest post. |
list_posts.py |
Published, draft or scheduled posts: title, audience, dates, URL. |
get_post.py |
One post saved as a Markdown file. |
weekly_report.py |
New and paid subscribers, cancellations, revenue, and each new post's opens and new subscribers. |
post_stats.py |
Opens, open rate, views, likes, restacks and new subscribers for your last N posts. |
daily_stats.py |
Subscribers, paid subscribers, followers and views day by day. |
traffic_sources.py |
Where your visitors came from, most views first. |
unanswered_comments.py |
Comments on your last N posts that you have not replied to. |
reply_to_comment.py |
A reply under one comment. |
list_subscribers.py |
Subscribers by plan or join date, with activity rating. |
export_subscribers.py |
The full list as the CSV that Export gives in the dashboard. |
add_subscribers.py |
Adds emails from a text or CSV file as free subscribers, after asking you. |
python examples/draft_from_markdown.py essay.md --publish-at 2026-11-03T08:00:00-05:00
python examples/unanswered_comments.py --last 3 --out todo.csvOn a schedule, cron on your own computer works while it is on: 0 8 * * 1 cd ~/substack-api-mcp-server && .venv/bin/python examples/weekly_report.py.
post_id comes from list_posts, comment_id from list_comments. substack-mcp tools prints this list, substack-mcp tools --json adds the input schemas.
| Tool | Access | REST route | What it does |
|---|---|---|---|
list_posts |
read | GET /posts |
Posts of your publication, newest first, with status and audience. |
get_post |
read | GET /posts/{post_id} |
Full post: title, subtitle, body as Markdown, audience, URL and dates. |
create_draft |
write | POST /drafts |
New draft from Markdown. Nothing is sent until you publish or schedule it. |
update_draft |
write | PATCH /drafts/{post_id} |
Change the title, subtitle, body or audience of a draft. |
duplicate_post |
write | POST /posts/{post_id}/duplicate |
Copy any post into a new draft. |
delete_post |
write | DELETE /posts/{post_id} |
Delete a draft, scheduled or published post, with its comments. |
| Tool | Access | REST route | What it does |
|---|---|---|---|
publish_post |
write | POST /posts/{post_id}/publish |
Publish a draft now. |
schedule_post |
write | PUT /posts/{post_id}/schedule |
Publish a draft at a set time, on the web and by email. |
cancel_scheduled_post |
write | DELETE /posts/{post_id}/schedule |
Stop a scheduled post from going out; it stays as a draft. |
unpublish_post |
write | DELETE /posts/{post_id}/publish |
Take a published post off the web and back to drafts. |
| Tool | Access | REST route | What it does |
|---|---|---|---|
list_sections |
read | GET /sections |
Sections of your publication, the separate newsletters inside it. |
list_post_tags |
read | GET /post-tags |
Tags your publication uses to group posts. |
create_post_tag |
write | POST /post-tags |
New tag for grouping your posts. |
tag_post |
write | PUT /posts/{post_id}/tags/{tag_id} |
Add a tag to a post, or take it off. |
| Tool | Access | REST route | What it does |
|---|---|---|---|
list_comments |
read | GET /posts/{post_id}/comments |
Comments on a post, with author, text and whether you replied. |
reply_to_comment |
write | POST /comments/{comment_id}/replies |
Reply as you under a comment on your post. |
pin_comment |
write | PUT /comments/{comment_id}/pin |
Pin a comment to the top of your post's comments, or unpin it. |
like_comment |
write | PUT /comments/{comment_id}/like |
Like or unlike a comment or a Note as you. |
remove_comment |
write | POST /comments/{comment_id}/remove |
Remove someone's comment from your post as the publication's moderator. |
delete_comment |
write | DELETE /comments/{comment_id} |
Delete one of your own comments, Notes or Note replies. |
| Tool | Access | REST route | What it does |
|---|---|---|---|
list_notes |
read | GET /notes |
Notes posted by you or any other Substack user, newest first, with likes and replies. |
list_note_replies |
read | GET /notes/{note_id}/replies |
Replies under a Note, with author, text and likes. |
publish_note |
write | POST /notes |
Short post in Substack Notes, now or at a set time, optionally with a link card to one of your posts. |
reply_to_note |
write | POST /notes/{note_id}/replies |
Reply as you under any Note or Note reply. |
| Tool | Access | REST route | What it does |
|---|---|---|---|
list_subscribers |
read | GET /subscribers |
Subscribers with plan, start date and activity rating. |
add_subscriber |
write | POST /subscribers |
Add one email to your list as a free subscriber. |
remove_subscriber |
write | DELETE /subscribers/{email} |
Remove one email from your subscriber list. |
export_subscribers |
read | GET /subscribers/export |
The full subscriber list as a CSV download link, with the row count. |
list_subscriber_tags |
read | GET /subscriber-tags |
Tags you use to group subscribers. |
create_subscriber_tag |
write | POST /subscriber-tags |
New private tag for grouping subscribers. |
tag_subscribers |
write | POST /subscriber-tags/{tag_id}/subscribers |
Put a tag on subscribers by email, or take it off. |
| Tool | Access | REST route | What it does |
|---|---|---|---|
get_publication_stats |
read | GET /stats |
Total and paid subscribers, new subscribers, paid cancellations and estimated revenue for a period. |
get_daily_stats |
read | GET /stats/daily |
Day by day: total and paid subscribers, followers and views. |
get_audience_insights |
read | GET /audience |
Where your subscribers live, and which publications share your readers. |
list_traffic_sources |
read | GET /traffic-sources |
Where your publication's visitors came from in a period, most views first. |
get_post_stats |
read | GET /posts/{post_id}/stats |
Recipients, opens, open rate, views, likes, restacks and subscribers gained. |
get_post_traffic |
read | GET /posts/{post_id}/traffic |
Where a post's readers came from: sources, categories and devices. |
get_post_engagement |
read | GET /posts/{post_id}/engagement |
Who liked and commented on a post, and the subscribers it won and lost. |
| Tool | Access | REST route | What it does |
|---|---|---|---|
list_recommendations |
read | GET /recommendations |
Publications you recommend, or that recommend you, with the subscribers each sent. |
recommend_publication |
write | PUT /recommendations/{publication_id} |
Recommend another publication to your readers, or stop recommending it. |
| Tool | Access | REST route | What it does |
|---|---|---|---|
get_feed |
read | GET /feed |
Your Substack home feed: posts and Notes, For you or Following. |
search_substack |
read | GET /search |
Search all of Substack for people, posts and Notes. |
list_activity |
read | GET /activity |
Your Substack notifications: replies, likes, new followers, restacks, newest first. |
get_unread_counts |
read | GET /unread-counts |
How many unread direct messages, message requests and activity notifications you have. |
list_conversations |
read | GET /conversations |
Your direct-message conversations, latest first, with the last message. |
get_conversation |
read | GET /conversations/{conversation_id} |
The latest messages of one direct-message conversation, oldest first. |
send_message |
write | POST /messages |
Send a direct message on Substack, in a conversation or to a person. |
follow_user |
write | PUT /users/{user}/follow |
Follow or unfollow a Substack user, so their Notes reach your feed. |
like_post |
write | PUT /posts/{post_id}/like |
Like or unlike any Substack post as you. |
list_subscriptions |
read | GET /subscriptions |
Publications you subscribe to, with your plan and whether their emails reach you. |
Never built, on purpose: your Substack sign-in, email, profile and account settings, your publication's address or ownership, and anything that moves money (paid plans, payouts, comps). Every request passes a guard that refuses writes to those Substack paths.
| Variable | Default | What it does |
|---|---|---|
SUBSTACK_MCP_HOME |
~/.config/substack-api-mcp |
Folder of session.json. |
SUBSTACK_AUTH_TOKEN |
Substack substack.sid cookie, instead of substack-mcp connect. |
|
SUBSTACK_PROXY |
Proxy for every request to Substack, e.g. http://user:pass@host:port. |
|
SUBSTACK_MCP_READ_ONLY |
1 hides and refuses every tool that changes Substack (same as --read-only). |
|
SUBSTACK_MCP_TOKEN |
Bearer token required by serve; needed to listen beyond 127.0.0.1. |
|
SUBSTACK_MCP_HOST, SUBSTACK_MCP_PORT |
127.0.0.1, 8787 |
Address of serve. |
- No password anywhere. Substack signs in by an emailed code. Only cookies are read, and only those of substack.com.
- The session file is yours.
session.jsonis created with mode 0600. Anyone who has it can act as you on Substack, so keep it out of git and shared folders. - Local by default.
servebinds to127.0.0.1and refuses another address withoutSUBSTACK_MCP_TOKEN. - substack.com only. Requests and redirects outside substack.com and its subdomains are refused.
- Read-only mode.
--read-onlyorSUBSTACK_MCP_READ_ONLY=1exposes the 25 read tools only. - Actions reach real people. Published and scheduled posts are emailed to your subscribers, and Notes, comments, replies and DMs go out under your name. MCP clients ask before running them; a script does not, so try it on a test publication first.
- Add only people who asked.
add_subscribersends Substack's welcome email. Importing people who did not sign up breaks Substack's rules.
The REST API answers {"error": "<code>", "message": "..."}. The MCP server returns the same code and message as a tool error.
| Status | Code | What to do |
|---|---|---|
| 401 | auth_required, auth_expired |
No session, Substack signed you out, or it asks you to sign in again before a sensitive action (subscriber lists). Sign out and in at substack.com in Chrome, then run substack-mcp connect. |
| 403 | app_permission_denied |
Your role in the publication cannot do this. |
| 403 | scope_denied |
The server runs read-only, or the call would change your account settings. |
| 403 | target_blocked |
Substack refused the request or showed a bot check. Open substack.com in Chrome once, then retry. |
| 409 | captcha_required |
Substack asks for a CAPTCHA. Solve it in Chrome. |
| 422 | invalid_input |
An argument is missing or wrong; the message names it. |
| 429 | rate_limited |
Substack is limiting the account. Wait a few minutes. |
| 502 | target_error |
Substack answered with an error, or changed its site. git pull for the latest tools, or open an issue. |
| This repo | Cotlet, hosted | |
|---|---|---|
| Tools | the same 50 | the same 50 |
| Runs | on your computer, while it is on | in the cloud, 24/7 |
| Substack session | your Chrome, cookies in a local file | your own Cloud Browser in the EU, profile encrypted with AES-256-GCM |
| When Substack changes its site | git pull once we have fixed it |
fixed for you, every tool tested daily |
| Approvals | your MCP client asks per call | posts, Notes, replies and DMs wait for your OK, switchable per tool |
| Works with | MCP clients on your machine, local scripts | Claude.ai and ChatGPT connectors, n8n, Make, Zapier, any HTTP client |
| Who may use it | you, for personal use | teams, businesses, agencies |
| Price | free | free 7-day trial with 2,000 credits, no card; then one-time packs from $29 for 5,000 credits |
On the hosted version a successful call uses 20 to 30 credits (25 for most tools), failed calls are free, and the Cloud Browser uses 50 credits a day while it has an app enabled for agents. Full pricing.
Does Substack have an official API? Only a limited one (checked 11 Oct 2026). Its Developer API looks up public creator profiles after approval, and there is no public API to write posts or Notes, manage subscribers or send email. RSS reads public posts only. Details and sources: cotlet.dev/integrations/substack-api-mcp-server.
Is there a Substack MCP server for Claude, ChatGPT or Cursor?
Yes, this one runs on your machine: substack-mcp mcp for Claude Code, Claude Desktop, Cursor and Codex. ChatGPT and Claude.ai connectors need a public HTTPS URL, which the hosted Substack MCP server gives you.
Can I schedule Substack posts from Markdown?
Yes: python examples/draft_from_markdown.py essay.md --publish-at 2026-11-03T08:00:00-05:00. The Substack editor does not import Markdown. The script sends the file as a draft with its headings, bold text, links, lists and code blocks, then schedules it. Images have to be public URLs.
Can it schedule Notes as well as posts?
Yes. publish_note takes a publish_at time and the Note waits in your scheduled Notes until then. A link card needs a published post.
Can I export my Substack subscribers to CSV?
Yes: python examples/export_subscribers.py --out subscribers.csv gives the same CSV as Export in the subscriber dashboard. list_subscribers.py --plan paid or --since 2026-10-01 gives a filtered list with plan, join date and activity rating.
Can I export my posts as Markdown?
Yes, one at a time with examples/get_post.py --post-id <id> --out post.md, including the paywalled part and drafts of your own publication. list_posts.py gives the IDs.
Is this a Substack scraper? It does what people want a scraper for (export posts, subscribers and stats) but does not parse HTML in a headless browser. It calls the same JSON endpoints the Substack web app calls, in your own account and publication.
Why does it need my Chrome? To read your Substack session. Substack signs in by an emailed code, so there is no password to give it anyway. Chrome is used once per sign-in, not for each call.
What happens when Substack signs me out?
Calls fail with auth_expired. With Chrome remote debugging on, the server reads the new cookies on the next call (click Allow when Chrome asks); otherwise run substack-mcp connect again. Subscriber lists sometimes ask for a fresh sign-in even with a valid session: sign out and in again at substack.com, then connect.
Does it need a paid Substack plan? No. Revenue, paid subscribers and cancellations exist only for publications with paid subscriptions.
Can it email all my subscribers? Only by publishing or scheduling a post, the way the editor sends it. Substack emails a post once; edits after the send show on the web and in the app.
Can it run on a VPS or in Docker?
Technically yes, with SUBSTACK_AUTH_TOKEN. Substack then sees your session from a datacenter IP, which looks less like you, and the license allows it only on a server you alone use. For an always-on setup the hosted version keeps your session in your own Cloud Browser on a fixed IP.
Will my Substack account get banned? Requests carry Chrome's fingerprint and go out one at a time, as you would click. Nobody can promise a platform never acts. Keep to your normal use and do not add or message people in bulk.
Can I use it at work or for my clients? No, the license is for personal use by one person. For a team, a company or client work, use Cotlet or write to contact@cotlet.dev.
Is it open source? The source is public, the license is not an open source license: personal use only, no business use, no hosting for others.
A tool broke or I need another one. Open an issue with the tool name and the error message.
Cotlet Personal Use License 1.0. In short:
- Allowed: one person, on their own computers, with their own Substack accounts, for their own purposes. Changing the code for yourself, and a GitHub fork under the same license.
- Not allowed: any use in or for a company or organization, your own business included; letting other people use your instance (shared server, team machine, cloud service); selling or offering it or a service built on it.
Earlier versions of this repo (the scripts for the hosted API, up to commit 4399e88) stay MIT.
Maintained by Cotlet (@cotletdev). Substack is a trademark of its owner. This project is unofficial and not affiliated with, endorsed or sponsored by Substack.