Diil Docs
  1. Docs
  2. API reference

GET /v1/blog — posts, pagination, single post

Updated:

Your editors write posts in the CRM, your site shows them. The blog API gives you a paginated list for the index page and a full post by its slug for the article page — two endpoints, and the “can you publish this by Friday” requests stop landing in your inbox.

Two endpoints, one for each kind of page you will build:

  • GET /v1/blog — the list: titles, covers, authors and dates, page by page. No post bodies, so it stays light.
  • GET /v1/blog/:slug — one post with everything, including the HTML body.

List blog posts

GET/v1/blog

https://back.sitecog.com/content/v1/blog

Returns a window of visible posts plus everything you need to paginate: the total count, the number of pages, whether there is more and where the next window starts.

Query parameters

langquerystringoptionalDefault: all active languages
Which translations to include in title and image. One code, a comma list (en,de) or a repeated parameter. See Languages.
limitquerynumberoptionalDefault: blog page size from the CRM, otherwise 12
How many posts to return, from 1 to 50. If the editor set a page size in the blog settings in the CRM, that is the default; if not, you get 12.
offsetquerynumberoptionalDefault: 0
How many posts to skip, from 0 to 100000. Pair it with limit for infinite scroll and “load more” buttons.
pagequerynumberoptional
Page number, starting at 1. The alternative to offset for numbered pagination. If both are present, page wins.
perPagequerynumberoptionalDefault: same as limit
Posts per page, with the same limits as limit (1–50). If both are present, perPage wins.
x-crm-keyheaderstringrequired
Your site key. Can also be passed as ?key=. See Site keys.

Example request

curl "https://back.sitecog.com/content/v1/blog?lang=en&limit=2" \
  -H "x-crm-key: pk_3f9c2a7e1b4d4c0e8a6f5b2d9c1e7a40"

Example response

200 OKjson
{
  "total": 29,
  "limit": 2,
  "offset": 0,
  "page": 1,
  "pages": 15,
  "hasMore": true,
  "nextOffset": 2,
  "posts": [
    {
      "slug": "how-we-chose-hosting",
      "author": "Anton Kravtsov",
      "publishedAt": "2026-08-10T00:00:00.000Z",
      "title": { "en": "How we chose hosting and got it wrong twice" },
      "image": { "en": "https://cdn.example.com/storage/your-site/blog/hosting-cover.jpg" }
    },
    {
      "slug": "noise-cancelling-explained",
      "author": null,
      "publishedAt": "2026-07-28T00:00:00.000Z",
      "title": { "en": "Noise cancelling, explained without the physics lecture" },
      "image": { "en": "https://cdn.example.com/storage/your-site/blog/anc-cover.jpg" }
    }
  ]
}

Response fields

totalnumber
How many posts are visible right now — drafts and scheduled posts are not counted. Perfect for “29 articles” under the heading.
limitnumber
The window size that was actually used, after defaults and limits were applied.
offsetnumber
How many posts were skipped. With page/perPage it is computed for you.
pagenumber | null
The current page number, starting at 1. null when offset is not a multiple of limit (say, limit=10&offset=5) — there is no honest page number for that.
pagesnumber
Total number of pages: ceil(total / limit). 0 for an empty blog.
hasMoreboolean
Whether there are posts after this window. Your “load more” button lives exactly as long as this is true.
nextOffsetnumber | null
The offset for the next window, or null when you have reached the end. Pass it straight into the next request.
postsPost[]
The posts of this window, in the order set in the CRM. No bodies here — fetch a single post for that.
posts →
slugstring
The post address, e.g. how-we-chose-hosting. Use it in your URLs and for GET /v1/blog/:slug.
authorstring | null
The author name as typed in the CRM, or null if nobody signed the post.
publishedAtstring (ISO 8601) | null
Publication date, or null if the post has none.
title{ [lang]: string }
Post title by language. Empty translations are left out, so a language may simply be missing — see the fallback helper below.
image{ [lang]: string }
Cover image URL by language. A post with one cover gets the same URL repeated for every requested language, so you can always read image[lang].

Pagination: infinite scroll or numbered pages

The API speaks both pagination dialects, so you don't have to translate one into the other in your head. Pick the one that matches your design.

Infinite scroll and “load more”: limit + offset

Ask for the first window, show it, and when the visitor scrolls down (or clicks the button), ask for the next one starting at nextOffset. When nextOffset is null, you are done.

# first window
curl "https://back.sitecog.com/content/v1/blog?lang=en&limit=12" \
  -H "x-crm-key: pk_3f9c2a7e1b4d4c0e8a6f5b2d9c1e7a40"

# next window: offset = nextOffset from the previous response
curl "https://back.sitecog.com/content/v1/blog?lang=en&limit=12&offset=12" \
  -H "x-crm-key: pk_3f9c2a7e1b4d4c0e8a6f5b2d9c1e7a40"

Numbered pages: page + perPage

Classic “1 2 3 … 15” pagination. Send the page number, get back pages to draw the links. Here is page 3 of 10 posts per page, out of 29:

curl "https://back.sitecog.com/content/v1/blog?lang=en&page=3&perPage=10" \
  -H "x-crm-key: pk_3f9c2a7e1b4d4c0e8a6f5b2d9c1e7a40"
200 OKjson
{
  "total": 29,
  "limit": 10,
  "offset": 20,
  "page": 3,
  "pages": 3,
  "hasMore": false,
  "nextOffset": null,
  "posts": [
    { "slug": "…", "author": "…", "publishedAt": "…", "title": { "en": "…" }, "image": { "en": "…" } }
  ]
}

Notice that the response always reports the window in both dialects: page and pages for numbered links, limit, offset and nextOffset for scrolling.

When both styles meet

Sent limit and perPage together? perPage wins. Sent offset and page? page wins. No error either way — but do yourself a favour and stick to one style per request.

Which posts are visible and in what order

The API shows exactly what a visitor should see, and nothing an editor is still working on:

  • Only published posts. Drafts never leave the CRM, no matter what parameters you send.
  • Scheduled posts wait their turn. If scheduling is enabled in the CRM, a post with a future publication date stays hidden until that moment. Because of caching it can show up a few minutes late (up to about five) — schedule launches with that in mind.
  • Order is set in the CRM. The blog settings decide: manual order (editors drag and drop posts), by publication date or by creation date, ascending or descending. The API returns posts in that order; there is no sort parameter, so the editor stays in charge.

Get one post

GET/v1/blog/:slug

https://back.sitecog.com/content/v1/blog/:slug

The full post by its slug: everything from the list plus body, the article itself as HTML.

Parameters

slugpathstringrequired
The post slug from the list, e.g. how-we-chose-hosting. Lowercase Latin letters, digits and hyphens, up to 120 characters. Anything else gives 404 post_not_found.
langquerystringoptionalDefault: all active languages
Which translations to include in title, image and body. Same rules as everywhere — see Languages.
x-crm-keyheaderstringrequired
Your site key, or ?key= if headers are not an option.

Example request

curl "https://back.sitecog.com/content/v1/blog/how-we-chose-hosting?lang=en,de" \
  -H "x-crm-key: pk_3f9c2a7e1b4d4c0e8a6f5b2d9c1e7a40"

Example response

200 OKjson
{
  "post": {
    "slug": "how-we-chose-hosting",
    "author": "Anton Kravtsov",
    "publishedAt": "2026-08-10T00:00:00.000Z",
    "title": {
      "en": "How we chose hosting and got it wrong twice",
      "de": "Wie wir Hosting gewählt haben"
    },
    "image": {
      "en": "https://cdn.example.com/storage/your-site/blog/hosting-cover.jpg",
      "de": "https://cdn.example.com/storage/your-site/blog/hosting-cover.jpg"
    },
    "body": {
      "en": "<p>Attempt one was the cheapest server we could find.</p><h2>What went wrong</h2><p>Everything, on a Friday night.</p>"
    }
  }
}

See the missing body.de? The German body hasn't been written yet, and empty translations are left out of blog maps instead of coming back as "". The single cover, on the other hand, is repeated for both languages.

Response fields

postobject
The post itself, wrapped in one key.
post →
slugstring
The post address, the one you asked for.
authorstring | null
Author name from the CRM, or null.
publishedAtstring (ISO 8601) | null
Publication date, or null. Format it for humans with new Date(post.publishedAt).toLocaleDateString(lang).
title{ [lang]: string }
Title by language. Empty translations are left out.
image{ [lang]: string }
Cover URL by language; a single cover is repeated for every requested language.
body{ [lang]: string }
The article as an HTML string, by language. Empty translations are left out. How to render it is right below.

Rendering the HTML body safely

body is ready-made HTML: headings, paragraphs, lists, links, images. To put it on the page you need your framework's “yes, I really mean raw HTML” switch — dangerouslySetInnerHTML in React, v-html in Vue.

There is no server-side fallback language: if a translation is missing, the key is just not there. A tiny helper covers it — the requested language, then your default one, then whatever is not empty:

const t = (map, lang, fallback = 'en') =>
  map?.[lang] || map?.[fallback] || Object.values(map ?? {})[0] || '';

export function Post({ post, lang }) {
  return (
    <article>
      <h1>{t(post.title, lang)}</h1>
      {/* body is sanitized by Diil when the post is saved */}
      <div className="prose" dangerouslySetInnerHTML={{ __html: t(post.body, lang) }} />
    </article>
  );
}

Building a blog in Next.js: index and post pages

A complete Next.js headless CMS blog in three files: a small data layer, the index with numbered pages and the post page, pre-rendered for every slug with generateStaticParams. The key stays on the server.

// lib/blog.ts — everything blog-related in one place
const API = 'https://back.sitecog.com/content/v1';
const LANG = 'en';
const headers = { 'x-crm-key': process.env.CRM_KEY! };

export type LangMap = Record<string, string>;
export type Post = {
  slug: string;
  author: string | null;
  publishedAt: string | null;
  title: LangMap;
  image: LangMap;
  body?: LangMap;
};

export const t = (map: LangMap | undefined, lang = LANG, fallback = 'en') =>
  map?.[lang] || map?.[fallback] || Object.values(map ?? {})[0] || '';

export async function getPosts(page = 1, perPage = 12) {
  const res = await fetch(`${API}/blog?lang=${LANG}&page=${page}&perPage=${perPage}`, {
    headers,
    next: { revalidate: 60 },
  });
  if (!res.ok) throw new Error(`Content API: ${res.status}`);
  return (await res.json()) as { total: number; page: number | null; pages: number; posts: Post[] };
}

export async function getPost(slug: string) {
  const res = await fetch(`${API}/blog/${slug}?lang=${LANG}`, {
    headers,
    next: { revalidate: 60 },
  });
  if (res.status === 404) return null;
  if (!res.ok) throw new Error(`Content API: ${res.status}`);
  return ((await res.json()) as { post: Post }).post;
}

// limit tops out at 50, so walk the whole blog with nextOffset
export async function getAllSlugs() {
  const slugs: string[] = [];
  let offset: number | null = 0;
  while (offset !== null) {
    const res = await fetch(`${API}/blog?lang=${LANG}&limit=50&offset=${offset}`, { headers });
    if (!res.ok) throw new Error(`Content API: ${res.status}`);
    const data: { posts: Post[]; nextOffset: number | null } = await res.json();
    slugs.push(...data.posts.map((post) => post.slug));
    offset = data.nextOffset;
  }
  return slugs;
}

Errors

StatusBodyWhat happened
400{"message":"invalid_lang", …}A code in lang doesn't look like a language code.
400{"message":"unknown_lang", …}A language in lang is not active on the site. The body lists the available ones.
400{"message":"too_many_langs","max":50}More than 50 codes in lang. Impressive, but no.
401{"message":"invalid_key"}Missing, malformed or revoked key.
404{"message":"post_not_found"}No visible post with this slug — a typo, a slug with forbidden characters, or a post that is a draft or not published yet.
405{"message":"method_not_allowed"}Anything other than GET or HEAD. The API is read-only.
429{"message":"rate_limit_exceeded"}Too many requests this minute. See Rate limits.

Bad pagination values are not on this list on purpose: they fall back to defaults instead of failing. Everything else is on the Errors page.

Tips from the trenches