A modern, type-safe TypeScript client for the Substack API. Browse publications, fetch posts and comments, search content, and publish notes — all through a clean, entity-based interface.
This project started as a fork of jakub-k-slys/substack-api and has since evolved into an independently maintained library with its own architecture, testing strategy, and release cadence. While the original provided a solid foundation, this version introduces a modern entity-based API, comprehensive OpenAPI specification, async iterator pagination, markdown-based note publishing, runtime type validation with io-ts, and extensive test coverage across unit, integration, end-to-end, and live API validation layers.
- Anonymous & Authenticated — Read public content without a token; authenticate with a
substack.sidcookie for write access - Entity-Based API — Navigate naturally:
profile.posts(),post.comments(),ownProfile.notes() - Async Iterators — Built-in pagination with
for await...of, no manual cursor handling - Note Publishing — Publish notes from markdown with optional link attachments via
me.publishNote(markdown) - Discovery & Search — Trending posts, category browsing, profile search, and explore feeds
- Chat API — Direct messages, inbox management, and publication chat rooms
- Comment & Note Interactions — Like/unlike comments, restack/unrestack notes
- Runtime Type Safety — io-ts codecs validate every API response beyond TypeScript's compile-time checks
- Rate Limiting & Retry — Token bucket with jitter, exponential backoff, and rotating Chrome/Edge browser fingerprinting
- Full TypeScript — Complete type definitions exported for consumers
pnpm add substack-api
# or
npm install substack-api
# or
yarn add substack-apiRequires Node.js 18 or higher.
import { SubstackClient } from 'substack-api';
const client = new SubstackClient({});
// Browse trending content
const trending = await client.topPosts();
// Search profiles
const results = await client.profileSearch('technology');
// Fetch a public profile and iterate their posts
const profile = await client.profileForSlug('platformer');
for await (const post of profile.posts({ limit: 5 })) {
console.log(`📄 ${post.title} — ${post.publishedAt.toLocaleDateString()}`);
}
// Get post details and comments
const post = await client.postForId(123456);
for await (const comment of post.comments({ limit: 10 })) {
console.log(`💬 ${comment.name}: ${comment.body}`);
}const client = new SubstackClient({
publicationUrl: 'https://yourpub.substack.com',
token: process.env.SUBSTACK_API_KEY!,
});
// Verify connectivity
const connected = await client.testConnectivity();
// Get your profile with write capabilities
const me = await client.ownProfile();
// List your recent posts
for await (const post of me.posts({ limit: 10 })) {
console.log(post.title);
}
// Publish a note with markdown formatting
await me.publishNote('Hello **world**!\n\nCheck out this [article](https://example.com)');
// Publish a note with a link attachment
await me.publishNote('Sharing something interesting:', {
linkUrl: 'https://example.com/article',
});The library includes built-in markdown-to-HTML and markdown-to-ProseMirror converters for publishing content:
import { markdownToHtml, markdownToNoteBody } from 'substack-api';
// Convert markdown to HTML (for posts/drafts)
const html = markdownToHtml('**bold** and *italic*');
// Convert markdown to ProseMirror JSON (for notes)
const body = markdownToNoteBody('**bold** and *italic*');Supported formatting: bold, italic, strikethrough, code, links, headings, lists (bullet and ordered, with nesting), blockquotes, code blocks, and horizontal rules.
const client = new SubstackClient({
publicationUrl: 'https://yourpub.substack.com',
token: process.env.SUBSTACK_API_KEY!,
});
const result = await client.createDraftFromMarkdown(
'# My Post\n\nThis is **bold** text with `code`.\n\n- Item 1\n- Item 2',
{ title: 'Draft from Markdown' }
);const me = await client.ownProfile();
await me.publishNote('Check out this **bold** take!\n\n- Point one\n- Point two');The client follows a service-oriented architecture with domain models:
SubstackClient
├── services/ # HTTP business logic (posts, notes, profiles, comments, discovery, publications)
├── domain/ # Entity classes with methods (Profile, Post, Note, Comment, OwnProfile)
├── internal/http-client.ts # HTTP abstraction with auth, rate limiting, retry, and browser fingerprinting
├── internal/rate-limiter.ts # Token bucket with jitter and FIFO queue
├── internal/retry.ts # Exponential backoff with Retry-After support
└── internal/types/ # io-ts codecs for runtime validation
Key patterns:
- Entity navigation — Domain objects expose related data as methods (
profile.posts(),post.comments()) - Async iterators — Pagination is transparent;
for await...ofhandles cursors automatically - Markdown adapters — Convert standard markdown to HTML or ProseMirror JSON for posts and notes
- Functional validation — io-ts codecs decode API responses with detailed error messages
const client = new SubstackClient({
publicationUrl: 'https://stratechery.substack.com',
});
for await (const post of client.publicationArchive({ limit: 20 })) {
console.log(`${post.title} — ${post.publishedAt.toLocaleDateString()}`);
}const client = new SubstackClient({});
// Full-text search across posts, people, publications, and notes
for await (const item of client.search('artificial intelligence', { limit: 20 })) {
if (item.type === 'post') {
console.log(`📝 ${item.post.title}`);
}
}
// Browse categories
const categories = await client.categories();
const techPubs = await client.categoryPublications('technology', { limit: 10 });const post = await client.postForId(123456);
for await (const comment of post.comments({ limit: 5 })) {
console.log(`${comment.name}: ${comment.body}`);
// Fetch replies
const replies = await client.commentReplies(comment.id);
for (const branch of replies.commentBranches) {
console.log(` ↳ ${branch.comments[0]?.name}: ${branch.comments[0]?.body}`);
}
}const me = await client.ownProfile();
await me.publishNote('Check out this interesting read:', {
linkUrl: 'https://example.com/article',
});Substack uses session cookies for authentication. To obtain your token:
- Log in to substack.com in your browser
- Open Developer Tools (F12) → Application/Storage → Cookies →
https://substack.com - Copy the value of the
substack.sidcookie - Pass it as the
tokeninSubstackConfig
Never commit your token. Use environment variables or repository secrets in CI.
| Option | Type | Required | Default | Description |
|---|---|---|---|---|
publicationUrl |
string |
No* | — | Publication base URL (e.g. https://yourpub.substack.com). Required for publication-scoped methods |
token |
string |
No | — | substack.sid cookie value. Omit for anonymous read-only access |
substackUrl |
string |
No | substack.com |
Base URL for global Substack endpoints |
urlPrefix |
string |
No | api/v1 |
URL prefix for API endpoints |
perPage |
number |
No | 25 |
Default items per page for pagination |
maxRequestsPerSecond |
number |
No | 25 |
Client-side request rate limit |
jitter |
boolean |
No | true |
Randomize request timing to avoid predictable cadence |
maxRetries |
number |
No | 3 |
Max retry attempts on 429/5xx responses |
baseDelayMs |
number |
No | 1000 |
Base delay (ms) for exponential backoff |
maxDelayMs |
number |
No | 30000 |
Maximum backoff delay (ms) |
headerMode |
'browser' | 'api' | 'minimal' |
No | 'api' |
HTTP header profile (see below) |
onRateLimit |
(info) => void |
No | — | Callback fired on every retry attempt |
onTokenExpired |
() => Promise<string> |
No | — | Callback to refresh the session token on 401 responses. The new token replaces substack.sid internally and the request retries once |
proxy |
{ host, port, protocol?, auth? } |
No | — | Proxy configuration passed to axios (e.g. { host: '127.0.0.1', port: 8080 }) |
* Required when using publication-scoped methods like publicationArchive(), publicationPosts(), publicationHomepage(), postReactors(), activeLiveStream(), markPostSeen(), etc. ownProfile() only requires a token.
The client includes built-in protection against rate limiting and detection:
- Token bucket — Requests are throttled to
maxRequestsPerSecond(default 25) with a FIFO queue that prevents burst patterns - Jitter — Each request is randomly delayed by up to 50% of the base interval, producing a natural, non-deterministic cadence
- Exponential backoff — 429 and 5xx responses trigger retries with full-jitter backoff (
random(0, min(base * 2^attempt, maxDelay))) - Retry-After — When Substack returns a
Retry-Afterheader, the client waits exactly that duration with no additional jitter - Browser fingerprint — Requests include realistic Chrome/Edge/Chromium headers (Sec-Ch-Ua, Sec-Fetch-*, Accept-Language, etc.) with a rotating User-Agent pool spanning Chrome 135–137 and Edge to prevent browser-identity fingerprinting
- Token rotation — Optional
onTokenExpiredcallback catches 401 responses, refreshes the session cookie, and retries the request transparently - Proxy support — Optional
proxyconfig routes all API requests through an HTTP/HTTPS proxy
| Mode | Use case | Headers sent |
|---|---|---|
'api' (default) |
JSON API requests | Chrome UA, Accept: application/json, Sec-Ch-Ua, Sec-Fetch-* (cors/empty/same-origin) |
'browser' |
Full browser emulation | Complete Chrome fingerprint with document/navigate Sec-Fetch headers |
'minimal' |
Bare minimum | Accept: application/json only, no User-Agent |
Pass an onRateLimit callback to monitor retry activity:
const client = new SubstackClient({
publicationUrl: 'https://yourpub.substack.com',
token: process.env.SUBSTACK_API_KEY!,
onRateLimit: (info) => {
console.log(`Retry #${info.attempt} after ${info.retryAfter ?? 'backoff'}s (HTTP ${info.statusCode})`);
},
});🌐 Interactive API documentation is available at https://christopher-s.github.io/substack-api/
The site renders the OpenAPI 3.1 specification with Scalar, allowing you to browse every endpoint, parameter, and response shape.
| Method | Description |
|---|---|
client.topPosts() |
Trending posts from the homepage feed |
client.profileForSlug(slug) |
Public profile by handle |
client.profileForId(id) |
Public profile by user ID |
client.postForId(id) |
Post details and comments |
client.noteForId(id) |
Note by ID |
client.commentForId(id) |
Comment by ID |
client.search(query, options?) |
Full-text search (posts, people, publications, notes) — async iterator |
client.profileSearch(query) |
Search user profiles |
client.exploreSearch(options) |
Explore feed with tab filtering |
client.discoverFeed(options) |
Discovery feed with tab selection |
client.activityFeed(options) |
Authenticated activity feed with tabs |
client.categories() |
All content categories |
client.categoryPublications(id) |
Publications in a category |
| Method | Description |
|---|---|
client.publicationArchive(options) |
Publication post archive (async iterator) |
client.publicationPosts(options) |
Full posts with body HTML (async iterator) |
client.publicationHomepage() |
Recent homepage posts |
client.postReactors(postId) |
Users who reacted to a post |
client.activeLiveStream(pubId) |
Active live stream for a publication |
| Method | Description |
|---|---|
client.profileActivity(id, options) |
Profile activity feed (posts, notes, comments, likes) |
client.profileLikes(id, options) |
Posts liked by a profile |
client.publicationFeed(id, options) |
Publication activity feed |
client.commentRepliesFeed(id, options) |
Paginated comment replies |
| Method | Description |
|---|---|
client.ownProfile() |
Get authenticated profile with write access |
me.publishNote(markdown, options?) |
Publish a note from markdown (optional linkUrl for link preview) |
client.createDraft(data) |
Create a draft post |
client.createDraftFromMarkdown(md, opts) |
Create a draft from markdown content |
client.updateDraft(id, data) |
Update an existing draft |
client.publishDraft(id) |
Publish a draft |
client.deleteDraft(id) |
Delete a draft |
client.createComment(postId, body) |
Post a comment |
client.deleteComment(id) |
Delete a comment |
client.likeComment(commentId) |
Like a comment |
client.unlikeComment(commentId) |
Unlike a comment |
| Method | Description |
|---|---|
client.testConnectivity() |
Verify API token works |
client.publishedPosts(options) |
Your published posts |
client.drafts(options) |
List your drafts |
client.scheduledPosts(options) |
List scheduled posts |
client.postCounts(query) |
Post statistics |
client.draft(id) |
Get a specific draft |
client.notesFeed(options) |
Your notes feed |
client.noteStats(entityKey) |
Note analytics (impressions, interactions) |
client.restackNote(noteId) |
Restack (re-share) a note |
client.unrestackNote(noteId) |
Remove a restack from a note |
| Method | Description |
|---|---|
client.publicationDetails() |
Publication metadata |
client.publicationTags() |
Publication tags |
client.liveStreams(status) |
Live streams |
client.eligibleHosts(pubId) |
Eligible chat hosts |
client.subscription() |
Current subscription |
| Method | Description |
|---|---|
client.dashboardSummary(options) |
Dashboard overview stats |
client.emailsTimeseries(options) |
Email timeseries data |
client.unreadActivity() |
Unread activity count |
client.unreadMessageCount() |
Unread message count |
client.subscriberStats() |
Subscriber statistics |
client.growthSources(options) |
Growth source breakdown |
client.growthTimeseries(data) |
Growth over time |
client.networkAttribution(options) |
Network attribution stats |
client.followerTimeseries(options) |
Follower growth data |
| Method | Description |
|---|---|
client.outgoingRecommendations(pubId) |
Your outgoing recommendations |
client.outgoingRecommendationStats() |
Recommendation statistics |
client.incomingRecommendationStats() |
Incoming recommendation stats |
client.recommendationsExist() |
Check if recommendations are set up |
client.suggestedRecommendations(pubId) |
Suggested publications to recommend |
| Method | Description |
|---|---|
client.chatUnreadCount() |
Unread chat message count |
client.chatInbox(options) |
Chat inbox threads |
client.chatInboxThreads(options) |
Paginated inbox threads |
client.chatDm(uuid, options) |
Direct message thread |
client.chatDmMessages(uuid, options) |
Paginated DM messages |
client.chatSendMessage(uuid, body) |
Send a chat message |
client.chatInvites() |
Pending chat invites |
client.chatReactions() |
Chat reactions |
| Method | Description |
|---|---|
client.likeNote(noteId) |
Like a note |
client.unlikeNote(noteId) |
Remove like from a note |
client.likePost(postId) |
Like a post |
client.unlikePost(postId) |
Remove like from a post |
client.followUser(userId) |
Follow a user |
client.unfollowUser(userId) |
Unfollow a user |
| Method | Description |
|---|---|
client.getReadingList(options) |
Get your reading list (async iterator) |
client.savePost(postId) |
Save a post to your reading list |
client.unsavePost(postId) |
Remove a post from your reading list |
| Method | Description |
|---|---|
client.getNotifications() |
Get your notifications |
client.markNotificationsSeen() |
Mark all notifications as seen |
| Method | Description |
|---|---|
client.pledgeSummary() |
Pledge revenue summary |
client.pledges() |
List pledges |
client.pledgePlans() |
Pledge plans for your publication |
client.pledgePlansSummary() |
Summary of pledge plans |
client.readerReferrals() |
Reader referral statistics |
| Method | Description |
|---|---|
client.publisherSettings() |
Publisher settings |
client.publicationUser() |
Publication user info |
client.sections() |
Publication sections |
client.publicationSettings() |
Publication-level settings |
client.bestsellerTier() |
Current bestseller tier |
client.subscriptionSettings() |
Subscription configuration |
client.boostSettings() |
Boost settings |
client.subscriptionsPage() |
Subscriptions page data |
| Method | Description |
|---|---|
client.growthEvents() |
Growth events data |
client.audienceLocation(options) |
Audience breakdown by location |
client.audienceLocationTotal() |
Total audience by location |
client.audienceOverlap() |
Audience overlap statistics |
client.visitorSources() |
Visitor source breakdown |
client.trafficTimeseries() |
Traffic over time |
client.emailStats() |
Email statistics |
client.email30dOpenRate() |
30-day email open rate |
| Method | Description |
|---|---|
client.commentReplies(commentId) |
Threaded replies to a comment |
client.trending(options) |
Trending posts with publications (async iterator) |
client.markPostSeen(postId) |
Mark a post as seen |
client.publicationExport() |
Publication export status/history |
client.publicationSearch(query) |
Search for publications |
client.subscriptions() |
List subscriptions |
client.outgoingRecommendationsPaginated(options) |
Paginated outgoing recommendations |
client.chatMarkInboxSeen() |
Mark chat inbox as seen |
client.chatMarkDmSeen(uuid) |
Mark a DM as seen |
client.chatMarkInvitesSeen() |
Mark chat invites as seen |
client.chatRealtimeToken() |
Get a realtime chat token |
See the docs site for the complete OpenAPI endpoint inventory.
The project uses a four-tier testing strategy:
| Tier | Command | Purpose |
|---|---|---|
| Unit | pnpm test:unit |
Fast tests with mocked HTTP responses |
| Integration | pnpm test:integration |
Entity interactions against local test server |
| E2E | pnpm test:e2e |
Live API calls (requires credentials) |
| Live Validation | pnpm test:unit --testPathPattern=live-api-validation |
Probes real endpoints for schema drift |
Run all tests:
pnpm testSee CONTRIBUTING.md for development setup, testing guidelines, and pull request process.
MIT