An unofficial Python SDK and CLI for managing Substack publications and drafts.
- Inspect authentication and publication status from the terminal.
- List publications and inspect, schedule, publish, or delete drafts.
- Use stable JSON output in scripts and automation.
- Create drafts and publish posts from Python.
- Convert Markdown into Substack's editor document format.
- Upload local images while rendering Markdown.
- Set audience, comment permissions, SEO title, SEO description, slug, sections, and tags.
- Publish now, schedule drafts, or keep drafts unpublished by default.
- Authenticate with email/password, cookies JSON, or a browser cookie string.
- Run a FastMCP server for AI-assisted publishing workflows.
pip install python-substackInstall the MCP server extra:
pip install "python-substack[mcp]"Copy .env.example to .env and fill in one authentication method:
EMAIL=
PASSWORD=
PUBLICATION_URL=
COOKIES_PATH=
COOKIES_STRING=Use either EMAIL and PASSWORD, or cookie-based authentication with COOKIES_PATH or COOKIES_STRING. Cookie authentication is usually the better option if Substack prompts for captcha or magic-link sign-in.
Newer Substack accounts may only have magic-link sign-in enabled. To set a password, sign out of Substack, choose "Sign in with password", then choose "Set a new password".
Create a draft from Markdown without publishing it:
substack drafts create post.mdSet metadata and repeat --tag to attach multiple tags:
substack --json drafts create post.md \
--title "My Post" \
--subtitle "Optional subtitle" \
--tag python \
--tag substack \
--slug my-post \
--search-engine-title "SEO title" \
--search-engine-description "SEO description"Creation and publishing are intentionally separate. Use the returned draft ID when the draft is ready:
substack drafts create post.md
substack drafts publish 12345 --no-sendCheck authentication, the selected publication, and subscriber count:
substack statusList available publications or target one without changing .env:
substack publications list
substack --publication-url https://example.substack.com drafts listList and inspect drafts:
substack drafts list --limit 10
substack drafts get 12345Schedule with a timezone-aware ISO 8601 timestamp, or remove a schedule:
substack drafts schedule 12345 --at 2026-08-01T09:00:00+03:00
substack drafts unschedule 12345Publishing and deletion prompt for confirmation. Use --yes for intentional non-interactive execution:
substack drafts publish 12345 --no-send
substack drafts delete 12345 --yesGlobal options must appear before the command. --json returns stable envelopes containing the raw Substack responses:
substack --json drafts list
substack --cookies cookies.json --json statusimport os
from dotenv import load_dotenv
from substack import Api
load_dotenv()
api = Api(
email=os.getenv("EMAIL"),
password=os.getenv("PASSWORD"),
publication_url=os.getenv("PUBLICATION_URL"),
)
result = api.create_draft_from_markdown(
title="Shipping with Python",
subtitle="A short note from a script",
markdown="""
# Hello
This draft was created from **Markdown**.

""",
tags=["python", "automation"],
slug="shipping-with-python",
)
print(result["draft"]["id"])create_draft_from_markdown creates a draft by default. It only publishes when publish=True is passed.
The existing standalone commands remain supported for compatibility.
Check authentication without creating a draft:
substack-auth-checkWith a cookies JSON file:
substack-auth-check --cookies cookies.jsonPublish a Markdown file as a draft:
substack-publish-markdown post.md --title "My Post"Create and publish:
substack-publish-markdown post.md --title "My Post" --publishPublish from YAML:
substack-publish-yaml draft.yamlUseful options:
substack-publish-markdown post.md \
--title "My Post" \
--subtitle "Optional subtitle" \
--tag python \
--tag substack \
--slug my-post \
--search-engine-title "SEO title" \
--search-engine-description "SEO description"Cookie authentication avoids logging in with email/password on every run and helps when Substack requires captcha or magic-link sign-in.
Use a cookies JSON file:
import os
from dotenv import load_dotenv
from substack import Api
load_dotenv()
api = Api(
cookies_path=os.getenv("COOKIES_PATH"),
publication_url=os.getenv("PUBLICATION_URL"),
)Or paste a browser cookie header into COOKIES_STRING:
import os
from dotenv import load_dotenv
from substack import Api
load_dotenv()
api = Api(
cookies_string=os.getenv("COOKIES_STRING"),
publication_url=os.getenv("PUBLICATION_URL"),
)To get a cookie string:
- Sign in to Substack in your browser.
- Open developer tools.
- Go to the network tab and refresh Substack.
- Select a request such as
subscription/unred/subscriptions. - Copy the full
cookierequest header value intoCOOKIES_STRING.
To export a working session to a cookies JSON file:
api.export_cookies("cookies.json")Then set:
COOKIES_PATH=cookies.jsonThe CLI also accepts a cookie JSON path:
substack-publish-markdown post.md --cookies cookies.jsonimport os
from dotenv import load_dotenv
from substack import Api
from substack.post import Post
load_dotenv()
api = Api(
email=os.getenv("EMAIL"),
password=os.getenv("PASSWORD"),
publication_url=os.getenv("PUBLICATION_URL"),
)
user_id = api.get_user_id()
post = Post(
title="How to publish a Substack post using Python",
subtitle="Created with python-substack",
user_id=user_id,
audience="everyone",
write_comment_permissions="everyone",
)
post.paragraph("This is a paragraph.")
post.add(
{
"type": "paragraph",
"content": [
{"content": "A link to "},
{
"content": "Substack",
"marks": [{"type": "link", "href": "https://substack.com"}],
},
],
}
)
post.add({"type": "paywall"})
post.add({"type": "captionedImage", "src": "https://example.com/image.png"})
draft = api.post_draft(post.get_draft())
api.prepublish_draft(draft.get("id"))
api.publish_draft(draft.get("id"))from substack.post import Post
post = Post("Title", "Subtitle", user_id=1)
post.from_markdown(
"""
# Heading
Paragraph with **bold**, *italic*, `code`, [links](https://example.com), and footnotes.[^1]
- Lists
- Images

[^1]: Footnote text.
"""
)Supported Markdown includes headings, paragraphs, bold, italic, inline code, strikethrough, superscript, subscript, links, images, linked images, image captions, code blocks, blockquotes, ordered lists, unordered lists, horizontal rules, footnotes, LaTeX math, pull quotes, and callouts. See docs/markdown.md for the full reference with examples.
When an Api instance is passed to from_markdown, local image paths are uploaded before the draft is created:
post.from_markdown(markdown_content, api=api)title: "My Post Title"
subtitle: "My Post Subtitle"
audience: "everyone"
write_comment_permissions: "everyone"
search_engine_title: "SEO title"
search_engine_description: "SEO description"
slug: "my-post-title"
tags:
- python
- substack
markdown: |
# Introduction
This post body is Markdown.The lower-level node format is also supported:
title: "My Post Title"
subtitle: "My Post Subtitle"
body:
0:
type: "heading"
level: 1
content: "Introduction"
1:
type: "paragraph"
content: "This is a paragraph."
2:
type: "captionedImage"
src: "local_image.jpg"Install the MCP extra:
pip install "python-substack[mcp]"Run the server over stdio:
substack-mcpEquivalent Python entry point:
python -c "from substack_mcp.mcp_server import main; main()"Available tools:
post_draft_from_markdown(...)put_draft(draft_id, update_payload)add_tags(draft_id, tags)prepublish_draft(draft_id)publish_draft(draft_id, send=True, share_automatically=False)
pip install pre-commit
pre-commit install
pytestRun the offline suite with:
pytest -m "not live"Live Substack tests are not part of normal CI. They are opt-in and require configured credentials:
RUN_SUBSTACK_E2E=1 pytest -m liveThe CLI operations smoke tests are separately opt-in. They create, inspect, and delete disposable drafts but never publish them:
RUN_SUBSTACK_CLI_E2E=1 pytest -m live tests/substack/test_cli_end_to_end.pyRelease changes are tracked in CHANGELOG.md. The maintainer release process is documented in docs/releasing.md.
This project is not affiliated with Substack.