kitful.aiDocs

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.

On this page

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
QueryMeaning
limitPosts per page: 50 by default, at most 100, or at most 25 with include=content
cursorThe next_cursor of the previous page
includecontent adds each post's body, headings and structured data
updated_sinceAn 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

FieldMeaning
idStable id. Key your copy on it, never on the slug
slugThe post's path under the blog
previous_slugsAlways [] for now
urlThe post's public address: your blog address plus the slug. It stays the same for the life of the post
localeThe article's language tag
title, excerpt, tagsAs written in Kitful. excerpt can be null
published_at, updated_atISO 8601 times
reading_time_minutesAt 225 words a minute, at least 1
word_countWords in the body
featured_imageurl, alt, width, height, or null. Width and height are null when unknown
seotitle and description for the page's head, and canonical_url
open_graphtitle, description and image_url for social previews
headingsWith the body: each heading's depth (2 to 6), slug (its id in the HTML) and text, for a table of contents
contentWith the body: html and markdown
json_ldWith 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:

  1. Read GET /api/v1/blog/posts?updated_since=1970-01-01T00:00:00Z&include=content on the first run.
  2. Follow next_cursor until it is null, keeping updated_since and include on every page.
  3. Save each published item by id, after deleting any stored post with the same slug and another id. Any other status, today "deleted", means the post left the site: such an item carries only id, slug and updated_at, and you delete your copy.
  4. Store next_updated_since from the last page and send it as updated_since on 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"
}
StatuscodeMeaning
400invalid_requestA query value or cursor is not valid
401invalid_tokenThe token is missing, malformed, replaced or removed
404not_foundNo post has this slug
410goneThe post was deleted
500server_errorKitful 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.html on a web page: its heading ids match headings, so a table of contents links straight in. Store content.markdown in 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.