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

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 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

ParamDefaultDescription
page1Page number (1-based).
limit20Items per page (max 100).
categoryOnly posts in this category.
tagOnly posts with this tag.
languageOnly posts in this ISO language code (e.g. en).
includeSet 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:

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 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
}
FieldTypeDescription
type"attribution" | "survey"The kind of event.
referrerstringThe document referrer; used to classify AI referrals.
utmobjectCaptured source / medium / campaign from the landing URL.
pathstringThe entry path on your site (e.g. /pricing).
surveyarrayFor survey events: a list of { questionId, answer }.
consentbooleanWhen false, the event is honored but not stored.

Response202 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
}
FieldTypeDescription
projectIdstringThe resolved project id (used for in-page content fetches).
attributionbooleanWhether passive attribution is enabled.
survey.enabledbooleanWhether to show the visitor survey.
survey.questionsarrayThe questions to render; the source question is always locked.
contentbooleanWhether 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.

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.