# API Substack

The posting API for Substack authors — draft, publish, and schedule posts from code.

HTML: https://apisubstack.com/
This markdown: https://apisubstack.com/index.md
Cabinet: https://apisubstack.com/account

## Sync any blog to Substack in one-click

You write we sync.

All agents supported. Works with Claude, Claude Code, ChatGPT, Codex, Cursor, Hermes, OpenClaw, Grok Bot, OpenCode, and Pi.

Generate an API key in the cabinet: https://apisubstack.com/account

## Security

This product is 100% safe for your Substack account.

Publishing runs from your own client (Python SDK, CLI, or MCP host on your machine). API Substack does not proxy your posts through third-party publishing services. Your `substack.sid` session and drafts stay under your control: the library talks to Substack from your environment, not from a shared third-party publisher.

## Get started

### MCP

```json
{
  "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"
      }
    }
  }
}
```

### API

```bash
curl https://rest.apisubstack.com/api/v1/keys/verify \
  -H "Authorization: Bearer ask_YOUR_KEY"
```

## Libraries

- Python client — SDK for scripts and apps. Repo: https://github.com/alxgntv/substack-api-client. Cabinet: https://apisubstack.com/account?view=mcp
- CLI — Draft, publish, and schedule from the terminal. Repo: https://github.com/alxgntv/substack-api-client. Cabinet: https://apisubstack.com/account?view=mcp
- MCP — For Cursor, Claude, and other MCP hosts. Repo: https://github.com/alxgntv/substack-api-mcp. Cabinet: https://apisubstack.com/account?view=mcp

# Install first
If the Python client, the CLI, or the MCP server is already installed, skip this section and continue with the method below.
If nothing is installed yet, install one product, then continue.

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 .
After install the CLI command is: substack-api

MCP server (separate product): https://github.com/alxgntv/substack-api-mcp
cd /path/to/Substack-API-MCP
python3 -m venv .venv
source .venv/bin/activate
pip install -e .
After install the MCP command is: substack-api-mcp

## Auth

Product gate: APISUBSTACK_API_KEY required. Substack session uses unofficial substack.sid cookie (expires; refresh on 401/403). Rate limit: 1 request per second per Substack endpoint.

Env: APISUBSTACK_API_KEY, SUBSTACK_PUBLICATION_URL, SUBSTACK_SID, SUBSTACK_USER_ID
Verify: GET /api/v1/keys/verify with Authorization: Bearer ask_…

## Stats

- 10K+ — Tens of thousands of blogs already use us
- 100K+ — Hundreds of thousands of articles already migrated
- 1 click — Move any blog post to Substack
- Any site — Works with blogs from any domain

## Built for Substack authors

If you write and publish a newsletter, this is for you. Connect your publication, authenticate with your session, and post without living in the browser editor.

- Create note
- Delete note
- Create draft / publish / schedule
- Create draft (low-level)
- Delete draft
- Pangram AI / AI-slop check
- Verify auth / profile
- Get draft
- Update draft
- Publish now
- Schedule
- List tags
- Create tag
- Attach tags
- Get post tags

## API Documentation

Every Substack client method. Request, response, Python, CLI, and MCP for each method. Rate limit for every endpoint: 1 request per second.

### 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

POST (+ optional POST) https://substack.com/api/v1/comment/attachment (optional) → https://substack.com/api/v1/comment/feed

Headers:
- Cookie (string, required): substack.sid=<SUBSTACK_SID> (browser session cookie)
- Accept (string, required): application/json
- Origin (string, required): Publication origin, e.g. https://yourname.substack.com
- Referer (string, required): https://substack.com/ (same as get_profile_self)
- User-Agent (string, required): Browser User-Agent string (client sends a Chrome UA by default)
- Content-Type (string, required): application/json

Path params: None

Query params: None

Body:
- bodyJson (object, required): ProseMirror doc object. SDK reuses ensure_draft_body() / ensureDraftBody(), then sets attrs.schemaVersion=v1.
- attachmentIds (string[], optional): Ids from POST /comment/attachment. SDK fills this when attachment_url is passed.
- tabId (string, required): Captured default: "for-you"
- surface (string, required): Captured default: "permalink"
- replyMinimumRole (string, required): Captured default: "everyone"
- (attachment) (object, optional): Optional first call POST /comment/attachment: { "url": string, "type": "link" }

Response type: object

Response fields:
- id (number, required): Note / feed comment id
- type (string, required): Captured value: "feed"
- status (string, required): Captured value: "published"
- body (string, optional): Plain-text body
- body_json (object, optional): ProseMirror bodyJson echo
- attachments (array, optional): Attached posts/links when attachment_url was used

Python example:

```python
note = client.create_note(
    body="nice",
    attachment_url="https://yourname.substack.com/p/your-post",
)
print(note["id"], note["status"])
```

CLI example:

```bash
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

DELETE https://substack.com/api/v1/comment/{id}

Headers:
- Cookie (string, required): substack.sid=<SUBSTACK_SID> (browser session cookie)
- Accept (string, required): application/json
- Origin (string, required): Publication origin, e.g. https://yourname.substack.com
- Referer (string, required): https://substack.com/ (same as get_profile_self)
- User-Agent (string, required): Browser User-Agent string (client sends a Chrome UA by default)

Path params:
- id (number | string, required): Note / feed comment id in the URL path

Query params: None

Body: No request body

Response type: object (normalized by SDK)

Response fields:
- status ("deleted", required): SDK-normalized status
- note_id (number | string, required): Deleted note id
- response (object, required): Raw Substack body (may be empty {})

Python example:

```python
result = client.delete_note(123456)
print(result["status"])
```

CLI example:

```bash
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

POST + PUT (+ optional) {publication}/api/v1/drafts → {publication}/api/v1/drafts/{id} → tags / publish / schedule

Headers:
- Cookie (string, required): substack.sid=<SUBSTACK_SID> (browser session cookie)
- Accept (string, required): application/json
- Origin (string, required): Publication origin, e.g. https://yourname.substack.com
- Referer (string, required): Usually {publication}/publish or the draft editor URL
- User-Agent (string, required): Browser User-Agent string (client sends a Chrome UA by default)
- Content-Type (string, required): application/json

Path params: None

Query params: None

Body:
- title (string, required): Post title (SDK maps to draft_title on Substack)
- body (string | object, required): Plain text or ProseMirror doc. Client converts plain text to draft_body JSON
- subtitle (string, optional): Optional subtitle (default "")
- audience (string, optional): Who can read the post (default "everyone")
- section_id (number | null, optional): Optional publication section id
- should_send_email (boolean, optional): Email subscribers on publish (default true)
- schedule_at (string | null, optional): ISO-8601 UTC datetime. If set, schedules instead of publishing
- publish (boolean, optional): If true and schedule_at is null → publish now. CLI --draft-only / MCP draft_only=true sets this false
- tags (string[], optional): Tag names to ensure and attach after draft update

Response type: object

Response fields:
- draft_id (number | string, required): Created draft id
- draft (object, required): Latest draft object from update_draft
- publication_url (string, required): Normalized publication origin
- edit_url (string, required): {publication}/publish/post/{draft_id}
- status ("draft" | "published" | "scheduled", optional): Outcome of the flow
- post_url (string, optional): Public post URL when published
- tags (array, optional): Attach results when tags were requested

Python example:

```python
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:

```bash
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

POST {publication}/api/v1/drafts

Headers:
- Cookie (string, required): substack.sid=<SUBSTACK_SID> (browser session cookie)
- Accept (string, required): application/json
- Origin (string, required): Publication origin, e.g. https://yourname.substack.com
- Referer (string, required): Usually {publication}/publish or the draft editor URL
- User-Agent (string, required): Browser User-Agent string (client sends a Chrome UA by default)
- Content-Type (string, required): application/json

Path params: None

Query params: None

Body:
- draft_title (string, required): Title (SDK arg: title)
- draft_subtitle (string, required): Subtitle (SDK arg: subtitle)
- draft_body (string, required): ProseMirror JSON string (SDK accepts plain text or object)
- audience (string, required): Default "everyone"
- type (string, required): Post type, default "newsletter"
- draft_bylines (array, required): [{ "id": <user_id>, "is_guest": false }]
- draft_section_id (number | null, optional): Section id when section_chosen is true
- section_chosen (boolean, required): Whether a section was selected
- detect_language (boolean, required): Language detection flag (default true on create)
- translations (array, required): Usually []
- draft_podcast_url (null, required): Sent as null for newsletter posts
- draft_podcast_duration (null, required): Sent as null for newsletter posts

Response type: object

Response fields:
- id (number | string, required): New draft id
- publication_id (number | string, optional): Publication id
- draft_updated_at (string, required): Timestamp required for later PUT update_draft

Python example:

```python
draft = client.create_draft(
    title="Draft only",
    subtitle="",
    body="Hello",
    audience="everyone",
)
print(draft["id"], draft["draft_updated_at"])
```

CLI example:

```bash
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

DELETE {publication}/api/v1/drafts/{id}

Headers:
- Cookie (string, required): substack.sid=<SUBSTACK_SID> (browser session cookie)
- Accept (string, required): application/json
- Origin (string, required): Publication origin, e.g. https://yourname.substack.com
- Referer (string, required): Usually {publication}/publish or the draft editor URL
- User-Agent (string, required): Browser User-Agent string (client sends a Chrome UA by default)

Path params:
- id (number | string, required): Draft id in the URL path

Query params: None

Body: No request body

Response type: object (normalized by SDK)

Response fields:
- status ("deleted", required): SDK-normalized status
- draft_id (number | string, required): Deleted draft id
- response (object, required): Raw Substack body (may be empty {})

Python example:

```python
result = client.delete_draft(123456)
print(result["status"])
```

CLI example:

```bash
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

GET {publication}/api/v1/drafts/{id}/pangram_detection

Headers:
- Cookie (string, required): substack.sid=<SUBSTACK_SID> (browser session cookie)
- Accept (string, required): application/json
- Origin (string, required): Publication origin, e.g. https://yourname.substack.com
- Referer (string, required): Usually {publication}/publish or the draft editor URL
- User-Agent (string, required): Browser User-Agent string (client sends a Chrome UA by default)

Path params:
- id (number | string, required): Draft id in the URL path

Query params: None

Body: No request body

Response type: object

Response fields:
- type (string, optional): Detection result type from Substack / Pangram
- header (string, optional): Human-readable summary header
- fraction_ai (number, optional): Estimated AI-generated fraction
- fraction_ai_assisted (number, optional): Estimated AI-assisted fraction
- fraction_human (number, optional): Estimated human-written fraction
- details (any, optional): Additional Pangram detail payload when present
- disclosure (any, optional): Disclosure / availability metadata when present

Python example:

```python
result = client.pangram_detection(123456)
print(result.get("header"), result.get("fraction_ai"), result.get("fraction_human"))
```

CLI example:

```bash
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

GET https://substack.com/api/v1/user/profile/self

Headers:
- Cookie (string, required): substack.sid=<SUBSTACK_SID> (browser session cookie)
- Accept (string, required): application/json
- Origin (string, required): https://substack.com/ (global profile endpoint)
- Referer (string, required): https://substack.com/ (global profile endpoint)
- User-Agent (string, required): Browser User-Agent string (client sends a Chrome UA by default)

Path params: None

Query params: None

Body: No request body

Response type: object

Response fields:
- id (number, required): Substack user id (used in draft_bylines)
- ... (any, optional): Additional profile fields from Substack

Python example:

```python
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:

```bash
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

GET {publication}/api/v1/drafts/{id}

Headers:
- Cookie (string, required): substack.sid=<SUBSTACK_SID> (browser session cookie)
- Accept (string, required): application/json
- Origin (string, required): Publication origin, e.g. https://yourname.substack.com
- Referer (string, required): Usually {publication}/publish or the draft editor URL
- User-Agent (string, required): Browser User-Agent string (client sends a Chrome UA by default)

Path params:
- id (number | string, required): Draft id in the URL path

Query params: None

Body: No request body

Response type: object

Response fields:
- id (number | string, required): Draft id
- draft_title (string, optional): Title
- draft_subtitle (string, optional): Subtitle
- draft_body (string, optional): ProseMirror JSON string
- draft_updated_at (string, optional): Optimistic-concurrency stamp for PUT

Python example:

```python
draft = client.get_draft(123456)
print(draft["draft_title"], draft["draft_updated_at"])
```

CLI example:

```bash
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

PUT {publication}/api/v1/drafts/{id}

Headers:
- Cookie (string, required): substack.sid=<SUBSTACK_SID> (browser session cookie)
- Accept (string, required): application/json
- Origin (string, required): Publication origin, e.g. https://yourname.substack.com
- Referer (string, required): Usually {publication}/publish or the draft editor URL
- User-Agent (string, required): Browser User-Agent string (client sends a Chrome UA by default)
- Content-Type (string, required): application/json

Path params:
- id (number | string, required): Draft id in the URL path

Query params: None

Body:
- last_updated_at (string, required): From latest get_draft/create_draft (client fetches it if omitted in SDK)
- draft_bylines (array, required): [{ "id": <user_id>, "is_guest": false }]
- detect_language (boolean, required): Usually false on update
- translations (array, required): Usually []
- draft_title (string, optional): SDK arg: title
- draft_subtitle (string, optional): SDK arg: subtitle
- draft_body (string, optional): ProseMirror JSON. SDK arg: body (plain text or object)
- audience (string, optional): Audience string
- draft_section_id (number | null, optional): SDK arg: section_id
- section_chosen (boolean, optional): Set true when draft_section_id is set
- should_send_email (boolean, optional): SDK arg: should_send_email
- write_comment_permissions (string, optional): e.g. "everyone"
- cover_image (string | null, optional): Cover image URL only — not a file upload

Response type: object

Response fields:
- id (number | string, required): Draft id
- draft_updated_at (string, required): New concurrency stamp
- draft_title (string, optional): Updated title

Python example:

```python
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:

```bash
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

POST {publication}/api/v1/drafts/{id}/publish

Headers:
- Cookie (string, required): substack.sid=<SUBSTACK_SID> (browser session cookie)
- Accept (string, required): application/json
- Origin (string, required): Publication origin, e.g. https://yourname.substack.com
- Referer (string, required): Usually {publication}/publish or the draft editor URL
- User-Agent (string, required): Browser User-Agent string (client sends a Chrome UA by default)
- Content-Type (string, required): application/json

Path params:
- id (number | string, required): Draft id in the URL path

Query params: None

Body:
- send (boolean, required): Email subscribers (SDK arg: send_email, default true)
- share_automatically (boolean, required): Default false
- audience (string, required): Default "everyone"

Response type: object

Response fields:
- id (number | string, required): Post id
- slug (string, optional): Public slug
- is_published (boolean, optional): Publish flag

Python example:

```python
published = client.publish_now(123456, send_email=True, audience="everyone")
print(published["slug"], published["is_published"])
```

CLI example:

```bash
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

GET then POST {publication}/api/v1/drafts/{id}/prepublish → {publication}/api/v1/drafts/{id}/scheduled_release

Headers:
- Cookie (string, required): substack.sid=<SUBSTACK_SID> (browser session cookie)
- Accept (string, required): application/json
- Origin (string, required): Publication origin, e.g. https://yourname.substack.com
- Referer (string, required): Usually {publication}/publish or the draft editor URL
- User-Agent (string, required): Browser User-Agent string (client sends a Chrome UA by default)
- Content-Type (string, required): application/json

Path params:
- id (number | string, required): Draft id in the URL path

Query params:
- publish_date (string, required): ISO-8601 UTC — query param on GET .../prepublish (same value as trigger_at)

Body:
- trigger_at (string, required): ISO-8601 UTC schedule time (POST scheduled_release body)
- post_audience (string, required): Default "everyone"
- email_audience (string, required): Default "everyone"

Response type: any

Response fields:
- (body) (object | empty, optional): Substack often returns empty or a small object. Prepublish may include errors[]

Python example:

```python
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:

```bash
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

GET {publication}/api/v1/publication/post-tag

Headers:
- Cookie (string, required): substack.sid=<SUBSTACK_SID> (browser session cookie)
- Accept (string, required): application/json
- Origin (string, required): Publication origin, e.g. https://yourname.substack.com
- Referer (string, required): Usually {publication}/publish or the draft editor URL
- User-Agent (string, required): 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>

Response fields:
- id (string, required): Tag id
- name (string, required): Tag display name
- slug (string, optional): Tag slug

Python example:

```python
tags = client.list_post_tags()
for tag in tags:
    print(tag["id"], tag["name"])
```

CLI example:

```bash
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

POST {publication}/api/v1/publication/post-tag

Headers:
- Cookie (string, required): substack.sid=<SUBSTACK_SID> (browser session cookie)
- Accept (string, required): application/json
- Origin (string, required): Publication origin, e.g. https://yourname.substack.com
- Referer (string, required): Usually {publication}/publish or the draft editor URL
- User-Agent (string, required): Browser User-Agent string (client sends a Chrome UA by default)
- Content-Type (string, required): application/json

Path params: None

Query params: None

Body:
- name (string, required): New tag name

Response type: object

Response fields:
- id (string, required): Created tag id
- name (string, required): Tag name
- slug (string, optional): Tag slug

Python example:

```python
tag = client.create_post_tag("product of the day")
print(tag["id"], tag["slug"])
```

CLI example:

```bash
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

GET/POST + POST {publication}/api/v1/publication/post-tag → {publication}/api/v1/post/{id}/tag/{tag_id}

Headers:
- Cookie (string, required): substack.sid=<SUBSTACK_SID> (browser session cookie)
- Accept (string, required): application/json
- Origin (string, required): Publication origin, e.g. https://yourname.substack.com
- Referer (string, required): Usually {publication}/publish or the draft editor URL
- User-Agent (string, required): Browser User-Agent string (client sends a Chrome UA by default)
- Content-Type (string, required): application/json

Path params:
- id (number | string, required): Post/draft id in attach URL
- tag_id (string, required): Tag id in attach URL

Query params: None

Body:
- (create tag) (object, optional): When creating missing tags: { "name": string }
- (attach tag) (object, required): Empty JSON object {} on POST /post/{id}/tag/{tag_id}

Response type: array<object> (SDK normalized)

Response fields:
- tag (object, required): Resolved/created tag
- status ("attached" | "already_attached", required): Attach result
- link (object, optional): Raw attach response when newly attached

Python example:

```python
attached = client.set_post_tags(123456, ["api-test", "newsletter"])
print(attached)
```

CLI example:

```bash
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

GET {publication}/api/v1/post/{id}/tag

Headers:
- Cookie (string, required): substack.sid=<SUBSTACK_SID> (browser session cookie)
- Accept (string, required): application/json
- Origin (string, required): Publication origin, e.g. https://yourname.substack.com
- Referer (string, required): Usually {publication}/publish or the draft editor URL
- User-Agent (string, required): Browser User-Agent string (client sends a Chrome UA by default)

Path params:
- id (number | string, required): Post/draft id in the URL path

Query params: None

Body: No request body

Response type: array<object>

Response fields:
- id (string, optional): Tag or link id
- post_tag_id (string, optional): Attached tag id (used by set_post_tags dedupe)
- name (string, optional): Tag name when present

Python example:

```python
tags = client.get_post_tags(123456)
print(tags)
```

CLI example:

```bash
substack-api get-post-tags --post-id 123456
```

## Python client, CLI, and MCP

Three separate products for the same posting job. The Python client for scripts and apps. The CLI for the terminal. The MCP server for AI tools such as Cursor or Claude. Pick one product — they are not the same package.

- Python client: Standalone Python SDK for Substack authors who post from scripts and apps.
- CLI: Standalone command-line tool for draft, publish, and schedule from the terminal.
- MCP server: Standalone MCP product for posting from AI tools such as Cursor or Claude.

- Plain-text → ProseMirror draft_body conversion
- Universal publication_url (*.substack.com or custom domain)
- cover_image URL on update_draft (URL string only — no binary image upload in SDK)

## They're writing about us

Posts about posting to Substack with API Substack.

- DEV Community: [Sync any blog via API to Substack in one-click](https://dev.to/dealbreaker/sync-any-blog-via-api-to-substack-in-one-click-l26)
- Indie Hackers: [Sync any blog via API to Substack in one-click](https://www.indiehackers.com/post/sync-any-blog-via-api-to-substack-in-one-click-de81e20855)
- Medium: [Sync any blog via API to Substack in one-click](https://medium.com/@lxgn/sync-any-blog-via-api-to-substack-in-one-click-22f2d5e2e9a1)
- LinkedIn: [How to post to Substack via API](https://www.linkedin.com/pulse/how-post-substack-via-api-alex-ign-ferle/)

## Very frequently asked questions

### Who is this for?

Writers and newsletter operators who publish on Substack and want to post with the Python client, the CLI, or the MCP server — each a separate product. If your job is to get posts out of your head and into your publication — this product is for you.

### What job does this close?

Help Substack authors post to their publication programmatically — draft, publish, and schedule without the browser UI. Supporting actions (update draft, delete draft, tags) exist to make that posting workflow complete.

### How do I post with it?

Authenticate with your browser session cookie (substack.sid), set your publication URL, then create, publish, or schedule with one of the separate products: Python client, CLI, MCP server. Each is its own product — pick the one that fits your workflow.
