cd /news/ai-agents/sync-any-blog-via-api-to-substack-in… Β· home β€Ί topics β€Ί ai-agents β€Ί article
[ARTICLE Β· art-140953] src=dev.to β†— pub= topic=ai-agents verified=true sentiment=↑ positive

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.

by read16 min views2 publishedSep 28, 2026

I created an API library that can sync any blog to Substack in one click. Notes and articles included.

It works with Claude, Codex, Cursor, or any agent.

Substack has no official public posting API. You write somewhere else, then you live in their browser editor. I wanted a client that can post Notes and newsletter articles from a script, a terminal, or an agent β€” draft, publish, schedule, tag, and delete without that UI.

The library is three products, not one package:

substack-api) for the terminal substack-api-mcp) for Cursor, Claude, and other MCP hosts Keys and docs live on apisubstack.com. Python client + CLI: substack-api-client. MCP: substack-api-mcp.

Generate an ask_* key, then either paste the MCP config into your agent or verify the key over HTTP.

{
  "mcpServers": {
    "substack-api": {
      "command": "substack-api-mcp",
      "args": [],
      "env": {
        "APISUBSTACK_API_KEY": "ask_YOUR_KEY",
        "SUBSTACK_PUBLICATION_URL": "https://yourname.substack.com",
        "SUBSTACK_SID": "YOUR_SUBSTACK_SID_VALUE"
      }
    }
  }
}
curl https://rest.apisubstack.com/api/v1/keys/verify \
  -H "Authorization: Bearer ask_YOUR_KEY"

You also need:

SUBSTACK_PUBLICATION_URL β€” *.substack.com or a custom domainSUBSTACK_SID β€” the unofficial substack.sid browser session cookieSUBSTACK_USER_ID β€” otherwise the client resolves it from profile/self Two things that bite people: the SID expires (refresh it on 401/403), and every Substack endpoint is 1 request per second. Do not poll. Do not hammer drafts from a loop.

Plain text is converted to ProseMirror draft_body for you. Cover images are URL strings only β€” there is no binary upload in the SDK.

If the Python client, the CLI, or the MCP server is already installed, skip this and jump to the method you need. If nothing is installed yet, install one product.

Python client and CLI (one repo): https://github.com/alxgntv/substack-api-client

python3 -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
pip install -e .

MCP server (separate product): https://github.com/alxgntv/substack-api-mcp

python3 -m venv .venv
source .venv/bin/activate
pip install -e .

Notes go through Substack's global https://substack.com/api/v1 host. Drafts, publish, schedule, and tags go through {publication}/api/v1. create_post() is an SDK flow (create β†’ update β†’ optional tags β†’ publish or schedule), not a single HTTP call.

Method Python CLI MCP
Create note create_note() create-note create_note
Delete note delete_note() delete-note delete_note
Create / publish / schedule create_post() create create_post
Create draft create_draft() create --draft-only create_post (draft_only=true )
Delete draft delete_draft() delete-draft delete_draft
AI check (Pangram) pangram_detection() pangram-detection pangram_detection
Verify auth / profile get_profile_self() profile test_connection
Get draft get_draft() get-draft get_draft
Update draft update_draft() update-draft update_draft
Publish now publish_now() publish publish_post
Schedule schedule_release() schedule schedule_post
List tags list_post_tags() list-tags list_tags
Create tag create_post_tag() create-tag create_tag
Attach tags set_post_tags() set-tags set_tags
Get post tags get_post_tags() get-post-tags get_post_tags

Rate limit for every endpoint: 1 request per second.

Request, response, Python, CLI, and MCP for each method.

Publish a Substack Note (feed comment). Optional link attachment is created first, then the note is posted.

create_note() create-note create_note POST (+ optional POST) https://substack.com/api/v1/comment/attachment (optional) β†’ https://substack.com/api/v1/comment/feed

Headers

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

Path params

None

Query params

Body

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

Type: object

Fields

Name Type Required Description
id number yes Note / feed comment id
type string yes Captured value: "feed"
status string yes Captured value: "published"
body string no Plain-text body
body_json object no ProseMirror bodyJson echo
attachments array no Attached posts/links when attachment_url was used

Same GLOBAL_API host as get_profile_self (https://substack.com/api/v1), not {publication}/api/v1. Referer is https://substack.com/. Origin stays the publication URL from existing _headers. Optional attachment is sequential: attachment then comment/feed. Captured surface=permalink tabId=for-you. Text-only notes omit attachmentIds. Image/file attachments were not in the capture (type=link only).

note = client.create_note(
    body="nice",
    attachment_url="https://yourname.substack.com/p/your-post",
)
print(note["id"], note["status"])
substack-api create-note \
  --body "nice" \
  --attachment-url "https://yourname.substack.com/p/your-post"

Delete an existing Substack Note (feed comment) by id.

delete_note() delete-note delete_note DELETE https://substack.com/api/v1/comment/{id}

Name Type Required Description
id `number \ string` yes

No request body

Type: object (normalized by SDK)

Name Type Required Description
status "deleted" yes SDK-normalized status
note_id `number \ string` yes
response object yes Raw Substack body (may be empty {})

Same GLOBAL_API host as create_note and get_profile_self. No request body. Reuses _request like delete_draft. Substack may return an empty response.

result = client.delete_note(123456)
print(result["status"])
substack-api delete-note --note-id 123456

High-level SDK flow: create draft β†’ update β†’ optional tags β†’ publish or schedule.

create_post() create create_post POST + PUT (+ optional) {publication}/api/v1/drafts β†’ {publication}/api/v1/drafts/{id} β†’ tags / publish / schedule

Name Type Required Description
Cookie string yes substack.sid= (browser session cookie)
Accept string yes application/json
Origin string yes Publication origin, e.g. https://yourname.substack.com
Referer string yes Usually {publication}/publish or the draft editor URL
User-Agent string yes Browser User-Agent string (client sends a Chrome UA by default)
Content-Type string yes application/json
Name Type Required Description
title string yes Post title (SDK maps to draft_title on Substack)
body `string \ object` yes
subtitle string no Optional subtitle (default "")
audience string no Who can read the post (default "everyone")
section_id `number \ null` no
should_send_email boolean no Email subscribers on publish (default true)
schedule_at `string \ null` no
publish boolean no If true and schedule_at is null β†’ publish now. CLI --draft-only / MCP draft_only=true sets this false
tags string[] no Tag names to ensure and attach after draft update
Name Type Required Description
draft_id `number \ string` yes
draft object yes Latest draft object from update_draft
publication_url string yes Normalized publication origin
edit_url string yes {publication}/publish/post/{draft_id}
status `"draft" \ "published" \ "scheduled"`
post_url string no Public post URL when published
tags array no Attach results when tags were requested

This is an SDK orchestration, not a single HTTP call. Underlying Substack calls use the headers above and JSON bodies documented on create-draft / update-draft / publish-now / schedule pages.

result = client.create_post(
    title="Hello from API",
    body="Plain text becomes ProseMirror draft_body.",
    subtitle="Optional",
    tags=["api-test"],
    publish=True,
)
print(result["draft_id"], result.get("post_url"))
substack-api create \
  --title "Hello from API" \
  --body "Plain text body" \
  --tags "api-test,newsletter"

Create an empty/partial draft without publishing.

create_draft() create --draft-only create_post (draft_only=true) POST {publication}/api/v1/drafts

Name Type Required Description
draft_title string yes Title (SDK arg: title)
draft_subtitle string yes Subtitle (SDK arg: subtitle)
draft_body string yes ProseMirror JSON string (SDK accepts plain text or object)
audience string yes Default "everyone"
type string yes Post type, default "newsletter"
draft_bylines array yes [{ "id": , "is_guest": false }]
draft_section_id `number \ null` no
section_chosen boolean yes Whether a section was selected
detect_language boolean yes Language detection flag (default true on create)
translations array yes Usually []
draft_podcast_url null yes Sent as null for newsletter posts
draft_podcast_duration null yes Sent as null for newsletter posts
Name Type Required Description
id `number \ string` yes
publication_id `number \ string` no
draft_updated_at string yes Timestamp required for later PUT update_draft

Prefer create_post() for the full flow. Referer: {publication}/publish/post?type=newsletter.

draft = client.create_draft(
    title="Draft only",
    subtitle="",
    body="Hello",
    audience="everyone",
)
print(draft["id"], draft["draft_updated_at"])
substack-api create \
  --title "Draft only" \
  --body "Hello" \
  --draft-only

Delete an existing draft.

delete_draft() delete-draft delete_draft DELETE {publication}/api/v1/drafts/{id}

Name Type Required Description
status "deleted" yes SDK-normalized status
draft_id `number \ string` yes
response object yes Raw Substack body (may be empty {})

No request body. Substack may return an empty response.

result = client.delete_draft(123456)
print(result["status"])
substack-api delete-draft --draft-id 123456

Run Substack's Pangram AI-text detection on a draft body (human / AI-assisted / AI fractions).

pangram_detection() pangram-detection pangram_detection GET {publication}/api/v1/drafts/{id}/pangram_detection

Name Type Required Description
type string no Detection result type from Substack / Pangram
header string no Human-readable summary header
fraction_ai number no Estimated AI-generated fraction
fraction_ai_assisted number no Estimated AI-assisted fraction
fraction_human number no Estimated human-written fraction
details any no Additional Pangram detail payload when present
disclosure any no Disclosure / availability metadata when present

No request body. Path param only. Referer: {publication}/publish/post/{id}. Requires a draft with enough text for detection.

result = client.pangram_detection(123456)
print(result.get("header"), result.get("fraction_ai"), result.get("fraction_human"))
substack-api pangram-detection --draft-id 123456

Test session cookie and resolve current user profile.

get_profile_self() profile test_connection GET https://substack.com/api/v1/user/profile/self

Name Type Required Description
Cookie string yes substack.sid= (browser session cookie)
Accept string yes application/json
Origin string yes https://substack.com/ (global profile endpoint)
Referer string yes https://substack.com/ (global profile endpoint)
User-Agent string yes Browser User-Agent string (client sends a Chrome UA by default)
Name Type Required Description
id number yes Substack user id (used in draft_bylines)
... any no Additional profile fields from Substack

No request body. Auth is only via Cookie: substack.sid. Used by resolve_user_id() when SUBSTACK_USER_ID is not set. MCP test_connection wraps this call.

from substack_api_client import SubstackClient, SubstackAuth

client = SubstackClient(
    publication_url="https://yourname.substack.com",
    auth=SubstackAuth(sid="YOUR_SUBSTACK_SID"),
)
profile = client.get_profile_self()
print(profile["id"])
substack-api profile \
  --publication-url "https://yourname.substack.com" \
  --sid "$SUBSTACK_SID"

Fetch an existing draft by id.

get_draft() get-draft get_draft GET {publication}/api/v1/drafts/{id}

Name Type Required Description
id `number \ string` yes
draft_title string no Title
draft_subtitle string no Subtitle
draft_body string no ProseMirror JSON string
draft_updated_at string no Optimistic-concurrency stamp for PUT
draft = client.get_draft(123456)
print(draft["draft_title"], draft["draft_updated_at"])
substack-api get-draft --draft-id 123456

Update title, subtitle, body, audience, section, email flag, or cover_image URL.

update_draft() update-draft update_draft PUT {publication}/api/v1/drafts/{id}

Name Type Required Description
last_updated_at string yes From latest get_draft/create_draft (client fetches it if omitted in SDK)
draft_bylines array yes [{ "id": , "is_guest": false }]
detect_language boolean yes Usually false on update
translations array yes Usually []
draft_title string no SDK arg: title
draft_subtitle string no SDK arg: subtitle
draft_body string no ProseMirror JSON. SDK arg: body (plain text or object)
audience string no Audience string
draft_section_id `number \ null` no
section_chosen boolean no Set true when draft_section_id is set
should_send_email boolean no SDK arg: should_send_email
write_comment_permissions string no e.g. "everyone"
cover_image `string \ null` no
Name Type Required Description
id `number \ string` yes
draft_updated_at string yes New concurrency stamp
draft_title string no Updated title

Binary image upload is not implemented. Only cover_image URL strings are supported.

updated = client.update_draft(
    123456,
    title="Updated title",
    body="New body",
    cover_image="https://cdn.example.com/cover.jpg",
)
print(updated["draft_updated_at"])
substack-api update-draft \
  --draft-id 123456 \
  --title "Updated title" \
  --body "New body"

Publish an existing draft immediately.

publish_now() publish publish_post POST {publication}/api/v1/drafts/{id}/publish

Name Type Required Description
send boolean yes Email subscribers (SDK arg: send_email, default true)
share_automatically boolean yes Default false
audience string yes Default "everyone"
Name Type Required Description
id `number \ string` yes
slug string no Public slug
is_published boolean no Publish flag

Referer: {publication}/publish/post/{id}.

published = client.publish_now(123456, send_email=True, audience="everyone")
print(published["slug"], published["is_published"])
substack-api publish --draft-id 123456

Schedule a draft: optional prepublish GET, then scheduled_release POST.

schedule_release() schedule schedule_post GET then POST {publication}/api/v1/drafts/{id}/prepublish β†’ {publication}/api/v1/drafts/{id}/scheduled_release

Name Type Required Description
publish_date string yes ISO-8601 UTC β€” query param on GET .../prepublish (same value as trigger_at)
Name Type Required Description
trigger_at string yes ISO-8601 UTC schedule time (POST scheduled_release body)
post_audience string yes Default "everyone"
email_audience string yes Default "everyone"

Type: any

Name Type Required Description
(body) `object \ empty` no

When run_prepublish=true (default), client GETs prepublish first and raises if errors is non-empty. Content-Type applies to the POST only.

from substack_api_client import utc_iso
from datetime import datetime, timedelta, timezone

when = utc_iso(datetime.now(timezone.utc) + timedelta(hours=2))
client.schedule_release(123456, trigger_at=when)
substack-api schedule \
  --draft-id 123456 \
  --at "2026-08-10T15:00:00.000Z"

List publication post tags.

list_post_tags() list-tags list_tags GET {publication}/api/v1/publication/post-tag

Type: array<object>

Name Type Required Description
id string yes Tag id
name string yes Tag display name
slug string no Tag slug

No request body. Referer: {publication}/publish.

tags = client.list_post_tags()
for tag in tags:
    print(tag["id"], tag["name"])
substack-api list-tags

Create a new publication post tag.

create_post_tag() create-tag create_tag POST {publication}/api/v1/publication/post-tag

Name Type Required Description
name string yes New tag name
Name Type Required Description
id string yes Created tag id
name string yes Tag name
slug string no Tag slug

Does not attach the tag to a post β€” use set_post_tags / attach_post_tag.

tag = client.create_post_tag("product of the day")
print(tag["id"], tag["slug"])
substack-api create-tag --name "product of the day"

Ensure tags exist (create missing) and attach them to a post/draft.

set_post_tags() set-tags set_tags GET/POST + POST {publication}/api/v1/publication/post-tag β†’ {publication}/api/v1/post/{id}/tag/{tag_id}

Name Type Required Description
id `number \ string` yes
tag_id string yes Tag id in attach URL
Name Type Required Description
(create tag) object no When creating missing tags: { "name": string }
(attach tag) object yes Empty JSON object {} on POST /post/{id}/tag/{tag_id}

Type: array<object> (SDK normalized)

Name Type Required Description
tag object yes Resolved/created tag
status `"attached" \ "already_attached"` yes
link object no Raw attach response when newly attached

SDK args: post_id + tag_names[]. Internally: list/create tags, then attach with empty JSON body.

attached = client.set_post_tags(123456, ["api-test", "newsletter"])
print(attached)
substack-api set-tags \
  --post-id 123456 \
  --tags "api-test,newsletter"

Read tags currently attached to a post.

get_post_tags() get-post-tags get_post_tags GET {publication}/api/v1/post/{id}/tag

Name Type Required Description
id string no Tag or link id
post_tag_id string no Attached tag id (used by set_post_tags dedupe)
name string no Tag name when present

No request body. Works for drafts and published posts.

tags = client.get_post_tags(123456)
print(tags)
substack-api get-post-tags --post-id 123456

Product gate: APISUBSTACK_API_KEY is required. Substack itself is authenticated with the unofficial substack.sid cookie.

APISUBSTACK_API_KEY (ask_* from GET https://rest.apisubstack.com/api/v1/keys/verify with Authorization: Bearer ask_…

<a href="https://apisubstack.com/login" class="ltag-offer__button crayons-btn crayons-btn--primary">Start free β€” $9/mo after</a>

If you write and publish a newsletter and you want posts out of your editor (or your agent) and into Substack without the browser UI, this is the client.

── more in #ai-agents 4 stories Β· sorted by recency
── more on @substack 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain β€” perfect for shipping the agent you just read about.

$git push zahid main
β†’ Live at https://your-agent.zahid.host βœ“
Get free account β†’ Pricing
from €0/mo Β· no card required
LIVE [news/sync-any-blog-via-ap…] indexed:0 read:16min 2026-09-28 Β· β€”