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.
Base URL
texthttps://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:
httpHTTP/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-LimitandX-RateLimit-Remaining; on a429,Retry-Aftertells 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
429s 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. |
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": { "relatedPosts": ["another-post"] }, "readingTime": 4, "wordCount": 850, "language": "en", "publishedAt": "2026-06-14T09:00:00Z" } }
An unknown or unpublished slug returns 404.
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 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:
tsasync 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=bodyover per-slug fetches for any bulk or build-time read. - Cap concurrency and honor
Retry-Afteron429(back off, then retry). - The public content responses are cached server-side, so reads stay fast.
Embed ingestion API
The Embed SDK 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 POSTs the finished article as JSON
to your configured URL and signs it so you can verify authenticity.
textPOST 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.
tsimport { 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.