Quickstart
From nothing to a live request. You will create an account, get a project, write one post, mint a read-only key, and fetch that post the way your website will.
The whole thing, in five steps
Sign in with Google
Open the dashboard and sign in with Google. There is no password to choose and no separate sign-up page — the first sign-in creates three things at once: you, your workspace, and a first project inside it. A workspace is the company; a project is one website. You are the owner, on the free plan, with no card.
texthttps://cmskite.com/loginFind your project
Signing in gives you an access token. Use it to list your projects; the one created for you comes back first. Everything content-related happens inside a project, and its id is what you point a key at. The dashboard shows both ids, so you can skip this call and copy them.
bashcurl https://api.cmskite.com/v1/projects \ -H "authorization: Bearer $ACCESS_TOKEN" \ -H "x-tenant-id: $WORKSPACE_ID"Write a post
Content is written with your dashboard session, not with an API key. That is deliberate: the key your website carries is read-only, so a leaked key cannot change what your readers see. Set the status to published or it stays a draft.
bashcurl -X POST https://api.cmskite.com/v1/blog/posts \ -H "authorization: Bearer $ACCESS_TOKEN" \ -H "x-tenant-id: $WORKSPACE_ID" \ -H "x-project-id: $PROJECT_ID" \ -H 'content-type: application/json' \ -d '{ "title": "Why the cold chain breaks at the last mile", "body": "# The last mile\n\nRefrigeration is solved until the van door opens.", "status": "published" }'Mint a read-only key
A key belongs to one project and can only read it. The secret is returned exactly once, in this response — it is stored as a keyed hash and cannot be recovered. Put it in your deployment environment, not in your repository.
bashcurl -X POST https://api.cmskite.com/v1/projects/$PROJECT_ID/api-keys \ -H "authorization: Bearer $ACCESS_TOKEN" \ -H "x-tenant-id: $WORKSPACE_ID" \ -H 'content-type: application/json' \ -d '{"name": "Production website"}'Read it from your site
This is the request your website makes. No workspace header, no project header — the key already knows which project it belongs to, which is why a client-supplied project id is refused rather than honoured.
bashcurl 'https://api.cmskite.com/v1/blog/posts?limit=10' \ -H "authorization: Bearer csk_live_..."
What comes back
Every response has the same envelope. A collection carries pagination; a single resource does not. requestId is on every response, success or failure, and is what to quote if you ever need to ask about one.
GET /v1/blog/posts
{
"success": true,
"data": [
{
"id": "post_01a0b9621db0700097df715c733e28b8",
"title": "Why the cold chain breaks at the last mile",
"slug": "why-the-cold-chain-breaks-at-the-last-mile",
"excerpt": null,
"body": "",
"bodyOmitted": true,
"bodyFormat": "markdown",
"status": "published",
"publishedAt": "2026-09-19T11:16:50.226Z",
"author": null,
"category": null,
"tags": [],
"seo": {}
}
],
"pagination": { "hasNext": false, "nextCursor": null, "limit": 10 },
"requestId": "01a0b962-1da2-7000-8be2-6d650010f16d"
}post_, cat_, tag_, prj_ — so an id pasted into a support ticket says what kind of thing it is, and passing a category id where a post id belongs fails at the boundary instead of as a confusing empty result.In the framework you actually use
In JavaScript or TypeScript, the SDK is the shortest path — it handles cursor pagination, slug redirects and errors for you, and it carries the types.
npm install cmskiteNext.js — app/blog/page.tsx
import { createCMSKite } from 'cmskite'
export const revalidate = 60
const cms = createCMSKite({ apiKey: process.env.NEXT_PUBLIC_CMSKITE_KEY! })
export default async function Blog() {
const { items } = await cms.posts.list({ limit: 20 })
return (
<ul>
{items.map((post) => (
<li key={post.id}>
<a href={`/blog/${post.slug}`}>{post.title}</a>
</li>
))}
</ul>
)
}A list leaves the body out to stay small (body is empty and bodyOmitted is true). Ask for one post by its slug to get it. The body is stored as written, so when bodyFormat is markdown, turn it into HTML before you render it.
Next.js — app/blog/[slug]/page.tsx
import { notFound } from 'next/navigation'
import { marked } from 'marked' // npm install marked
export default async function Post({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params
const post = await cms.posts.findBySlug(slug)
if (!post) notFound()
const html = post.bodyFormat === 'markdown' ? await marked.parse(post.body) : post.body
return (
<article>
<h1>{post.title}</h1>
<div dangerouslySetInnerHTML={{ __html: html }} />
</article>
)
}Everything below works without it. The API is HTTP with a bearer token, so the fetch built into your runtime is enough, and in any other language it is all you need.
The same page, with plain fetch
export const revalidate = 60
async function posts() {
const res = await fetch('https://api.cmskite.com/v1/blog/posts?limit=20', {
headers: { authorization: `Bearer ${process.env.NEXT_PUBLIC_CMSKITE_KEY}` },
next: { revalidate: 60 },
})
if (!res.ok) throw new Error(`CMSKite: ${res.status}`)
const { data } = await res.json()
return data
}
export default async function Blog() {
const all = await posts()
return (
<ul>
{all.map((post) => (
<li key={post.id}>
<a href={`/blog/${post.slug}`}>{post.title}</a>
</li>
))}
</ul>
)
}Python
import os, httpx
client = httpx.Client(
base_url="https://api.cmskite.com/v1",
headers={"authorization": f"Bearer {os.environ['CMSKITE_API_KEY']}"},
)
response = client.get("/blog/posts", params={"limit": 20})
response.raise_for_status()
for post in response.json()["data"]:
print(post["slug"], post["title"])PHP
<?php
$ch = curl_init('https://api.cmskite.com/v1/blog/posts?limit=20');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => ['authorization: Bearer ' . getenv('CMSKITE_API_KEY')],
]);
$payload = json_decode(curl_exec($ch), true);
foreach ($payload['data'] as $post) {
echo $post['slug'], "\n";
}Before you go to production
- Keep the key out of your repository: put it in an environment variable. It is read-only (it can read published posts and count views, nothing else), so it is safe in code that runs in the browser, which the view tracker needs.
- Handle
429. The response carriesRetry-AfterandRateLimitheaders on every request, including successful ones, so you can pace yourself before you are refused. - Cache. A blog post changes far less often than it is read; a sixty-second revalidate removes almost all of your traffic.
- Restrict the key to your own origins if it will be used from a browser. See allowed origins under Authentication.







