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.
| Header | Value |
|---|---|
| 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
/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.
| Parameter | Type | Description |
|---|---|---|
| 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
}| Field | Type | Description |
|---|---|---|
| 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
/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": "…" }
]
}
}| Field | Type | Description |
|---|---|---|
| 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
| Code | error | What 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.