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.

View as markdown
Quickstart
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.

Base URL
https://topics.so/api
Response envelope
{
  "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.

View as markdown
Paste this into your assistant
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.
Or point it at these
https://topics.so/llms.txt
https://topics.so/docs/api.md
https://topics.so/api/v1/openapi.json

Authentication

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.

Request header
Authorization: Bearer tso_G_USxN6rS4rzt0Enh3B_-uxinWqPhJHa
Missing or invalid key
{
  "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.

PlanPer secondPer monthKeys
Free15001
Creator525,0005
Studio20250,00020

Every response reports where you stand on both, so a client can pace itself instead of waiting to be turned away.

HeaderMeaning
X-RateLimit-LimitRequests allowed in one second.
X-RateLimit-RemainingRequests left in the current second.
X-RateLimit-ResetUnix time, in seconds, when the current window resets.
X-Quota-LimitRequests allowed this calendar month.
X-Quota-RemainingRequests left this calendar month.
Retry-AfterSeconds 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.

Response headers
HTTP/1.1 200 OK
X-RateLimit-Limit: 1
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1785115680
X-Quota-Limit: 500
X-Quota-Remaining: 483

Caching

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=100 reads a hundred posts in one request; perPage=10 spends 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.

Next.js, revalidating hourly
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();
Anywhere else
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.

CodeStatusMeaning
unauthorized401No key was sent, or the key is unknown or revoked.
forbidden403The key is valid but lacks a scope the endpoint requires.
notFound404Nothing with that id exists in this brand.
validationError400A query parameter is missing, malformed, or out of range.
rateLimitExceeded429Too many requests in one minute. Retry after the window resets.
quotaExceeded429The monthly request quota is used up. It resets at the start of next month.
Error response
{
  "error": {
    "code": "rateLimitExceeded",
    "message": "Too many requests. Slow down and try again shortly."
  }
}

List posts

GET/api/v1/posts

Every 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"
Response
{
  "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

GET/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"
Response
{
  "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.