{"slug": "sync-any-blog-via-api-to-substack-in-one-click", "title": "Sync any blog via API to Substack in one-click", "summary": "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.", "body_md": "I created an API library that can sync any blog to Substack in one click. Notes and articles included.\n\nIt works with Claude, Codex, Cursor, or any agent.\n\nSubstack 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.\n\nThe library is three products, not one package:\n\n`substack-api`) for the terminal` substack-api-mcp`) for Cursor, Claude, and other MCP hosts\nKeys 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).\n\nGenerate an `ask_*` key, then either paste the MCP config into your agent or verify the key over HTTP.\n\n```\n{\n  \"mcpServers\": {\n    \"substack-api\": {\n      \"command\": \"substack-api-mcp\",\n      \"args\": [],\n      \"env\": {\n        \"APISUBSTACK_API_KEY\": \"ask_YOUR_KEY\",\n        \"SUBSTACK_PUBLICATION_URL\": \"https://yourname.substack.com\",\n        \"SUBSTACK_SID\": \"YOUR_SUBSTACK_SID_VALUE\"\n      }\n    }\n  }\n}\ncurl https://rest.apisubstack.com/api/v1/keys/verify \\\n  -H \"Authorization: Bearer ask_YOUR_KEY\"\n```\n\nYou also need:\n\n`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`\nTwo 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.\n\nPlain text is converted to ProseMirror `draft_body` for you. Cover images are URL strings only — there is no binary upload in the SDK.\n\nIf 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.\n\nPython client and CLI (one repo): [https://github.com/alxgntv/substack-api-client](https://github.com/alxgntv/substack-api-client)\n\n```\npython3 -m venv .venv\nsource .venv/bin/activate\npip install -r requirements.txt\npip install -e .\n# CLI command: substack-api\n```\n\nMCP server (separate product): [https://github.com/alxgntv/substack-api-mcp](https://github.com/alxgntv/substack-api-mcp)\n\n```\npython3 -m venv .venv\nsource .venv/bin/activate\npip install -e .\n# MCP command: substack-api-mcp\n```\n\nNotes 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.\n\n| Method | Python | CLI | MCP | \n|---|---|---|---|\n| Create note | `create_note()` | `create-note` | `create_note` | \n| Delete note | `delete_note()` | `delete-note` | `delete_note` | \n| Create / publish / schedule | `create_post()` | `create` | `create_post` | \n| Create draft | `create_draft()` | `create --draft-only` | `create_post` (`draft_only=true` ) | \n| Delete draft | `delete_draft()` | `delete-draft` | `delete_draft` | \n| AI check (Pangram) | `pangram_detection()` | `pangram-detection` | `pangram_detection` | \n| Verify auth / profile | `get_profile_self()` | `profile` | `test_connection` | \n| Get draft | `get_draft()` | `get-draft` | `get_draft` | \n| Update draft | `update_draft()` | `update-draft` | `update_draft` | \n| Publish now | `publish_now()` | `publish` | `publish_post` | \n| Schedule | `schedule_release()` | `schedule` | `schedule_post` | \n| List tags | `list_post_tags()` | `list-tags` | `list_tags` | \n| Create tag | `create_post_tag()` | `create-tag` | `create_tag` | \n| Attach tags | `set_post_tags()` | `set-tags` | `set_tags` | \n| Get post tags | `get_post_tags()` | `get-post-tags` | `get_post_tags` | \n\nRate limit for every endpoint: **1 request per second**.\n\nRequest, response, Python, CLI, and MCP for each method.\n\nPublish a Substack Note (feed comment). Optional link attachment is created first, then the note is posted.\n\n`create_note()`\n`create-note`\n`create_note`\n`POST (+ optional POST) https://substack.com/api/v1/comment/attachment (optional) → https://substack.com/api/v1/comment/feed`\n\n**Headers**\n\n| Name | Type | Required | Description | \n|---|---|---|---|\n| `Cookie` | `string` | yes | substack.sid= (browser session cookie) | \n| `Accept` | `string` | yes | application/json | \n| `Origin` | `string` | yes | Publication origin, e.g. [https://yourname.substack.com](https://yourname.substack.com) | \n| `Referer` | `string` | yes | [https://substack.com/](https://substack.com/) (same as get_profile_self) | \n| `User-Agent` | `string` | yes | Browser User-Agent string (client sends a Chrome UA by default) | \n| `Content-Type` | `string` | yes | application/json | \n\n**Path params**\n\n*None*\n\n**Query params**\n\n**Body**\n\n| Name | Type | Required | Description | \n|---|---|---|---|\n| `bodyJson` | `object` | yes | ProseMirror doc object. SDK reuses ensure_draft_body() / ensureDraftBody(), then sets attrs.schemaVersion=v1. | \n| `attachmentIds` | `string[]` | no | Ids from POST /comment/attachment. SDK fills this when attachment_url is passed. | \n| `tabId` | `string` | yes | Captured default: \"for-you\" | \n| `surface` | `string` | yes | Captured default: \"permalink\" | \n| `replyMinimumRole` | `string` | yes | Captured default: \"everyone\" | \n| `(attachment)` | `object` | no | Optional first call POST /comment/attachment: { \"url\": string, \"type\": \"link\" } | \n\nType: `object`\n\n**Fields**\n\n| Name | Type | Required | Description | \n|---|---|---|---|\n| `id` | `number` | yes | Note / feed comment id | \n| `type` | `string` | yes | Captured value: \"feed\" | \n| `status` | `string` | yes | Captured value: \"published\" | \n| `body` | `string` | no | Plain-text body | \n| `body_json` | `object` | no | ProseMirror bodyJson echo | \n| `attachments` | `array` | no | Attached posts/links when attachment_url was used | \n\nSame 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).\n\n```\nnote = client.create_note(\n    body=\"nice\",\n    attachment_url=\"https://yourname.substack.com/p/your-post\",\n)\nprint(note[\"id\"], note[\"status\"])\nsubstack-api create-note \\\n  --body \"nice\" \\\n  --attachment-url \"https://yourname.substack.com/p/your-post\"\n```\n\nDelete an existing Substack Note (feed comment) by id.\n\n`delete_note()`\n`delete-note`\n`delete_note`\n`DELETE https://substack.com/api/v1/comment/{id}`\n\n| Name | Type | Required | Description | \n|---|---|---|---|\n| `id` | `number \\ | string` | yes | \n\n*No request body*\n\nType: `object (normalized by SDK)`\n\n| Name | Type | Required | Description | \n|---|---|---|---|\n| `status` | `\"deleted\"` | yes | SDK-normalized status | \n| `note_id` | `number \\ | string` | yes | \n| `response` | `object` | yes | Raw Substack body (may be empty {}) | \n\nSame GLOBAL_API host as create_note and get_profile_self. No request body. Reuses _request like delete_draft. Substack may return an empty response.\n\n```\nresult = client.delete_note(123456)\nprint(result[\"status\"])\nsubstack-api delete-note --note-id 123456\n```\n\nHigh-level SDK flow: create draft → update → optional tags → publish or schedule.\n\n`create_post()`\n`create`\n`create_post`\n`POST + PUT (+ optional) {publication}/api/v1/drafts → {publication}/api/v1/drafts/{id} → tags / publish / schedule`\n\n| Name | Type | Required | Description | \n|---|---|---|---|\n| `Cookie` | `string` | yes | substack.sid= (browser session cookie) | \n| `Accept` | `string` | yes | application/json | \n| `Origin` | `string` | yes | Publication origin, e.g. [https://yourname.substack.com](https://yourname.substack.com) | \n| `Referer` | `string` | yes | Usually {publication}/publish or the draft editor URL | \n| `User-Agent` | `string` | yes | Browser User-Agent string (client sends a Chrome UA by default) | \n| `Content-Type` | `string` | yes | application/json | \n\n| Name | Type | Required | Description | \n|---|---|---|---|\n| `title` | `string` | yes | Post title (SDK maps to draft_title on Substack) | \n| `body` | `string \\ | object` | yes | \n| `subtitle` | `string` | no | Optional subtitle (default \"\") | \n| `audience` | `string` | no | Who can read the post (default \"everyone\") | \n| `section_id` | `number \\ | null` | no | \n| `should_send_email` | `boolean` | no | Email subscribers on publish (default true) | \n| `schedule_at` | `string \\ | null` | no | \n| `publish` | `boolean` | no | If true and schedule_at is null → publish now. CLI --draft-only / MCP draft_only=true sets this false | \n| `tags` | `string[]` | no | Tag names to ensure and attach after draft update | \n\n| Name | Type | Required | Description | \n|---|---|---|---|\n| `draft_id` | `number \\ | string` | yes | \n| `draft` | `object` | yes | Latest draft object from update_draft | \n| `publication_url` | `string` | yes | Normalized publication origin | \n| `edit_url` | `string` | yes | {publication}/publish/post/{draft_id} | \n| `status` | `\"draft\" \\ | \"published\" \\ | \"scheduled\"` | \n| `post_url` | `string` | no | Public post URL when published | \n| `tags` | `array` | no | Attach results when tags were requested | \n\nThis 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.\n\n```\nresult = client.create_post(\n    title=\"Hello from API\",\n    body=\"Plain text becomes ProseMirror draft_body.\",\n    subtitle=\"Optional\",\n    tags=[\"api-test\"],\n    publish=True,\n)\nprint(result[\"draft_id\"], result.get(\"post_url\"))\nsubstack-api create \\\n  --title \"Hello from API\" \\\n  --body \"Plain text body\" \\\n  --tags \"api-test,newsletter\"\n```\n\nCreate an empty/partial draft without publishing.\n\n`create_draft()`\n`create --draft-only`\n`create_post (draft_only=true)`\n`POST {publication}/api/v1/drafts`\n\n| Name | Type | Required | Description | \n|---|---|---|---|\n| `draft_title` | `string` | yes | Title (SDK arg: title) | \n| `draft_subtitle` | `string` | yes | Subtitle (SDK arg: subtitle) | \n| `draft_body` | `string` | yes | ProseMirror JSON string (SDK accepts plain text or object) | \n| `audience` | `string` | yes | Default \"everyone\" | \n| `type` | `string` | yes | Post type, default \"newsletter\" | \n| `draft_bylines` | `array` | yes | [{ \"id\": , \"is_guest\": false }] | \n| `draft_section_id` | `number \\ | null` | no | \n| `section_chosen` | `boolean` | yes | Whether a section was selected | \n| `detect_language` | `boolean` | yes | Language detection flag (default true on create) | \n| `translations` | `array` | yes | Usually [] | \n| `draft_podcast_url` | `null` | yes | Sent as null for newsletter posts | \n| `draft_podcast_duration` | `null` | yes | Sent as null for newsletter posts | \n\n| Name | Type | Required | Description | \n|---|---|---|---|\n| `id` | `number \\ | string` | yes | \n| `publication_id` | `number \\ | string` | no | \n| `draft_updated_at` | `string` | yes | Timestamp required for later PUT update_draft | \n\nPrefer create_post() for the full flow. Referer: {publication}/publish/post?type=newsletter.\n\n```\ndraft = client.create_draft(\n    title=\"Draft only\",\n    subtitle=\"\",\n    body=\"Hello\",\n    audience=\"everyone\",\n)\nprint(draft[\"id\"], draft[\"draft_updated_at\"])\nsubstack-api create \\\n  --title \"Draft only\" \\\n  --body \"Hello\" \\\n  --draft-only\n```\n\nDelete an existing draft.\n\n`delete_draft()`\n`delete-draft`\n`delete_draft`\n`DELETE {publication}/api/v1/drafts/{id}`\n\n| Name | Type | Required | Description | \n|---|---|---|---|\n| `status` | `\"deleted\"` | yes | SDK-normalized status | \n| `draft_id` | `number \\ | string` | yes | \n| `response` | `object` | yes | Raw Substack body (may be empty {}) | \n\nNo request body. Substack may return an empty response.\n\n```\nresult = client.delete_draft(123456)\nprint(result[\"status\"])\nsubstack-api delete-draft --draft-id 123456\n```\n\nRun Substack's Pangram AI-text detection on a draft body (human / AI-assisted / AI fractions).\n\n`pangram_detection()`\n`pangram-detection`\n`pangram_detection`\n`GET {publication}/api/v1/drafts/{id}/pangram_detection`\n\n| Name | Type | Required | Description | \n|---|---|---|---|\n| `type` | `string` | no | Detection result type from Substack / Pangram | \n| `header` | `string` | no | Human-readable summary header | \n| `fraction_ai` | `number` | no | Estimated AI-generated fraction | \n| `fraction_ai_assisted` | `number` | no | Estimated AI-assisted fraction | \n| `fraction_human` | `number` | no | Estimated human-written fraction | \n| `details` | `any` | no | Additional Pangram detail payload when present | \n| `disclosure` | `any` | no | Disclosure / availability metadata when present | \n\nNo request body. Path param only. Referer: {publication}/publish/post/{id}. Requires a draft with enough text for detection.\n\n```\nresult = client.pangram_detection(123456)\nprint(result.get(\"header\"), result.get(\"fraction_ai\"), result.get(\"fraction_human\"))\nsubstack-api pangram-detection --draft-id 123456\n```\n\nTest session cookie and resolve current user profile.\n\n`get_profile_self()`\n`profile`\n`test_connection`\n`GET https://substack.com/api/v1/user/profile/self`\n\n| Name | Type | Required | Description | \n|---|---|---|---|\n| `Cookie` | `string` | yes | substack.sid= (browser session cookie) | \n| `Accept` | `string` | yes | application/json | \n| `Origin` | `string` | yes | [https://substack.com/](https://substack.com/) (global profile endpoint) | \n| `Referer` | `string` | yes | [https://substack.com/](https://substack.com/) (global profile endpoint) | \n| `User-Agent` | `string` | yes | Browser User-Agent string (client sends a Chrome UA by default) | \n\n| Name | Type | Required | Description | \n|---|---|---|---|\n| `id` | `number` | yes | Substack user id (used in draft_bylines) | \n| `...` | `any` | no | Additional profile fields from Substack | \n\nNo 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.\n\n``` python\nfrom substack_api_client import SubstackClient, SubstackAuth\n\nclient = SubstackClient(\n    publication_url=\"https://yourname.substack.com\",\n    auth=SubstackAuth(sid=\"YOUR_SUBSTACK_SID\"),\n)\nprofile = client.get_profile_self()\nprint(profile[\"id\"])\nsubstack-api profile \\\n  --publication-url \"https://yourname.substack.com\" \\\n  --sid \"$SUBSTACK_SID\"\n```\n\nFetch an existing draft by id.\n\n`get_draft()`\n`get-draft`\n`get_draft`\n`GET {publication}/api/v1/drafts/{id}`\n\n| Name | Type | Required | Description | \n|---|---|---|---|\n| `id` | `number \\ | string` | yes | \n| `draft_title` | `string` | no | Title | \n| `draft_subtitle` | `string` | no | Subtitle | \n| `draft_body` | `string` | no | ProseMirror JSON string | \n| `draft_updated_at` | `string` | no | Optimistic-concurrency stamp for PUT | \n\n```\ndraft = client.get_draft(123456)\nprint(draft[\"draft_title\"], draft[\"draft_updated_at\"])\nsubstack-api get-draft --draft-id 123456\n```\n\nUpdate title, subtitle, body, audience, section, email flag, or cover_image URL.\n\n`update_draft()`\n`update-draft`\n`update_draft`\n`PUT {publication}/api/v1/drafts/{id}`\n\n| Name | Type | Required | Description | \n|---|---|---|---|\n| `last_updated_at` | `string` | yes | From latest get_draft/create_draft (client fetches it if omitted in SDK) | \n| `draft_bylines` | `array` | yes | [{ \"id\": , \"is_guest\": false }] | \n| `detect_language` | `boolean` | yes | Usually false on update | \n| `translations` | `array` | yes | Usually [] | \n| `draft_title` | `string` | no | SDK arg: title | \n| `draft_subtitle` | `string` | no | SDK arg: subtitle | \n| `draft_body` | `string` | no | ProseMirror JSON. SDK arg: body (plain text or object) | \n| `audience` | `string` | no | Audience string | \n| `draft_section_id` | `number \\ | null` | no | \n| `section_chosen` | `boolean` | no | Set true when draft_section_id is set | \n| `should_send_email` | `boolean` | no | SDK arg: should_send_email | \n| `write_comment_permissions` | `string` | no | e.g. \"everyone\" | \n| `cover_image` | `string \\ | null` | no | \n\n| Name | Type | Required | Description | \n|---|---|---|---|\n| `id` | `number \\ | string` | yes | \n| `draft_updated_at` | `string` | yes | New concurrency stamp | \n| `draft_title` | `string` | no | Updated title | \n\nBinary image upload is not implemented. Only cover_image URL strings are supported.\n\n```\nupdated = client.update_draft(\n    123456,\n    title=\"Updated title\",\n    body=\"New body\",\n    cover_image=\"https://cdn.example.com/cover.jpg\",\n)\nprint(updated[\"draft_updated_at\"])\nsubstack-api update-draft \\\n  --draft-id 123456 \\\n  --title \"Updated title\" \\\n  --body \"New body\"\n```\n\nPublish an existing draft immediately.\n\n`publish_now()`\n`publish`\n`publish_post`\n`POST {publication}/api/v1/drafts/{id}/publish`\n\n| Name | Type | Required | Description | \n|---|---|---|---|\n| `send` | `boolean` | yes | Email subscribers (SDK arg: send_email, default true) | \n| `share_automatically` | `boolean` | yes | Default false | \n| `audience` | `string` | yes | Default \"everyone\" | \n\n| Name | Type | Required | Description | \n|---|---|---|---|\n| `id` | `number \\ | string` | yes | \n| `slug` | `string` | no | Public slug | \n| `is_published` | `boolean` | no | Publish flag | \n\nReferer: {publication}/publish/post/{id}.\n\n```\npublished = client.publish_now(123456, send_email=True, audience=\"everyone\")\nprint(published[\"slug\"], published[\"is_published\"])\nsubstack-api publish --draft-id 123456\n```\n\nSchedule a draft: optional prepublish GET, then scheduled_release POST.\n\n`schedule_release()`\n`schedule`\n`schedule_post`\n`GET then POST {publication}/api/v1/drafts/{id}/prepublish → {publication}/api/v1/drafts/{id}/scheduled_release`\n\n| Name | Type | Required | Description | \n|---|---|---|---|\n| `publish_date` | `string` | yes | ISO-8601 UTC — query param on GET .../prepublish (same value as trigger_at) | \n\n| Name | Type | Required | Description | \n|---|---|---|---|\n| `trigger_at` | `string` | yes | ISO-8601 UTC schedule time (POST scheduled_release body) | \n| `post_audience` | `string` | yes | Default \"everyone\" | \n| `email_audience` | `string` | yes | Default \"everyone\" | \n\nType: `any`\n\n| Name | Type | Required | Description | \n|---|---|---|---|\n| `(body)` | `object \\ | empty` | no | \n\nWhen run_prepublish=true (default), client GETs prepublish first and raises if errors is non-empty. Content-Type applies to the POST only.\n\n``` python\nfrom substack_api_client import utc_iso\nfrom datetime import datetime, timedelta, timezone\n\nwhen = utc_iso(datetime.now(timezone.utc) + timedelta(hours=2))\nclient.schedule_release(123456, trigger_at=when)\nsubstack-api schedule \\\n  --draft-id 123456 \\\n  --at \"2026-08-10T15:00:00.000Z\"\n```\n\nList publication post tags.\n\n`list_post_tags()`\n`list-tags`\n`list_tags`\n`GET {publication}/api/v1/publication/post-tag`\n\nType: `array<object>`\n\n| Name | Type | Required | Description | \n|---|---|---|---|\n| `id` | `string` | yes | Tag id | \n| `name` | `string` | yes | Tag display name | \n| `slug` | `string` | no | Tag slug | \n\nNo request body. Referer: {publication}/publish.\n\n```\ntags = client.list_post_tags()\nfor tag in tags:\n    print(tag[\"id\"], tag[\"name\"])\nsubstack-api list-tags\n```\n\nCreate a new publication post tag.\n\n`create_post_tag()`\n`create-tag`\n`create_tag`\n`POST {publication}/api/v1/publication/post-tag`\n\n| Name | Type | Required | Description | \n|---|---|---|---|\n| `name` | `string` | yes | New tag name | \n\n| Name | Type | Required | Description | \n|---|---|---|---|\n| `id` | `string` | yes | Created tag id | \n| `name` | `string` | yes | Tag name | \n| `slug` | `string` | no | Tag slug | \n\nDoes not attach the tag to a post — use set_post_tags / attach_post_tag.\n\n```\ntag = client.create_post_tag(\"product of the day\")\nprint(tag[\"id\"], tag[\"slug\"])\nsubstack-api create-tag --name \"product of the day\"\n```\n\nEnsure tags exist (create missing) and attach them to a post/draft.\n\n`set_post_tags()`\n`set-tags`\n`set_tags`\n`GET/POST + POST {publication}/api/v1/publication/post-tag → {publication}/api/v1/post/{id}/tag/{tag_id}`\n\n| Name | Type | Required | Description | \n|---|---|---|---|\n| `id` | `number \\ | string` | yes | \n| `tag_id` | `string` | yes | Tag id in attach URL | \n\n| Name | Type | Required | Description | \n|---|---|---|---|\n| `(create tag)` | `object` | no | When creating missing tags: { \"name\": string } | \n| `(attach tag)` | `object` | yes | Empty JSON object {} on POST /post/{id}/tag/{tag_id} | \n\nType: `array<object> (SDK normalized)`\n\n| Name | Type | Required | Description | \n|---|---|---|---|\n| `tag` | `object` | yes | Resolved/created tag | \n| `status` | `\"attached\" \\ | \"already_attached\"` | yes | \n| `link` | `object` | no | Raw attach response when newly attached | \n\nSDK args: post_id + tag_names[]. Internally: list/create tags, then attach with empty JSON body.\n\n```\nattached = client.set_post_tags(123456, [\"api-test\", \"newsletter\"])\nprint(attached)\nsubstack-api set-tags \\\n  --post-id 123456 \\\n  --tags \"api-test,newsletter\"\n```\n\nRead tags currently attached to a post.\n\n`get_post_tags()`\n`get-post-tags`\n`get_post_tags`\n`GET {publication}/api/v1/post/{id}/tag`\n\n| Name | Type | Required | Description | \n|---|---|---|---|\n| `id` | `string` | no | Tag or link id | \n| `post_tag_id` | `string` | no | Attached tag id (used by set_post_tags dedupe) | \n| `name` | `string` | no | Tag name when present | \n\nNo request body. Works for drafts and published posts.\n\n```\ntags = client.get_post_tags(123456)\nprint(tags)\nsubstack-api get-post-tags --post-id 123456\n```\n\nProduct gate: `APISUBSTACK_API_KEY` is required. Substack itself is authenticated with the unofficial `substack.sid` cookie.\n\n`APISUBSTACK_API_KEY` (`ask_*` from `GET https://rest.apisubstack.com/api/v1/keys/verify` with `Authorization: Bearer ask_…`\n\n```\n<a href=\"https://apisubstack.com/login\" class=\"ltag-offer__button crayons-btn crayons-btn--primary\">Start free — $9/mo after</a>\n```\n\nIf 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.", "url": "https://wpnews.pro/news/sync-any-blog-via-api-to-substack-in-one-click", "canonical_source": "https://dev.to/dealbreaker/sync-any-blog-via-api-to-substack-in-one-click-l26", "published_at": "2026-09-28 11:33:08+00:00", "updated_at": "2026-09-28 11:50:02.256437+00:00", "lang": "en", "topics": ["ai-agents", "agent-protocols", "developer-tools", "ai-tools"], "entities": ["Substack", "Claude", "Codex", "Cursor", "Pangram", "apisubstack.com"], "also_reported_by": [], "alternates": {"html": "https://wpnews.pro/news/sync-any-blog-via-api-to-substack-in-one-click", "markdown": "https://wpnews.pro/news/sync-any-blog-via-api-to-substack-in-one-click.md", "text": "https://wpnews.pro/news/sync-any-blog-via-api-to-substack-in-one-click.txt", "jsonld": "https://wpnews.pro/news/sync-any-blog-via-api-to-substack-in-one-click.jsonld"}}