Content API

Your site reads its published posts from Contivo with one authenticated GET. Nothing is pushed into your codebase, there is no plugin, and it works with any stack that can make an HTTP request.

Base URL https://www.contivo.app · read-only · JSON

How it works

Contivo writes and publishes on the schedule you set. When a post goes live it is assigned a permanent slug and marked published — and that is the end of Contivo's involvement. Your site asks for posts when it wants them.

That direction matters: Contivo never has write access to your codebase, your deploy pipeline or your CMS. If you turn Contivo off, your site keeps serving whatever it last fetched.

Authentication

Every request carries a site key as a bearer token. Create one under Connections → Websites. It is shown once, at creation, and cannot be recovered afterwards — if you lose it, create a new site connection.

Every request
Authorization: Bearer ctv_your_site_key

Keep the key server-side. It can read everything this workspace has ever published. The endpoints do send permissive CORS headers, so a browser can call them — but doing so ships your key to every visitor. Fetch in a server component, a route handler, at build time, or from your backend.

List posts

GET https://www.contivo.app/api/v1/posts

Returns published posts for the workspace that owns the key, newest first.

ParameterTypeMeaning
limit1–100, default 20How many posts to return. Values outside the range are clamped, not rejected.
cursorstringThe nextCursor from a previous response. Omit for the first page.
channeldefault "blog"Which channel to return. Pass "all" to include social posts too — usually you do not want these on a website.
Response
{
  "posts": [
    {
      "id": "cmtj8m9cw0004669budmzpagu",
      "slug": "eliminating-the-triage-bottleneck",
      "title": "Eliminating the Triage Bottleneck",
      "content": "Manual bug triage remains a major source of friction…",
      "excerpt": "Manual bug triage remains a major source of friction for engineering teams…",
      "channel": "blog",
      "publishedAt": "2026-09-02T02:00:00.000Z",
      "updatedAt": "2026-09-02T02:00:04.118Z"
    }
  ],
  "nextCursor": "cmtj8m9cw0004669budmzpagu"
}

nextCursor is null when there are no more pages. That is the only reliable end-of-list signal — do not stop on a short page.

Get one post

GET https://www.contivo.app/api/v1/posts/{slug}

Returns { "post": { … } } with the same object as above. Route your pages on slug, never on id: the slug is assigned once at publish and never changes, which is what keeps your URLs stable.

The post object

FieldTypeNotes
idstringStable internal identifier. Good as a React key, not for URLs.
slugstringURL-safe, unique per workspace, permanent from the moment of publish.
titlestringThe post topic.
contentstringThe full body, as Markdown. Render it with whatever you already use.
excerptstringFirst ~200 characters with Markdown stripped. For cards, lists and meta descriptions.
channelstringNormally "blog". Only other values if you asked for channel=all.
publishedAtstring | nullISO 8601 UTC.
updatedAtstringISO 8601 UTC. Useful for cache keys, sitemaps and lastmod.

Errors

StatusBody errorWhat it means
401unauthorizedMissing, malformed, unknown or revoked key — or the site connection is not active. All four return the same response on purpose, so nobody can use this endpoint to work out which keys exist.
404not_foundSingle post only. No published post with that slug in this workspace. A slug belonging to another customer also returns 404, never 403.

An empty posts array is not an error. It means the key is valid and nothing has been published to that channel yet — Autopilot has to publish something before anything appears here.

Caching and freshness

Responses are sent with Cache-Control: no-store, so the API never asks a CDN or browser to hold onto them. Caching is entirely yours to decide, which is the right way round: your framework knows your traffic and your tolerance for staleness.

Next.js — revalidate every five minutes
const res = await fetch("https://www.contivo.app/api/v1/posts", {
  headers: { Authorization: `Bearer ${process.env.CONTIVO_SITE_KEY}` },
  next: { revalidate: 300 },
});

A five-minute window is a sensible default. For instant updates, pair a long revalidate with the webhook below rather than polling harder.

Revalidate webhook

Optional. Set a revalidate URL on the site connection and Contivo will call it right after each publish, so your cache clears immediately instead of on the next interval.

What Contivo sends
POST <your revalidate URL>
Content-Type: application/json
Authorization: Bearer <your revalidate secret>   // only if you set one

{ "slug": "eliminating-the-triage-bottleneck", "event": "post.published" }
Next.js — app/api/revalidate/route.ts
import { revalidatePath } from "next/cache";

export async function POST(req: Request) {
  if (req.headers.get("authorization") !== `Bearer ${process.env.CONTIVO_REVALIDATE_SECRET}`) {
    return new Response("Unauthorized", { status: 401 });
  }
  const { slug } = await req.json();
  revalidatePath("/blog");
  revalidatePath(`/blog/${slug}`);
  return Response.json({ revalidated: true });
}

The call times out after 8 seconds and its status is recorded against the site connection. A failure is logged and never blocks the publish — the post is already live and your site will pick it up on its next scheduled fetch regardless.

Recipes

Check your key from a terminal

curl -sS -H "Authorization: Bearer $CONTIVO_SITE_KEY" \
  "https://www.contivo.app/api/v1/posts?limit=1" | jq

Fetch every post, one page at a time

async function allPosts(key) {
  const out = [];
  let cursor = null;
  do {
    const url = new URL("https://www.contivo.app/api/v1/posts");
    url.searchParams.set("limit", "100");
    if (cursor) url.searchParams.set("cursor", cursor);

    const res = await fetch(url, { headers: { Authorization: `Bearer ${key}` } });
    if (!res.ok) throw new Error(`Contivo returned ${res.status}`);

    const page = await res.json();
    out.push(...page.posts);
    cursor = page.nextCursor;      // null ends the loop
  } while (cursor);
  return out;
}

Static site generation

Next.js — generateStaticParams
export async function generateStaticParams() {
  const res = await fetch("https://www.contivo.app/api/v1/posts?limit=100", {
    headers: { Authorization: `Bearer ${process.env.CONTIVO_SITE_KEY}` },
  });
  const { posts } = await res.json();
  return posts.map((p) => ({ slug: p.slug }));
}

Python

import os, requests

r = requests.get(
    "https://www.contivo.app/api/v1/posts",
    headers={"Authorization": f"Bearer {os.environ['CONTIVO_SITE_KEY']}"},
    params={"limit": 20},
    timeout=10,
)
r.raise_for_status()
for post in r.json()["posts"]:
    print(post["slug"], "-", post["title"])

PHP / WordPress

$response = wp_remote_get(
  'https://www.contivo.app/api/v1/posts?limit=20',
  ['headers' => ['Authorization' => 'Bearer ' . getenv('CONTIVO_SITE_KEY')]]
);
$posts = json_decode(wp_remote_retrieve_body($response), true)['posts'];

Limits and guarantees

PropertyValueNotes
MethodsGET, OPTIONSRead-only. There is no way to write content through this API.
Page sizemax 100Larger values are clamped rather than rejected.
Scopeone workspaceA key can only ever read the workspace it was created in.
SlugspermanentAssigned at publish, never rewritten. Safe to use in URLs and sitemaps.
RevokingimmediateDeleting or disabling a site connection makes its key 401 on the next request.

There is no published rate limit today. Cache your responses anyway — a site that fetches on every page view is fragile for its own reasons, not just ours.


Something here wrong or missing? It is generated from the same code that serves the API, so a mismatch is a bug worth reporting. Print this page to save it as a PDF.

Content API — Contivo for developers