GitHub - can3p/substack-api-notes

3 min read Original article ↗

Notes on substack API

Why?

Substack is a great platform for writing, they've done a fantastic amount of things in the way others should have years ago. The only thing that's missing for me is a cli client which would allow me to work on my posts locally, put them on version control and do all other nice stuff we all like to do.

My needs regarding markup are very modest and the aim is to ideally implement a cli client that would allow to edit and publish the posts without web editor like I've done with cl-journal

There is no public API hence everything in this repo is a result of experiments and reverse engineering, use at your own risk!

No code solution

First of all, you might not need any api to begin with since substack editor allows you to insert rich content right from the clipboard. Based on my experiments you can add generic html only the editor refuses to paste any data containing substack specific elements like foot notes, it's still an option though.

Here's how I was able to convert markdown from cli and get it directly into clipboard on my linux box:

cat substack/drafts/new_post.md| markdown_py | wl-copy -t text/html

If you were able to achieve more, let me know!

Substack seems to be using Prose Mirror for their editor.

API notes

API root: https://<blog name>.substack.com/api/v1/

Authorization

All requests require a cookie substack.sid, which you can get from dev tools.

Upload a file

Endpoint: POST <api-root>/image

Payload: { "image": "<base64 encoded image>" }

Example response: {"id":"23824782","url":"...","contentType":"image/png","bytes":28315}

Posts

Create a draft

Endpoint: POST <api-root>/drafts

Payload:

{
  "draft_title": "",
  "draft_subtitle": "",
  "draft_podcast_url": "",
  "draft_podcast_duration": null,
  "draft_video_upload_id": null,
  "draft_podcast_upload_id": null,
  "draft_podcast_preview_upload_id": null,
  "draft_voiceover_upload_id": null,
  "draft_body": "{\"type\":\"doc\",\"content\":[{\"type\":\"paragraph\",\"content\":[{\"type\":\"text\",\"text\":\"test\"}]}]}",
  "section_chosen": false,
  "draft_section_id": null,
  "draft_bylines": [
    {
      "id": <user id>,
      "is_guest": false
    }
  ],
  "audience": "everyone",
  "type": "newsletter"
}

Example response:

{
  "type": "newsletter",
  "draft_title": "",
  "draft_subtitle": "",
  "draft_podcast_url": "",
  "audience": "everyone",
  "section_chosen": false,
  "draft_podcast_duration": null,
  "draft_video_upload_id": null,
  "draft_podcast_upload_id": null,
  "draft_podcast_preview_upload_id": null,
  "draft_voiceover_upload_id": null,
  "publication_id": <publication id>,
  "word_count": 1,
  "write_comment_permissions": "everyone",
  "should_send_email": true,
  "show_guest_bios": true,
  "cover_image": null,
  "description": null,
  "search_engine_description": null,
  "search_engine_title": null,
  "slug": null,
  "social_title": null,
  "podcast_description": null,
  "free_unlock_required": false,
  "syndicate_voiceover_to_rss": false,
  "audience_before_archived": null,
  "exempt_from_archive_paywall": false,
  "explicit": null,
  "default_comment_sort": null,
  "draft_body": "{\"type\":\"doc\",\"content\":[{\"type\":\"paragraph\",\"content\":[{\"type\":\"text\",\"text\":\"test\"}]}]}",
  "draft_section_id": null,
  "should_send_free_preview": false,
  "body": null,
  "draft_created_at": "2023-12-20T13:39:52.110Z",
  "draft_updated_at": "2023-12-20T13:39:52.110Z",
  "email_sent_at": null,
  "id": <numeric id>,
  "is_published": false,
  "podcast_duration": null,
  "podcast_url": null,
  "video_upload_id": null,
  "podcast_upload_id": null,
  "podcast_preview_upload_id": null,
  "voiceover_upload_id": null,
  "post_date": null,
  "reply_to_post_id": null,
  "section_id": null,
  "subscriber_set_id": null,
  "subtitle": null,
  "title": null,
  "uuid": "<post uuid>",
  "editor_v2": false,
  "draftVideoUpload": null,
  "draftPodcastUpload": null,
  "podcast_episode_number": null,
  "podcast_season_number": null,
  "podcast_episode_type": null,
  "should_syndicate_to_other_feed": null,
  "syndicate_to_section_id": null,
  "hide_from_feed": null
}

The id field from the response is needed to update the draft. draft_body JSON conforms to Substack document format

Update a draft

Endpoint: PUT <api-root>/drafts/<id> where id is taken from create draft endpoint

Payload: Same as with create draft endpoint

Publish a draft

TODO

Update a published post

Looks like, you'll need to hit the update draft endpont and then publish the draft all over again, not tested.

Feedback

If you've like to provide more info, please open a pull request.

License

All rights for the API belong to substack and the whole repo is just a result of my curiosity.