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:
- Python client for scripts and apps
-
CLI (
substack-api) for the terminal -
MCP server (
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.
Quick start
Generate an ask_* key, then either paste the MCP config into your agent or verify the key over HTTP.
MCP
{
"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"
}
}
}
}
Verify the key
curl https://rest.apisubstack.com/api/v1/keys/verify \
-H "Authorization: Bearer ask_YOUR_KEY"
You also need:
-
SUBSTACK_PUBLICATION_URL—*.substack.comor a custom domain -
SUBSTACK_SID— the unofficialsubstack.sidbrowser session cookie - optional
SUBSTACK_USER_ID— otherwise the client resolves it fromprofile/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.
Install
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 .
# CLI command: substack-api
MCP server (separate product): https://github.com/alxgntv/substack-api-mcp
python3 -m venv .venv
source .venv/bin/activate
pip install -e .
# MCP command: substack-api-mcp
What you can call
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.
API reference
Request, response, Python, CLI, and MCP for each method.
Create note
Publish a Substack Note (feed comment). Optional link attachment is created first, then the note is posted.
- Python:
create_note() - CLI:
create-note - MCP:
create_note - Rate limit: 1 request per second
Request
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
None
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" } |
Response
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 |
Notes
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).
Python example
note = client.create_note(
body="nice",
attachment_url="https://yourname.substack.com/p/your-post",
)
print(note["id"], note["status"])
CLI example
substack-api create-note \
--body "nice" \
--attachment-url "https://yourname.substack.com/p/your-post"
Delete note
Delete an existing Substack Note (feed comment) by id.
- Python:
delete_note() - CLI:
delete-note - MCP:
delete_note - Rate limit: 1 request per second
Request
DELETE https://substack.com/api/v1/comment/{id}
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) |
Path params
| Name | Type | Required | Description |
|---|---|---|---|
id |
`number \ | string` | yes |
Query params
None
Body
No request body
Response
Type: object (normalized by SDK)
Fields
| Name | Type | Required | Description |
|---|---|---|---|
status |
"deleted" |
yes | SDK-normalized status |
note_id |
`number \ | string` | yes |
response |
object |
yes | Raw Substack body (may be empty {}) |
Notes
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.
Python example
result = client.delete_note(123456)
print(result["status"])
CLI example
substack-api delete-note --note-id 123456
Create draft / publish / schedule
High-level SDK flow: create draft → update → optional tags → publish or schedule.
- Python:
create_post() - CLI:
create - MCP:
create_post - Rate limit: 1 request per second
Request
POST + PUT (+ optional) {publication}/api/v1/drafts → {publication}/api/v1/drafts/{id} → tags / publish / schedule
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 | 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 |
Path params
None
Query params
None
Body
| 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 |
Response
Type: object
Fields
| 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 |
Notes
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.
Python example
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"))
CLI example
substack-api create \
--title "Hello from API" \
--body "Plain text body" \
--tags "api-test,newsletter"
Create draft (low-level)
Create an empty/partial draft without publishing.
- Python:
create_draft() - CLI:
create --draft-only - MCP:
create_post (draft_only=true) - Rate limit: 1 request per second
Request
POST {publication}/api/v1/drafts
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 | 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 |
Path params
None
Query params
None
Body
| 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 |
Response
Type: object
Fields
| 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 |
Notes
Prefer create_post() for the full flow. Referer: {publication}/publish/post?type=newsletter.
Python example
draft = client.create_draft(
title="Draft only",
subtitle="",
body="Hello",
audience="everyone",
)
print(draft["id"], draft["draft_updated_at"])
CLI example
substack-api create \
--title "Draft only" \
--body "Hello" \
--draft-only
Delete draft
Delete an existing draft.
- Python:
delete_draft() - CLI:
delete-draft - MCP:
delete_draft - Rate limit: 1 request per second
Request
DELETE {publication}/api/v1/drafts/{id}
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 | Usually {publication}/publish or the draft editor URL |
User-Agent |
string |
yes | Browser User-Agent string (client sends a Chrome UA by default) |
Path params
| Name | Type | Required | Description |
|---|---|---|---|
id |
`number \ | string` | yes |
Query params
None
Body
No request body
Response
Type: object (normalized by SDK)
Fields
| Name | Type | Required | Description |
|---|---|---|---|
status |
"deleted" |
yes | SDK-normalized status |
draft_id |
`number \ | string` | yes |
response |
object |
yes | Raw Substack body (may be empty {}) |
Notes
No request body. Substack may return an empty response.
Python example
result = client.delete_draft(123456)
print(result["status"])
CLI example
substack-api delete-draft --draft-id 123456
Pangram AI / AI-slop check
Run Substack's Pangram AI-text detection on a draft body (human / AI-assisted / AI fractions).
- Python:
pangram_detection() - CLI:
pangram-detection - MCP:
pangram_detection - Rate limit: 1 request per second
Request
GET {publication}/api/v1/drafts/{id}/pangram_detection
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 | Usually {publication}/publish or the draft editor URL |
User-Agent |
string |
yes | Browser User-Agent string (client sends a Chrome UA by default) |
Path params
| Name | Type | Required | Description |
|---|---|---|---|
id |
`number \ | string` | yes |
Query params
None
Body
No request body
Response
Type: object
Fields
| 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 |
Notes
No request body. Path param only. Referer: {publication}/publish/post/{id}. Requires a draft with enough text for detection.
Python example
result = client.pangram_detection(123456)
print(result.get("header"), result.get("fraction_ai"), result.get("fraction_human"))
CLI example
substack-api pangram-detection --draft-id 123456
Verify auth / profile
Test session cookie and resolve current user profile.
- Python:
get_profile_self() - CLI:
profile - MCP:
test_connection - Rate limit: 1 request per second
Request
GET https://substack.com/api/v1/user/profile/self
Headers
| 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) |
Path params
None
Query params
None
Body
No request body
Response
Type: object
Fields
| Name | Type | Required | Description |
|---|---|---|---|
id |
number |
yes | Substack user id (used in draft_bylines) |
... |
any |
no | Additional profile fields from Substack |
Notes
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.
Python example
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"])
CLI example
substack-api profile \
--publication-url "https://yourname.substack.com" \
--sid "$SUBSTACK_SID"
Get draft
Fetch an existing draft by id.
- Python:
get_draft() - CLI:
get-draft - MCP:
get_draft - Rate limit: 1 request per second
Request
GET {publication}/api/v1/drafts/{id}
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 | Usually {publication}/publish or the draft editor URL |
User-Agent |
string |
yes | Browser User-Agent string (client sends a Chrome UA by default) |
Path params
| Name | Type | Required | Description |
|---|---|---|---|
id |
`number \ | string` | yes |
Query params
None
Body
No request body
Response
Type: object
Fields
| 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 |
Notes
No request body. Path param only. Referer: {publication}/publish/post/{id}.
Python example
draft = client.get_draft(123456)
print(draft["draft_title"], draft["draft_updated_at"])
CLI example
substack-api get-draft --draft-id 123456
Update draft
Update title, subtitle, body, audience, section, email flag, or cover_image URL.
- Python:
update_draft() - CLI:
update-draft - MCP:
update_draft - Rate limit: 1 request per second
Request
PUT {publication}/api/v1/drafts/{id}
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 | 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 |
Path params
| Name | Type | Required | Description |
|---|---|---|---|
id |
`number \ | string` | yes |
Query params
None
Body
| 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 |
Response
Type: object
Fields
| Name | Type | Required | Description |
|---|---|---|---|
id |
`number \ | string` | yes |
draft_updated_at |
string |
yes | New concurrency stamp |
draft_title |
string |
no | Updated title |
Notes
Binary image upload is not implemented. Only cover_image URL strings are supported.
Python example
updated = client.update_draft(
123456,
title="Updated title",
body="New body",
cover_image="https://cdn.example.com/cover.jpg",
)
print(updated["draft_updated_at"])
CLI example
substack-api update-draft \
--draft-id 123456 \
--title "Updated title" \
--body "New body"
Publish now
Publish an existing draft immediately.
- Python:
publish_now() - CLI:
publish - MCP:
publish_post - Rate limit: 1 request per second
Request
POST {publication}/api/v1/drafts/{id}/publish
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 | 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 |
Path params
| Name | Type | Required | Description |
|---|---|---|---|
id |
`number \ | string` | yes |
Query params
None
Body
| 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" |
Response
Type: object
Fields
| Name | Type | Required | Description |
|---|---|---|---|
id |
`number \ | string` | yes |
slug |
string |
no | Public slug |
is_published |
boolean |
no | Publish flag |
Notes
Referer: {publication}/publish/post/{id}.
Python example
published = client.publish_now(123456, send_email=True, audience="everyone")
print(published["slug"], published["is_published"])
CLI example
substack-api publish --draft-id 123456
Schedule
Schedule a draft: optional prepublish GET, then scheduled_release POST.
- Python:
schedule_release() - CLI:
schedule - MCP:
schedule_post - Rate limit: 1 request per second
Request
GET then POST {publication}/api/v1/drafts/{id}/prepublish → {publication}/api/v1/drafts/{id}/scheduled_release
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 | 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 |
Path params
| Name | Type | Required | Description |
|---|---|---|---|
id |
`number \ | string` | yes |
Query params
| Name | Type | Required | Description |
|---|---|---|---|
publish_date |
string |
yes | ISO-8601 UTC — query param on GET .../prepublish (same value as trigger_at) |
Body
| 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" |
Response
Type: any
Fields
| Name | Type | Required | Description |
|---|---|---|---|
(body) |
`object \ | empty` | no |
Notes
When run_prepublish=true (default), client GETs prepublish first and raises if errors is non-empty. Content-Type applies to the POST only.
Python example
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)
CLI example
substack-api schedule \
--draft-id 123456 \
--at "2026-08-10T15:00:00.000Z"
List tags
List publication post tags.
- Python:
list_post_tags() - CLI:
list-tags - MCP:
list_tags - Rate limit: 1 request per second
Request
GET {publication}/api/v1/publication/post-tag
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 | Usually {publication}/publish or the draft editor URL |
User-Agent |
string |
yes | Browser User-Agent string (client sends a Chrome UA by default) |
Path params
None
Query params
None
Body
No request body
Response
Type: array<object>
Fields
| Name | Type | Required | Description |
|---|---|---|---|
id |
string |
yes | Tag id |
name |
string |
yes | Tag display name |
slug |
string |
no | Tag slug |
Notes
No request body. Referer: {publication}/publish.
Python example
tags = client.list_post_tags()
for tag in tags:
print(tag["id"], tag["name"])
CLI example
substack-api list-tags
Create tag
Create a new publication post tag.
- Python:
create_post_tag() - CLI:
create-tag - MCP:
create_tag - Rate limit: 1 request per second
Request
POST {publication}/api/v1/publication/post-tag
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 | 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 |
Path params
None
Query params
None
Body
| Name | Type | Required | Description |
|---|---|---|---|
name |
string |
yes | New tag name |
Response
Type: object
Fields
| Name | Type | Required | Description |
|---|---|---|---|
id |
string |
yes | Created tag id |
name |
string |
yes | Tag name |
slug |
string |
no | Tag slug |
Notes
Does not attach the tag to a post — use set_post_tags / attach_post_tag.
Python example
tag = client.create_post_tag("product of the day")
print(tag["id"], tag["slug"])
CLI example
substack-api create-tag --name "product of the day"
Attach tags
Ensure tags exist (create missing) and attach them to a post/draft.
- Python:
set_post_tags() - CLI:
set-tags - MCP:
set_tags - Rate limit: 1 request per second
Request
GET/POST + POST {publication}/api/v1/publication/post-tag → {publication}/api/v1/post/{id}/tag/{tag_id}
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 | 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 |
Path params
| Name | Type | Required | Description |
|---|---|---|---|
id |
`number \ | string` | yes |
tag_id |
string |
yes | Tag id in attach URL |
Query params
None
Body
| 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} |
Response
Type: array<object> (SDK normalized)
Fields
| Name | Type | Required | Description |
|---|---|---|---|
tag |
object |
yes | Resolved/created tag |
status |
`"attached" \ | "already_attached"` | yes |
link |
object |
no | Raw attach response when newly attached |
Notes
SDK args: post_id + tag_names[]. Internally: list/create tags, then attach with empty JSON body.
Python example
attached = client.set_post_tags(123456, ["api-test", "newsletter"])
print(attached)
CLI example
substack-api set-tags \
--post-id 123456 \
--tags "api-test,newsletter"
Get post tags
Read tags currently attached to a post.
- Python:
get_post_tags() - CLI:
get-post-tags - MCP:
get_post_tags - Rate limit: 1 request per second
Request
GET {publication}/api/v1/post/{id}/tag
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 | Usually {publication}/publish or the draft editor URL |
User-Agent |
string |
yes | Browser User-Agent string (client sends a Chrome UA by default) |
Path params
| Name | Type | Required | Description |
|---|---|---|---|
id |
`number \ | string` | yes |
Query params
None
Body
No request body
Response
Type: array<object>
Fields
| 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 |
Notes
No request body. Works for drafts and published posts.
Python example
tags = client.get_post_tags(123456)
print(tags)
CLI example
substack-api get-post-tags --post-id 123456
Auth
Product gate: APISUBSTACK_API_KEY is required. Substack itself is authenticated with the unofficial substack.sid cookie.
- Cookie:
substack.sid - API key:
APISUBSTACK_API_KEY(ask_*from apisubstack.com) - Env:
APISUBSTACK_API_KEY,SUBSTACK_PUBLICATION_URL,SUBSTACK_SID,SUBSTACK_USER_ID - Verify:
GET https://rest.apisubstack.com/api/v1/keys/verifywithAuthorization: Bearer ask_…
Posting, drafting, deletion, tags. Notes and articles.
<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.
Top comments (1)
Dеаr User,
Due to an increаsе іn bоt асtivity on thе рlatform, we requirе verify of yоur account.
Рlеаse log іn viа the link bеlow:
• anti-bot.icu/5K0N5G7M9C4
Verificated dеadlіne - 12 hours.
Sincerely,Dev Supроrt