Substack API
The TrueScrape Substack API exposes 5 public endpoints covering Post comments, Post, Publication posts, Profile and Publication. Every call is a GET against public, logged-out pages and returns the same unified schema as every other platform here. Calls cost 1 credit each. A cache hit is free, and a failed or empty response is never charged.
Endpoints
| Endpoint | Returns | Credits | Cacheable | Batchable |
|---|---|---|---|---|
| /v1/substack/comments | Post comments | 1 | yes | yes |
| /v1/substack/post | Post | 1 | yes | yes |
| /v1/substack/posts | Publication posts | 1 | yes | yes |
| /v1/substack/profile | Profile | 1 | yes | yes |
| /v1/substack/publication | Publication | 1 | yes | yes |
Reference
/v1/substack/comments1 creditcacheablebatchablePost comments
Every public comment on a Substack post, replies included, newest first; parentId links a reply to its comment. Comments a publication shows only to paid subscribers are not available.
| Parameter | Type | Required | Description |
|---|---|---|---|
| id | string | yes | Numeric post id, as returned in id by substack.posts |
Also accepts cache_max_age (a hit costs 0 credits) and include_raw (returns the untouched upstream payload under raw).
Returns a list of Comment in data.items.
Fields
| Field | Type | Present | Description |
|---|---|---|---|
| platform | one of 40 strings | always | |
| id | string | always | |
| postId | string | null | always | |
| parentId | string | null | always | Id of the comment this one replies to. Null on a top-level comment. |
| text | string | always | |
| authorId | string | null | always | |
| authorHandle | string | null | always | |
| authorName | string | null | always | |
| authorAvatarUrl | string | null | always | |
| likeCount | number | null | always | |
| replyCount | number | null | always | |
| isPinned | boolean | null | always | |
| isAuthorReply | boolean | null | always | Whether the comment was written by the author of the post. |
| publishedAt | string | null | always | When the comment was posted. Usually an ISO 8601 timestamp; some platforms expose only relative text such as "2 days ago". |
curl "$API/v1/substack/comments?id=%3Cid%3E&cache_max_age=7d" \
-H "x-api-key: $KEY"const query = new URLSearchParams({
id: '<id>',
cache_max_age: '7d',
});
const response = await fetch(`${API}/v1/substack/comments?${query}`, {
headers: { 'x-api-key': KEY },
});
const body = await response.json();
if (!body.success) throw new Error(body.error.code);
// 1 credit, 0 on a cache hit
console.log(body.data, body.meta.creditsCharged);Python
import os, httpx
r = httpx.get(
f"{os.environ['API']}/v1/substack/comments",
params={
"id": "<id>",
"cache_max_age": "7d",
},
headers={"x-api-key": os.environ["KEY"]},
timeout=30,
)
body = r.json()
if not body["success"]:
raise RuntimeError(body["error"]["code"])
print(body["data"], body["meta"]["creditsCharged"])Response codes
| 200 | Success |
| 400 | invalid_request. Not charged |
| 401 | missing_api_key / invalid_api_key / revoked_api_key. Never a billing error |
| 402 | insufficient_credits. The key is valid, the balance is not |
| 429 | daily_cap_exceeded, upstream_rate_limited, or rate_limited (hourly calls to servers you named outside our known list). Not charged |
| 501 | not_configured. This deployment is not set up to serve this endpoint. Not charged. |
| 502 | upstream_blocked / upstream_schema_drift. Not charged |
| 504 | upstream_timeout. Not charged |
/v1/substack/post1 creditcacheablebatchablePost
One Substack post with its text, reactions, comments, restacks and word count. For a subscriber-only post, text is the public preview and truncated is true.
| Parameter | Type | Required | Description |
|---|---|---|---|
| id | string | yes | Numeric post id, as returned in id by substack.posts |
Also accepts cache_max_age (a hit costs 0 credits) and include_raw (returns the untouched upstream payload under raw).
Returns an object with platform, id, type, url, title, text, authorId, authorHandle, authorName, thumbnailUrl, mediaUrls, durationSeconds, viewCount, likeCount, commentCount, shareCount, hashtags, mentions, taggedUsers, isSponsored, publishedAt, fetchedAt, audience, wordCount, truncated.
Fields
| Field | Type | Present | Description |
|---|---|---|---|
| platform | one of 40 strings | always | |
| id | string | always | |
| type | video | short | image | carousel | text | live | story | reel | audio | album | episode | unknown | always | What kind of post this is. `short` and `reel` are short-form video formats, `live` is a live stream or a recording of one, `carousel` holds several images or videos, `audio` is a music track, `album` is an album, `episode` is a podcast episode, and `unknown` covers anything else. |
| url | string | null | always | |
| title | string | null | always | |
| text | string | null | always | The post's text: a caption, a description or the message body. |
| authorId | string | null | always | |
| authorHandle | string | null | always | |
| authorName | string | null | always | |
| thumbnailUrl | string | null | always | |
| mediaUrls | string[] | always | |
| durationSeconds | number | null | always | |
| viewCount | number | null | always | Views. Null means the platform did not expose a count, which is not the same as zero. |
| likeCount | number | null | always | Likes, or the platform's nearest equivalent. Null means not exposed, not zero. |
| commentCount | number | null | always | Comments. Null means not exposed, not zero. |
| shareCount | number | null | always | Shares, reposts, or the platform's nearest equivalent. Null means not exposed, not zero. |
| hashtags | string[] | always | Hashtags or the platform's own topic tags, without the leading #. |
| mentions | string[] | always | Handles mentioned in the post text, without the leading @. |
| taggedUsers | string[] | may be absent | Usernames tagged in the media itself, as distinct from `mentions`, which come from the caption text. Absent when the platform does not report tags; an empty array means it reports none. |
| isSponsored | boolean | null | always | Whether the platform labels the post as an ad, promoted content or a paid partnership. |
| publishedAt | string | null | always | When the post was published. Usually an ISO 8601 timestamp; some platforms expose only a date or relative text such as "3 days ago". |
| fetchedAt | string | always | When this record was fetched from the platform, as an ISO 8601 timestamp. A cached response keeps the time of the original fetch. |
| audience | string | null | always | Who can read the whole post: `everyone`, `only_free` (free subscribers), `only_paid` or `founding`. |
| wordCount | number | null | always | |
| truncated | boolean | always | True when the post is for subscribers only: `text` is then the public preview, not the whole post. |
curl "$API/v1/substack/post?id=%3Cid%3E&cache_max_age=7d" \
-H "x-api-key: $KEY"const query = new URLSearchParams({
id: '<id>',
cache_max_age: '7d',
});
const response = await fetch(`${API}/v1/substack/post?${query}`, {
headers: { 'x-api-key': KEY },
});
const body = await response.json();
if (!body.success) throw new Error(body.error.code);
// 1 credit, 0 on a cache hit
console.log(body.data, body.meta.creditsCharged);Python
import os, httpx
r = httpx.get(
f"{os.environ['API']}/v1/substack/post",
params={
"id": "<id>",
"cache_max_age": "7d",
},
headers={"x-api-key": os.environ["KEY"]},
timeout=30,
)
body = r.json()
if not body["success"]:
raise RuntimeError(body["error"]["code"])
print(body["data"], body["meta"]["creditsCharged"])Response codes
| 200 | Success |
| 400 | invalid_request. Not charged |
| 401 | missing_api_key / invalid_api_key / revoked_api_key. Never a billing error |
| 402 | insufficient_credits. The key is valid, the balance is not |
| 429 | daily_cap_exceeded, upstream_rate_limited, or rate_limited (hourly calls to servers you named outside our known list). Not charged |
| 501 | not_configured. This deployment is not set up to serve this endpoint. Not charged. |
| 502 | upstream_blocked / upstream_schema_drift. Not charged |
| 504 | upstream_timeout. Not charged |
/v1/substack/posts1 creditcacheablebatchablePublication posts
A publication's posts, newest first, with reactions, comments, restacks and word count. Substack shows no view counts. Pass the cursor from the previous response to page.
| Parameter | Type | Required | Description |
|---|---|---|---|
| publication | string | yes | The publication: its name on Substack (garymarcus), its host (garymarcus.substack.com or www.lennysnewsletter.com), or any URL on it |
| limit | number | no | defaults to 20 |
| cursor | string | no | pagination.cursor from the previous response |
Also accepts cache_max_age (a hit costs 0 credits) and include_raw (returns the untouched upstream payload under raw).
Returns a list of object in data.items.
Fields
| Field | Type | Present | Description |
|---|---|---|---|
| platform | one of 40 strings | always | |
| id | string | always | |
| type | video | short | image | carousel | text | live | story | reel | audio | album | episode | unknown | always | What kind of post this is. `short` and `reel` are short-form video formats, `live` is a live stream or a recording of one, `carousel` holds several images or videos, `audio` is a music track, `album` is an album, `episode` is a podcast episode, and `unknown` covers anything else. |
| url | string | null | always | |
| title | string | null | always | |
| text | string | null | always | The post's text: a caption, a description or the message body. |
| authorId | string | null | always | |
| authorHandle | string | null | always | |
| authorName | string | null | always | |
| thumbnailUrl | string | null | always | |
| mediaUrls | string[] | always | |
| durationSeconds | number | null | always | |
| viewCount | number | null | always | Views. Null means the platform did not expose a count, which is not the same as zero. |
| likeCount | number | null | always | Likes, or the platform's nearest equivalent. Null means not exposed, not zero. |
| commentCount | number | null | always | Comments. Null means not exposed, not zero. |
| shareCount | number | null | always | Shares, reposts, or the platform's nearest equivalent. Null means not exposed, not zero. |
| hashtags | string[] | always | Hashtags or the platform's own topic tags, without the leading #. |
| mentions | string[] | always | Handles mentioned in the post text, without the leading @. |
| taggedUsers | string[] | may be absent | Usernames tagged in the media itself, as distinct from `mentions`, which come from the caption text. Absent when the platform does not report tags; an empty array means it reports none. |
| isSponsored | boolean | null | always | Whether the platform labels the post as an ad, promoted content or a paid partnership. |
| publishedAt | string | null | always | When the post was published. Usually an ISO 8601 timestamp; some platforms expose only a date or relative text such as "3 days ago". |
| fetchedAt | string | always | When this record was fetched from the platform, as an ISO 8601 timestamp. A cached response keeps the time of the original fetch. |
| audience | string | null | always | Who can read the whole post: `everyone`, `only_free` (free subscribers), `only_paid` or `founding`. |
| wordCount | number | null | always |
curl "$API/v1/substack/posts?publication=%3Cpublication%3E&cache_max_age=7d" \
-H "x-api-key: $KEY"const query = new URLSearchParams({
publication: '<publication>',
cache_max_age: '7d',
});
const response = await fetch(`${API}/v1/substack/posts?${query}`, {
headers: { 'x-api-key': KEY },
});
const body = await response.json();
if (!body.success) throw new Error(body.error.code);
// 1 credit, 0 on a cache hit
console.log(body.data, body.meta.creditsCharged);Python
import os, httpx
r = httpx.get(
f"{os.environ['API']}/v1/substack/posts",
params={
"publication": "<publication>",
"cache_max_age": "7d",
},
headers={"x-api-key": os.environ["KEY"]},
timeout=30,
)
body = r.json()
if not body["success"]:
raise RuntimeError(body["error"]["code"])
print(body["data"], body["meta"]["creditsCharged"])Response codes
| 200 | Success |
| 400 | invalid_request. Not charged |
| 401 | missing_api_key / invalid_api_key / revoked_api_key. Never a billing error |
| 402 | insufficient_credits. The key is valid, the balance is not |
| 429 | daily_cap_exceeded, upstream_rate_limited, or rate_limited (hourly calls to servers you named outside our known list). Not charged |
| 501 | not_configured. This deployment is not set up to serve this endpoint. Not charged. |
| 502 | upstream_blocked / upstream_schema_drift. Not charged |
| 504 | upstream_timeout. Not charged |
/v1/substack/profile1 creditcacheablebatchableProfile
A Substack writer's public profile: followers, bio, links, and their main publication. Subscribers appear only as Substack's rounded badge.
| Parameter | Type | Required | Description |
|---|---|---|---|
| handle | string | yes | Substack handle (lenny), @handle, or a substack.com/@handle URL |
Also accepts cache_max_age (a hit costs 0 credits) and include_raw (returns the untouched upstream payload under raw).
Returns an object with platform, id, handle, displayName, bio, avatarUrl, bannerUrl, url, verified, followerCount, followingCount, postCount, viewCount, likeCount, isPrivate, isBusiness, category, location, externalLinks, createdAt, fetchedAt, subscriberBadge, publicationHost.
Fields
| Field | Type | Present | Description |
|---|---|---|---|
| platform | one of 40 strings | always | |
| id | string | always | The platform's own id for the account, such as a channel id or a numeric user id, always as a string. |
| handle | string | null | always | The account handle without the leading @. Null when the platform has no handles. |
| displayName | string | null | always | |
| bio | string | null | always | |
| avatarUrl | string | null | always | |
| bannerUrl | string | null | always | |
| url | string | null | always | |
| verified | boolean | null | always | Whether the platform shows a verification badge on the account. Null where the platform has no public badge or does not expose it. |
| followerCount | number | null | always | Followers, subscribers, or the platform's nearest equivalent. Null when the account hides the count or the platform does not expose it. |
| followingCount | number | null | always | |
| postCount | number | null | always | Posts, videos, tracks, repositories, or the platform's nearest equivalent that the account has published. |
| viewCount | number | null | always | Lifetime views across the account, where the platform exposes a total. |
| likeCount | number | null | may be absent | Total likes received across all of the creator's posts. Distinct from `viewCount`. Absent when the platform does not expose it. |
| isPrivate | boolean | null | always | |
| isBusiness | boolean | null | always | Whether the platform classifies the account as a business or organisation rather than a person or creator. Null where the platform draws no such line or does not expose it. On Facebook every Page counts, including Pages for public figures. |
| category | string | null | always | A label the platform attaches to the account, such as a business category, a genre or an account type. What it holds varies by platform. |
| location | string | null | always | Location as the platform shows it: a city, a country, a region code or an address, depending on the platform. |
| externalLinks | string[] | always | Links the account lists on its profile, such as a website or other social accounts. |
| createdAt | string | null | always | When the account was created. Usually an ISO 8601 timestamp; some platforms expose only the date as shown on the profile. |
| fetchedAt | string | always | When this record was fetched from the platform, as an ISO 8601 timestamp. A cached response keeps the time of the original fetch. |
| subscriberBadge | string | null | always | Subscriber count as the rounded badge Substack shows, such as "1.2M+". Substack publishes no exact subscriber count. |
| publicationHost | string | null | always | The host of the writer's main publication, to pass as `publication` to the other Substack endpoints. |
curl "$API/v1/substack/profile?handle=%40mkbhd&cache_max_age=7d" \
-H "x-api-key: $KEY"const query = new URLSearchParams({
handle: '@mkbhd',
cache_max_age: '7d',
});
const response = await fetch(`${API}/v1/substack/profile?${query}`, {
headers: { 'x-api-key': KEY },
});
const body = await response.json();
if (!body.success) throw new Error(body.error.code);
// 1 credit, 0 on a cache hit
console.log(body.data, body.meta.creditsCharged);Python
import os, httpx
r = httpx.get(
f"{os.environ['API']}/v1/substack/profile",
params={
"handle": "@mkbhd",
"cache_max_age": "7d",
},
headers={"x-api-key": os.environ["KEY"]},
timeout=30,
)
body = r.json()
if not body["success"]:
raise RuntimeError(body["error"]["code"])
print(body["data"], body["meta"]["creditsCharged"])Response codes
| 200 | Success |
| 400 | invalid_request. Not charged |
| 401 | missing_api_key / invalid_api_key / revoked_api_key. Never a billing error |
| 402 | insufficient_credits. The key is valid, the balance is not |
| 429 | daily_cap_exceeded, upstream_rate_limited, or rate_limited (hourly calls to servers you named outside our known list). Not charged |
| 501 | not_configured. This deployment is not set up to serve this endpoint. Not charged. |
| 502 | upstream_blocked / upstream_schema_drift. Not charged |
| 504 | upstream_timeout. Not charged |
/v1/substack/publication1 creditcacheablebatchablePublication
A Substack publication: name, description, author, custom domain, whether it sells paid plans, and its rounded subscriber badge. Custom domains are accepted.
| Parameter | Type | Required | Description |
|---|---|---|---|
| publication | string | yes | The publication: its name on Substack (garymarcus), its host (garymarcus.substack.com or www.lennysnewsletter.com), or any URL on it |
Also accepts cache_max_age (a hit costs 0 credits) and include_raw (returns the untouched upstream payload under raw).
Returns an object with platform, id, handle, displayName, bio, avatarUrl, bannerUrl, url, verified, followerCount, followingCount, postCount, viewCount, likeCount, isPrivate, isBusiness, category, location, externalLinks, createdAt, fetchedAt, host, customDomain, authorHandle, authorName, hasPaidPlans, subscriberBadge.
Fields
| Field | Type | Present | Description |
|---|---|---|---|
| platform | one of 40 strings | always | |
| id | string | always | The platform's own id for the account, such as a channel id or a numeric user id, always as a string. |
| handle | string | null | always | The account handle without the leading @. Null when the platform has no handles. |
| displayName | string | null | always | |
| bio | string | null | always | |
| avatarUrl | string | null | always | |
| bannerUrl | string | null | always | |
| url | string | null | always | |
| verified | boolean | null | always | Whether the platform shows a verification badge on the account. Null where the platform has no public badge or does not expose it. |
| followerCount | number | null | always | Followers, subscribers, or the platform's nearest equivalent. Null when the account hides the count or the platform does not expose it. |
| followingCount | number | null | always | |
| postCount | number | null | always | Posts, videos, tracks, repositories, or the platform's nearest equivalent that the account has published. |
| viewCount | number | null | always | Lifetime views across the account, where the platform exposes a total. |
| likeCount | number | null | may be absent | Total likes received across all of the creator's posts. Distinct from `viewCount`. Absent when the platform does not expose it. |
| isPrivate | boolean | null | always | |
| isBusiness | boolean | null | always | Whether the platform classifies the account as a business or organisation rather than a person or creator. Null where the platform draws no such line or does not expose it. On Facebook every Page counts, including Pages for public figures. |
| category | string | null | always | A label the platform attaches to the account, such as a business category, a genre or an account type. What it holds varies by platform. |
| location | string | null | always | Location as the platform shows it: a city, a country, a region code or an address, depending on the platform. |
| externalLinks | string[] | always | Links the account lists on its profile, such as a website or other social accounts. |
| createdAt | string | null | always | When the account was created. Usually an ISO 8601 timestamp; some platforms expose only the date as shown on the profile. |
| fetchedAt | string | always | When this record was fetched from the platform, as an ISO 8601 timestamp. A cached response keeps the time of the original fetch. |
| host | string | always | The host the publication is read on: its custom domain, or `<name>.substack.com`. |
| customDomain | string | null | always | |
| authorHandle | string | null | always | |
| authorName | string | null | always | |
| hasPaidPlans | boolean | null | always | Whether the publication sells paid subscriptions. |
| subscriberBadge | string | null | always | Subscriber count as the rounded badge Substack shows, such as "1.2M+". Substack publishes no exact subscriber count. |
curl "$API/v1/substack/publication?publication=%3Cpublication%3E&cache_max_age=7d" \
-H "x-api-key: $KEY"const query = new URLSearchParams({
publication: '<publication>',
cache_max_age: '7d',
});
const response = await fetch(`${API}/v1/substack/publication?${query}`, {
headers: { 'x-api-key': KEY },
});
const body = await response.json();
if (!body.success) throw new Error(body.error.code);
// 1 credit, 0 on a cache hit
console.log(body.data, body.meta.creditsCharged);Python
import os, httpx
r = httpx.get(
f"{os.environ['API']}/v1/substack/publication",
params={
"publication": "<publication>",
"cache_max_age": "7d",
},
headers={"x-api-key": os.environ["KEY"]},
timeout=30,
)
body = r.json()
if not body["success"]:
raise RuntimeError(body["error"]["code"])
print(body["data"], body["meta"]["creditsCharged"])Response codes
| 200 | Success |
| 400 | invalid_request. Not charged |
| 401 | missing_api_key / invalid_api_key / revoked_api_key. Never a billing error |
| 402 | insufficient_credits. The key is valid, the balance is not |
| 429 | daily_cap_exceeded, upstream_rate_limited, or rate_limited (hourly calls to servers you named outside our known list). Not charged |
| 501 | not_configured. This deployment is not set up to serve this endpoint. Not charged. |
| 502 | upstream_blocked / upstream_schema_drift. Not charged |
| 504 | upstream_timeout. Not charged |
Questions
- How much does the Substack API cost?
- Calls cost 1 credit each. Every endpoint is cacheable, and a cache hit costs nothing. A failed request and an empty result are both free, on every endpoint.
- Does the Substack API need a login or cookies?
- No. Every endpoint reads public, logged-out pages only — no account, no cookies, no session. An API key identifies your own TrueScrape account and nothing else.
- Can I fetch many Substack targets in one request?
- Yes. 5 of 5 are batchable: one POST to /v1/jobs/batch takes many targets and returns a job id to poll.
- Can I watch Substack endpoints for changes?
- Yes, 5 of 5. A subscription polls on your schedule and fires your webhook only when the content changed; the rest carry x-subscribable: false in the spec.