Every endpoint below is classified:
- ✅ Verified — tested with curl in 2026, response structure documented
- 🟡 Reported — documented in another client; not personally re-tested
- ❓ Inferred — pattern-guess; no successful call observed
- ❌ Doesn't exist — tested and confirmed 404; documented to save you the time
Base hosts:
substack.com— account-wide endpoints (your profile, settings, global feed){subdomain}.substack.com— publication-scoped endpoints (drafts, posts, tags)
All endpoints require the auth cookie unless marked otherwise.
Host: substack.com
Returns: Your profile + every publication you have a role on.
{
"id": 12345,
"name": "Your Name",
"handle": "yourhandle",
"photo_url": "https://...",
"bio": "...",
"publicationUsers": [
{
"id": 9902568,
"publication": {
"id": 99999,
"name": "Your Newsletter",
"subdomain": "yournewsletter",
"logo_url": "https://..."
},
"role": "admin",
"is_primary": true
}
]
}Use for: validating a cookie + discovering owned publications. Single most
useful endpoint in this surface. The publicationUsers[].id is your
publicationUserId — required when creating drafts (as the byline reference).
Host: substack.com
Returns: Account-level settings (notifications, defaults, etc.).
Host: substack.com or any pub subdomain
Returns: Publications you're SUBSCRIBED to (NOT owned — use
/user/profile/self for owned).
Required query: tvOnly (false works) — endpoint 400s without it.
{
"subscriptions": [...],
"publications": [
{ "id": 2309986, "name": "...", "subdomain": "...", ... }
]
}Host: substack.com
Returns: Global reader feed — posts from across publications the user can
see, paginated.
Host: substack.com
Returns: All publication categories (Culture, Tech, etc.). Public — no auth
needed.
Host: substack.com
Returns: Your public profile data — the read-side companion to whatever
the writer's profile page shows. Path is the slug form:
{numeric_user_id}-{handle}, e.g. /api/v1/user/12012312-anthonydavidadams/public_profile/self.
Host: substack.com
Returns: Flat array of user_ids the cookie holder has blocked.
Host: substack.com or {subdomain}.substack.com
Returns: { count, max, lastViewedAt }. The activity feed unread-count.
max is a boolean — when true the count is capped (display "99+" instead of
the exact number).
Host: substack.com or {subdomain}.substack.com
Returns: { token: "<JWT>" }. The JWT (aud: zync, includes a permissions
array and short TTL ~1h) authenticates websocket subscriptions to Substack's
realtime service for things like Notes refresh + chat. The token is signed
with Substack's prod issuer key. The web app refreshes it about every 60s.
Host: substack.com
Body: { email, password }. Legacy email+password login path. Returns 400
with errors: [{param: "email"|"password"}] when fields are missing.
Host: substack.com
Body: { email }. Sends a magic-link email. Returns 400 with
errors: [{param: "email"}] when missing. This is the modern passwordless
login flow.
The link in the email points to https://substack.com/sign-in?token=<jwt>,
which appears to be handled by a server-side route rather than /api/v1/*.
The session cookie is set by that handler. Not currently mapped.
Host: substack.com or {subdomain}.substack.com
Body:
{
"type": "newest_seen_chat_item_published_at",
"value_datetime": "2026-06-23T18:20:44.638Z"
}Generic per-user key/value setter, similar pattern to the publication
POST /settings but for user-level state. Field naming:
type— string, the setting keyvalue_<type>— the typed value field (value_datetime,value_string,value_bool, etc., based on the setting's expected shape)
Verified type values:
newest_seen_chat_item_published_at— last-seen marker for chat badge clearing
Other types likely exist — capture from any UI action that updates a user preference.
All draft endpoints require admin/editor role on the pub. Returns
403 Not authorized if you're just a subscriber.
Host: {subdomain}.substack.com
Returns: Array of draft objects. Use as a "do I have edit access" probe.
Host: {subdomain}.substack.com
Returns: Single draft with full content + metadata.
Host: {subdomain}.substack.com
Required body:
{
"draft_title": "...",
"draft_subtitle": "...",
"draft_body": "<p>HTML or Substack block JSON</p>",
"type": "newsletter",
"draft_bylines": [
{ "id": 12345, "publicationUserId": 9902568 }
]
}Important: draft_bylines is required (not optional as some docs claim).
Get the IDs from /user/profile/self — id is your user id,
publicationUserId is publicationUsers[N].id for the pub.
Returns: Created draft with id, draft_title, draft_body, etc.
Host: {subdomain}.substack.com
Body: Same shape as POST. Updates in place — useful for "replace existing
draft" flows that preserve the draft URL in the editor's inbox.
Host: {subdomain}.substack.com
Returns: 200 on success. 404 if already gone.
Host: {subdomain}.substack.com
Body:
{
"send": true,
"share_automatically": false
}Returns: Published post with slug. send: true emails subscribers
immediately — IRREVERSIBLE.
Host: {subdomain}.substack.com
Optional query: publish_date=<ISO datetime> — when scheduling, the web
app passes the proposed time so prepublish can validate it.
Returns: { errors: [...], suggestions: [...] } — pre-flight check.
Empty arrays = ready to publish.
Note: The verb is GET, not PUT/POST as some older clients say.
Host: {subdomain}.substack.com
Body:
{
"trigger_at": "2027-01-01T12:00:00.000Z",
"post_audience": "everyone"
}Returns: The draft object with scheduling info attached.
Critical: The field name is trigger_at, NOT post_date /
publish_date / scheduled_at. Substack rejects the request with
{"errors":[{"param":"trigger_at","msg":"Invalid value"}]} if you use the
wrong name — that error is also how I found the right one.
post_audience accepts: everyone (verified). Other values likely
mirror the audience options in the publish UI (subscribers_only,
paid_subscribers_only).
Host: {subdomain}.substack.com
Returns: Array — [] if not scheduled, or
[{ trigger_at, post_audience, email_audience }] when active.
Host: {subdomain}.substack.com
Returns: Array of cancelled schedule IDs. Unschedules the draft.
Host: {subdomain}.substack.com
Public — no auth required for public posts. Returns published-post listings
for browsing.
Host: {subdomain}.substack.com
Required query params: offset, limit. Optional: order_by
(post_date), order_direction (desc/asc).
Returns: { posts, offset, limit, total, isCapped }. Admin view with
engagement context.
Host: {subdomain}.substack.com
Required query: offset, limit, order_by, order_direction.
order_by accepts draft_updated_at. Returns the standard
{posts, offset, limit, total, isCapped} shape — but the items are drafts,
not published posts. Use as a paginated drafts list (alternative to
GET /drafts?limit=N which doesn't paginate cleanly).
Host: {subdomain}.substack.com
Returns:
{
"published": 40, "publishedIsCapped": false,
"drafts": 16, "draftsIsCapped": false,
"scheduled": 0, "scheduledIsCapped": false
}Single-call dashboard counts. Useful for "where do I have work" probes
without hitting each of /published, /drafts, /scheduled separately.
Host: {subdomain}.substack.com
Returns: Same shape as other post_management/* list endpoints. Drafts
that are live-stream drafts specifically.
Host: {subdomain}.substack.com
Required query: offset, limit, order_by=draft_updated_at,
order_direction=desc.
Important: Unlike /published, order_by only accepts draft_updated_at
(NOT post_date). Returns same {posts, offset, limit, total, isCapped} shape.
Host: {subdomain}.substack.com
Returns: The post-level theme override (per-post styling). Empty/null
when the post uses the publication default. Used by the editor to
restore custom per-post styling.
Host: {subdomain}.substack.com
Returns: LinkedIn auto-cross-post state for the given post (queued,
uploading, uploaded, error). Sibling of the YouTube authorization
endpoints in the Cross-posting section.
Host: {subdomain}.substack.com
Returns: The writer's referral code object. Idempotent — calling
it creates a code on first invocation, returns the existing one
thereafter. Used by the post editor on first load to make sure a
referral code exists before rendering referral UI.
Host: {subdomain}.substack.com (use the pub's actual host — custom
domain or {sub}.substack.com)
Returns: { post: {...} } — single post with full content. Works for
public + auth'd-subscriber posts.
The slug-based lookup variant 404s. Use /posts/by-id/{post_id} instead;
post IDs are returned in every list endpoint.
Substack treats Notes as a kind of "comment" internally — the endpoint
names use comment even though the UI calls them Notes.
Host: substack.com
Body:
{
"bodyJson": {
"type": "doc",
"attrs": { "schemaVersion": "v1", "title": null },
"content": [
{ "type": "paragraph", "content": [{ "type": "text", "text": "Hello." }] }
]
},
"replyMinimumRole": "everyone"
}Returns: Created comment object with id. Use the id to delete.
Important: bodyJson is a ProseMirror document tree, not plain text or
HTML. The simplest valid shape is a doc containing one paragraph with one
text node. For richer notes (links, mentions, images), inspect the
ProseMirror schema by capturing a complex Note via DevTools.
Host: {subdomain}.substack.com
Returns: { drafts: [], hasMore: boolean, nextCursor: string|null } —
list of saved Notes drafts for the publication.
Host: substack.com
Returns: { items: [...] } — the user's published Notes (and other
profile activity) in chronological order. Each item has entity_key
(c-{comment_id} for notes, p-{post_id} for posts), type, context
(timestamp + nested data), users.
Host: substack.com
Returns: { items: [...] } — the cookie holder's personalized Notes
home feed. Same item shape as /reader/feed/profile/{user_id} —
entity_key prefixes are c-{id} for notes/comments, p-{id} for posts.
Host: substack.com
Returns: [user_id_1, user_id_2, ...] — flat JSON array of user IDs
the cookie holder follows. The first entry is always the user's own id.
Note: Despite the /feed prefix, this is NOT a content feed — it's
the following list. Sibling /feed/{name} paths (foryou, home,
top, notes, recommended, trending, etc.) all 404.
Host: substack.com
Body: empty (-d '' or no body). The Cloudflare-friendly
Content-Length: 0 header is fine.
Use: Mark a feed item as seen (the web client fires this on view for
analytics). Mostly useful for clients that want to mirror the web's
read-tracking behavior.
Host: substack.com
Returns: 200 {} on success. The {comment_id} is the numeric id
(without the c- prefix from entity_key). Works on Notes you authored.
Returns 403 even with valid cookie. The read path for Notes is
/api/v1/reader/feed/profile/{user_id}, NOT this. The comment/feed
endpoint is POST-only.
Host: {subdomain}.substack.com
Body:
{
"filters": { "order_by_desc_nulls_last": "subscription_created_at" },
"limit": 50,
"offset": 0
}Returns:
{
"count": 78,
"subscribers": [
{
"user_id": 305187638,
"user_email_address": "...",
"user_name": "...",
"user_photo_url": null,
"subscription_id": 1358293107,
"subscription_created_at": "2026-06-04T19:51:29.324Z",
"subscription_interval": "free", // free / monthly / annual
"subscription_type": null,
"is_subscribed": false,
"is_founding": false,
"is_free_trial": false,
"is_gift": false,
"is_comp": false,
"is_bitcoin": false,
"activity_rating": 1,
"total_revenue_generated": 0,
"total_count": 78
}
],
"pendingImports": [],
"pendingCRMImportsCount": 0,
"order": { "by": "subscription_created_at", "direction": "desc" },
"chartCounts": {
"created_at": "2022-11-15T14:06:31.157Z",
"subscribers": 0,
"lifetime_subscribers": 0,
// ... daily snapshots
},
"batchSubscriberActions": [],
"lastSync": "2026-06-23T05:35:09.172Z"
}Important: This is a POST, not a GET. Body's filters object supports
sort variants; limit/offset for pagination. count is the total subscriber
count — useful even when you only need the number.
Why a POST for a read? Substack uses POST for endpoints that take rich filter objects in the body. Same pattern as other "search with filters" endpoints in the surface.
Host: {subdomain}.substack.com
Body: Empty body returns {} (200) — endpoint exists and accepts the
call but the no-op suggests it silently ignores empty input. The web UI's
"Add subscriber" form sends { email, name? }. Use with care: this adds a
real subscriber to your publication.
Host: {subdomain}.substack.com
Returns: 400 { error: "Subscription not found" } for an unknown id,
which confirms the path exists. subscription_id is the subscription_id
field from POST /subscriber-stats. Removes a subscriber from the
publication.
Host: {subdomain}.substack.com
Returns:
{
"total": 0,
"is_added": 0,
"is_skipped": 0,
"is_limited": 0,
"passImportVerification": true
}Subscriber-import job status (CSV uploads from the UI). passImportVerification
gates whether you can run another import — Substack throttles new pubs.
Host: substack.com or {subdomain}.substack.com
Returns: Paginated /api/v1/subscriptions data (publications the user
is subscribed to). Use the _v2 form for new code — the non-versioned
endpoint is still around but the web app has moved on.
The "Inbox" is the reader's home — posts from publications they subscribe to, sorted reverse-chronologically. The "Reader feed" is the Notes home (For You / Subscribed / etc. tabs).
Host: substack.com
Query (verified from web capture): inboxType=inbox, surface=inbox_all,
limit=20. Other surface values likely mirror the inbox filter tabs
(inbox_listen, inbox_paid, inbox_saved, inbox_history).
Returns: { posts: [...] } — the user's inbox: posts from subscribed
publications. Each post item has full post shape (id, title, slug,
publication_id, etc.).
Host: substack.com
Body: empty.
Returns: { ok: true }. Marks all inbox items as seen.
Host: substack.com
Returns:
{
"tabs": [
{ "id": "for-you", "name": "For you", "type": "base", "layout": "post_queue_head", "trackingParameters": {...} },
{ "id": "subscribed", "name": "Following", "type": "secondary", "layout": "...", ... },
...
]
}The list of tabs in the Notes home feed. layout controls the rendering
style of items in that tab.
Host: substack.com
The entity_key prefix (c- for notes/comments, p- for posts) routes
to the right item shape. Used by the SPA when expanding a single feed
item into a detail view.
(Already documented in Notes section above — same shape; works for any feed item entity_key.)
Host: substack.com
Returns: Global search results across publications, posts, and Notes.
Host: substack.com
Returns: { modules: [...] } — content modules to populate the
search-page surface before the user types anything: trending topics
(type: "trending_topics" with suggestedSearches), trending posts per
category (type: "trending", name: "Business" | "Tech" | ...), etc.
Useful for clients that want to build a discovery surface.
Host: {subdomain}.substack.com
Returns: Mute state for a specific post — whether the cookie holder
has muted notifications for replies/reactions on that post. Per-post,
not per-publication.
Host: substack.com
Returns:
{
"reasons": [
{ "id": "violated_reply_rules", "label": "Violated reply rules" },
{ "id": "slop", "label": "Slop" },
{ "id": "clickbait", "label": "Clickbait" },
{ "id": "spam", "label": "Spam" },
{ "id": "shilling", "label": "Shilling" },
{ "id": "political", "label": "..." },
...
]
}The enum of moderator-delete reasons. Use when calling the (not yet
mapped) POST /comment/moderation/delete endpoint, which the writer
admin UI fires when removing a reader comment.
Host: substack.com
Returns:
{
"suggestedReactionTypes": ["thumbs_up", "upvote", "face_with_tears_of_joy", "double_exclamation_mark"],
"categories": [
{ "title": "Frequently used", "reactionTypes": ["broken_heart", "thinking_face", ...] },
...
]
}Catalog of reaction emojis available on Notes/comments. The reaction
name strings (e.g. thumbs_up) are used as the payload when posting
a reaction (POST endpoint not yet mapped).
These are subscriptions the cookie holder has TO other publications
(different from POST /subscriber-stats which lists subscribers OF a
publication you own).
Host: substack.com or {subdomain}.substack.com
Returns:
{
"subscriptions": [
{
"id": 215478136,
"user_id": 12012312,
"publication_id": 1,
"expiry": null,
"bundle_id": null,
"email_disabled": true,
"membership_state": "free_signup",
"type": null,
...
}
]
}Paginated list of the user's subscriptions. membership_state values
include free_signup, subscribed, etc.
Host: substack.com
Returns: { items: [{ id, pub: {author_id, author_name, ...}, ... }] } —
the user's "top" (most-engaged-with) subscriptions, used for discovery
surfaces in the Substack app.
Host: {subdomain}.substack.com
Returns: The cookie holder's subscription to this specific pub
(if any). Useful for "am I subscribed to X?" checks without iterating
the full subscriptions list.
Host: {subdomain}.substack.com
Returns: The publication's archive — all published posts,
typically used to render the /archive reader page. Same data is
available via /posts?limit=N with pagination — /archive is the
unpaginated full-list variant.
Reader comments (on posts)
Reader comments are distinct from Notes despite both using the /comment
namespace. These are the comments that appear under a published post.
Host: {subdomain}.substack.com
Returns:
{
"comments": [
{
"id": 281390316,
"user_id": 12012312,
"body": "...",
"body_json": { /* ProseMirror tree */ },
...
}
],
"automod_hidden_comments": []
}Use the post_id integer (not slug). Comments authored by users who got
auto-flagged appear under automod_hidden_comments.
Host: {subdomain}.substack.com
Body: { "body": "comment text" } — minimum required.
Returns: The created comment with id, user_id, body, body_json
(parsed ProseMirror tree). Substack auto-converts plain body text into
the canonical ProseMirror shape.
Empty body returns 400 { error: "Please type in a comment" }.
Host: {subdomain}.substack.com works too (not just substack.com —
the Notes section already documented this path on the account host).
Returns: 200 {} on success. Same endpoint deletes both reader
comments and Notes.
Host: {subdomain}.substack.com
Body:
{ "reaction": "❤", "surface": "reader" }Fields:
reaction— the literal emoji character (e.g."❤","👍"). Not the named reaction string (thumbs_up). The Notes/threads reaction catalog (GET /threads/reactions) returns named strings, but the wire format for post reactions is the actual unicode.surface— verified value:"reader". Other surfaces likely include"editor","feed", etc. (telemetry-flavored).
Returns: 200. The post's reaction_count increments. Idempotent
per-user.
Host: {subdomain}.substack.com
Body: Empty ({}). Removes the cookie holder's reaction from the
post.
The "Recommendations" feature lets publications recommend each other to
their subscribers. Important: these endpoints 403 from a plain curl
session but 200 from a real browser session, even with the same
cookie. The difference appears to be either a header sent by the SPA or
referer-based gating. If you need to hit them headlessly, send full
browser-style headers including Sec-Fetch-*, Referer, Origin.
Host: {subdomain}.substack.com
Body:
{
"recommended_publication_id": 7928971,
"recommending_publication_id": 1193634,
"source": "recommendation-stats",
"suggested": false
}Fields:
recommended_publication_id— the pub being recommendedrecommending_publication_id— the pub doing the recommendingsource— string tracking the click origin. Observed values:recommendation-stats(clicked from the dashboard's suggestions). Likely alsomanual-search,suggested-modal, etc.suggested— boolean, whether the rec came from Substack's suggested list (vs. manual search)
Returns: 200 on success. The endpoint is idempotent — recommending
the same pub twice is a no-op (matches the recommendations/from/{from}/to/{to}
existence-check pattern).
Host: {subdomain}.substack.com — note the trailing slash in the
path (/recommendations/ not /recommendations).
Body:
{
"recommended_publication_id": 7928971,
"recommending_publication_id": 1193634,
"source": "recommendation-stats"
}Same fields as PUT minus suggested. Removes an outgoing recommendation.
Host: {subdomain}.substack.com (the recommending pub's host)
Optional query: offset, limit, paginate=true (the web UI passes
offset=0&limit=50&paginate=true).
Returns: [ <recommendation> ] array. Empty [] if no recommendations
set. publication_id is the recommender's id (from /user/profile/self
or /publication).
Host: {subdomain}.substack.com
Returns: Cheap "do recommendations exist for this pub?" probe — used
by the SPA to decide whether to show the recommendations UI.
Host: {subdomain}.substack.com
Returns: ML-suggested publications to recommend, based on overlap
with the pub's subscribers. Used to populate the "Add recommendation"
modal.
Host: {from_pub_id}.substack.com
Returns: Existence + metadata for a specific recommendation edge.
Used by the "Add recommendation" modal to gate the Add button (so you
can't double-add).
Host: {subdomain}.substack.com
Required query: offset, limit, order_by (e.g. xp_signups),
order_direction (desc/asc).
Returns: Incoming recommendations TO this pub with subscriber-acquisition
stats. Used for the "Incoming recommendations" table in the dashboard
("Substack X sent you Y subs from their recommendation").
Host: {subdomain}.substack.com
Required query: query (search string), page (zero-indexed).
Returns: Paginated list of publication objects matching the search.
Used by the "Add recommendation" search box. Each result has at minimum
id, name, subdomain, logo_url, custom_domain.
Host: substack.com
Returns: 403 from curl, observed in browser captures. Likely the
"recommendations involving you" cross-cut view.
The web UI's "Add recommendation" → publication-typeahead → submit
fires a POST we haven't captured yet. Likely shape:
POST /recommendations with { publication_id, note? }. PR welcome.
Substack has writer-to-writer DMs (separate from Notes and from publication chat).
Host: substack.com
Returns: Inbox of DMs.
Host: substack.com or {subdomain}.substack.com
Returns:
{
"unreadCount": 0,
"pendingInviteCount": 0,
"pendingInviteUnreadCount": 0,
"newPendingInviteUnreadCount": 0,
"pubChatUnreadCount": 0
}Combined badge counts: DMs (unreadCount), pending DM-invites
(pendingInvite*), and publication chat (pubChatUnreadCount).
Not yet mapped. Likely POST /messages with {recipient_user_id, body}.
Host: {subdomain}.substack.com
Returns: { account, plans }. Both null when Stripe isn't connected
yet. When connected, returns Stripe Connect account info and any configured
plans.
Host: {subdomain}.substack.com
Returns:
{
"enabled": true,
"payment_pledge_plans": [
{ "name": "Yearly", "amount": 5000, "interval": "year", "currency": "usd" },
{ "name": "Monthly", "amount": 500, "interval": "month", "currency": "usd" },
{ "name": "Founding", "amount": ..., ... }
]
}The publication's configured pledge/subscription tier list. Amounts are
in cents. enabled indicates the pub has paid subscriptions configured
(even if Stripe isn't fully connected).
Host: {subdomain}.substack.com
Returns: Summary stats for the pledge plans.
Substack supports cross-posting video to YouTube and LinkedIn.
Host: {subdomain}.substack.com
Returns: Auth status for YouTube cross-post. Whether the writer has
linked a YouTube channel.
Host: {subdomain}.substack.com
Returns: Whether to show the "connect YouTube" banner in the editor.
Host: {subdomain}.substack.com
Returns: Auth status for LinkedIn cross-post.
Host: {subdomain}.substack.com
Returns:
{
"suggestion": {
"header": "Engage in comments",
"body": "...",
...
}
}Server-side "grow your publication" tips. Rotates over time. Used to populate the dashboard's growth card.
Host: {subdomain}.substack.com
Returns: 204 (no body). Substack's client-side analytics batch
endpoint. The web app fires this constantly with page-view and interaction
events. Documenting only so people who notice it in their captures
understand what it is. Not useful for clients — don't call it
manually.
Host: {subdomain}.substack.com
Returns: All-time publication stats.
{
"subscribers": 1234,
"appSubscribers": 87,
"appSubscribersLast30Days": 12,
"totalEmail": 45000,
"openRate": 32.4,
"pledgesAmount": 0,
"numPledges": 0,
"pledgeCurrency": "usd",
"isBestseller": false
}Includes open rate — the single most useful publication-level metric for editorial feedback loops.
Host: {subdomain}.substack.com
Returns: { posts: [<post>], total: 1 } — the post object with a
nested stats dict containing the full per-post engagement breakdown.
stats shape (31 fields):
{
// delivery
sent: 77,
delivered: 74,
// open metrics
opens: 84, // total opens (incl. multiple per user)
opened: 21, // unique users who opened
open_rate: 0.283784, // opened / delivered
// click metrics
clicks: 2,
clicked: 1,
click_through_rate: 0.047619,
engagement_rate: 0.047619,
// post-level
views: 113,
shares: 1,
signups: 1,
subscribes: 0,
// funnel within 1 day of receipt
signups_within_1_day: 0,
subscriptions_within_1_day: 0,
unsubscribes_within_1_day: 0,
disables_within_1_day: 0,
// podcast
downloads: 0,
downloads_day7: 0,
downloads_day30: 0,
downloads_day90: 0,
podcast_preview_downloads: 0,
podcast_preview_downloads_day30: 0,
// video
video_views: 0,
video_minutes_watched: 0,
// breakdowns
firstWeekDailyStats: [<7 daily snapshots>],
links: [<clicked-link records>],
referrers: { /* traffic sources */ },
comps: { /* gift/comp subscribers */ },
has_more_links: false,
// monetization
estimated_value: 0
}Why this endpoint exists at post_management/detail instead of
something obvious like /post/{id}/stats: Substack's "post detail" admin
page bundles the post object + its stats together. The offset and
limit params are part of the management list query convention (vestigial
on the single-post detail call).
This is the canonical per-post analytics endpoint — use it for any engagement-feedback loop. Open rate, click-through rate, and the daily 7-day trend are particularly useful as AI prompt signals.
Host: {subdomain}.substack.com
Returns: Array of [date_string, count] pairs, one per day going back
~1 year. The count is daily email sends. Useful for plotting send volume.
[["2025/06/24", 72], ["2025/06/25", 72], ["2025/06/26", 71], ...]Host: {subdomain}.substack.com
Returns:
{
"rows": [
{
"publication_id": 1193634,
"time_window": "90 days",
"is_subscribed": false,
"criteria": 3,
"label": "Substack existing accounts",
"subs_count": 1,
"pct_time_window_total": 1,
"data_updated_at": "..."
}
]
}Subscriber attribution by source — how many came from each channel ("Substack existing accounts", "Notes", "Direct", etc.) over a time window.
Host: {subdomain}.substack.com
Returns: { pledgeAndUserData: [...], summary: { count, hasMore } }.
Per-user paid-pledge breakdown.
Host: {subdomain}.substack.com
Returns: Permission flag for deleting archived stats. Admin-only.
Host: {subdomain}.substack.com
Required query: from_date=YYYY-MM-DD, to_date=YYYY-MM-DD,
order_by (e.g. users), order_direction (desc/asc).
Returns: Subscriber/visitor source breakdown for the date window —
the "Growth sources" table in the writer dashboard. Used to attribute
new subs to channels (Notes, recommendations, direct, etc.).
Host: {subdomain}.substack.com
Required query: from_date=YYYY-MM-DD, to_date=YYYY-MM-DD.
Returns: Growth events timeline — discrete events that drove
subscriber inflow (new post sent, viral note, recommendation switched on,
etc.). Used to annotate the growth chart.
Host: {subdomain}.substack.com
Returns: Partial chart series updates. The web app fires this when
the user pans/zooms the growth chart. Body shape not yet enumerated;
probably {from_date, to_date, granularity, metric}. PR welcome.
Host: {subdomain}.substack.com
Query: range accepts integer day counts. Verified: 7, 30, 365.
Returns: Start/end snapshot for the window.
{
"totalSubscribersStart": 1200, "totalSubscribersEnd": 1234,
"paidSubscribersStart": 45, "paidSubscribersEnd": 47,
"arrStart": 5400, "arrEnd": 5640,
"totalViewsStart": 0, "totalViewsEnd": 0,
"pledgedArrStart": 0, "pledgedArrEnd": 0
}Use to compute deltas: subs added, ARR change, etc. over the window.
Host: {subdomain}.substack.com
Query: status (scheduled / live / ended), stream_type
(rtmp_only / others)
Returns: { liveStreams: [...], hasMore: boolean }. Empty array for
pubs with no live streams.
Host: {subdomain}.substack.com
Returns: { users: [{ id, name, handle }] }. Users who can host a
live stream for this publication (admins/editors).
Host: {subdomain}.substack.com — important: this 403s from
substack.com but 200s from the pub subdomain.
Returns: The full publication object:
{
"id": 1193634,
"subdomain": "anthonydavidadams",
"is_personal_mode": false,
"name": "The Impossible Path",
"custom_domain": null,
"logo_url": "https://substackcdn.com/...",
...
}Use as the read-side companion to PUT /publication (settings update).
Host: {subdomain}.substack.com
Body: A single-field partial publication object. The web UI saves
one field per call — clicking "Save" on a specific field sends only
that field, not the whole object.
{ "hero_text": "Notes on life, love & leadership." }Confirmed writable fields (captured by patching window.fetch in the
settings UI and toggling fields):
name— string (publication name)hero_text— string (publication short description)language— string (ISO 639-1 code;en,es, etc. — Substack reloads the UI immediately to render in the new language)welcome_email_content— stringified ProseMirror doc (TipTap JSON encoded as a single string, not a nested object). The doc shape is the standard{type:"doc", content:[...]}. Captured via the Welcome Email editor at/publish/settings/edit?bodyField=welcome_email_content&titleField=welcome_email_subject.welcome_email_subject— string (per the URL query of the welcome email editor; not directly captured but the field naming pattern is consistent withwelcome_email_content)
With empty body returns {"error":"No valid publication parameters provided"}.
Other fields likely accepted (from the GET /publication response shape):
logo_url, custom_domain, is_personal_mode, and several theme/color
fields. PRs welcome to upgrade these to ✅ Confirmed.
Host: {subdomain}.substack.com
Returns: The publication's settings — a flat key/value map of boolean
toggles and a few non-boolean values. Separate from the publication
object itself (which holds name/logo/etc.).
Host: {subdomain}.substack.com
Body: Single-field partial settings object. The web UI fires this
the moment you toggle a switch.
{ "high_res_recording_beta": true }Confirmed writable fields:
high_res_recording_beta— boolean (live-video high-resolution toggle)
Many other toggles exist in the settings UI: subscribe prompts on post
page, enable auto-clips, archive nav visibility, about-page visibility,
notes-tab visibility, email-confirmation requirement, comments/likes/
restacks toggle, automatic moderation, restack visibility, subscriber
chat, live video replays, private mode, allow cross-posting, allow
listing on Substack.com, AI training opt-out, hide stats, show
approximate subscriber count. Each fires PUT /publication_settings
with {field: bool}. Field names match the form field names — visible
in the DOM if you inspect the toggle's underlying input.
Host: {subdomain}.substack.com
Returns: Publication-verification status (Substack's blue-check
equivalent). Fired on the dashboard.
Host: {subdomain}.substack.com
Returns: Logo metadata.
Host: {subdomain}.substack.com
Returns: { bestseller_tier: null | <tier> }. The Substack
"Bestseller" badge tier the publication qualifies for (or null).
Host: {subdomain}.substack.com
Returns: Array of category tags the publication has chosen.
[
{ "id": 355, "name": "Health & Wellness", "canonical_name": "Health & Wellness", "active": true, "parent_tag_id": null },
{ "id": 96, "name": "Culture", "canonical_name": "culture", "active": true, "parent_tag_id": null }
]Note: These are the publication-level discovery tags (think:
categories Substack groups pubs into), distinct from /publication/post-tag
which is the per-post tag system.
Host: {subdomain}.substack.com
Returns: Same as /publication/post-tag but each tag includes a
navigationBarItem field — when non-null, the tag appears in the pub's
top navigation bar.
Host: {subdomain}.substack.com
Returns: { can_alias: true | false }. Whether the pub can be aliased
to a different subdomain (subscription-tier or admin-permission gated).
Host: {subdomain}.substack.com
Returns: Current state of any in-flight ownership transfer.
Host: {subdomain}.substack.com
Returns: { pub_users: [{ id, publication_id, user_id, public, public_rank, name, bio, ... }] }.
The per-user publication roles + bios. Note the underscore in the path
(publication_user) — sibling endpoint /publication/users (plural,
slash) also exists and gives a slightly different shape.
Host: {subdomain}.substack.com
Returns: Array of pending co-author invites. Empty [] when none.
Note: NOT /publication/invites (which 403s).
Host: {subdomain}.substack.com
Body: { email, name } — both required.
Returns: Empty { email, name } returns 400 with both fields named
in errors. With valid fields, sends a co-author invite to that email.
Host: {subdomain}.substack.com
Returns: Array of publication sections (multi-section pubs use these
to split content into topic streams). Empty [] for non-sectioned pubs.
Host: {subdomain}.substack.com
Returns: Custom pub pages (about, archive, etc.). Empty [] when
none are configured.
Host: {subdomain}.substack.com
Returns: Array of subscriber CSV-export jobs (the "Download
subscribers" UI flow creates one). Each entry has the job status + a
download URL when complete.
Host: {subdomain}.substack.com
Returns: Users with roles on this publication (admins, editors).
Host: {subdomain}.substack.com
Returns: Array of post tags configured for the publication.
Host: {subdomain}.substack.com
Body: { "name": "..." } — create a new tag.
Returns: { id: "<uuid>", publication_id, slug, name, hidden }. Note that
tag IDs are UUIDs, not integers.
Host: {subdomain}.substack.com
Returns: 200 {} on success.
Host: {subdomain}.substack.com
Body: empty (no JSON body required).
Returns:
{
"id": "<uuid — the post↔tag attachment id>",
"publication_id": 1193634,
"post_id": 184700516,
"post_tag_id": "<the tag uuid>"
}Note: post_id is the integer post id (from /post_management/* or
/posts/by-id). tag_id is the uuid returned by
POST /publication/post-tag. They're different types — easy to mix up.
Host: {subdomain}.substack.com
Returns: 200 {} on success. Detaches without deleting the tag itself.
When you GET a post via /posts/by-id/{id}, attached tags are in a
postTags array (not tags):
{
"post": {
"id": 184700516,
"postTags": [
{ "id": "<uuid>", "publication_id": 1193634, "name": "...", "slug": "...", "hidden": false }
]
}
}Host: substack.com
Body:
{
"name": "My Publication",
"subdomain": "mypublication",
"hero_text": "A description longer than the minimum length."
}Required fields (enumerated by sending progressively-filled bodies and
reading the field-level error messages): name, subdomain, hero_text.
With all three populated and a valid (non-taken) subdomain, Substack
returns:
{ "error": "Please complete the captcha to continue", "type": "single" }That's the wall. Publication creation is captcha-gated by design — not headlessly automatable with cookies alone. The mechanism is also not a thin client-side toggle: the request includes a captcha-solution token in the body (likely from hCaptcha or similar), which Substack verifies server-side.
For automation: create your pub once via the web UI, then use the API for everything thereafter. The captcha is on creation only.
Host: {subdomain}.substack.com
Returns: Onboarding checklist state (about_page completed, first_post
sent, etc.).
Host: {subdomain}.substack.com
Body (JSON): { "image": "data:image/png;base64,<base64-encoded-bytes>" }
Returns:
{
"id": 285574576,
"url": "https://substack-post-media.s3.amazonaws.com/public/images/6d5c5adf-5fc3-4c6b-a455-fb5136830ca5_10x10.png",
"contentType": "image/png",
"bytes": 75,
"imageWidth": 10,
"imageHeight": 10
}Important — exact field names (verified against a live upload, round 15):
bytes— file size in bytes. NOTbytes_required.imageWidth/imageHeight— pixel dimensions. NOTwidth/height.
Some references (including earlier versions of this one) had the wrong
field names. The error mode is silent: client code reads the missing
fields as undefined, falls back to 0, and embeds an image block with
zero dimensions — which Substack renders as an empty placeholder ("my
image didn't show up" with no error in the console). Use these exact
names.
Bucket: the returned url is on substack-post-media.s3.amazonaws.com,
under the path /public/images/<uuid>_<W>x<H>.<ext>. Foreign URLs (e.g.
your own CDN) are silently dropped by the editor's render pipeline — you
MUST upload via this endpoint first if you want the image to render.
Important: This endpoint takes a base64 data URI as JSON, NOT a
multipart upload. Multipart returns 400 "Invalid value". Some older clients
have this wrong.
To embed an uploaded image in a draft's draft_body, use Substack's
nested captionedImage block. The outer block contains an image2 child
(yes, image2 — not image), plus an optional caption child:
{
"type": "captionedImage",
"content": [
{
"type": "image2",
"attrs": {
"src": "<url from /api/v1/image response>",
"srcNoWatermark": null,
"fullscreen": null,
"imageSize": null,
"width": 10,
"height": 10,
"resizeWidth": null,
"bytes": 75,
"alt": null,
"title": null,
"type": "image/png",
"href": null,
"belowTheFold": false,
"topImage": false,
"internalRedirect": null
}
},
{
"type": "caption",
"content": [{ "type": "text", "text": "Caption text here" }]
}
]
}The 14 image2.attrs fields are all part of Substack's editor schema.
Most can be null/false/0, but width, height, and bytes are
critical — zeroes cause the editor to render the image as an empty
placeholder. Source them from the upload response (imageWidth,
imageHeight, bytes).
The image2 node by itself (without the captionedImage wrapper) gets
silently stripped on save. So does a plain {type:"image", attrs:{src}}
node — that's a different schema that Substack's parser doesn't accept.
Substack's native subscribe button — the publication's branded CTA that appears in many writers' posts. As a draft_body block:
{
"type": "subscribeWidget",
"attrs": {
"url": "https://yourpub.substack.com/subscribe",
"text": "Subscribe",
"language": "en"
}
}All three attrs are optional. With url omitted the editor auto-binds it
to the current publication's subscribe page. text overrides the button
label. language is for localized button copy. Substack's editor itself
emits the full triplet on a fresh insert; mirroring that shape is the
safe play.
The widget is a leaf node (no content array). Place it as a
top-level block in the doc, ideally with an hr above it to visually
separate from preceding content.
For clients building draft bodies from scratch, here's the full set of
node types we've verified Substack accepts in draft_body. All examples
are top-level block nodes unless noted; inline text nodes carry marks
for emphasis.
| Node | Required attrs | Notes |
|---|---|---|
doc |
— | Root. Contains an array of block nodes in content. |
paragraph |
attrs.textAlign (nullable) |
Default block. content is inline text/marks. |
heading |
attrs.level (2–4), attrs.textAlign |
level: 1 is reserved for the post title. |
bullet_list |
— | Contains list_item children. |
ordered_list |
— | Contains list_item children. |
list_item |
— | Contains paragraph children. |
blockquote |
— | Contains paragraph/heading children. |
horizontal_rule |
— | Leaf node. Adjacent rules collapse into one in the rendered post. |
captionedImage |
— | Wrapper for image2 + optional caption. See above. |
image2 |
14 attrs (see image embed section) | Only valid inside a captionedImage. |
caption |
— | Only valid inside a captionedImage. Holds inline text nodes. |
subscribeWidget |
optional url/text/language |
Leaf node. See above. |
Inline text marks (apply via marks: [{type: "..."}] on a text node):
| Mark | Required attrs | Notes |
|---|---|---|
strong |
— | Bold. |
em |
— | Italic. |
code |
— | Monospace. |
link |
attrs.href (required), attrs.target, attrs.rel |
External links should use target: "_blank", rel: "noopener". |
These are the silent-failure modes that have eaten the most time for clients building against this API:
-
draft_bodyis a STRING, not a JSON object. The field's value is a stringified ProseMirror document. If you submit a JSON object, Substack stores it as a single text node and the post renders as visible{type:"doc",...}markup. -
Foreign image URLs are silently stripped. The editor's render pipeline only displays images on
substack-post-media.s3.amazonaws.com,substackcdn.com, orsubstack-video.s3-accelerate.amazonaws.com. Embed a foreign URL and Substack accepts the draft but shows no image. Upload viaPOST /api/v1/imagefirst, then use the returned URL. -
image2.attrs.width/height/bytesmust be the real upload values. Zeroes are accepted but render as empty placeholders. Source these from the upload response'simageWidth,imageHeight,bytes. -
Plain
{type:"image", attrs:{src}}doesn't work. Use the nestedcaptionedImage > image2shape exclusively. -
HTML in
draft_bodymostly works, but with caveats. Email-template HTML with<table>,<tr>,<td>,mso-conditionalcomments, and inline<style>blocks renders as visible markup. For complex content, build a ProseMirror doc and stringify it. -
The
trigger_atfield onscheduled_release— NOTpost_date,publish_date, orscheduled_at(see Scheduling). -
The
reactionfield onPOST /post/{id}/reactionis a literal emoji character ("❤"), NOT a named reaction string. TheGET /threads/reactionscatalog returns named strings, but the wire format for post reactions is the unicode character. -
DELETE /recommendations/has a trailing slash in the path —/recommendations/not/recommendations. The bare path 404s. -
DELETE /community/posts/{id}for chat threads, NOTDELETE /community/publications/{pub_id}/posts/{id}(that 404s). -
Thread create
idis client-generated — Substack uses the UUID you send as the canonical thread id, not one it assigns. -
POST /publicationis captcha-gated — body fields validate, but a captcha-solution token is also required server-side. Not headlessly automatable; create pubs via the web UI then drive the API for everything else. -
Server-side fetch of
image_urlneeds absolute URLs. If your own app stores images as relative paths (e.g./uploads/x.png) and you're building the draft body server-side, absolutize against your app's public URL before callingPOST /api/v1/image. Node'sfetchcan't parse relative paths.
The audio insert flow in the post editor reveals a 4-step S3-presigned multipart upload sequence. Audio is uploaded to S3 directly (not through Substack), then Substack is told to complete the multipart upload and transcode. The same path is reused for podcast episodes.
Host: {subdomain}.substack.com
Body: Empty ({}) — though the web client may send richer
parameters (file size, content type) for chunking decisions; needs
more testing.
Returns: A response containing the media_upload_id (UUID) and one
or more S3 pre-signed PUT URLs targeting
https://substack-video.s3-accelerate.amazonaws.com/video_upload/post/{post_id}/{upload_id}/original?....
Yes, audio uploads use the substack-video bucket and the video_upload
path — that's not a typo, it's a unified media store.
Host: substack-video.s3-accelerate.amazonaws.com
Body: Raw file bytes.
Returns: 200 from S3 with an ETag header. Save the ETag —
you need it for the next step.
Host: {subdomain}.substack.com
Body:
{
"duration": null,
"multipart_upload_id": "bdohqlFEhD4U2rlPq1Nuero.Lg_LoZoh6Y...",
"multipart_upload_etags": ["\"7934201b4a7be4e377bca86ae28f0714\""]
}Fields:
duration— file duration in seconds.nullis accepted (Substack measures it during transcode).multipart_upload_id— opaque token from the S3 presigned URL'suploadIdquery param. Substack uses this to call S3's CompleteMultipartUpload on the writer's behalf.multipart_upload_etags— array of ETags returned by each S3 part upload. ETag values include surrounding quote characters, which must be preserved ("abc...").
Returns: 200 on success → transcoding starts asynchronously.
Host: {subdomain}.substack.com
Returns: Upload + transcode status. The web client polls this
until status indicates "ready" or "failed."
Validation: Substack rejects very-short audio files (~<1s of real audio) with a generic "Something went wrong with your audio file upload" error after the transcode step. Use real-world content (≥1 second of non-silence) when probing this flow.
Substack Chat is internally called "threads" (/publication_threads_settings,
threads_v2_enabled). This is the publication-level chat for subscribers,
distinct from Notes (/comment/feed) and from DMs (/messages/inbox).
Host: {subdomain}.substack.com
Body:
{
"threads_v2_enabled": true,
"create_thread_minimum_role": "free_subscriber",
"reader_thread_notifications_enabled": true
}Fields:
threads_v2_enabled— master toggle.trueenables Chat for the pub;falsedisables it.create_thread_minimum_role— who can start a new thread. Verified value:free_subscriber. The "Who can start conversations?" dropdown in the create-chat modal shows "Everyone" as the default — likely alsoeveryone,paid_subscriber,founding_member.reader_thread_notifications_enabled— whether subscribers get push notifications for new threads.
Returns: 200 on success. Same endpoint enables and disables —
just flip threads_v2_enabled. The publication shows "Start your
subscriber chat" UI when disabled and a thread list when enabled.
Host: {subdomain}.substack.com
Body:
{
"id": "aa85fa7d-fe8f-43d3-a692-54096ca94752",
"body": "thread message text",
"media_urls": [],
"audience": "all_subscribers",
"type": "media",
"send_email": false,
"send_push": true,
"link_url": null
}Fields:
id— client-generated UUID (used for idempotency; the server uses this as the canonical thread id, not assigned server-side).body— the thread message body (plain text or ProseMirror — needs more testing for rich content).media_urls— array of attached media URLs (empty for text-only).audience— verified:"all_subscribers". The Substack admin UI toggle would also produce"paid_subscribers","founding_members".type— verified:"media". Probably also"text","video","link".send_email— bool. The "Send as email" checkbox in the thread composer.send_push— bool. Whether subscribers get a push notification.link_url— string|null for embedded link previews.
Returns: Created community-post (thread) object.
Host: {subdomain}.substack.com — note the path is
/community/posts/{id}, NOT /community/publications/{pub_id}/posts/{id}
(that 404s).
Returns: 200. Soft-delete — the thread record persists with an
empty body and status set, but it's no longer visible to subscribers.
Host: {subdomain}.substack.com
Returns: { threads: [{ communityPost: {...} }] }. List of threads
for the publication. Each communityPost has full thread shape
including id (uuid), body, type, audience, reaction_count,
comment_count, pub_moderation_status, is_locked, media_assets.
Host: {subdomain}.substack.com
Returns: Scheduled threads (drafts with trigger_at set, mirror of
the post-scheduling pattern but on the community/threads surface).
Host: substack.com
Returns: { publications: [...] } — publications in a category.
category_type accepts: all, top, new. Get category IDs from
/api/v1/categories.
These returned 404 (HTML page) against the live API. Most public references that list them are wrong — they probably worked in an earlier Substack version.
- ❌
GET /api/v1/me - ❌
GET /api/v1/account - ❌
GET /api/v1/account/me - ❌
GET /api/v1/profile - ❌
GET /api/v1/users/me - ❌
GET /api/v1/reader/inbox - ❌
GET /api/v1/notes(might exist scoped differently; not at this path) - ❌
GET /api/v1/comments - ❌
GET /api/v1/contacts,GET /api/v1/subscribers,GET /api/v1/free_subscribers— none of these GET forms exist. UsePOST /api/v1/subscriber-statsinstead. - ❌
GET /api/v1/post_management/draft - ❌
GET /api/v1/post_management/stats - ❌
GET /api/v1/stats
Use /api/v1/user/profile/self for "current user" info, not the variants above.
These exist but return 403 even with a valid cookie:
- 🔒
GET /api/v1/admin/profile - 🔒
GET /api/v1/admin/publications - 🔒
GET /api/v1/user/profile(note: WITHOUT/self) - 🔒
GET /api/v1/user/me - 🔒
GET /api/v1/publicationonsubstack.com— but 200 from the pub subdomain ({sub}.substack.com). The endpoint is host-specific. - 🔒
GET /api/v1/publication/subscriptions(likely needs Pro plan or higher permission tier) - 🔒
GET /api/v1/publication/recommendationsfromsubstack.com— but the equivalent/recommendations/from/{pub_id}on the pub host works.
Two-host trick: several endpoints are gated on substack.com but open
on the pub subdomain (or vice versa). If you get 403 on one host, try the
other before assuming the endpoint is dead.
Browser-vs-curl gap: a small number of endpoints (notably the
recommendations one) return 403 from a plain curl but 200 from a real
Chromium session with the same cookie. The difference appears to be
either a header sent by the SPA (Sec-Fetch-*, Referer, Origin) or
referer-based gating. If a "should work" endpoint 403s from curl, try
adding full browser headers, or drive a real Chromium with Playwright /
Puppeteer.
Cookie: connect.sid=...; substack.sid=... # auth
User-Agent: Mozilla/5.0 # default Node/Python UAs often 403
Content-Type: application/json # for POST/PUT
Accept: application/json
Most data endpoints don't require:
Origin/Refererheaders- CSRF tokens
X-Requested-Withheaders
The /admin/* paths that 403 might need something more — see the 403 section
above.
Mixed conventions across the surface:
limit(often capped at 100)offset(zero-indexed)cursor(for cursor-based endpoints)
Check the response for total, isCapped, or next_cursor fields.
| Status | Meaning |
|---|---|
200 |
Success |
204 |
Success, empty body (deletes) |
400 |
Bad request — body lists which param is invalid/missing |
401 |
Cookie missing or expired |
403 |
Cookie valid but you lack permission for this resource |
404 (HTML) |
Endpoint or resource doesn't exist |
404 (JSON {error: "User not found"}) |
Resource exists but you can't see it |
409 |
Conflict — name uniqueness violation (rare) |
429 |
Rate limited — back off and retry |
500 |
Substack internal — usually transient |
These exist as features in the Substack web app but the endpoint paths haven't been identified. The fastest way to crack each:
- Open Substack in Chrome, F12 → Network tab, filter to Fetch/XHR
- Perform the action in the UI
- The matching request appears in Network — right-click → Copy as cURL
- Strip the cookie + headers down to the minimum that still works
- PR the result here
Currently unsolved (need targeted captures — pattern-guessing exhausted):
- (SOLVED —
POST /drafts/{id}/scheduled_releasewith body{trigger_at, post_audience}. See "Scheduling" section above.) - (SOLVED — Notes are at
POST /comment/feedfor creating,GET /reader/feedfor the home feed,GET /reader/feed/profile/{user_id}for a profile,DELETE /comment/{id}for deletion. See the "Notes" section above.) - (SOLVED —
GET /post_management/detail/{post_id}?offset=0&limit=1returns the post with a nestedstatsdict containing 31 engagement fields. See "Stats & analytics" section above.) - (SOLVED — Recommendations at
GET /recommendations/from/{publication_id}on the pub host. 403 from raw curl, 200 from a real browser. See "Recommendations" above.) - (SOLVED — Reader comments at
GET /post/{id}/comments,POST /post/{id}/commentbody{body}, delete viaDELETE /comment/{id}. See "Reader comments" above.) - (SOLVED — DMs at
GET /messages/inbox+/messages/unread-count. Send-DM endpoint still not mapped — capture the "Send" click in the DM compose UI.) - (SOLVED — Inbox + reader feed:
GET /inbox/top,POST /inbox/seen,GET /reader/feed/tabs. See "Inbox & reader feed".) - Image MULTIPART upload — JSON+base64 works (
/api/v1/image); multipart to the same path 400s. PUT /publicationwritable field allowlist — endpoint exists, empty body returns "No valid publication parameters provided". The full field allowlist needs capture from the settings UI (each toggle fires a PUT — capture name, logo_url, hero_text, theme, custom CSS, welcome email, custom_domain, language, etc.).POST /api/v1/settingsacceptedsettingNamevalues — enum of settings keys the generic key-value setter accepts. Capture from the account-settings page's toggles.POST /subscriber/addrequired body — endpoint exists, empty body returns{}(no-op). The web UI's "Add subscriber" form sends{email, name?, tier?}. Capture from a real "Add subscriber" click.- Send DM — likely
POST /messageswith{recipient_user_id, body}. - Add / remove recommendation — likely
POST /recommendationswith{publication_id, note?}. Capture from the "Add recommendation" flow. - Reaction post —
GET /threads/reactionsreturns the catalog of reaction types. The POST that applies one is not yet mapped. - Comment moderation delete — likely
POST /comment/moderation/deletewith areason_idfrom/comment/moderation/delete_reasons. - Custom domain setup — UI flow exists but endpoints not mapped.
- Welcome email / auto-sequences — UI exists, endpoints not mapped.
- Audio / video / podcast upload — separate from
/image. Likely S3-presigned-URL pattern (request signed URL, PUT bytes to S3, notify Substack the asset is ready). - Substack Chat (writer chats) — entirely uncracked. Distinct from Notes and from DMs.
- Mobile push token registration — not mapped.
- Round 14 (final write-side sweep, 2026-06-23) — with user consent,
ran add-then-revert cycles on the remaining write-side endpoints:
(a) Welcome email body via
PUT /publicationwithwelcome_email_content(stringified ProseMirror doc). (b) Post reactions:POST /post/{id}/reactionbody{reaction: "❤", surface: "reader"}— reaction is the literal emoji character, not the named string.DELETE /post/{id}/reactionempty body for unreact. (c) Substack Chat send thread:POST /community/publications/{pub_id}/postswith client-generated UUID id,body,audience,type,send_email,send_push,link_url. The client generates the id for idempotency. (d) Substack Chat delete thread at the surprising pathDELETE /community/posts/{thread_id}— note: NOT under/community/publications/{pub_id}/posts/{id}(that 404s). (e) Generic user setting:PUT /user-settingwith{type, value_<typed>}. Documented the custom domain UI path (/publish/domain/pay) and noted it's $50 one-time + DNS — won't capture without paying. Probed comment moderation delete — the endpoint path differs from/comment/moderation/delete(404'd); needs another user's comment to trigger the right UI. - Round 13 (Chrome-extension destructive cycles, 2026-06-23) —
with explicit user consent, ran add-then-revert cycles on real
publication actions to capture write-side bodies that couldn't be
reached by passive observation. Cracked:
(a)
PUT /recommendationsbody andDELETE /recommendations/body (note the trailing slash); idempotent design. (b) The full audio upload sequence:POST /audio/upload→ S3 presigned PUT tosubstack-video.s3-accelerate.amazonaws.com→POST /audio/upload/{id}/transcodewith multipart_upload_id + etags →GET /audio/upload/{id}polling for status. (c) Substack Chat enable/disable viaPOST /publication/{pub_id}/publication_threads_settingswiththreads_v2_enabled,create_thread_minimum_role,reader_thread_notifications_enabled. - Round 12 (Chrome-extension live capture, 2026-06-23) — drove the
user's real Chromium via the Claude Code Chrome integration, monkey-
patched
window.fetchandXMLHttpRequest.sendfrom alocalStorage- backed log (so captures survived page reloads), and toggled real settings/fields to read the actual write-side request bodies. Key wins:PUT /publicationbody shape (single field per save:{hero_text},{language: "en"},{name}), discovery of the separatePUT /publication_settingsendpoint for boolean toggles ({high_res_recording_beta: true}), the full recommendations surface (/recommendations/exist,/recommendations/{pub}/suggested,/recommendations/from/{from}/to/{to},/recommendations/stats/to), publication search (/publication/search?query=...&page=N), growth analytics (/publication/stats/growth/{sources,events,partial-timeseries}), editor-side endpoints (/post/{id}/theme,/video/linkedin/{post_id}/auto-upload-state,/user/writer_referrals/code,/publication/verify_status), and the fullinbox/topquery-param set. - Round 10 + 11 (Playwright capture sweep, 2026-06-23) — drove a
headless Chromium with the writer cookie through the admin SPA
(publish dashboard, settings sub-pages, Notes home, search). Picked
up 70+ endpoints in two runs, including the previously-403'd
recommendations endpoint (works in-browser due to header/referer
gating), the messages/DMs inbox surface, paid-pledge config, Stripe
account status, per-post mute settings, comment moderation reasons,
reaction catalog, reader feed tabs, global search modules, and
publication CSV-export jobs. Capture script lives at
/tmp/substack-capture/capture.js. - Round 9 (curl probe sweep, 2026-06-23) — empirical-error probing
enumerated required fields by sending intentionally-invalid POSTs.
Cracked publication creation (captcha-gated, fields
{name, subdomain, hero_text}), magic-link login (POST /email-login), generic settings setter (POST /settings), reader-comment create (POST /post/{id}/comment), co-author invite (POST /publication/invite), subscriber add/remove (POST /subscriber/add,DELETE /subscriber/{id}), publication settings update (PUT /publication), subscriber import status (GET /import). - Per-post stats →
GET /post_management/detail/{post_id}withoffset=0&limit=1(round 8 — captured via publish/posts/detail/{id}). Thestatsdict has 31 fields including open_rate, ctr, opens, clicks, daily breakdowns, referrers, and unsubscribe metrics. Lives inpost_management/(the admin list namespace) rather than at a/statspath. - Post scheduling →
POST /drafts/{id}/scheduled_releasewith{trigger_at, post_audience}(round 6 — captured via drafts → Edit → Schedule). The field istrigger_at, notpost_dateorpublish_date. - Notes API →
POST /comment/feed(create),GET /reader/feed(home),GET /feed/drafts(drafts list),DELETE /comment/{id}(round 5). - Subscribers list →
POST /api/v1/subscriber-stats(round 4 — captured via Substack admin → publish/subscribers). It's a POST with filters in the body, which is why every GET probe missed it. - Publication-level stats →
/api/v1/publish-dashboard/summaryand/publish-dashboard/summary-v2?range={N}(round 3 — captured via Substack admin → publish/home dashboard)
Find a new endpoint? Test with curl, then PR with:
- Method + path
- Required host (
substack.comvs{sub}.substack.com) - Required headers / query params
- Sample response (sanitize user data)
- Classification (✅ / 🟡 / ❓)
- Date you verified it