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.
Authorization: Bearer ctv_your_site_keyKeep 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/postsReturns published posts for the workspace that owns the key, newest first.
| Parameter | Type | Meaning |
|---|---|---|
| limit | 1–100, default 20 | How many posts to return. Values outside the range are clamped, not rejected. |
| cursor | string | The nextCursor from a previous response. Omit for the first page. |
| channel | default "blog" | Which channel to return. Pass "all" to include social posts too — usually you do not want these on a website. |
{
"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
| Field | Type | Notes |
|---|---|---|
| id | string | Stable internal identifier. Good as a React key, not for URLs. |
| slug | string | URL-safe, unique per workspace, permanent from the moment of publish. |
| title | string | The post topic. |
| content | string | The full body, as Markdown. Render it with whatever you already use. |
| excerpt | string | First ~200 characters with Markdown stripped. For cards, lists and meta descriptions. |
| channel | string | Normally "blog". Only other values if you asked for channel=all. |
| publishedAt | string | null | ISO 8601 UTC. |
| updatedAt | string | ISO 8601 UTC. Useful for cache keys, sitemaps and lastmod. |
Errors
| Status | Body error | What it means |
|---|---|---|
| 401 | unauthorized | Missing, 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. |
| 404 | not_found | Single 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.
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.
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" }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" | jqFetch 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
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
| Property | Value | Notes |
|---|---|---|
| Methods | GET, OPTIONS | Read-only. There is no way to write content through this API. |
| Page size | max 100 | Larger values are clamped rather than rejected. |
| Scope | one workspace | A key can only ever read the workspace it was created in. |
| Slugs | permanent | Assigned at publish, never rewritten. Safe to use in URLs and sitemaps. |
| Revoking | immediate | Deleting 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.