Sembalia
Back to home

Content API

A read-only REST API that serves your project content to your own site: a paginated list and the full piece by slug, with sanitised HTML, images, the author card, categories, internal links and structured data. This is the integration for a hand-built site that prefers to PULL content at build time rather than receive it over a webhook.

Three steps to start

  • Create a key in the panel, under project Settings, API keys. It is shown ONCE: store it in your secret manager, because we only keep its hash.
  • Try the list with the curl below. A 200 with an items array means you are done: there is nothing else to configure.
  • Fetch each piece by slug when you are about to render it. The list deliberately omits the HTML so that asking for the index stays cheap.

API base: https://api.sembalia.com/public/v1. Every response is UTF-8 JSON, and every date is ISO 8601 in UTC.

Authentication

Every request carries the project key. Either header works and both do exactly the same; the second exists because some HTTP clients and CDNs mangle the Authorization header.

HeaderValue
Authorization Bearer rk_… (the full key, prefix included)
X-Api-Key rk_… (the full key, without the word Bearer)

The key identifies the PROJECT, not a user: a key can only read its own project content, and there is no way to name another one. Revoking it in the panel invalidates it immediately, with no grace period.

This is a server-side key. Put it in the browser (a call from the front end, a NEXT_PUBLIC_ or VITE_ variable) and anyone who opens devtools has it. Call from your backend or during the build.

List content

GET /public/v1/contents Project key required

Returns published pieces, newest first. Use it to build the blog index and the route list of a static site.

ParameterTypeDescription
kind article | news Filter by piece type. Omit it to get both.
limit integer 1-100 How many pieces to return. Defaults to 20.
offset integer ≥ 0 How many to skip. Defaults to 0.

200 response:

{
  "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
}
FieldTypeDescription
items[].id uuid Internal identifier of the piece. Stable.
items[].kind string article or news.
items[].title string The headline, exactly as published.
items[].slug string The URL part. It is the key you use to fetch the detail.
items[].metaDescription string | null Meta description, 155 characters at most.
items[].words integer | null Body word count.
items[].publishedAt ISO date | null When it went live.
items[].updatedAt ISO date Last change. Handy to refresh only what moved.
hasMore boolean Whether more pieces follow this page.
offset, limit integer The effective query values, already normalised.

The list carries neither html nor images. That is deliberate: the index of a blog with hundreds of pieces would weigh megabytes, and you almost never need the body to render it.

One piece by slug

GET /public/v1/contents/{slug} Project key required

Returns the full piece, ready to render. It answers 404 when the slug is not in your project or the piece is not published yet, which from the outside is the same case.

{
  "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": "…" }
    ]
  }
}
FieldTypeDescription
html string | null The body as HTML, already sanitised on our side against an allowlist of tags. No <script>, no style attributes.
outline string[] | null The headings in order. Build a table of contents without parsing the HTML.
author object | null The person on your team who signs: name, slug, bio, role and url (their public profile). Null when your brand signs: we never make up an author.
images[] array kind (hero or inline), url, mimeType, width, height and alt. The alt is written in the language of the piece; it is not the title.
categories[] array id, name, slug and primary. Exactly one has primary true.
tags[] array id, name and slug.
internalLinks[] array Related articles, for you to render wherever you want (they are not inside html): anchor, targetSlug, targetTitle and targetUrl, the real URL of the piece including the blog path. Published pieces only.
jsonLd array schema.org structured data, ready for the <head>.
publishedUrl string | null The final URL when we know it (for example when we also publish to WordPress).
sourceUrl string | null On a news piece, the source it came from.
wpPostId integer | null The WordPress post id, when the piece also lives there.

jsonLd is a separate field and deliberately NOT inside html: the HTML is written by a model and sanitised before storage, and sanitising strips <script>. Render it yourself in the <head>, which is where it belongs anyway.

Response codes

CodeerrorWhat happened
200 — All good.
400 — A parameter out of range (limit above 100, negative offset, a kind that is neither article nor news).
401 missing_api_key No key header arrived, or it does not start with rk_.
401 invalid_api_key The key does not exist or has been revoked.
404 not_found That slug is not in your project, or the piece is not published yet.
429 — You went over the rate limit.
HTTP/1.1 401 Unauthorized
content-type: application/json

{ "error": "invalid_api_key" }

Errors always carry an object with an error key and a stable snake_case code: branch on it without reading the prose.

Rate limit

300 requests per minute per IP. Past that you get a 429, and the right move is to retry with backoff.

Building a site of hundreds of pages? Ask for the list with limit=100 and cache it for the build instead of fetching every piece twice. With updatedAt you can refresh only what changed since your last build.

Examples

curl, to check the key in ten seconds:

# The latest published
curl -s "https://api.sembalia.com/public/v1/contents?limit=20" \
  -H "Authorization: Bearer rk_your_key"

# News only, skipping the first 20
curl -s "https://api.sembalia.com/public/v1/contents?kind=news&limit=20&offset=20" \
  -H "Authorization: Bearer rk_your_key"

# One piece by slug, with the full HTML
curl -s "https://api.sembalia.com/public/v1/contents/colorimetria-personal-guia-practica" \
  -H "Authorization: Bearer rk_your_key"

# Alternative header, in case your HTTP client makes Authorization awkward
curl -s "https://api.sembalia.com/public/v1/contents" \
  -H "X-Api-Key: rk_your_key"

Next.js with static generation, the most common case:

// Next.js (App Router): the list for the routes, the detail for the page.
const API = 'https://api.sembalia.com/public/v1';
const headers = { Authorization: `Bearer ${process.env.SEMBALIA_API_KEY}` };

export async function generateStaticParams() {
  const routes = [];
  // The list is paginated: walk it until hasMore is 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 page = await res.json();
    routes.push(...page.items.map((c) => ({ slug: c.slug })));
    if (!page.hasMore) break;
  }
  return routes;
}

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 belongs in <head>, not inside html: the HTML is sanitised and
          <script> tags are stripped on purpose. */}
      <script
        type="application/ld+json"
        dangerouslySetInnerHTML={{ __html: JSON.stringify(content.jsonLd) }}
      />
      <div dangerouslySetInnerHTML={{ __html: content.html }} />
    </article>
  );
}

Dependency-free PHP, for a hand-built site:

<?php
// Plain PHP, no dependencies.
function sembalia(string $path): array {
    $ch = curl_init('https://api.sembalia.com/public/v1' . $path);
    curl_setopt_array($ch, [
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . getenv('SEMBALIA_API_KEY')],
        CURLOPT_TIMEOUT => 15,
    ]);
    $body = curl_exec($ch);
    $code = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
    curl_close($ch);
    if ($code !== 200) {
        throw new RuntimeException("Sembalia returned $code");
    }
    return json_decode($body, true);
}

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

What it returns, and what it does not

  • Published content only. Drafts and scheduled pieces stay out: the API is what your site can show today.
  • Only content we wrote. Your previous archive, the one we import to analyse it, is deliberately excluded: it is already published on your site and serving it back would make you render duplicates of your own articles.
  • The slug does not change once a piece is published. It is the URL Google has indexed, so it is locked on purpose: you can use it as a primary key on your side.

If what you want is to hear about it the moment something is published, rather than polling, use the webhook: the same content arrives pushed and signed. Plenty of people run both, the webhook as a rebuild trigger and the API as the source during the build.