API
Blog API
Read a site's published posts with its read token, copy them into your own database, and refresh your site when a post changes.
The Blog API serves the posts you publish to a site's headless destination. Your site reads them and renders them on its own pages. On Next.js, the Next.js package does all of this for you.
Authenticate
Send the destination's read token on every request:
Authorization: Bearer kit_read_...
A read token opens one site's published posts and nothing else. Kitful shows it once, when you connect the destination. New token in the destination's settings makes a new one, with a new notification secret, and stops the old ones at once. Disabling the destination pauses publishing to it, and reads go on. Removing the destination stops its token.
Quick start
Read the newest posts from your terminal:
curl https://kitful.ai/api/v1/blog/posts \
-H "Authorization: Bearer $KITFUL_READ_TOKEN"
Read every post from server code, page by page:
const API = 'https://kitful.ai/api/v1';
const headers = { Authorization: `Bearer ${KITFUL_READ_TOKEN}` }; // your read token, from a server-only secret
async function allPosts() {
const posts = [];
let cursor = null;
do {
const url = new URL(`${API}/blog/posts`);
url.searchParams.set('limit', '100');
if (cursor) url.searchParams.set('cursor', cursor);
const res = await fetch(url, { headers });
if (!res.ok) throw new Error(`Kitful answered ${res.status}`);
const page = await res.json();
posts.push(...page.data);
cursor = page.next_cursor;
} while (cursor);
return posts;
}
List posts
GET https://kitful.ai/api/v1/blog/posts
| Query | Meaning |
|---|---|
limit | Posts per page: 50 by default, at most 100, or at most 25 with include=content |
cursor | The next_cursor of the previous page |
include | content adds each post's body, headings and structured data |
updated_since | An ISO 8601 time with its offset, such as 2026-10-22T09:00:00Z: returns changes instead, see Copy posts into your database |
Without updated_since, the answer lists live posts, newest first:
{
"data": [
{
"id": "01EXAMPLEARTICLE",
"status": "published",
"slug": "plan-a-crm-migration",
"previous_slugs": [],
"url": "https://example.com/blog/plan-a-crm-migration",
"locale": "en-US",
"title": "How to plan a CRM migration",
"excerpt": "A practical migration plan for growing teams.",
"tags": ["crm"],
"published_at": "2026-10-20T09:00:00.000Z",
"updated_at": "2026-10-21T14:30:00.000Z",
"reading_time_minutes": 7,
"word_count": 1480,
"featured_image": {
"url": "https://img.kitful.ai/example.webp",
"alt": "A migration plan on a whiteboard",
"width": 1536,
"height": 864
},
"seo": {
"title": "How to Plan a CRM Migration",
"description": "Prepare data, people and timing for a safer CRM move.",
"canonical_url": "https://example.com/blog/plan-a-crm-migration"
},
"open_graph": {
"title": "How to Plan a CRM Migration",
"description": "Prepare data, people and timing for a safer CRM move.",
"image_url": "https://img.kitful.ai/example.webp"
}
}
],
"next_cursor": "WyJsaXN0Ii..."
}
next_cursor is null on the last page. Posts published while you page through never shift the pages you have not read yet.
Read one post
GET https://kitful.ai/api/v1/blog/posts/{slug}
Returns one post with every field, including the body. Encode the slug for the URL, as encodeURIComponent does. A slug that belonged to a deleted post answers 410 with code: "gone", and a slug that never existed answers 404 with code: "not_found".
Post fields
| Field | Meaning |
|---|---|
id | Stable id. Key your copy on it, never on the slug |
slug | The post's path under the blog |
previous_slugs | Always [] for now |
url | The post's public address: your blog address plus the slug. It stays the same for the life of the post |
locale | The article's language tag |
title, excerpt, tags | As written in Kitful. excerpt can be null |
published_at, updated_at | ISO 8601 times |
reading_time_minutes | At 225 words a minute, at least 1 |
word_count | Words in the body |
featured_image | url, alt, width, height, or null. Width and height are null when unknown |
seo | title and description for the page's head, and canonical_url |
open_graph | title, description and image_url for social previews |
headings | With the body: each heading's depth (2 to 6), slug (its id in the HTML) and text, for a table of contents |
content | With the body: html and markdown |
json_ld | With the body: BlogPosting and BreadcrumbList structured data. Print it in a <script type="application/ld+json"> and escape < as \u003c |
Render your own page heading from title. Fields can be added within v1, and none are removed or renamed, so ignore fields you don't know. If the post you get back has a different slug from the one you asked for, redirect to it.
Copy posts into your database
A site that keeps its own copy, such as a Lovable app, follows changes instead of reading every post:
- Read
GET /api/v1/blog/posts?updated_since=1970-01-01T00:00:00Z&include=contenton the first run. - Follow
next_cursoruntil it isnull, keepingupdated_sinceandincludeon every page. - Save each published item by
id, after deleting any stored post with the same slug and anotherid. Any other status, today"deleted", means the post left the site: such an item carries onlyid,slugandupdated_at, and you delete your copy. - Store
next_updated_sincefrom the last page and send it asupdated_sinceon the next run.
One run, with db standing for your own storage:
async function syncOnce(db) {
let cursor = null;
let next;
do {
const url = new URL(`${API}/blog/posts`);
url.searchParams.set('updated_since', await db.getUpdatedSince()); // 1970-01-01T00:00:00Z at first
url.searchParams.set('include', 'content');
url.searchParams.set('limit', '25');
if (cursor) url.searchParams.set('cursor', cursor);
const res = await fetch(url, { headers });
if (!res.ok) throw new Error(`Kitful answered ${res.status}`);
const page = await res.json();
for (const item of page.data) {
if (item.status === 'published') {
await db.deleteOtherWithSlug(item.slug, item.id);
await db.savePost(item);
} else {
await db.deletePost(item.id);
}
}
cursor = page.next_cursor;
next = page.next_updated_since;
} while (cursor);
await db.setUpdatedSince(next); // only after every page succeeded
}
Changes come oldest first. next_updated_since is one minute behind the time of the last page, so a post saved while you read comes back on the next run, and the same post can arrive twice. Saving by id makes that harmless. Run one sync at a time. Each run first marks a sync as wanted, then takes a lease, and stops if another run holds it. The running sync clears the mark before it reads, clears the lease when it ends, and runs again if the mark came back meanwhile, so a notification that lands mid-run is never lost. Let a lease expire after 10 minutes, so a run that crashed doesn't block the next. Run it when a notification arrives and once an hour, so a missed notification is caught up.
A cursor from a list read is refused by a sync read, and the other way round, with 400 and code: "invalid_request". Start again without the cursor.
Notifications
Give the destination a notification URL and Kitful sends a signed POST to it after a post is published, updated or deleted. The body names what changed, never the post itself, so read the post through the API:
{
"type": "post.updated",
"timestamp": "2026-10-22T09:00:05.000Z",
"data": {
"post": {
"id": "01EXAMPLEARTICLE",
"slug": "plan-a-crm-migration",
"updated_at": "2026-10-22T09:00:04.512Z"
},
"paths": ["/blog", "/blog/plan-a-crm-migration"],
"tags": ["kitful:posts", "kitful:post:plan-a-crm-migration"]
}
}
type is post.published, post.updated, post.deleted, or webhook.test when you press Send test, which carries empty paths and tags. paths are the pages on your site to refresh, and tags are the cache tags the Next.js package uses. Ignore an event type you don't know.
Requests follow the Standard Webhooks specification, with the headers webhook-id, webhook-timestamp and webhook-signature. Verify them with any Standard Webhooks library and the whsec_ secret the destination shows:
import { Webhook } from 'standardwebhooks';
const webhook = new Webhook(notificationSecret);
const notification = webhook.verify(rawBody, Object.fromEntries(request.headers));
The secret is the base64 of the SHA-256 digest of your read token, so a site that holds the token can derive it instead of storing a second value.
Answer with any 2xx status within 10 seconds. Kitful tries once more after a network error, a timeout, a 5xx, 408 or 429, and never follows a redirect: save the final URL. The destination's settings show the last result.
Errors
Errors answer JSON with error, message and code:
{
"error": "This read token is not valid. Copy a new one from Kitful.",
"message": "This read token is not valid. Copy a new one from Kitful.",
"code": "invalid_token"
}
| Status | code | Meaning |
|---|---|---|
| 400 | invalid_request | A query value or cursor is not valid |
| 401 | invalid_token | The token is missing, malformed, replaced or removed |
| 404 | not_found | No post has this slug |
| 410 | gone | The post was deleted |
| 500 | server_error | Kitful failed; try again |
Limits
Kitful sets no request limit on these routes today; if one ever answers 429, wait for its Retry-After. Cache what you read and refresh it on a notification: the Next.js package and the sync above each read a post once per change. Next.js does not cache a 404 or 410, so each visit to a missing or deleted post's address is one read.
Common questions
- HTML or Markdown? Render
content.htmlon a web page: its heading ids matchheadings, so a table of contents links straight in. Storecontent.markdownin a system that keeps Markdown. - Does it work with any framework or CMS? Yes. Anything that can send an HTTPS request from a server can read posts. Keep the token off the browser.
- How do I know a post changed? Add a notification URL, and Kitful tells your site within seconds. Without one, read again on a schedule, or run the sync above every hour.
- Can I change a post through the API? No. The Blog API only reads. Edit the article in Kitful and click Update on site.