Skip to Content
ReferenceHeadless delivery API

Headless delivery API

This is a developer reference. If you’re setting up a headless space for the first time, start with Create a headless space.

Every headless space serves its published posts from a public, CDN-cacheable read API. There is no authentication and no API key — the API returns published posts only (drafts and scheduled posts never appear), and a blog is public by definition.

Your space’s base URL — including its workspace and site identifiers — is shown on the site’s Delivery API page in the customer portal, along with ready-made feed URLs per language. All paths below are relative to that base.

Endpoints

RouteReturns
GET /postsPublished posts, newest first (summary fields only)
GET /posts/{slug}One published post, full contract (see below)
GET /termsCategories and tags with post counts

Query parameters (/posts)

ParamDescription
languagePosts in one language, as a bare language code (e.g. de; regional campaign variants such as de_AT collapse to it). Any of the twenty supported content languages. Each language is a separate blog — one campaign per language.
categoryFilter by category slug. Mutually exclusive with tag.
tagFilter by tag slug. Mutually exclusive with category.
cursorNext (older) page — pass the previous response’s nextCursor.
beforePrevious (newer) page — pass the response’s prevCursor.
limitItems per page; default and maximum 50.

language stacks with category or tag: /posts?language=de&category=news is the German blog’s “News” archive. /terms?language=de scopes the taxonomy the same way.

Pagination

List responses are bidirectional: each returns nextCursor (toward older posts) and prevCursor (toward newer). A null cursor means that direction has no more pages — render each page purely from its URL token, no cursor trail needed.

Single-post response contract

GET /posts/{slug} returns { post } with:

  • id, slug, title, excerpt — identity and summary text.
  • html — the rendered, sanitized body. Root-relative links are absolutized against your site’s public URL. Safe to inject as-is: strict tag allowlist, no <script>/<style>/on* attributes.
  • blocks — the structured block array, if you prefer rendering to injecting html.
  • metaTitle, metaDescription, keyphrase, categories, tags.
  • featuredImage, bodyImage — { url, width, height, alt?, caption? }.
  • og — { title, description, image }, ready for Open Graph tags.
  • author — { name, bio, avatarUrl, profileUrl, sameAs }, the real person the site credits for the post, or null when the site has no author set for the post’s persona. It is never the persona name. bio, avatarUrl and profileUrl can be null; sameAs is an array of the author’s other profile URLs and can be empty. You set the author per persona for each site in the customer portal, see What is a persona?.
  • schema — an array of schema.org JSON-LD objects. The first entry is a BlogPosting for the post: headline, description, image, @id and mainEntityOfPage from the post’s canonical URL, datePublished, dateModified, and the author as a Person (name, description, image, url, sameAs). Empty fields are left out, and so is author when the post has none. There is no publisher: your site owns its Organization node. HowTo and FAQPage entries follow when the post contains those sections. Delivered out of band because the body sanitizer strips <script> — emit one <script type="application/ld+json"> per entry into your page <head> for rich-result eligibility. The matching body sections carry structura-howto / structura-faq classes for styling.
  • publishedAt, updatedAt — ISO 8601. updatedAt is your revalidation signal.

The list response returns the summary subset only — no html, blocks, author or schema.

Article markup and the author byline

If you emit every schema entry, your post pages carry article and author markup that search engines can use for rich results, with no extra work. If your site already builds its own Article or BlogPosting node, leave ours out, or each page reports two articles:

const jsonLd = post.schema.filter((entry) => entry["@type"] !== "BlogPosting");

Then take the author for your own node from post.author.

For a visible byline, show post.author.name, the avatarUrl image and the bio, and link the name to profileUrl when it is set. When post.author is null, leave the byline out.

Consuming from a framework

Fetch at build or revalidation time and treat the API as your CMS:

// Next.js App Router — a blog index with ISR const BASE = process.env.STRUCTURA_DELIVERY_BASE; // from the Delivery API page export default async function BlogIndex() { const res = await fetch(`${BASE}/posts?language=en`, { next: { revalidate: 300 }, }); const { posts } = await res.json(); // render titles/excerpts; link to /blog/[slug] }
// One post page — getStaticPaths-friendly const res = await fetch(`${BASE}/posts/${slug}`); const { post } = await res.json(); // inject post.html, emit post.schema entries as JSON-LD in <head> // (filter out the BlogPosting entry if you build your own Article node), // render a byline from post.author when it is not null

Responses are CDN-cached; poll-based setups should key revalidation off updatedAt rather than re-rendering unconditionally.

Last verified