I created an API library that can sync any blog to Substack in one click. Notes and articles included.
It works with Claude, Codex, Cursor, or any agent.
Substack has no official public posting API. You write somewhere else, then you live in their browser editor. I wanted a client that can post Notes and newsletter articles from a script, a terminal, or an agent β draft, publish, schedule, tag, and delete without that UI.
The library is three products, not one package:
substack-api) for the terminal substack-api-mcp) for Cursor, Claude, and other MCP hosts
Keys and docs live on apisubstack.com. Python client + CLI: substack-api-client. MCP: substack-api-mcp.
Generate an ask_* key, then either paste the MCP config into your agent or verify the key over HTTP.
{
"mcpServers": {
"substack-api": {
"command": "substack-api-mcp",
"args": [],
"env": {
"APISUBSTACK_API_KEY": "ask_YOUR_KEY",
"SUBSTACK_PUBLICATION_URL": "https://yourname.substack.com",
"SUBSTACK_SID": "YOUR_SUBSTACK_SID_VALUE"
}
}
}
}
curl https://rest.apisubstack.com/api/v1/keys/verify \
-H "Authorization: Bearer ask_YOUR_KEY"
You also need:
SUBSTACK_PUBLICATION_URL β *.substack.com or a custom domainSUBSTACK_SID β the unofficial substack.sid browser session cookieSUBSTACK_USER_ID β otherwise the client resolves it from profile/self
Two things that bite people: the SID expires (refresh it on 401/403), and every Substack endpoint is 1 request per second. Do not poll. Do not hammer drafts from a loop.
Plain text is converted to ProseMirror draft_body for you. Cover images are URL strings only β there is no binary upload in the SDK.
If the Python client, the CLI, or the MCP server is already installed, skip this and jump to the method you need. If nothing is installed yet, install one product.
Python client and CLI (one repo): https://github.com/alxgntv/substack-api-client
python3 -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
pip install -e .
MCP server (separate product): https://github.com/alxgntv/substack-api-mcp
python3 -m venv .venv
source .venv/bin/activate
pip install -e .
Notes go through Substack's global https://substack.com/api/v1 host. Drafts, publish, schedule, and tags go through {publication}/api/v1. create_post() is an SDK flow (create β update β optional tags β publish or schedule), not a single HTTP call.
| Method | Python | CLI | MCP |
|---|---|---|---|
| Create note | create_note() |
create-note |
create_note |
| Delete note | delete_note() |
delete-note |
delete_note |
| Create / publish / schedule | create_post() |
create |
create_post |
| Create draft | create_draft() |
create --draft-only |
create_post (draft_only=true ) |
| Delete draft | delete_draft() |
delete-draft |
delete_draft |
| AI check (Pangram) | pangram_detection() |
pangram-detection |
pangram_detection |
| Verify auth / profile | get_profile_self() |
profile |
test_connection |
| Get draft | get_draft() |
get-draft |
get_draft |
| Update draft | update_draft() |
update-draft |
update_draft |
| Publish now | publish_now() |
publish |
publish_post |
| Schedule | schedule_release() |
schedule |
schedule_post |
| List tags | list_post_tags() |
list-tags |
list_tags |
| Create tag | create_post_tag() |
create-tag |
create_tag |
| Attach tags | set_post_tags() |
set-tags |
set_tags |
| Get post tags | get_post_tags() |
get-post-tags |
get_post_tags |
Rate limit for every endpoint: 1 request per second.
Request, response, Python, CLI, and MCP for each method.
Publish a Substack Note (feed comment). Optional link attachment is created first, then the note is posted.
create_note()
create-note
create_note
POST (+ optional POST) https://substack.com/api/v1/comment/attachment (optional) β https://substack.com/api/v1/comment/feed
Headers
| Name | Type | Required | Description |
|---|---|---|---|
Cookie |
string |
yes | substack.sid= (browser session cookie) |
Accept |
string |
yes | application/json |
Origin |
string |
yes | Publication origin, e.g. https://yourname.substack.com |
Referer |
string |
yes | https://substack.com/ (same as get_profile_self) |
User-Agent |
string |
yes | Browser User-Agent string (client sends a Chrome UA by default) |
Content-Type |
string |
yes | application/json |
Path params
None
Query params
Body
| Name | Type | Required | Description |
|---|---|---|---|
bodyJson |
object |
yes | ProseMirror doc object. SDK reuses ensure_draft_body() / ensureDraftBody(), then sets attrs.schemaVersion=v1. |
attachmentIds |
string[] |
no | Ids from POST /comment/attachment. SDK fills this when attachment_url is passed. |
tabId |
string |
yes | Captured default: "for-you" |
surface |
string |
yes | Captured default: "permalink" |
replyMinimumRole |
string |
yes | Captured default: "everyone" |
(attachment) |
object |
no | Optional first call POST /comment/attachment: { "url": string, "type": "link" } |
Type: object
Fields
| Name | Type | Required | Description |
|---|---|---|---|
id |
number |
yes | Note / feed comment id |
type |
string |
yes | Captured value: "feed" |
status |
string |
yes | Captured value: "published" |
body |
string |
no | Plain-text body |
body_json |
object |
no | ProseMirror bodyJson echo |
attachments |
array |
no | Attached posts/links when attachment_url was used |
Same GLOBAL_API host as get_profile_self (https://substack.com/api/v1), not {publication}/api/v1. Referer is https://substack.com/. Origin stays the publication URL from existing _headers. Optional attachment is sequential: attachment then comment/feed. Captured surface=permalink tabId=for-you. Text-only notes omit attachmentIds. Image/file attachments were not in the capture (type=link only).
note = client.create_note(
body="nice",
attachment_url="https://yourname.substack.com/p/your-post",
)
print(note["id"], note["status"])
substack-api create-note \
--body "nice" \
--attachment-url "https://yourname.substack.com/p/your-post"
Delete an existing Substack Note (feed comment) by id.
delete_note()
delete-note
delete_note
DELETE https://substack.com/api/v1/comment/{id}
| Name | Type | Required | Description |
|---|---|---|---|
id |
`number \ | string` | yes |
No request body
Type: object (normalized by SDK)
| Name | Type | Required | Description |
|---|---|---|---|
status |
"deleted" |
yes | SDK-normalized status |
note_id |
`number \ | string` | yes |
response |
object |
yes | Raw Substack body (may be empty {}) |
Same GLOBAL_API host as create_note and get_profile_self. No request body. Reuses _request like delete_draft. Substack may return an empty response.
result = client.delete_note(123456)
print(result["status"])
substack-api delete-note --note-id 123456
High-level SDK flow: create draft β update β optional tags β publish or schedule.
create_post()
create
create_post
POST + PUT (+ optional) {publication}/api/v1/drafts β {publication}/api/v1/drafts/{id} β tags / publish / schedule
| Name | Type | Required | Description |
|---|---|---|---|
Cookie |
string |
yes | substack.sid= (browser session cookie) |
Accept |
string |
yes | application/json |
Origin |
string |
yes | Publication origin, e.g. https://yourname.substack.com |
Referer |
string |
yes | Usually {publication}/publish or the draft editor URL |
User-Agent |
string |
yes | Browser User-Agent string (client sends a Chrome UA by default) |
Content-Type |
string |
yes | application/json |
| Name | Type | Required | Description |
|---|---|---|---|
title |
string |
yes | Post title (SDK maps to draft_title on Substack) |
body |
`string \ | object` | yes |
subtitle |
string |
no | Optional subtitle (default "") |
audience |
string |
no | Who can read the post (default "everyone") |
section_id |
`number \ | null` | no |
should_send_email |
boolean |
no | Email subscribers on publish (default true) |
schedule_at |
`string \ | null` | no |
publish |
boolean |
no | If true and schedule_at is null β publish now. CLI --draft-only / MCP draft_only=true sets this false |
tags |
string[] |
no | Tag names to ensure and attach after draft update |
| Name | Type | Required | Description |
|---|---|---|---|
draft_id |
`number \ | string` | yes |
draft |
object |
yes | Latest draft object from update_draft |
publication_url |
string |
yes | Normalized publication origin |
edit_url |
string |
yes | {publication}/publish/post/{draft_id} |
status |
`"draft" \ | "published" \ | "scheduled"` |
post_url |
string |
no | Public post URL when published |
tags |
array |
no | Attach results when tags were requested |
This is an SDK orchestration, not a single HTTP call. Underlying Substack calls use the headers above and JSON bodies documented on create-draft / update-draft / publish-now / schedule pages.
result = client.create_post(
title="Hello from API",
body="Plain text becomes ProseMirror draft_body.",
subtitle="Optional",
tags=["api-test"],
publish=True,
)
print(result["draft_id"], result.get("post_url"))
substack-api create \
--title "Hello from API" \
--body "Plain text body" \
--tags "api-test,newsletter"
Create an empty/partial draft without publishing.
create_draft()
create --draft-only
create_post (draft_only=true)
POST {publication}/api/v1/drafts
| Name | Type | Required | Description |
|---|---|---|---|
draft_title |
string |
yes | Title (SDK arg: title) |
draft_subtitle |
string |
yes | Subtitle (SDK arg: subtitle) |
draft_body |
string |
yes | ProseMirror JSON string (SDK accepts plain text or object) |
audience |
string |
yes | Default "everyone" |
type |
string |
yes | Post type, default "newsletter" |
draft_bylines |
array |
yes | [{ "id": , "is_guest": false }] |
draft_section_id |
`number \ | null` | no |
section_chosen |
boolean |
yes | Whether a section was selected |
detect_language |
boolean |
yes | Language detection flag (default true on create) |
translations |
array |
yes | Usually [] |
draft_podcast_url |
null |
yes | Sent as null for newsletter posts |
draft_podcast_duration |
null |
yes | Sent as null for newsletter posts |
| Name | Type | Required | Description |
|---|---|---|---|
id |
`number \ | string` | yes |
publication_id |
`number \ | string` | no |
draft_updated_at |
string |
yes | Timestamp required for later PUT update_draft |
Prefer create_post() for the full flow. Referer: {publication}/publish/post?type=newsletter.
draft = client.create_draft(
title="Draft only",
subtitle="",
body="Hello",
audience="everyone",
)
print(draft["id"], draft["draft_updated_at"])
substack-api create \
--title "Draft only" \
--body "Hello" \
--draft-only
Delete an existing draft.
delete_draft()
delete-draft
delete_draft
DELETE {publication}/api/v1/drafts/{id}
| Name | Type | Required | Description |
|---|---|---|---|
status |
"deleted" |
yes | SDK-normalized status |
draft_id |
`number \ | string` | yes |
response |
object |
yes | Raw Substack body (may be empty {}) |
No request body. Substack may return an empty response.
result = client.delete_draft(123456)
print(result["status"])
substack-api delete-draft --draft-id 123456
Run Substack's Pangram AI-text detection on a draft body (human / AI-assisted / AI fractions).
pangram_detection()
pangram-detection
pangram_detection
GET {publication}/api/v1/drafts/{id}/pangram_detection
| Name | Type | Required | Description |
|---|---|---|---|
type |
string |
no | Detection result type from Substack / Pangram |
header |
string |
no | Human-readable summary header |
fraction_ai |
number |
no | Estimated AI-generated fraction |
fraction_ai_assisted |
number |
no | Estimated AI-assisted fraction |
fraction_human |
number |
no | Estimated human-written fraction |
details |
any |
no | Additional Pangram detail payload when present |
disclosure |
any |
no | Disclosure / availability metadata when present |
No request body. Path param only. Referer: {publication}/publish/post/{id}. Requires a draft with enough text for detection.
result = client.pangram_detection(123456)
print(result.get("header"), result.get("fraction_ai"), result.get("fraction_human"))
substack-api pangram-detection --draft-id 123456
Test session cookie and resolve current user profile.
get_profile_self()
profile
test_connection
GET https://substack.com/api/v1/user/profile/self
| Name | Type | Required | Description |
|---|---|---|---|
Cookie |
string |
yes | substack.sid= (browser session cookie) |
Accept |
string |
yes | application/json |
Origin |
string |
yes | https://substack.com/ (global profile endpoint) |
Referer |
string |
yes | https://substack.com/ (global profile endpoint) |
User-Agent |
string |
yes | Browser User-Agent string (client sends a Chrome UA by default) |
| Name | Type | Required | Description |
|---|---|---|---|
id |
number |
yes | Substack user id (used in draft_bylines) |
... |
any |
no | Additional profile fields from Substack |
No request body. Auth is only via Cookie: substack.sid. Used by resolve_user_id() when SUBSTACK_USER_ID is not set. MCP test_connection wraps this call.
from substack_api_client import SubstackClient, SubstackAuth
client = SubstackClient(
publication_url="https://yourname.substack.com",
auth=SubstackAuth(sid="YOUR_SUBSTACK_SID"),
)
profile = client.get_profile_self()
print(profile["id"])
substack-api profile \
--publication-url "https://yourname.substack.com" \
--sid "$SUBSTACK_SID"
Fetch an existing draft by id.
get_draft()
get-draft
get_draft
GET {publication}/api/v1/drafts/{id}
| Name | Type | Required | Description |
|---|---|---|---|
id |
`number \ | string` | yes |
draft_title |
string |
no | Title |
draft_subtitle |
string |
no | Subtitle |
draft_body |
string |
no | ProseMirror JSON string |
draft_updated_at |
string |
no | Optimistic-concurrency stamp for PUT |
draft = client.get_draft(123456)
print(draft["draft_title"], draft["draft_updated_at"])
substack-api get-draft --draft-id 123456
Update title, subtitle, body, audience, section, email flag, or cover_image URL.
update_draft()
update-draft
update_draft
PUT {publication}/api/v1/drafts/{id}
| Name | Type | Required | Description |
|---|---|---|---|
last_updated_at |
string |
yes | From latest get_draft/create_draft (client fetches it if omitted in SDK) |
draft_bylines |
array |
yes | [{ "id": , "is_guest": false }] |
detect_language |
boolean |
yes | Usually false on update |
translations |
array |
yes | Usually [] |
draft_title |
string |
no | SDK arg: title |
draft_subtitle |
string |
no | SDK arg: subtitle |
draft_body |
string |
no | ProseMirror JSON. SDK arg: body (plain text or object) |
audience |
string |
no | Audience string |
draft_section_id |
`number \ | null` | no |
section_chosen |
boolean |
no | Set true when draft_section_id is set |
should_send_email |
boolean |
no | SDK arg: should_send_email |
write_comment_permissions |
string |
no | e.g. "everyone" |
cover_image |
`string \ | null` | no |
| Name | Type | Required | Description |
|---|---|---|---|
id |
`number \ | string` | yes |
draft_updated_at |
string |
yes | New concurrency stamp |
draft_title |
string |
no | Updated title |
Binary image upload is not implemented. Only cover_image URL strings are supported.
updated = client.update_draft(
123456,
title="Updated title",
body="New body",
cover_image="https://cdn.example.com/cover.jpg",
)
print(updated["draft_updated_at"])
substack-api update-draft \
--draft-id 123456 \
--title "Updated title" \
--body "New body"
Publish an existing draft immediately.
publish_now()
publish
publish_post
POST {publication}/api/v1/drafts/{id}/publish
| Name | Type | Required | Description |
|---|---|---|---|
send |
boolean |
yes | Email subscribers (SDK arg: send_email, default true) |
share_automatically |
boolean |
yes | Default false |
audience |
string |
yes | Default "everyone" |
| Name | Type | Required | Description |
|---|---|---|---|
id |
`number \ | string` | yes |
slug |
string |
no | Public slug |
is_published |
boolean |
no | Publish flag |
Referer: {publication}/publish/post/{id}.
published = client.publish_now(123456, send_email=True, audience="everyone")
print(published["slug"], published["is_published"])
substack-api publish --draft-id 123456
Schedule a draft: optional prepublish GET, then scheduled_release POST.
schedule_release()
schedule
schedule_post
GET then POST {publication}/api/v1/drafts/{id}/prepublish β {publication}/api/v1/drafts/{id}/scheduled_release
| Name | Type | Required | Description |
|---|---|---|---|
publish_date |
string |
yes | ISO-8601 UTC β query param on GET .../prepublish (same value as trigger_at) |
| Name | Type | Required | Description |
|---|---|---|---|
trigger_at |
string |
yes | ISO-8601 UTC schedule time (POST scheduled_release body) |
post_audience |
string |
yes | Default "everyone" |
email_audience |
string |
yes | Default "everyone" |
Type: any
| Name | Type | Required | Description |
|---|---|---|---|
(body) |
`object \ | empty` | no |
When run_prepublish=true (default), client GETs prepublish first and raises if errors is non-empty. Content-Type applies to the POST only.
from substack_api_client import utc_iso
from datetime import datetime, timedelta, timezone
when = utc_iso(datetime.now(timezone.utc) + timedelta(hours=2))
client.schedule_release(123456, trigger_at=when)
substack-api schedule \
--draft-id 123456 \
--at "2026-08-10T15:00:00.000Z"
List publication post tags.
list_post_tags()
list-tags
list_tags
GET {publication}/api/v1/publication/post-tag
Type: array<object>
| Name | Type | Required | Description |
|---|---|---|---|
id |
string |
yes | Tag id |
name |
string |
yes | Tag display name |
slug |
string |
no | Tag slug |
No request body. Referer: {publication}/publish.
tags = client.list_post_tags()
for tag in tags:
print(tag["id"], tag["name"])
substack-api list-tags
Create a new publication post tag.
create_post_tag()
create-tag
create_tag
POST {publication}/api/v1/publication/post-tag
| Name | Type | Required | Description |
|---|---|---|---|
name |
string |
yes | New tag name |
| Name | Type | Required | Description |
|---|---|---|---|
id |
string |
yes | Created tag id |
name |
string |
yes | Tag name |
slug |
string |
no | Tag slug |
Does not attach the tag to a post β use set_post_tags / attach_post_tag.
tag = client.create_post_tag("product of the day")
print(tag["id"], tag["slug"])
substack-api create-tag --name "product of the day"
Ensure tags exist (create missing) and attach them to a post/draft.
set_post_tags()
set-tags
set_tags
GET/POST + POST {publication}/api/v1/publication/post-tag β {publication}/api/v1/post/{id}/tag/{tag_id}
| Name | Type | Required | Description |
|---|---|---|---|
id |
`number \ | string` | yes |
tag_id |
string |
yes | Tag id in attach URL |
| Name | Type | Required | Description |
|---|---|---|---|
(create tag) |
object |
no | When creating missing tags: { "name": string } |
(attach tag) |
object |
yes | Empty JSON object {} on POST /post/{id}/tag/{tag_id} |
Type: array<object> (SDK normalized)
| Name | Type | Required | Description |
|---|---|---|---|
tag |
object |
yes | Resolved/created tag |
status |
`"attached" \ | "already_attached"` | yes |
link |
object |
no | Raw attach response when newly attached |
SDK args: post_id + tag_names[]. Internally: list/create tags, then attach with empty JSON body.
attached = client.set_post_tags(123456, ["api-test", "newsletter"])
print(attached)
substack-api set-tags \
--post-id 123456 \
--tags "api-test,newsletter"
Read tags currently attached to a post.
get_post_tags()
get-post-tags
get_post_tags
GET {publication}/api/v1/post/{id}/tag
| Name | Type | Required | Description |
|---|---|---|---|
id |
string |
no | Tag or link id |
post_tag_id |
string |
no | Attached tag id (used by set_post_tags dedupe) |
name |
string |
no | Tag name when present |
No request body. Works for drafts and published posts.
tags = client.get_post_tags(123456)
print(tags)
substack-api get-post-tags --post-id 123456
Product gate: APISUBSTACK_API_KEY is required. Substack itself is authenticated with the unofficial substack.sid cookie.
APISUBSTACK_API_KEY (ask_* from GET https://rest.apisubstack.com/api/v1/keys/verify with Authorization: Bearer ask_β¦
<a href="https://apisubstack.com/login" class="ltag-offer__button crayons-btn crayons-btn--primary">Start free β $9/mo after</a>
If you write and publish a newsletter and you want posts out of your editor (or your agent) and into Substack without the browser UI, this is the client.