GuidesFEEDS
Substack API alternatives
Substack has no public API for posts; its help center says so and gives no timeline for one. Every publication serves its 20 latest posts as RSS at /feed, but without an Access-Control-Allow-Origin header, so a browser fetch() fails. Through the free proxy.cors.dev (no signup, GET and HEAD, about 600 requests a minute per IP) all 7 Substack feeds we tried loaded on 28 October 2026, while the undocumented /api/v1/archive JSON answered 429 for 5 of 7 publications.
Does Substack have an API
Substack's help center states: "At this time, we don't have a public API and we don't have a timeline for when/if this would become available." You can't register an application or generate a developer token to fetch your newsletter's posts.
Substack does have Developer API Terms at substack.com/api-tos, last updated 8 January 2026, for approved partners. That access covers profile-level data only: name, LinkedIn URL, social links, total subscriber count, bestseller status, leaderboard recognitions, profile summary, profile URL and publication URL. Posts aren't included, and the terms let Substack set rate limits at its discretion and forbid circumventing them.
To show recent posts on your website, you have two options: the public RSS feed, or undocumented JSON routes under /api/v1/.
The /feed URL every publication has
Every Substack publication serves an RSS feed at /feed. On a default Substack subdomain, the URL is https://<name>.substack.com/feed, and for publications on a custom domain it's https://<custom domain>/feed, such as https://www.lennysnewsletter.com/feed. When a publication has a custom domain, the substack.com URL redirects to it; the proxy follows that redirect.
The feed contains the 20 most recent posts. Each item in the XML includes:
titlelinkpubDatedescription(the post subtitle)dc:creatorcontent:encoded(the post HTML)enclosure(a cover image, or the audio file for podcast episodes)
Substack feeds don't send an Access-Control-Allow-Origin header, so calling fetch() directly from browser JavaScript on your own site fails with a CORS error. You can get around this in the browser by putting the feed URL after https://proxy.cors.dev/, which requires no signup and no key on the free tier.
Tested on 28 October 2026 at a pace of one request every 3 seconds through https://proxy.cors.dev/, all 7 Substack feeds returned 200. Every feed stayed under the free tier's 6 MiB response limit, even with full post HTML in content:encoded.
| Publication | Feed URL | Feed size | Archive JSON through cors.dev |
|---|---|---|---|
| Lenny's Newsletter | www.lennysnewsletter.com/feed | 280,914 bytes | 429 |
| Astral Codex Ten | www.astralcodexten.com/feed | 753,866 bytes | 429 |
| Noahpinion | noahpinion.substack.com/feed | 1,641,703 bytes | 429 |
| Simon Willison's Newsletter | simonw.substack.com/feed | 2,226,512 bytes | 200 |
| Slow Boring | www.slowboring.com/feed | 200,094 bytes | 429 |
| The Free Press | www.thefp.com/feed | 162,220 bytes | 429 |
| One Useful Thing | www.oneusefulthing.org/feed | 922,268 bytes | 200 |
Load the feed in the browser
To load a publication's feed from client-side JavaScript, prepend https://proxy.cors.dev/ to the feed URL, such as https://proxy.cors.dev/https://www.lennysnewsletter.com/feed. The snippet below defines latestSubstackPosts(publication, limit), fetches /feed through the proxy, retries twice on 429 after 3 and then 6 seconds, and returns the title, URL, published date, subtitle and media URL for each post.
const PROXY = 'https://proxy.cors.dev/';
const decode = (html) => new DOMParser().parseFromString(`<body>${html}`, 'text/html').body.textContent;
async function latestSubstackPosts(publication, limit = 10) {
const feedUrl = `https://${publication}/feed`;
// Substack answers 429 when an IP sends too much, so we back off and retry twice
for (let attempt = 1; ; attempt++) {
const res = await fetch(PROXY + feedUrl);
if (res.status === 429 && attempt < 3) {
await new Promise((resolve) => setTimeout(resolve, 3000 * attempt));
continue;
}
if (!res.ok) throw new Error(`${feedUrl} answered ${res.status} (${res.headers.get('X-Cors-Source') ?? 'proxy'})`);
const doc = new DOMParser().parseFromString(await res.text(), 'text/xml');
return [...doc.querySelectorAll('item')].slice(0, limit).map((item) => ({
title: item.querySelector('title')?.textContent ?? '',
url: item.querySelector('link')?.textContent ?? '',
published: new Date(item.querySelector('pubDate')?.textContent ?? ''),
subtitle: decode(item.querySelector('description')?.textContent ?? ''),
media: item.querySelector('enclosure')?.getAttribute('url') ?? null,
}));
}
}
console.log(await latestSubstackPosts('www.lennysnewsletter.com', 3));The description element in Substack's XML contains HTML entities, so the snippet decodes it before returning the subtitle string. If you extend the code to read content:encoded for the full post body, treat it as third-party HTML and sanitize it before inserting it into your page.
For safe rendering, see fetching RSS feeds in JavaScript; to turn the whole feed into rss2json-style JSON, see the rss2json alternative.
Undocumented JSON endpoints
Substack publications also answer on JSON routes under /api/v1/ that aren't documented anywhere, so they can change without notice. The archive listing is smaller than the RSS feed and lets you page past the feed's 20 posts:
/api/v1/archive?sort=new&limit=10: returns a JSON array of post summaries (43,295 bytes for 10 Noahpinion posts), and you can add&offset=10for the next page. Fields includeid,title,slug,post_date,audience,canonical_url,subtitle,cover_image,podcast_url,reactions,restacksandtruncated_body_text, whilebody_htmlis empty in this listing./api/v1/posts?limit=10: returns full post bodies (893,354 bytes for 10 Noahpinion posts)./sitemap.xml: lists post URLs in XML (1,373 post URLs in 199,659 bytes for Noahpinion).
Through the proxy on 28 October 2026, requesting /api/v1/archive?sort=new&limit=5 answered 429 for 5 of the 7 tested publications and only returned 200 for 2 of them (Simon Willison's Newsletter at 27,117 bytes, and One Useful Thing at 24,186 bytes). The /feed RSS URLs were far more reliable than the JSON endpoints across the same test.
Handling Substack's 429 responses
When Substack rate-limits a request, it returns status 429 with a 17-byte "Too Many Requests" body and no Retry-After header. When you fetch through cors.dev, that response includes the header X-Cors-Source: upstream, which tells you the 429 came from Substack and not from the proxy.
In a re-test the same day, all 7 /api/v1/archive URLs returned 200 from a server IP while most of the same requests through the proxy got 429, though 5 /feed requests in a row at one every 1.5 seconds all returned 200 through the proxy. The difference is the IP address: the free proxy's outbound IPs are shared with everyone else using it, and Substack throttles them.
By contrast, the cors.dev free tier allows about 600 requests a minute per IP for GET and HEAD requests with no daily or monthly quota, and if you pass that limit the proxy itself answers 429 with a Retry-After header. To avoid hitting Substack's upstream rate limit from browser code, follow these steps:
- Cache in the browser
Fetch the feed at most once per page view and keep the parsed result in
localStorageor your own cache for a few minutes. - Back off on 429
Wait and retry when you see a
429status, just like the code snippet above retries twice after 3 and then 6 seconds. - Move steady traffic off the client
For steady traffic, fetch the feed on your own server or at build time on a schedule rather than from every visitor's browser. Proxy caching won't absorb it: Substack feeds answer with
Set-CookieandCache-Control: no-cache, so cors.dev Pro'sX-Cors-Cacheheader passes them through uncached.
Good questions.
How can I tell if a 429 error came from Substack or from cors.dev?
Check the response headers for X-Cors-Source: upstream and Retry-After. Substack's 429 carries a 17-byte "Too Many Requests" body without a Retry-After header, and cors.dev tags it with X-Cors-Source: upstream. If you exceed the cors.dev free tier limit of about 600 requests a minute per IP, the proxy's own 429 response includes a Retry-After header.
Can I get more than the 20 latest posts from a Substack publication?
The /feed RSS URL only ever lists the 20 most recent posts. To go further back, you have to paginate through the undocumented /api/v1/archive?sort=new&limit=10 endpoint using &offset=10, or fetch /sitemap.xml to get a list of every post URL. Keep in mind that /api/v1/archive?sort=new&limit=5 returned 429 for 5 of 7 publications through the proxy in our October 2026 test, and none of the JSON routes are documented.
Is it safe to render the post HTML from the RSS feed directly on my page?
Not without sanitizing it first: content:encoded is third-party HTML, so run it through a sanitizer such as DOMPurify before inserting it into the DOM, or render plain text instead. The subtitle in description only needs its HTML entities decoded, which latestSubstackPosts() does. For plain-text previews, the undocumented /api/v1/archive endpoint has a truncated_body_text field.
What are the limits on the cors.dev free tier when fetching feeds?
The free tier supports GET and HEAD requests up to 6 MiB with a 30-second deadline, about 600 requests a minute per IP, and no daily or monthly quota. All 7 Substack feeds we tested on 28 October 2026 ranged from 162,220 bytes (The Free Press) to 2,226,512 bytes (Simon Willison's Newsletter), which fits inside the 6 MiB cap. You don't need a signup or an API key to use https://proxy.cors.dev/.
What does cors.dev Pro add for fetching Substack feeds?
cors.dev Pro costs $5 a month for 500,000 requests (with a 7-day trial of 1,000 requests and no card). It locks access to your exact allowed origins, raises throughput to 3,000 requests a minute per account and 100 in flight, and adds POST, PUT, PATCH and DELETE with bodies up to 1 MiB plus Authorization and custom header forwarding, with the same 6 MiB cap and 30-second deadline. Its X-Cors-Cache header won't cache Substack feeds, which answer with Set-Cookie and Cache-Control: no-cache (X-Cors-Cache-Status: BYPASS), and Pro doesn't hide an API key: anything your browser code sends is visible to anyone using the page.