# Topics.so API

Read a brand's published social posts and their metrics over HTTP. Responses are JSON. This document is the complete reference.

Base URL: `https://topics.so/api`

## Authentication

Every endpoint requires an API key, sent as a bearer token:

```http
Authorization: Bearer tso_your_key_here
```

Keys are created in the Topics.so dashboard under **API**, and the secret is shown only once, at creation.

**An API key is a secret.** Call this API from a server, a background job, or a build step, and read the key from an environment variable. Never put a key in browser JavaScript, a mobile app bundle, or anything else shipped to a user: the API sends no CORS headers, so browser requests will fail anyway.

Keys are scoped to one brand and carry a list of scopes. `postsRead` grants both endpoints below.

## Rate limits and quotas

| Plan | Requests/second | Requests/month | Active keys |
|---|---|---|---|
| Free | 1 | 500 | 1 |
| Creator | 5 | 25,000 | 5 |
| Studio | 20 | 250,000 | 20 |

Limits apply per brand, not per key. Every response reports where you stand:

| Header | Meaning |
|---|---|
| `X-RateLimit-Limit` | Requests allowed in one second. |
| `X-RateLimit-Remaining` | Requests left in the current second. |
| `X-RateLimit-Reset` | Unix time (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. |

Exceeding the per-second limit returns `429 rateLimitExceeded` with `Retry-After: 1`. A throttled request does **not** consume monthly quota, so a retry costs nothing but time. Exhausting the monthly quota returns `429 quotaExceeded` until the calendar month rolls over.

## Caching (read this before you write a polling loop)

The metrics behind this API are refreshed by a job that runs every 30 minutes. Requesting the same data more often than that cannot return anything new, it just spends quota. On the Free plan the entire month is 500 requests, which is roughly 16 per day: an uncached page that calls the API on every visit will exhaust it.

Build the integration to fetch on a schedule and serve from cache in between:

- **Cache for at least 30 minutes.** An hour or a day is entirely reasonable for published-post data.
- **Cache the rendered result, not just the HTTP call**, so traffic spikes cannot turn into API calls.
- **Fetch at build time or on a cron** where you can, rather than per request.
- **Page in bulk.** `perPage=100` fetches a hundred posts for one request; `perPage=10` spends ten times the quota for the same data.
- **Never call this API from a client-side effect.** Every visitor's page load would become a request, and it would leak your key.

Next.js App Router, revalidating hourly:

```javascript
const response = await fetch("https://topics.so/api/v1/posts?perPage=100", {
	headers: { Authorization: `Bearer ${process.env.TOPICS_API_KEY}` },
	// One request an hour, no matter how much traffic the page gets.
	next: { revalidate: 3600 }
});

const { data, pagination } = await response.json();
```

Anywhere else, a small in-memory cache does the same job:

```javascript
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 breaking 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 uses the same envelope. Branch on `code`; the message is written for people and may change.

```json
{
  "error": {
    "code": "rateLimitExceeded",
    "message": "Too many requests. Slow down and try again shortly."
  }
}
```

| Code | HTTP | 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. |

## Pagination

List endpoints answer with `data` and `pagination`. Page through by incrementing `page` until it reaches `pagination.pageCount`. `perPage` accepts 1 to 100 and defaults to 20.

## Endpoints

### List posts

```http
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.

Required scope: `postsRead`

#### Query parameters

| Name | Type | Required | Notes |
|---|---|---|---|
| `page` | integer | no | Page number, starting at 1. Default `1`. |
| `perPage` | integer | no | Posts per page. Default `20`. Max `100`. |
| `q` | string | no | Free-text search across title, caption, and cover text. |
| `from` | string | no | Only posts published on or after this date (yyyy-mm-dd). |
| `to` | string | no | Only posts published on or before this date (yyyy-mm-dd). |
| `platform` | string | no | Restrict to one platform. One of: `instagram`, `linkedin`, `youtube`. |
| `format` | string | no | Restrict to one content format. One of: `carousel`, `image`, `reel`, `story`, `video`. |
| `hashtag` | string | no | Restrict to posts carrying this hashtag, without the leading #. |
| `sort` | string | no | Field to sort on. One of: `published`, `views`, `impressions`, `clicks`, `comments`, `engagementRate`. Default `published`. |
| `dir` | string | no | Sort direction. One of: `asc`, `desc`. Default `desc`. |

#### Request

```bash
curl "https://topics.so/api/v1/posts?perPage=5&platform=instagram" \
  -H "Authorization: Bearer $TOPICS_API_KEY"
```

```javascript
const response = await fetch("https://topics.so/api/v1/posts?perPage=5&platform=instagram", {
	headers: { Authorization: `Bearer ${process.env.TOPICS_API_KEY}` }
});

const data = await response.json();
```

#### Response

```json
{
  "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

```http
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.

Required scope: `postsRead`

#### Path parameters

| Name | Type | Required | Notes |
|---|---|---|---|
| `id` | string | yes | The post id, as returned by the list endpoint. |

#### Request

```bash
curl "https://topics.so/api/v1/posts/cm8xk21p40001s6014r9e2n7q" \
  -H "Authorization: Bearer $TOPICS_API_KEY"
```

```javascript
const response = await fetch("https://topics.so/api/v1/posts/cm8xk21p40001s6014r9e2n7q", {
	headers: { Authorization: `Bearer ${process.env.TOPICS_API_KEY}` }
});

const data = await response.json();
```

#### Response

```json
{
  "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"
    }
  ]
}
```

## Field notes

- All timestamps are ISO 8601 strings in UTC.
- `engagementRate` is a fraction, not a percentage: `0.0786` means 7.86%.
- A `null` metric means the platform does not report it, which is different from a real zero. `reach` is null on YouTube, `saves` is Instagram only, and `clicks` is null when a post carries no link. Render nulls as an em dash or hide them; do not coerce them to `0`.
- `snapshots` (on the single-post endpoint) are cumulative lifetime totals, oldest first. For a per-day series, difference consecutive entries.
- `hashtags` are normalized: lowercased, no leading `#`. A value from this array can be passed straight back as the `hashtag` filter.
- `mediaUrls` is ordered, cover first, and may be empty.

## Machine-readable spec

OpenAPI 3.1: `https://topics.so/api/v1/openapi.json` (no authentication required).

This document: `https://topics.so/docs/api.md`
