# API reference

Terradium exposes a **public content API** so you can pull your published posts
into any frontend — a static site, an app, or your own CMS. Articles published to
the built-in CMS are served read-only through this API; it is gated by a
per-project API key.

A machine-readable OpenAPI 3 document for this surface (plus healthz and MCP)
is published at [https://terradium.io/openapi.json](/openapi.json). The API
host is still `https://api.terradium.io`. For agent writes, use
[MCP](/docs/mcp) with a `td_mcp_` project token, not this read-only key.

## Base URL

```text
https://api.terradium.io/api/v1
```

All public content endpoints are scoped to a project:

```text
/api/v1/public/content/{projectId}/...
```

Find your **project ID** and create **API keys** under **Project settings → API
access** in the dashboard.

## Authentication

Send your project API key with every request, either header works:

```bash
# Preferred
curl -H "X-API-Key: YOUR_API_KEY" \
  https://api.terradium.io/api/v1/public/content/PROJECT_ID/posts

# Or a bearer token
curl -H "Authorization: Bearer YOUR_API_KEY" \
  https://api.terradium.io/api/v1/public/content/PROJECT_ID/posts
```

A missing or invalid key returns `401`; a key that doesn't match the project
returns `404`.

## API keys

API keys are managed per project, under **project settings → API access**.

- **Create a key** — give it a name (e.g. `production website`) and click create.
  The full key is shown **once** — copy it immediately and store it securely; you
  won't be able to see it again.
- **Use it** — send it as `X-API-Key` (or a bearer token) on every request, as
  shown above. The API access card also includes ready-to-paste integration
  snippets.
- **Revoke** — remove a key at any time from the same screen. Revoked keys stop
  working immediately; issue a new one to replace it.

Keys are scoped to a single project, so each project's published content is read
with its own key. The list shows each key's name, masked prefix, and when it was
last used.

## Rate limits

The public content API is rate-limited to protect the service. When you exceed
the limit, requests return `429 Too Many Requests`:

```http
HTTP/1.1 429 Too Many Requests
Retry-After: 23
X-RateLimit-Limit: 1200
X-RateLimit-Remaining: 0

{ "error": "rate limit exceeded", "code": "rate_limited" }
```

- The limit is **1200 requests per minute**, counted **per client IP** (it is not
  per API key — all traffic from one egress IP, e.g. a CI build server, shares the
  same budget).
- Every response carries `X-RateLimit-Limit` and `X-RateLimit-Remaining`; on a
  `429`, `Retry-After` tells you how many seconds until the window resets.
- **Always respect `Retry-After`.** A good client backs off for that long and
  retries, rather than hammering the endpoint.

> The biggest source of `429`s is a static-site build that fetches one post at a
> time. Don't do that — see below.

## Endpoints

### GET /api/v1/public/content/{projectId}/posts

List published posts, newest first. Supports pagination and filtering.

**Query parameters**

| Param | Default | Description |
| --- | --- | --- |
| `page` | `1` | Page number (1-based). |
| `limit` | `20` | Items per page (max `100`). |
| `category` | — | Only posts in this category. |
| `tag` | — | Only posts with this tag. |
| `language` | — | Only posts in this ISO language code (e.g. `en`). |
| `include` | — | Set to `body` to return each post's **full content** (`htmlContent`, `markdownContent`, and all metadata) instead of the summary. See [Fetching all posts for a static build](#fetching-all-posts-for-a-static-build). |

**Response**

```json
{
  "data": [
    {
      "slug": "my-first-post",
      "title": "My first post",
      "excerpt": "A short summary…",
      "categories": ["guides"],
      "tags": ["intro"],
      "keywords": ["terradium"],
      "language": "en",
      "readingTime": 4,
      "featuredImage": "https://assets.terradium.io/…/featured.jpg",
      "publishedAt": "2026-06-14T09:00:00Z"
    }
  ],
  "pagination": { "page": 1, "limit": 20, "total": 42, "totalPages": 3 }
}
```

With `include=body`, each item in `data` is the **full post object** — identical
to what `GET /posts/{slug}` returns (`htmlContent`, `markdownContent`, `author`,
`seoMetadata`, `wordCount`, …), still paginated. This lets you pull every post
body in pages of 100 instead of one request per slug.

### GET /api/v1/public/content/{projectId}/posts/{slug}

Fetch a single published post by slug, including its rendered HTML and markdown.

**Response**

```json
{
  "data": {
    "slug": "my-first-post",
    "title": "My first post",
    "htmlContent": "<h1>…</h1>",
    "markdownContent": "# …",
    "excerpt": "A short summary…",
    "metaDescription": "…",
    "featuredImage": "https://assets.terradium.io/…/featured.jpg",
    "categories": ["guides"],
    "tags": ["intro"],
    "keywords": ["terradium"],
    "author": null,
    "seoMetadata": {
      "canonicalUrl": "https://example.com/blog/my-post",
      "ogTitle": "My post",
      "ogDescription": "A short summary…",
      "ogImage": "https://assets.terradium.io/…/featured.jpg",
      "ogUrl": "https://example.com/blog/my-post",
      "jsonLd": { "@context": "https://schema.org", "@graph": [] },
      "relatedPosts": ["another-post"]
    },
    "readingTime": 4,
    "wordCount": 850,
    "language": "en",
    "publishedAt": "2026-06-14T09:00:00Z"
  }
}
```

An unknown or unpublished slug returns `404`.

`seoMetadata` carries everything you need for the post's `<head>`:

- `jsonLd` — a schema.org `@graph` (Article, Organization, author `Person`, and a
  `FAQPage` when the body is question-shaped). Inject it verbatim inside
  `<script type="application/ld+json">`.
- `canonicalUrl` / `ogUrl`, `ogTitle`, `ogDescription`, `ogImage` — the canonical
  link and Open Graph tags. Keys with no value are omitted, so render each one
  only when present.

All URLs here honour your project's [Blog URL pattern](/docs/projects), so they
point at your own site, not at Terradium.

### GET /api/v1/public/content/{projectId}/categories

List the categories used across published posts, with counts.

**Response**

```json
{
  "data": [
    { "name": "guides", "count": 12 },
    { "name": "changelog", "count": 3 }
  ]
}
```

## Fetching all posts for a static build

If you statically generate your site (Next.js `output: export`, Astro, Hugo, a
build step, etc.), the natural-but-wrong approach is: list the slugs, then fetch
`GET /posts/{slug}` once per post. A site with a few hundred posts then fires
hundreds of requests in a tight burst — and the count **grows with every post you
publish**. That trips the [rate limit](#rate-limits) and fails the build.

**Do this instead: page through `?include=body`.** One request per 100 posts
returns every body and all metadata, so the request count scales with
`ceil(posts / 100)` — a 10,000-post site is ~100 requests, not 10,000:

```ts
async function getAllPostsWithBody(projectId: string, apiKey: string) {
  const base = `https://api.terradium.io/api/v1/public/content/${projectId}`;
  const all = [];
  for (let page = 1; ; page++) {
    const res = await fetch(
      `${base}/posts?limit=100&include=body&page=${page}`,
      { headers: { "X-API-Key": apiKey } },
    );
    if (res.status === 429) {
      // Respect Retry-After, then retry the same page.
      await new Promise((r) =>
        setTimeout(r, (Number(res.headers.get("retry-after")) || 1) * 1000),
      );
      page--;
      continue;
    }
    const { data, pagination } = await res.json();
    all.push(...data);
    if (page >= pagination.totalPages) break;
  }
  return all; // each item has htmlContent + markdownContent + metadata
}
```

Fetch the full list **once** at the start of the build and reuse it for every
page (post pages, the index, sitemaps, related-post lookups) instead of
re-fetching per route. Each item already includes both `htmlContent` and
`markdownContent`, so you don't need a second pass to get clean markdown.

**Guidelines**

- Prefer `?include=body` over per-slug fetches for any bulk or build-time read.
- Cap concurrency and **honor `Retry-After`** on `429` (back off, then retry).
- The public content responses are cached server-side, so reads stay fast.

## Embed ingestion API

The [Embed SDK](/docs/embed) talks to a separate, public set of endpoints under
`/api/v1/public/embed`. These power visitor attribution and the visitor survey.
Unlike the content API, the embed endpoints use **permissive CORS** (they're
callable from any origin, since they run in your visitors' browsers) and the
ingestion endpoint is **rate-limited**.

Authenticate with your **project API key**, sent as `X-API-Key: <key>` or
`Authorization: Bearer <key>`. The project is resolved from the key — you don't
pass a project id (see **Project settings → Embed**).

### POST /api/v1/public/embed/events

Record a single visitor event — either an attribution event (where the visitor
came from) or a survey response.

Rate-limited: when you exceed the limit the endpoint returns `429 Too Many
Requests` with a `Retry-After` header. **No PII is collected by default**, and the
source IP address is **anonymized server-side** — raw IPs are never stored.

**Request body**

```json
{
  "type": "attribution",
  "referrer": "https://chatgpt.com/...",
  "utm": { "source": "...", "medium": "...", "campaign": "..." },
  "path": "/pricing",
  "survey": [{ "questionId": "source", "answer": "ChatGPT" }],
  "consent": true
}
```

| Field | Type | Description |
| --- | --- | --- |
| `type` | `"attribution" \| "survey"` | The kind of event. |
| `referrer` | string | The document referrer; used to classify AI referrals. |
| `utm` | object | Captured `source` / `medium` / `campaign` from the landing URL. |
| `path` | string | The entry path on your site (e.g. `/pricing`). |
| `survey` | array | For `survey` events: a list of `{ questionId, answer }`. |
| `consent` | boolean | When `false`, the event is honored but not stored. |

**Response** — `202 Accepted`

```json
{ "ok": true }
```

### GET /api/v1/public/embed/config

Return the embed's remote configuration — which features are enabled and the
survey questions to show. The embed calls this on load, so you can flip features
from the dashboard without redeploying your site. The response is **cacheable**.

**Response**

```json
{
  "projectId": "8f3c…",
  "attribution": true,
  "survey": {
    "enabled": true,
    "questions": [
      {
        "id": "source",
        "label": "How did you find us?",
        "type": "single",
        "options": ["Search engine (Google, Bing)", "AI assistant (ChatGPT, Perplexity, Gemini, Claude)", "Social media", "Other"],
        "locked": true
      }
    ]
  },
  "content": false
}
```

| Field | Type | Description |
| --- | --- | --- |
| `projectId` | string | The resolved project id (used for in-page content fetches). |
| `attribution` | boolean | Whether passive attribution is enabled. |
| `survey.enabled` | boolean | Whether to show the visitor survey. |
| `survey.questions` | array | The questions to render; the `source` question is always `locked`. |
| `content` | boolean | Whether in-page content blocks are enabled. |

## Webhook publishing

Instead of the built-in CMS, a project can publish to your own endpoint. When a
project's target is **webhook**, Terradium `POST`s the finished article as JSON
to your configured URL and signs it so you can verify authenticity.

```text
POST https://your-app.example.com/hooks/terradium
X-Terradium-Signature: sha256=<hex HMAC of the raw body>
```

Verify the signature with HMAC-SHA256 over the raw request body using your
project's webhook secret, then compare against the header value.

```ts
import { createHmac, timingSafeEqual } from "node:crypto";

function verify(rawBody: string, header: string, secret: string): boolean {
  const expected =
    "sha256=" + createHmac("sha256", secret).update(rawBody).digest("hex");
  const a = Buffer.from(header);
  const b = Buffer.from(expected);
  return a.length === b.length && timingSafeEqual(a, b);
}
```

The payload contains the article's `title`, `slug`, `html`, `markdown`,
`excerpt`, `metaDescription`, `featuredImage`, `categories`, `tags`, `keywords`,
`language`, and `publishedAt`.
