Skip to main content

A Python wrapper around the Substack API.

Project description

Python Substack

Unofficial Python tools for publishing to Substack.

Downloads Release Build

Features

  • 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.

Installation

pip install python-substack

Install the MCP server extra:

pip install "python-substack[mcp]"

Setup

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".

Quickstart

import 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**.

![Alt text](https://example.com/image.png "Image caption")
""",
    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.

CLI

Check authentication without creating a draft:

substack-auth-check

With a cookies JSON file:

substack-auth-check --cookies cookies.json

Publish 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" --publish

Publish from YAML:

substack-publish-yaml draft.yaml

Useful 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

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:

  1. Sign in to Substack in your browser.
  2. Open developer tools.
  3. Go to the network tab and refresh Substack.
  4. Select a request such as subscription/unred/subscriptions.
  5. Copy the full cookie request header value into COOKIES_STRING.

To export a working session to a cookies JSON file:

api.export_cookies("cookies.json")

Then set:

COOKIES_PATH=cookies.json

The CLI also accepts a cookie JSON path:

substack-publish-markdown post.md --cookies cookies.json

Low-Level Post Builder

import 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"))

Markdown Support

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

![Alt](local-image.png "Caption")

[^1]: Footnote text.
"""
)

Supported Markdown includes headings, paragraphs, bold, italic, inline code, strikethrough, links, images, linked images, image captions, code blocks, blockquotes, ordered lists, unordered lists, horizontal rules, and footnotes.

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)

YAML Drafts

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"

MCP Server

Install the MCP extra:

pip install "python-substack[mcp]"

Run the server over stdio:

substack-mcp

Equivalent 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)

Development

pip install pre-commit
pre-commit install
pytest

Live Substack tests are opt-in. Set RUN_SUBSTACK_E2E=1 and configure credentials before running them.

Release changes are tracked in CHANGELOG.md.

Disclaimer

This project is not affiliated with Substack.

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

python_substack-0.1.25.tar.gz (23.5 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

python_substack-0.1.25-py3-none-any.whl (25.1 kB view details)

Uploaded Python 3

File details

Details for the file python_substack-0.1.25.tar.gz.

File metadata

  • Download URL: python_substack-0.1.25.tar.gz
  • Upload date:
  • Size: 23.5 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: poetry/2.4.1 CPython/3.11.0 Linux/6.17.0-1018-azure

File hashes

Hashes for python_substack-0.1.25.tar.gz
Algorithm Hash digest
SHA256 b45e90820453b722219b45436585dec95fbc2126908d833da2b7267717285fd4
MD5 5a0149893d3c7d6742defdf5d03b9e9d
BLAKE2b-256 79f58e96a482271b5a444589d38d1670f3ae9f4b898d3e7a2ecfcc511a98da03

See more details on using hashes here.

File details

Details for the file python_substack-0.1.25-py3-none-any.whl.

File metadata

  • Download URL: python_substack-0.1.25-py3-none-any.whl
  • Upload date:
  • Size: 25.1 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? No
  • Uploaded via: poetry/2.4.1 CPython/3.11.0 Linux/6.17.0-1018-azure

File hashes

Hashes for python_substack-0.1.25-py3-none-any.whl
Algorithm Hash digest
SHA256 0d4d7d45ee69333125451c5f37ef9ebfc6400d009f8384b62f0d3cb40a87778c
MD5 245e6746c868a1ba03847cf50189523f
BLAKE2b-256 e8e82b11fdce90b7fdf457c072804f4b17281e98f65a5b587a8aa15ff532c6ac

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page