Safe Substack draft automation from Markdown with Python, CLI, and MCP.
Post.from_markdown(markdown_content, api=None) converts a Markdown document
into Substack’s document format. Parsing is handled by
markdown-it-py (CommonMark)
plus a few plugins, so standard CommonMark works as you’d expect. This page
documents everything that maps to a Substack node.
from substack.post import Post
post = Post(title="My Post", subtitle="", user_id=api.get_user_id())
post.from_markdown(open("post.md").read(), api=api)
draft = api.post_draft(post.get_draft())
Pass api= when your Markdown references local images so they can be uploaded
(see Images); it is optional otherwise.
| Markdown | Result |
|---|---|
**bold** |
bold |
*italic* |
italic |
***bold italic*** |
bold italic |
`inline code` |
inline code |
~~strikethrough~~ |
|
^superscript^ |
superscript |
~subscript~ |
subscript |
[text](https://example.com) |
link |
<https://example.com> |
autolinked URL |
Note that subscript uses a single tilde (~x~) and strikethrough uses a double
tilde (~~x~~); both work in the same document.
Levels 1–6, using # through ######. Headings may contain inline formatting
and links.
# Heading level 1
## Heading level 2 with **bold** and a [link](https://example.com)
Blank lines separate paragraphs. A single newline within a paragraph is treated as a space (soft break), matching CommonMark.
Bullet lists (-, *, or +) and ordered lists (1.), including nesting:
- Bullet one
- Bullet two
- Nested bullet
1. Nested number
1. Ordered one
2. Ordered two
> A blockquote.
>
> With multiple paragraphs.
Fenced code blocks, with an optional language for syntax highlighting, and indented code blocks:
```python
print("hello")
```
---
A paragraph containing only an image becomes a captioned image.

.[](https://target.com).api= is passed and the src is a local path (not an
http(s) URL), the file is uploaded to Substack and the returned URL is used.
Local paths resolve relative to the current working directory.
References become inline anchors; definitions become footnote blocks at the end, numbered by order of first appearance. Labels may be numeric or named, and a definition may contain block content such as lists or multiple paragraphs.
A claim that needs support.[^1] Another, with a named label.[^source]
[^1]: The supporting detail, with a [link](https://example.com).
[^source]: Author, *Title* (2025).
A reference used more than once is emitted as a separate numbered anchor each time, mirroring the Substack editor. Definitions that are never referenced are dropped.
Inline math with single dollars, block math with double dollars:
Einstein showed $E=mc^2$ inline.
$$
\int_0^\infty e^{-x} \, dx = 1
$$
Delimiters follow Pandoc’s rules: the opening $ must not be followed by
whitespace, and the closing $ must not be preceded by whitespace or followed
by a digit. Ordinary dollar amounts ($5 million to $10 million) therefore
stay plain text. A label after a block ($$ ... $$ (label)) is accepted but
discarded, since Substack has no equation labels. Unclosed math delimiters remain
plain text.
These use fenced-container syntax (:::), since they have no native Markdown
equivalent:
:::pullquote
A highlighted pull quote. **Formatting** works inside.
:::
:::callout
A callout block, e.g. an aside or note.
:::
Empty pull quotes and callouts produce an empty paragraph so the resulting
document remains valid. Unknown ::: container names remain ordinary text.