API reference
Your posts, wherever you need them.
A versioned REST API over the same data your dashboard reads. Create a key, call it from your server, and render your work anywhere.
Building with an AI assistant? Copy the whole reference as markdown and paste it in. It has everything it needs, including the caching rules.
curl "https://topics.so/api/v1/posts?perPage=5" \
-H "Authorization: Bearer $TOPICS_API_KEY"Overview
Every endpoint lives under one base URL and returns JSON. Requests are scoped to the brand that owns the key, so a key only ever sees its own posts, its own metrics, and nothing belonging to anyone else.
List endpoints answer with a data array and a pagination object. Single-resource endpoints answer with the object itself.
The version sits in the path. Anything that would break an existing integration ships as a new version, so v1 keeps working.
https://topics.so/api{
"data": [
"…"
],
"pagination": {
"page": 1,
"perPage": 20,
"total": 128,
"pageCount": 7
}
}Build with AI
Most integrations here are written by an assistant, so the whole reference is published as one markdown file at /docs/api.md. Copy it, paste it into Claude or ChatGPT, and describe what you want built.
Assistants that fetch their own context will find /llms.txt at the root, following the llmstxt.org convention, and the OpenAPI document at /api/v1/openapi.json.
The markdown is generated from the same definitions as this page, so it is never out of date, and it states the two things assistants get wrong most often: the key belongs on the server, and the responses need caching.
Build a Topics.so API integration.
The complete reference is at:
https://topics.so/docs/api.md
Fetch it, follow it exactly, and pay attention
to the caching and server-side key rules.https://topics.so/llms.txt
https://topics.so/docs/api.md
https://topics.so/api/v1/openapi.jsonAuthentication
Create a key on the API page of your dashboard. The secret is shown once, at the moment it is created, and never again. Send it as a bearer token on every request.
A key is a secret. Call the API from your server and keep the key in an environment variable. Never ship it in browser code, where anyone can read it.
Each key carries scopes that decide what it can reach. A request for something outside a key's scopes returns 403 forbidden. Revoking a key in the dashboard takes effect immediately.
Authorization: Bearer tso_G_USxN6rS4rzt0Enh3B_-uxinWqPhJHa{
"error": {
"code": "unauthorized",
"message": "That API key is not valid."
}
}Rate limits
Limits are per brand and scale with your plan. Requests are counted per second, and again across the calendar month.
| Plan | Per second | Per month | Keys |
|---|---|---|---|
| Free | 1 | 500 | 1 |
| Creator | 5 | 25,000 | 5 |
| Studio | 20 | 250,000 | 20 |
Every response reports where you stand on both, so a client can pace itself instead of waiting to be turned away.
| Header | Meaning |
|---|---|
| X-RateLimit-Limit | Requests allowed in one second. |
| X-RateLimit-Remaining | Requests left in the current second. |
| X-RateLimit-Reset | Unix time, in seconds, when the current window resets. |
| X-Quota-Limit | Requests allowed this calendar month. |
| X-Quota-Remaining | Requests left this calendar month. |
| Retry-After | Seconds to wait before retrying. Sent with both 429 responses. |
Going over the per-second limit returns 429 rateLimitExceeded, and the window reopens the next second. Using up the monthly quota returns 429 quotaExceeded, which clears at the start of the next month.
HTTP/1.1 200 OK
X-RateLimit-Limit: 1
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1785115680
X-Quota-Limit: 500
X-Quota-Remaining: 483Caching
The metrics behind this API are refreshed every 30 minutes. Asking for the same data more often than that cannot return anything new, it only spends quota. Cache the responses and the free plan goes a long way; skip caching and 500 requests a month disappears in an afternoon.
- Cache for at least 30 minutes. An hour or a day is entirely reasonable for published-post data.
- Cache the rendered page, not just the HTTP call, so a traffic spike cannot become an API spike.
- Fetch at build time or on a schedule where you can, rather than on every request.
- Page in bulk.
perPage=100reads a hundred posts in one request;perPage=10spends ten times the quota for the same posts. - Serve the last good response if a request fails, so a 429 degrades to slightly stale data instead of a broken page.
Throttled requests do not consume monthly quota, so a retry after a 429 costs nothing but the wait.
const response = await fetch("https://topics.so/api/v1/posts?perPage=100", {
headers: { Authorization: `Bearer ${process.env.TOPICS_API_KEY}` },
// One request an hour, however much traffic the page gets.
next: { revalidate: 3600 }
});
const { data, pagination } = await response.json();let cache = { at: 0, data: null };
const TTL_MS = 60 * 60 * 1000;
export async function getPosts() {
if (cache.data && Date.now() - cache.at < TTL_MS) return cache.data;
const response = await fetch("https://topics.so/api/v1/posts?perPage=100", {
headers: { Authorization: `Bearer ${process.env.TOPICS_API_KEY}` }
});
if (!response.ok) {
// Serve stale data rather than break the page on a 429.
if (cache.data) return cache.data;
throw new Error(`Topics.so API ${response.status}`);
}
cache = { at: Date.now(), data: await response.json() };
return cache.data;
}Errors
Every failure returns the same envelope. Branch on code: the message is written for people and may change.
| Code | Status | Meaning |
|---|---|---|
unauthorized | 401 | No key was sent, or the key is unknown or revoked. |
forbidden | 403 | The key is valid but lacks a scope the endpoint requires. |
notFound | 404 | Nothing with that id exists in this brand. |
validationError | 400 | A query parameter is missing, malformed, or out of range. |
rateLimitExceeded | 429 | Too many requests in one minute. Retry after the window resets. |
quotaExceeded | 429 | The monthly request quota is used up. It resets at the start of next month. |
{
"error": {
"code": "rateLimitExceeded",
"message": "Too many requests. Slow down and try again shortly."
}
}List posts
/api/v1/postsEvery post across your connected accounts, newest first by default. Filter, search, sort, and page through them the same way the dashboard does. Each post carries its latest metrics.
Requires the postsRead scope.
Query parameters
pageintegeroptional- Page number, starting at 1.Defaults to 1.
perPageintegeroptional- Posts per page.Defaults to 20. Maximum 100.
qstringoptional- Free-text search across title, caption, and cover text.
fromstringoptional- Only posts published on or after this date (yyyy-mm-dd).
tostringoptional- Only posts published on or before this date (yyyy-mm-dd).
platformstringoptional- Restrict to one platform.One of
instagram, linkedin, youtube. formatstringoptional- Restrict to one content format.One of
carousel, image, reel, story, video. hashtagstringoptional- Restrict to posts carrying this hashtag, without the leading #.
sortstringoptional- Field to sort on.One of
published, views, impressions, clicks, comments, engagementRate. Defaults to published. dirstringoptional- Sort direction.One of
asc, desc. Defaults to desc.
curl "https://topics.so/api/v1/posts?perPage=5&platform=instagram" \
-H "Authorization: Bearer $TOPICS_API_KEY"{
"data": [
{
"id": "cm8xk21p40001s6014r9e2n7q",
"platform": "instagram",
"format": "carousel",
"title": "Three openings that land",
"url": "https://www.instagram.com/p/DBv2Qm1Nq8x/",
"caption": "Three ways to open a talk that actually land. #publicspeaking #storytelling",
"hashtags": [
"publicspeaking",
"storytelling"
],
"thumbnailUrl": "https://media.topics.so/posts/cm8xk21p40001/cover.jpg",
"mediaUrls": [
"https://media.topics.so/posts/cm8xk21p40001/1.jpg",
"https://media.topics.so/posts/cm8xk21p40001/2.jpg"
],
"publishedAt": "2026-07-11T15:02:11.000Z",
"metrics": {
"views": 18420,
"impressions": 21031,
"reach": 15877,
"likes": 1204,
"comments": 86,
"shares": 143,
"saves": 219,
"clicks": null,
"engagementRate": 0.0786,
"capturedAt": "2026-07-24T06:00:00.000Z"
}
}
],
"pagination": {
"page": 1,
"perPage": 5,
"total": 128,
"pageCount": 26
}
}Retrieve a post
/api/v1/posts/{id}One post by id, with its full metric history. Snapshots are cumulative lifetime readings, oldest first, so a daily series is the difference between consecutive entries.
Requires the postsRead scope.
Path parameters
idstringrequired- The post id, as returned by the list endpoint.
curl "https://topics.so/api/v1/posts/cm8xk21p40001s6014r9e2n7q" \
-H "Authorization: Bearer $TOPICS_API_KEY"{
"id": "cm8xk21p40001s6014r9e2n7q",
"platform": "instagram",
"format": "carousel",
"title": "Three openings that land",
"url": "https://www.instagram.com/p/DBv2Qm1Nq8x/",
"caption": "Three ways to open a talk that actually land. #publicspeaking #storytelling",
"hashtags": [
"publicspeaking",
"storytelling"
],
"thumbnailUrl": "https://media.topics.so/posts/cm8xk21p40001/cover.jpg",
"mediaUrls": [
"https://media.topics.so/posts/cm8xk21p40001/1.jpg",
"https://media.topics.so/posts/cm8xk21p40001/2.jpg"
],
"publishedAt": "2026-07-11T15:02:11.000Z",
"metrics": {
"views": 18420,
"impressions": 21031,
"reach": 15877,
"likes": 1204,
"comments": 86,
"shares": 143,
"saves": 219,
"clicks": null,
"engagementRate": 0.0786,
"capturedAt": "2026-07-24T06:00:00.000Z"
},
"snapshots": [
{
"views": 12045,
"impressions": 14210,
"reach": 15877,
"likes": 1204,
"comments": 86,
"shares": 143,
"saves": 219,
"clicks": null,
"engagementRate": 0.0721,
"capturedAt": "2026-07-17T06:00:00.000Z"
},
{
"views": 18420,
"impressions": 21031,
"reach": 15877,
"likes": 1204,
"comments": 86,
"shares": 143,
"saves": 219,
"clicks": null,
"engagementRate": 0.0786,
"capturedAt": "2026-07-24T06:00:00.000Z"
}
]
}OpenAPI spec
The full specification is served as OpenAPI 3.1, generated from the same definitions that produced this page. Point your client generator, editor, or API console at it.