Tech Brief: Substack Unofficial API and Automation

How to script Substack without a browser

tech-brief · 25% AI

Post 2026-A-0105

Substack has no official public API. It does, however, have a comprehensive internal API that powers its web app – and a growing ecosystem of community libraries wraps it. The archerships.com publishing pipeline uses the python-substack library (ma2za) to create drafts, upload cover images, and publish posts without a running browser.

This brief documents the API endpoints, the library, and the two publishing approaches in the archerships toolchain.

API Structure

Substack’s API lives at https://{publication}.substack.com/api/v1/ for publication-specific endpoints and https://substack.com/api/v1/ for cross-publication features (Notes, recommendations). Authentication uses session cookies – the library extracts these from Brave’s SQLite cookie store on macOS or from a ~/.config/substack/cookies.json file.

The canonical endpoint reference is the substack-api-reference repo by Anthony David Adams (129 verified endpoints, captured by driving the live API through 14 rounds of probing).

Core publish endpoints

Table.1.publish-endpoints

Endpoint Method Purpose
drafts GET List drafts (filter, offset, limit)
drafts POST Create a new draft
drafts/{id} PUT Update an existing draft
drafts/{id} DELETE Delete a draft
drafts/{id}/prepublish POST Run prepublish validation
drafts/{id}/publish POST Publish a draft
drafts/{id}/scheduled_release POST Schedule publication
posts GET List published posts
posts/{id} GET Get a single published post

Notes (micro-blogging)

Substack treats Notes as “comments” internally – the endpoint names use comment even though the UI calls them Notes.

Table.2.notes-endpoints

Endpoint Method Purpose
/api/v1/comment/feed POST Create a Note (host: substack.com)
/api/v1/reader/feed/profile/{id} GET User’s published Notes and activity
/api/v1/feed/home GET Cookie-holder’s personalized Notes feed
/api/v1/feed/drafts?limit=N GET Saved Note drafts for the publication
/api/v1/me/notes GET Authenticated user’s own Notes

Draft body format

All drafts use Substack’s ProseMirror JSON schema. A minimal paragraph:

{
  "type": "doc",
  "content": [
    {
      "type": "paragraph",
      "content": [
        { "type": "text", "text": "Hello, world." }
      ]
    }
  ]
}

The supported block types include paragraph, heading, bullet_list, ordered_list, captionedImage, code_block, blockquote, and horizontal_rule. Inline marks include strong, em, link, and code.

image upload

Images are uploaded separately via GET /api/v1/image (the endpoint name is misleading – it is a multipart upload, driven by api.get_image(path) in the library). The response is a Substack CDN URL at substack-post-media.s3.amazonaws.com. Once uploaded, images are referenced by their CDN URL in the draft body as captionedImage or image2 nodes.

Cover images

Substack has two distinct image slots:

Setting the thumbnail does not automatically set the hero. To get a full-width cover, inject a captionedImage node at position 0 in the body’s content array with the CDN URL from get_image.

python-substack Library

The python-substack library (PyPI, by ma2za) wraps the API with a Python client. Core classes:

key methods

Table.3.api-methods

Method Purpose
Api.get_user_id() Get authenticated user ID
Api.get_drafts(filter, offset, limit) List drafts
Api.post_draft(draft_body) Create draft from dict
Api.put_draft(draft_id, draft_body) Update draft
Api.delete_draft(draft_id) Delete draft
Api.prepublish_draft(draft_id) Run prepublish check
Api.publish_draft(draft_id, send, share_automatically) Publish
Api.get_image(file_path) Upload image, return CDN URL
Api.get_sections() List publication sections
Api.call(endpoint, method, **params) Generic API call for undocumented endpoints

Generic call

The library does not expose Notes endpoints directly, but api._session is a fully authenticated requests.Session. Any endpoint can be reached:

# Read your Note drafts
resp = api._session.get(
    f"{api.publication_url}/api/v1/feed/drafts?limit=10"
)

# Post a Note
resp = api._session.post(
    "https://substack.com/api/v1/comment/feed",
    json={
        "bodyJson": {
            "type": "doc",
            "attrs": {"schemaVersion": "v1", "title": None},
            "content": [
                {
                    "type": "paragraph",
                    "content": [{"type": "text", "text": "Hello, Substack."}]
                }
            ]
        },
        "replyMinimumRole": "everyone"
    }
)

archerships Publishing Pipeline

The archerships publishing toolchain has two paths for Substack:

API publisher (default)

substack-api-publisher.py (19 KB) uses python-substack. It reads an essay .md, strips pandoc image attributes, resolves image paths, uploads inline images and cover art, builds a ProseMirror draft, sets section and audience, and publishes. No browser needed.

substack-api-publisher.py --essay src/essays/SLUG/SLUG.md --cover path/to/cover.jpg

Flow: 1. Load essay frontmatter (title, subtitle, section) via essay_frontmatter.py 2. Strip pandoc extended image attributes ({alt="..." width="..."}) 3. Resolve relative image paths to absolute 4. Upload cover via api.get_image() -> CDN URL 5. Parse markdown body -> ProseMirror JSON via Post.from_markdown(body_md, api=api) (this also uploads inline images) 6. Build draft dict, inject cover captionedImage at body content[0] with CDN URL, set cover_image for thumbnail 7. api.post_draft() -> api.prepublish_draft() -> api.publish_draft() 8. Fetch published URL, write to essay frontmatter published_at

Playwright publisher (legacy)

substack-publisher.py (21 KB) drives the real Substack editor through Chromium CDP. It navigates to the publish page, pastes title/subtitle/body into the editor textareas, clicks through audience/comments/section settings, and hits Publish. Used when the API publisher is unavailable or for debugging the editor flow directly.

substack-publisher.py --new-post path/to/essay.html

The Playwright publisher is slower, requires a running browser, and is fragile against DOM changes. The API publisher has replaced it as the default in hydra-publish.

hydra-publish orchestration

hydra-publish (21 KB) is the top-level script that adapts cover images to platform ratios (hydra-adapt-image), then calls the appropriate publisher for each target platform:

hydra-publish SLUG --platforms substack --image cover.png

For Substack, it runs substack-api-publisher.py --essay essay.md --cover adapted-cover.jpg. The adapted cover is produced by hydra-adapt-image at 1200x600 (2:1 ratio) for the substack-cover context.

Authentication

The API publisher authenticates by extracting session cookies from Brave’s SQLite database:

# macOS: ~/Library/Application Support/BraveSoftware/Brave-Browser/Default/Cookies
# Key cookie: connect.sid for *.substack.com

If cookies are expired, the publisher falls back to ~/.config/substack/cookies.json. The export_cookies method in python-substack dumps the current session to this file.

Image Upload and cover handling

Inline images in essay markdown are uploaded during Post.from_markdown() via the api parameter. The library converts local ![alt](path) references to CDN URLs in the ProseMirror output.

Cover images go through a two-step process: 1. Upload via api.get_image(cover_path) -> CDN URL 2. Inject into draft body as first captionedImage node with that CDN URL 3. Set cover_image on the top-level draft dict for the dashboard thumbnail

The second step is critical – setting cover_image alone only produces the small thumbnail. The full hero image must be injected into the body as the first content node.

Pitfalls

Markdown preprocessing

Pandoc generates images with extended attributes ({alt="..." width="..." height="..."}) that the Substack ProseMirror parser rejects. The API publisher strips these before from_markdown() via regex.

Footnotes in API body

The markdown -> ProseMirror conversion preserves [^N] footnote markers as literal text nodes. Substack’s web renderer does not interpret these as footnote anchors – they display as raw [^1] text. For best results, convert footnotes to inline parens or endnotes before publishing to Substack.

Cover image as first image in body

If the markdown body already contains an image as its first element, from_markdown will use IT as the first captionedImage. The cover injection at content[0] must happen AFTER from_markdown, not before – otherwise the CDN URL from get_image() is not available and the local file path is embedded in the draft (which the Substack CDN will not render).

python-substack version pinning

python-substack is under active development and the API surface changes. Pin to a known version in requirements. The archerships pipeline uses python3.11 with the library installed in the Homebrew Python environment.

Notes


Want to stay in touch?

Support my work