Skribra API
Fetch your published articles from Skribra and render them on your own site. One API key, three endpoints, no infrastructure of your own.
Overview#
Skribra has two ways to get articles onto a site you control, and they are opposites.
The webhook pushes. Skribra POSTs each article to an endpoint you host, once, at the moment it publishes. You have to receive it, store it, and serve your blog from your copy — which means running an endpoint and a database, and keeping that copy correct as articles get refreshed and rewritten.
The API pulls. You ask for articles when you render. There is nothing to receive and nothing to store, because Skribra stays the source of truth. If you would rather not run a database to hold a copy of your own blog, this is the one you want.
Both are available at once, and they return the same article shape, so moving between them does not mean rewriting your templates.
Set it up with AI#
The whole integration is a typed client, an index page and a post page. If you use an AI assistant, hand it the brief below and let it write those against your own stack — it carries the endpoints, the article shape, the redirect rules and the caching guidance, so it does not have to guess.
Paste this into ChatGPT, Claude, Cursor or Copilot inside your own repo. It writes the API client, the blog index and the post page against whatever stack you already use.
Your API key is not in the prompt, and should never be pasted into an AI chat. Add it to your own project as SKRIBRA_API_KEY.
Preview the prompt
You are setting up the Skribra content API in this repository. Skribra
publishes SEO blog articles; this integration PULLS them on request, so there is no
webhook to host and no database to keep in sync.
Work with the stack that is already here. Detect the framework, router and styling
conventions from the existing code and match them — do not introduce a new data
layer, a new HTTP client, or a new styling approach.
## What to build
1. A small typed API client for Skribra.
2. A blog index page listing published articles, paginated.
3. A blog post page rendering one article by slug.
4. Correct <head> metadata on both, plus the JSON-LD the API returns.
## The API
Base URL: https://api.skribra.com/v1
Auth: Authorization: Bearer <key> (every request)
Project: <your-project-id>
Read the key from an environment variable — SKRIBRA_API_KEY. Never commit it and
never inline it. Add it to the env example file if the project has one. It is a
server-side secret: do NOT reference it from client-side code, a browser bundle, or
any NEXT_PUBLIC_/VITE_/PUBLIC_ prefixed variable.
### GET /articles?projectId=&page=&limit=&sort=&order=&updatedSince=
List published articles, newest first. Returns:
{ "items": PublicApiArticleCard[],
"pagination": { "page": 1, "limit": 20, "total": 42,
"total_pages": 3, "has_more": true } }
PublicApiArticleCard: id, title, slug, excerpt?, image_url?, type?,
reading_time?, published_at?, updated_at?, keyword?
Query params: page (default 1), limit (default 20, max 100), sort (one of
published_at | updated_at | title, default published_at), order (asc | desc,
default desc), updatedSince (ISO date — returns only articles changed since then).
### GET /articles/<slug>?projectId=
One full article:
id, title, slug, content_html, meta_description?, image_url?, type?,
reading_time?, created_at?, lastEdited?, last_change_summary?,
toc_html?, related_articles[], sources[], json_ld[],
metadata { projectId, userId, targetDate? },
redirect_to?, redirect_type?, noindex?, unpublished?
content_html is the article body ONLY — the table of contents (toc_html), the
related-articles list and the JSON-LD are returned as siblings so you can place
them where the layout wants. related_articles is { title, slug, url? }; sources is
{ title?, url, publisher? }. Those three arrays are always present, defaulting to [].
### GET /projects
Lists the caller's projects: id, name?, url?, api_enabled. Useful once, to confirm
the key works and the project is switched on.
## Rules that matter
- content_html is trusted, pre-sanitised HTML from Skribra. Render it as HTML
(dangerouslySetInnerHTML / v-html / {@html}). Do not escape it and do not run it
through a Markdown parser.
- Honour the consolidation fields on the detail route, in this order:
unpublished === true -> 404
redirect_to -> redirect to it (301 when redirect_type is
"permanent", else 302)
noindex === true -> render, but emit <meta name="robots" content="noindex">
Skipping these leaves dead URLs live after Skribra consolidates two posts.
- Emit each entry of json_ld as its own <script type="application/ld+json">.
It is already built for this article — do not hand-roll schema.
- Use meta_description for the description tag, image_url for og:image, and
updated_at / lastEdited for the modified time.
- Cache aggressively and prefer build-time fetching (SSG/ISR) where the framework
supports it. Articles change rarely. Rate limits are per account, not per IP:
300 list requests/hour and 600 detail requests/hour, so a per-visitor fetch will
exhaust them. If the framework has no build step, cache responses for at least an
hour.
- Use updatedSince for incremental revalidation rather than refetching everything.
- Errors return { "error": { "code", "message" } } with codes: unauthorized,
invalid_query, project_not_found, project_api_disabled, not_found, rate_limited.
A 429 includes Retry-After. Treat project_api_disabled as "the human needs to
switch this project on in Skribra", not as a bug to retry.
## Finally
Do not invent endpoints or fields beyond those listed. If something is genuinely
missing, say so rather than guessing. When you are done, tell me exactly which
files you created or changed, and what I still have to do myself (set
SKRIBRA_API_KEY, and anything else).
Full reference: https://skribra.com/docs/integrations/apiThe rest of this page is the same information for reading yourself, and the reference for when you want to go further than the brief covers.
Authentication#
Every request carries a bearer token. Generate a key under Integrations → Skribra API in your dashboard.
Authorization: Bearer skr_live_9fK2xQ7vN4pL8sT1wY6zR3bM5cJ0hG2dThe key is account-level: one key reaches every project you enable, and rotating it breaks anything still using the old one, everywhere. If Authorization is awkward in your runtime, send X-Api-Key instead.
Server-side only. This key reads every enabled project on your account, so it must never reach a browser. Keep it in an environment variable and call the API from your server, your build, or a serverless function.
Enabling a project#
A key on its own fetches nothing. Each project has its own switch under Integrations → Skribra API, off by default, so a key can never expose a project you did not mean to share. Requests for a project that is off return 403 project_api_disabled.
Endpoints#
Base URL: https://api.skribra.com/v1
| Endpoint | Returns |
|---|---|
GET /v1/projects | Your projects, and whether the API is on for each |
GET /v1/articles | A page of published articles, without the body |
GET /v1/articles/:slug | One article, with its full HTML |
List projects#
Start here — this is how you find the projectId every other call needs. Projects with the API switched off are listed too, with api_enabled: false, so a 403 elsewhere is easy to explain.
List articles#
curl "https://api.skribra.com/v1/articles?projectId=65f0…010&page=1&limit=12" \
-H "Authorization: Bearer $SKRIBRA_API_KEY"| Parameter | Type | Required | Description |
|---|---|---|---|
projectId | string | Yes | Which project to read. Get it from GET /v1/projects. |
page | number | No | 1-based. Defaults to 1. |
limit | number | No | Defaults to 20, capped at 100. |
updatedSince | ISO 8601 | No | Only articles created or edited since this moment. |
sort | string | No | published_at (default), updated_at, or title. |
order | string | No | desc (default) or asc. |
List rows are deliberately light — title, slug, excerpt, cover image, dates. The article body is the bulk of an article and an index page does not need it, so it is not here.
{
"data": {
"items": [
{
"id": "65f0…01a",
"title": "How to measure activation rate",
"slug": "how-to-measure-activation-rate",
"excerpt": "Activation is the moment a new user…",
"image_url": "https://cdn.skribra.com/…jpg",
"type": "Guide",
"reading_time": 7,
"published_at": "2026-08-14T00:00:00.000Z",
"updated_at": "2026-08-19T09:12:44.000Z"
}
],
"pagination": { "page": 1, "limit": 12, "total": 84, "total_pages": 7, "has_more": true }
}
}Unpublished articles, and any that Curator has redirected away during consolidation, are filtered out server-side. What you get back is what should be on your blog.
Get an article#
GET /v1/articles/:slug?projectId=… returns the full article. content_html is the body on its own — no table of contents, no related-articles block, no JSON-LD baked in. Those come back as toc_html, related_articles and json_ld beside it, so you can place each one where your layout wants it rather than unpicking one blob.
Slugs are unique within a project, not across your account, so projectId is required here too.
Rendering a blog#
The whole integration, with no database anywhere in it:
// app/blog/[slug]/page.tsx — the whole integration, no database.
const API = "https://api.skribra.com/v1";
const headers = { Authorization: `Bearer ${process.env.SKRIBRA_API_KEY}` };
const PROJECT = process.env.SKRIBRA_PROJECT_ID;
// Rebuild every hour so Keeper's refreshes and Curator's rewrites land.
export const revalidate = 3600;
export async function generateStaticParams() {
const res = await fetch(`${API}/articles?projectId=${PROJECT}&limit=100`, { headers });
const { data } = await res.json();
return data.items.map((a) => ({ slug: a.slug }));
}
export default async function Page({ params }: { params: { slug: string } }) {
const res = await fetch(
`${API}/articles/${params.slug}?projectId=${PROJECT}`,
{ headers }
);
if (!res.ok) notFound();
const { data: article } = await res.json();
return (
<article>
<h1>{article.title}</h1>
<div dangerouslySetInnerHTML={{ __html: article.content_html }} />
</article>
);
}The index page is the same shape: fetch /v1/articles, map over data.items, link each card at its slug. Use data.pagination.total_pages to render your pager.
Caching and staying in sync#
Articles do not stop changing once they publish. Keeper refreshes them as they age and Curator rewrites or consolidates them, so a copy you took last month is already out of date. Two ways to keep up:
Revalidate on a timer. The simplest thing that works. Rebuild or revalidate hourly and you are never far behind. Responses carry Cache-Control: public, max-age=60 and an ETag, so a conditional request costs almost nothing.
Or sync incrementally. Pass updatedSince with the timestamp of your last run and you get only what changed. Useful when you have hundreds of articles and re-walking every page is wasteful.
Errors and rate limits#
Errors return a stable code alongside the message, so you can branch on the code and show the message.
// 403 — the key is valid, but this project has the API switched off.
{
"code": "project_api_disabled",
"message": "API access is not enabled for this project"
}| Status | Code | Meaning |
|---|---|---|
| 400 | invalid_query | A parameter is missing or malformed — the message names which. |
| 401 | unauthorized | Missing, unknown, or revoked API key. |
| 403 | project_api_disabled | Your project, but the API is switched off for it. |
| 404 | project_not_found | No such project on this account. |
| 404 | not_found | No published article with that slug in that project. |
| 429 | rate_limited | Over the limit. Honour the Retry-After header. |
Limits are per account: 300 list requests an hour and 2,000 a day, with more headroom on the article endpoint. Those are sized for building and revalidating, not for calling Skribra on every page view — cache responses on your side and you will not come near them.
FAQ#
Do I need a database? No. That is the point of this over the webhook. Fetch when you render, or at build time, and Skribra remains the only copy.
Can I use both the API and the webhook? Yes. They return the same article shape, so you can push into a cache and pull for anything the push missed.
Does one key read several projects? Yes — every project you switch on. Pass a different projectId per call.
What happens when I unpublish an article? It disappears from the list and its slug starts returning 404. If you cache, it clears on your next revalidation.