Diil Docs
  1. Documentación
  2. Referencia de la API

GET /v1/blog — artículos, paginación y un artículo

Actualizado:

Tus editores escriben posts en el CRM y tu web los muestra. La API del blog te da una lista paginada para la portada y un post completo por su slug para la página del artículo: dos endpoints, y los “¿me lo publicas antes del viernes?” dejan de llegar a tu bandeja de entrada.

Dos endpoints, uno para cada tipo de página que vas a construir:

  • GET /v1/blog — la lista: títulos, portadas, autores y fechas, página a página. Sin el cuerpo de los posts, así que va ligera.
  • GET /v1/blog/:slug — un post con todo, incluido el cuerpo en HTML.

Listar los posts del blog

GET/v1/blog

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

Devuelve una ventana de posts visibles y todo lo que necesitas para paginar: el total, el número de páginas, si quedan más y dónde empieza la siguiente ventana.

Parámetros de consulta

langquerystringopcionalPor defecto: todos los idiomas activos
Qué traducciones incluir en title e image. Un código, una lista separada por comas (en,de) o el parámetro repetido. Consulta Idiomas.
limitquerynumberopcionalPor defecto: tamaño de página del blog en el CRM; si no, 12
Cuántos posts devolver, de 1 a 50. Si el editor ha fijado un tamaño de página en los ajustes del blog en el CRM, ese es el valor por defecto; si no, recibes 12.
offsetquerynumberopcionalPor defecto: 0
Cuántos posts saltarse, de 0 a 100000. Combínalo con limit para el scroll infinito y los botones de “cargar más”.
pagequerynumberopcional
Número de página, empezando por 1. La alternativa a offset para la paginación numerada. Si llegan los dos, gana page.
perPagequerynumberopcionalPor defecto: igual que limit
Posts por página, con los mismos límites que limit (1–50). Si llegan los dos, gana perPage.
x-crm-keyheaderstringobligatorio
La clave de tu sitio. También puedes pasarla como ?key=. Consulta Claves del sitio.

Ejemplo de petición

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

Ejemplo de respuesta

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" }
    }
  ]
}

Campos de la respuesta

totalnumber
Cuántos posts son visibles ahora mismo: los borradores y los programados no cuentan. Perfecto para poner “29 artículos” bajo el título.
limitnumber
El tamaño de ventana que se ha usado de verdad, después de aplicar valores por defecto y límites.
offsetnumber
Cuántos posts se han saltado. Con page/perPage se calcula por ti.
pagenumber | null
El número de la página actual, empezando por 1. null cuando offset no es múltiplo de limit (por ejemplo, limit=10&offset=5): ahí no hay un número de página honesto.
pagesnumber
Número total de páginas: ceil(total / limit). 0 si el blog está vacío.
hasMoreboolean
Si hay posts después de esta ventana. Tu botón de “cargar más” vive exactamente mientras esto sea true.
nextOffsetnumber | null
El offset de la siguiente ventana, o null cuando has llegado al final. Pásalo tal cual en la siguiente petición.
postsPost[]
Los posts de esta ventana, en el orden fijado en el CRM. Aquí no hay cuerpos: para eso, pide un post concreto.
posts →
slugstring
La dirección del post, p. ej. how-we-chose-hosting. Úsala en tus URL y para GET /v1/blog/:slug.
authorstring | null
El nombre del autor tal como se escribió en el CRM, o null si nadie firmó el post.
publishedAtstring (ISO 8601) | null
Fecha de publicación, o null si el post no tiene.
title{ [lang]: string }
Título del post por idioma. Las traducciones vacías se omiten, así que puede que un idioma simplemente no esté: mira el helper de respaldo más abajo.
image{ [lang]: string }
URL de la portada por idioma. Si el post tiene una sola portada, la misma URL se repite para cada idioma pedido, así que siempre puedes leer image[lang].

Paginación: scroll infinito o páginas numeradas

La API habla los dos dialectos de paginación, así que no tienes que traducir uno al otro de cabeza. Elige el que encaje con tu diseño.

Scroll infinito y “cargar más”: limit + offset

Pide la primera ventana, muéstrala y, cuando el visitante haga scroll hacia abajo (o pulse el botón), pide la siguiente empezando en nextOffset. Cuando nextOffset sea null, has terminado.

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

# siguiente ventana: offset = nextOffset de la respuesta anterior
curl "https://back.sitecog.com/content/v1/blog?lang=en&limit=12&offset=12" \
  -H "x-crm-key: pk_3f9c2a7e1b4d4c0e8a6f5b2d9c1e7a40"

Páginas numeradas: page + perPage

La paginación clásica “1 2 3 … 15”. Envía el número de página y recibe pages para pintar los enlaces. Aquí va la página 3 con 10 posts por página, de un total de 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": "…" } }
  ]
}

Fíjate en que la respuesta siempre describe la ventana en los dos dialectos: page y pages para los enlaces numerados; limit, offset y nextOffset para el scroll.

Cuando se juntan los dos estilos

¿Has enviado limit y perPage a la vez? Gana perPage. ¿offset y page? Gana page. En ningún caso hay error, pero hazte un favor y quédate con un solo estilo por petición.

Qué posts son visibles y en qué orden

La API muestra exactamente lo que debe ver un visitante, y nada de lo que un editor aún tiene entre manos:

  • Solo posts publicados. Los borradores nunca salen del CRM, mandes los parámetros que mandes.
  • Los posts programados esperan su turno. Si la programación está activada en el CRM, un post con fecha de publicación futura sigue oculto hasta ese momento. Por la caché puede aparecer unos minutos tarde (hasta unos cinco), así que tenlo en cuenta al programar lanzamientos.
  • El orden se decide en el CRM. Mandan los ajustes del blog: orden manual (los editores arrastran los posts), por fecha de publicación o por fecha de creación, ascendente o descendente. La API devuelve los posts en ese orden; no hay parámetro de ordenación, así que quien manda es el editor.

Obtener un post

GET/v1/blog/:slug

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

El post completo por su slug: todo lo de la lista más body, el artículo en sí en HTML.

Parámetros

slugpathstringobligatorio
El slug del post sacado de la lista, p. ej. how-we-chose-hosting. Letras latinas minúsculas, dígitos y guiones, hasta 120 caracteres. Cualquier otra cosa da 404 post_not_found.
langquerystringopcionalPor defecto: todos los idiomas activos
Qué traducciones incluir en title, image y body. Las mismas reglas de siempre: consulta Idiomas.
x-crm-keyheaderstringobligatorio
La clave de tu sitio, o ?key= si las cabeceras no son una opción.

Ejemplo de petición

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

Ejemplo de respuesta

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>"
    }
  }
}

¿Ves que falta body.de? El cuerpo en alemán aún no está escrito, y en los mapas del blog las traducciones vacías se omiten en lugar de volver como "". La portada única, en cambio, se repite para los dos idiomas.

Campos de la respuesta

postobject
El post en sí, envuelto en una sola clave.
post →
slugstring
La dirección del post, la misma que pediste.
authorstring | null
Nombre del autor en el CRM, o null.
publishedAtstring (ISO 8601) | null
Fecha de publicación, o null. Dale formato para humanos con new Date(post.publishedAt).toLocaleDateString(lang).
title{ [lang]: string }
Título por idioma. Las traducciones vacías se omiten.
image{ [lang]: string }
URL de la portada por idioma; una portada única se repite para cada idioma pedido.
body{ [lang]: string }
El artículo como cadena HTML, por idioma. Las traducciones vacías se omiten. Cómo renderizarlo, justo debajo.

Renderizar el cuerpo HTML de forma segura

body es HTML listo para usar: títulos, párrafos, listas, enlaces, imágenes. Para meterlo en la página necesitas el interruptor de “sí, de verdad quiero HTML en crudo” de tu framework: dangerouslySetInnerHTML en React, v-html en Vue.

No hay idioma de respaldo en el servidor: si falta una traducción, la clave simplemente no está. Un helper diminuto lo resuelve: el idioma pedido, luego tu idioma por defecto y luego cualquiera que no esté vacío:

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>
      {/* Diil sanea el body al guardar el post */}
      <div className="prose" dangerouslySetInnerHTML={{ __html: t(post.body, lang) }} />
    </article>
  );
}

Un blog en Next.js: portada y página del post

Un blog completo con Next.js y un CMS headless en tres archivos: una pequeña capa de datos, la portada con páginas numeradas y la página del post, prerrenderizada para cada slug con generateStaticParams. La clave se queda en el servidor.

// lib/blog.ts — todo lo del blog en un solo sitio
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 no pasa de 50, así que recorremos todo el blog con 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;
}

Errores

EstadoCuerpoQué ha pasado
400{"message":"invalid_lang", …}Un código de lang no tiene pinta de código de idioma.
400{"message":"unknown_lang", …}Un idioma de lang no está activo en el sitio. El cuerpo enumera los disponibles.
400{"message":"too_many_langs","max":50}Más de 50 códigos en lang. Impresionante, pero no.
401{"message":"invalid_key"}Clave ausente, mal formada o revocada.
404{"message":"post_not_found"}No hay ningún post visible con este slug: una errata, un slug con caracteres prohibidos o un post que es borrador o aún no se ha publicado.
405{"message":"method_not_allowed"}Cualquier cosa que no sea GET o HEAD. La API es de solo lectura.
429{"message":"rate_limit_exceeded"}Demasiadas peticiones en este minuto. Consulta Límites de peticiones.

Los valores de paginación incorrectos no están en esta lista a propósito: vuelven a los valores por defecto en lugar de fallar. Todo lo demás está en la página Errores.

Consejos desde las trincheras