Sync any blog via API to Substack in one-click A developer released an open-source API library that syncs any blog to Substack in one click, covering both Notes and newsletter articles. The project ships as three products — a Python client and CLI, and an MCP server for agents such as Claude, Codex, and Cursor — since Substack offers no official public posting API. It handles draft, publish, schedule, tag, and delete operations, and warns that the Substack SID cookie expires and every endpoint is limited to one request per second. 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