Sembalia
Volver al inicio

API de contenido

Una API REST de solo lectura para servir el contenido de tu proyecto desde tu propia web: listado paginado y pieza completa por slug, con el HTML ya saneado, las imágenes, la ficha del autor, las categorías, los enlaces internos y los datos estructurados. Es la integración de quien tiene su web hecha a medida y prefiere TIRAR del contenido cuando construye, en vez de recibirlo por webhook.

Empezar en tres pasos

  • Crea una clave en el panel, en Ajustes del proyecto, apartado Claves de API. Se enseña UNA vez: guárdala en tu gestor de secretos, porque solo conservamos su hash.
  • Prueba el listado con el curl de más abajo. Si responde 200 con un array items, ya está: no hay nada más que configurar.
  • Pide cada pieza por su slug cuando la vayas a renderizar. El listado no trae el HTML a propósito, para que pedir el índice sea barato.

Base de la API: https://api.sembalia.com/public/v1. Todas las respuestas son JSON con UTF-8, y todas las fechas van en ISO 8601 con zona UTC.

Autenticación

Cada petición lleva la clave del proyecto. Se acepta en cualquiera de las dos cabeceras, que hacen exactamente lo mismo: la segunda existe porque algunos clientes HTTP y algunos CDN manipulan la cabecera Authorization.

CabeceraValor
Authorization Bearer rk_… (la clave completa, con su prefijo)
X-Api-Key rk_… (la clave completa, sin la palabra Bearer)

La clave identifica al PROYECTO, no a un usuario: una clave solo puede leer el contenido de su proyecto, y no hay forma de nombrar otro. Revocarla en el panel la invalida en el acto, sin periodo de gracia.

La clave es de servidor. Si la pones en el navegador (una llamada desde el front, una variable NEXT_PUBLIC_ o VITE_) se la queda cualquiera que abra las herramientas de desarrollo. Llama desde tu backend o durante el build.

Listar contenido

GET /public/v1/contents Requiere clave de proyecto

Devuelve las piezas publicadas, de la más reciente a la más antigua. Sirve para construir el índice del blog y la lista de rutas de un sitio estático.

ParámetroTipoDescripción
kind article | news Filtra por tipo de pieza. Sin él vienen los dos.
limit entero 1-100 Cuántas piezas devolver. Por defecto 20.
offset entero ≥ 0 Cuántas saltar. Por defecto 0.

Respuesta 200:

{
  "items": [
    {
      "id": "c1a2e8f0-4b17-4c93-9a55-0b2f7e6d1a30",
      "kind": "article",
      "title": "Colorimetría personal: guía práctica",
      "slug": "colorimetria-personal-guia-practica",
      "metaDescription": "Qué es el subtono, cómo identificarlo y qué colores…",
      "words": 1240,
      "publishedAt": "2026-08-28T09:00:00.000Z",
      "updatedAt": "2026-08-29T11:04:12.100Z"
    }
  ],
  "hasMore": false,
  "offset": 0,
  "limit": 20
}
CampoTipoDescripción
items[].id uuid Identificador interno de la pieza. Estable.
items[].kind string article o news.
items[].title string El titular, tal cual se publica.
items[].slug string La parte de la URL. Es la clave con la que se pide el detalle.
items[].metaDescription string | null Meta description, 155 caracteres como mucho.
items[].words entero | null Palabras del cuerpo.
items[].publishedAt fecha ISO | null Cuándo se publicó.
items[].updatedAt fecha ISO Última modificación. Útil para refrescar solo lo que cambió.
hasMore booleano Si quedan más piezas después de esta página.
offset, limit entero Los valores efectivos de la consulta, ya normalizados.

El listado NO trae el html ni las imágenes. Es deliberado: el índice de un blog con cientos de piezas pesaría megabytes y casi nunca se necesita el cuerpo para pintarlo.

Una pieza por su slug

GET /public/v1/contents/{slug} Requiere clave de proyecto

Devuelve la pieza completa, lista para renderizar. Responde 404 si el slug no existe en tu proyecto o si la pieza todavía no está publicada, que desde fuera son el mismo caso.

{
  "content": {
    "id": "c1a2e8f0-4b17-4c93-9a55-0b2f7e6d1a30",
    "kind": "article",
    "status": "published",
    "title": "Colorimetría personal: guía práctica",
    "slug": "colorimetria-personal-guia-practica",
    "metaDescription": "Qué es el subtono, cómo identificarlo y qué colores…",
    "html": "<p>…</p><h2>…</h2>",
    "outline": ["Qué es el subtono", "Cómo identificarlo"],
    "words": 1240,
    "sourceUrl": null,
    "scheduledAt": "2026-08-28T09:00:00.000Z",
    "publishedAt": "2026-08-28T09:00:00.000Z",
    "publishedUrl": null,
    "wpPostId": null,
    "createdAt": "2026-08-27T18:22:03.000Z",
    "updatedAt": "2026-08-29T11:04:12.100Z",
    "author": { "name": "Elena Ruiz", "slug": "elena-ruiz", "bio": "…", "role": "…", "url": "https://…" },
    "images": [
      { "kind": "hero", "url": "https://…/hero.png", "mimeType": "image/png",
        "width": 1536, "height": 864, "alt": "Paleta de tonos fríos sobre lino" }
    ],
    "categories": [{ "id": "…", "name": "Guías", "slug": "guias", "primary": true }],
    "tags": [{ "id": "…", "name": "Color", "slug": "color" }],
    "internalLinks": [
      { "anchor": "test de color", "targetSlug": "test-de-color", "targetTitle": "…",
        "targetUrl": "https://tuweb.com/blog/test-de-color" }
    ],
    "jsonLd": [
      { "@context": "https://schema.org", "@type": "Article", "headline": "…" }
    ]
  }
}
CampoTipoDescripción
html string | null El cuerpo en HTML, ya saneado por nosotros contra una lista de etiquetas permitidas. No lleva <script> ni atributos de estilo.
outline string[] | null Los encabezados en orden. Sirve para montar un índice sin parsear el HTML.
author objeto | null La persona de tu equipo que firma: name, slug, bio, role y url (su perfil público). Null cuando firma tu marca: nunca inventamos un autor.
images[] array kind (hero o inline), url, mimeType, width, height y alt. El alt está redactado en el idioma de la pieza, no es el título.
categories[] array id, name, slug y primary. Solo una lleva primary en true.
tags[] array id, name y slug.
internalLinks[] array Los artículos relacionados, para que los pintes donde quieras (no van dentro de html): anchor, targetSlug, targetTitle y targetUrl, la URL real de la pieza con la ruta del blog. Solo piezas ya publicadas.
jsonLd array Datos estructurados schema.org listos para el <head>.
publishedUrl string | null La URL final si la conocemos (por ejemplo cuando además publicamos en WordPress).
sourceUrl string | null En una noticia, la fuente que la originó.
wpPostId entero | null El id del post en WordPress, si la pieza también vive allí.

El jsonLd va en un campo aparte y NO dentro del html a propósito: el HTML lo escribe un modelo y se sanea antes de guardarlo, y el saneado elimina los <script>. Píntalo tú en el <head>, que además es su sitio.

Códigos de respuesta

CódigoerrorQué pasó
200 — Todo bien.
400 — Un parámetro fuera de rango (limit por encima de 100, offset negativo, kind que no es article ni news).
401 missing_api_key No llegó ninguna cabecera con la clave, o no empieza por rk_.
401 invalid_api_key La clave no existe o está revocada.
404 not_found Ese slug no está en tu proyecto, o la pieza aún no está publicada.
429 — Has pasado el límite de peticiones.
HTTP/1.1 401 Unauthorized
content-type: application/json

{ "error": "invalid_api_key" }

Los errores siempre traen un objeto con la clave error y un código estable en snake_case: puedes ramificar sobre él sin leer el texto.

Límite de peticiones

300 peticiones por minuto y por IP. Al pasarse se responde 429 y conviene reintentar con espera creciente.

Si vas a construir un sitio de cientos de páginas, pide el listado con limit=100 y cachea el resultado durante el build en vez de pedir cada pieza dos veces. Con updatedAt puedes refrescar solo lo que cambió desde tu última construcción.

Ejemplos

curl, para comprobar la clave en diez segundos:

# Lo último publicado
curl -s "https://api.sembalia.com/public/v1/contents?limit=20" \
  -H "Authorization: Bearer rk_tu_clave"

# Solo noticias, saltando las 20 primeras
curl -s "https://api.sembalia.com/public/v1/contents?kind=news&limit=20&offset=20" \
  -H "Authorization: Bearer rk_tu_clave"

# Una pieza por su slug, con el HTML entero
curl -s "https://api.sembalia.com/public/v1/contents/colorimetria-personal-guia-practica" \
  -H "Authorization: Bearer rk_tu_clave"

# Cabecera alternativa, por si tu cliente HTTP complica el Authorization
curl -s "https://api.sembalia.com/public/v1/contents" \
  -H "X-Api-Key: rk_tu_clave"

Next.js con generación estática, que es el caso más frecuente:

// Next.js (App Router): la lista para las rutas, el detalle para la página.
const API = 'https://api.sembalia.com/public/v1';
const headers = { Authorization: `Bearer ${process.env.SEMBALIA_API_KEY}` };

export async function generateStaticParams() {
  const rutas = [];
  // El listado va paginado: se recorre hasta que hasMore es false.
  for (let offset = 0; ; offset += 100) {
    const res = await fetch(`${API}/contents?limit=100&offset=${offset}`, { headers });
    if (!res.ok) throw new Error(`Sembalia ${res.status}`);
    const pagina = await res.json();
    rutas.push(...pagina.items.map((c) => ({ slug: c.slug })));
    if (!pagina.hasMore) break;
  }
  return rutas;
}

export default async function Post({ params }) {
  const res = await fetch(`${API}/contents/${params.slug}`, { headers });
  if (res.status === 404) notFound();
  const { content } = await res.json();
  return (
    <article>
      <h1>{content.title}</h1>
      {/* jsonLd va en el <head>, no dentro de html: el HTML se sanea y los
          <script> se eliminan a propósito. */}
      <script
        type="application/ld+json"
        dangerouslySetInnerHTML={{ __html: JSON.stringify(content.jsonLd) }}
      />
      <div dangerouslySetInnerHTML={{ __html: content.html }} />
    </article>
  );
}

PHP sin dependencias, para una web hecha a mano:

<?php
// PHP puro, sin dependencias.
function sembalia(string $ruta): array {
    $ch = curl_init('https://api.sembalia.com/public/v1' . $ruta);
    curl_setopt_array($ch, [
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . getenv('SEMBALIA_API_KEY')],
        CURLOPT_TIMEOUT => 15,
    ]);
    $cuerpo = curl_exec($ch);
    $codigo = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
    curl_close($ch);
    if ($codigo !== 200) {
        throw new RuntimeException("Sembalia devolvió $codigo");
    }
    return json_decode($cuerpo, true);
}

$ultimos = sembalia('/contents?limit=10&kind=article');
foreach ($ultimos['items'] as $pieza) {
    echo $pieza['title'], ' -> /blog/', $pieza['slug'], PHP_EOL;
}

Qué devuelve y qué no

  • Solo contenido PUBLICADO. Los borradores y lo programado no salen: la API es lo que tu web puede enseñar hoy.
  • Solo contenido escrito por nosotros. Tu archivo anterior, el que importamos para analizarlo, queda fuera a propósito: ya está publicado en tu web y servírtelo de vuelta te haría renderizar duplicados de tus propios artículos.
  • El slug no cambia una vez publicada la pieza. Es la URL que Google tiene indexada, así que se bloquea a propósito: puedes usarlo como clave primaria en tu lado.

Si lo que quieres es enterarte en cuanto se publique algo, en vez de preguntar cada tanto, usa el webhook: te llega el mismo contenido empujado y firmado. Mucha gente usa los dos, el webhook como disparador de la reconstrucción y la API como fuente durante el build.