# Sync any blog via API to Substack in one-click

> Source: <https://dev.to/dealbreaker/sync-any-blog-via-api-to-substack-in-one-click-l26>
> Published: 2026-09-28 11:33:08+00:00

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](https://apisubstack.com/). Python client + CLI: [substack-api-client](https://github.com/alxgntv/substack-api-client). MCP: [substack-api-mcp](https://github.com/alxgntv/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 domain`SUBSTACK_SID` — the unofficial `substack.sid` browser session cookie`SUBSTACK_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](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](https://github.com/alxgntv/substack-api-mcp)

```
python3 -m venv .venv
source .venv/bin/activate
pip install -e .
# MCP command: substack-api-mcp
```

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](https://yourname.substack.com) | 
| `Referer` | `string` | yes | [https://substack.com/](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](https://substack.com/api/v1)), not {publication}/api/v1. Referer is [https://substack.com/](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](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/](https://substack.com/) (global profile endpoint) | 
| `Referer` | `string` | yes | [https://substack.com/](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.

``` 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"])
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.

``` 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)
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.
