Skip to content

Headless

In headless mode, content is still written and published in Canverly, but the pages are drawn by a front end of your own. That front end reads the posts through the public read API, which is already in production, and builds the HTML however it wants.

The trade-off is that everything the platform does while rendering the page becomes your front end’s responsibility. The list is in What your own front end no longer gets.

Use a secret key (ck_…) created with only the posts:read scope.

  • Create the key in the admin, under Configurações → Integrações API. It is shown only once; Canverly stores only the hash.
  • Scopes are opt-in per key: the key gets only what was requested at creation. To read posts, posts:read is enough. Do not request posts:write for the front end: with it, the key can publish and edit.
  • Each key is valid for one site. The site is derived from the key, never from a request parameter.

The key goes in the header, on every call:

Janela do terminal
curl -s "https://api.canverly.com/v1/posts?limit=10" \
-H "Authorization: Bearer ck_YOUR_KEY"

The ck_ is a secret. It lives in an environment variable or in a secrets vault on the server that renders your front end, and it never goes to the browser.

  • Do not put the key in a variable with a public prefix (PUBLIC_*, NEXT_PUBLIC_*, VITE_*). Those prefixes exist precisely to embed the value in the JavaScript the browser downloads.
  • The read routes do not return CORS headers for a ck_. Calling the API with it from the browser does not work, by design.
  • Sending a ck_ in the query string (?key=ck_…) is rejected with 400, because it would show up in access logs, in the Referer and in the browser history.

The public read key (pk_…) was built for the widget: it sits in the page HTML and only works on the origins registered on the key itself, checked through the Origin header. For a front end that fetches content on the server, a ck_ with only posts:read is the way to go. Details in Keys, scopes & restrictions.

  • The key can have restrictions by IP/CIDR, region and time window. For a front end with a fixed egress IP, the IP restriction is a good extra layer. A blocked request gets 403 with the dimension that blocked it.
  • The read routes accept 600 requests per minute per key. Above that, 429 with Retry-After. Honor the header and cache on your side (see Caching and updates).

Two endpoints cover the whole front end.

Endpoint What for
GET /v1/posts Paginated listing of the public archive, with filters. Without the post body.
GET /v1/posts/{reference} One post, by slug or id, with the body already in HTML (content_html).

Both only see what is published. Drafts, posts scheduled for the future and restricted posts are never selected.

The listing returns items (each in the PublicPost format) and next_cursor.

Field Note
id 26-character ULID.
slug Use it in your front end’s URL.
title, excerpt Plain text.
published_at, updated_at RFC-3339, in UTC.
cover_url Absolute URL of the cover, or null when there is no image.
author The post’s author, or null. Handle the null.
categories, tags Lists of terms.
url Canonical address of the post on the platform site, not on your front end.
reading_time Minutes, at least 1.
language, post_type E.g. pt-BR, post.

The detail (GET /v1/posts/{reference}) has the same fields plus content_html, the body already rendered and sanitized.

A post that does not exist, a draft, a scheduled post, a restricted post and a post from another site all return the same 404. This is on purpose: a 403 would confirm that the draft exists. In your front end, treat the API’s 404 as the page’s 404.

Pagination is by cursor, from newest to oldest. There is no page number, total or has_more.

  1. Request the first page: GET /v1/posts?limit=10.
  2. If the response includes next_cursor, the next page is the same call with &cursor=<next_cursor>.
  3. A missing or null next_cursor means the end of the archive.
Janela do terminal
curl -s "https://api.canverly.com/v1/posts?limit=10&cursor=eyJwIjoi..." \
-H "Authorization: Bearer ck_YOUR_KEY"
  • limit ranges from 1 to 50 (default 20). Out of range is 400: the value is never silently clamped.
  • The cursor is opaque. Do not decode it or build it; an invalid cursor is 400.
  • Publishing a post mid-navigation does not make anyone skip or repeat an item.
  • In practice, your front end offers “older” (and “back to start”). Jumping directly to page N is not possible with a cursor.

The filters of GET /v1/posts combine with pagination:

Parameter Effect
category Category slug.
tag Tag slug.
q Search in title and excerpt.
since RFC-3339. Only posts with updated_at >= since. Useful for incremental sync.
language BCP-47 tag, e.g. pt-BR.

The starter’s category page is GET /v1/posts?category=<slug>. The details of each parameter are in List & read posts.

In Canverly, a post body is a block document, not HTML. For the front end, the API renders that document on the server and delivers the finished result in content_html. Your front end does not need to know the blocks format.

Block HTML in content_html
paragraph <p>
heading <h1> to <h6> (default <h2>)
list <ul class="cv-list cv-list-bullet"> or <ol class="cv-list cv-list-ordered">
quote <blockquote class="cv-quote">
callout <aside class="cv-callout …">
code <pre class="cv-code">
image <figure class="cv-img"><img …><figcaption class="cv-caption">
gallery <div class="cv-gallery">
table <table class="cv-table">
divider <hr class="cv-divider">
columns One <section class="cv-column"> per column, in sequence (no grid)
embed, video A link: <p class="cv-embed-link"><a href="…"> (no player)
html <div class="cv-html"> with the block’s HTML, sanitized

The cv-* classes are the same as on the platform site. Style them in your CSS; the API does not send a stylesheet.

  • Ad blocks (ad), product cards (product_card) and story pages (story_page) come out empty, by a decision documented in the OpenAPI.
  • Any other block type the renderer does not know also comes out empty, without warning. That is the case, today, for the related posts block (related_posts): if you want the section, build it with GET /v1/posts?category=, using the post’s first category, and remove the post itself from the list.
  • Video and embed do not become an <iframe>: they become a link. If you want the player, build it in your front end from the link.

The API sanitizes the HTML on output, with a closed allowlist: no <script>, <iframe> or <form>, and video embeds become links. Even so, treat content_html as HTML coming from another system and sanitize it again on your server, with the same allowlist, before injecting it. The starter does this. If something ever changes on the other side, your front end stays free of XSS.

  • Cover: cover_url is an absolute CDN URL, or null. The API does not yet report the cover’s dimensions: reserve the space in CSS (fixed aspect ratio) to avoid layout shift.
  • Body images: they arrive as <img src="…" alt="…" loading="lazy"> inside <figure class="cv-img">, without width and height. Reserve the space in CSS. An image block without an address comes out empty.
  • Content policy: images come from a CDN domain, not yours. Allow that domain in the img-src of your CSP.
Route API Cache-Control
GET /v1/posts public, max-age=60, stale-while-revalidate=300
GET /v1/posts/{reference} public, max-age=300, stale-while-revalidate=600
  • Responses include an ETag. Send it back in If-None-Match and, if nothing changed, the response is 304 with no body.
  • There is currently no notification from the platform to your front end when a post is published, edited or unpublished. A new post shows up on your front end when the cache (the API’s and yours) expires. If your front end keeps a local copy, use GET /v1/posts?since=<last sync> to fetch only what changed.

The error body and the full list of codes are in Errors & rate limits. In the front end:

Status When What the front end does
304 If-None-Match matched Reuses the copy it already has.
400 limit outside 1..50, invalid cursor, ck_ in the query Fix the call.
401 Key missing, invalid or revoked Check the environment variable.
403 Missing posts:read scope, or a key restriction blocked it Check the key in the admin.
404 Post does not exist or is not public Return 404 on your page.
429 More than 600 req/min on the key Wait for Retry-After; increase your cache.

These routes also accept a ck_, but each one requires a scope in addition to posts:read. Only add the scope if your front end needs the data.

Endpoint Scope What it returns
GET /v1/sites/me sites:read Site identity: id, slug, primary_domain, default_language.
GET /v1/sites/me/seo seo:read Site SEO.
GET /v1/posts/{reference}/seo seo:read Post SEO.
GET /v1/sites/me/settings site:read The site’s settings object, in full.

The platform site does a number of things while rendering the page. In headless mode, none of that reaches your front end. Each item below becomes your job, or ceases to exist.

The platform site caches the entire HTML page and invalidates it when the content changes. Your front end gets only the API’s HTTP cache, described above, and is not notified of publication. Building and invalidating the cache of your pages is up to you.

  • Sitemaps. The platform generates the site’s sitemaps. Your front end needs to generate its own, from GET /v1/posts (walking the cursor) or from a local copy synced with since.
  • Structured data (JSON-LD). The API does not deliver JSON-LD; your front end builds its own, faithful to the content.
  • Canonical. Each post’s url field points to the platform site. The canonical of your pages is your decision. If the platform site stays online with the same content, the two addresses compete with each other in search engines: decide which one is canonical before publishing the front end.
  • hreflang and languages. The platform builds the hreflang tags and the language prefix in URLs. Your front end receives each post’s language; the API does not currently report which posts are translations of each other.
  • Also gone: robots.txt, llms.txt, RSS/Atom feeds and the pages’ Open Graph/Twitter metadata.

The platform’s Web Stories are AMP pages. They do not exist in headless mode: the story_page block comes out empty in content_html.

On the platform site, the consent control decides whether the third-party code pasted by the owner is emitted. Your front end has no banner and no such control: consent management, and LGPD compliance, become yours.

Ad blocks (ad) and the layout’s ad slots do not reach your front end. If the site lives on ads, monetization has to be built again.

These also stay only on the platform site: author pages, search, comments, forms and newsletter, and the theme. The theme’s menu, logo and colors have no dedicated read in the API: they only exist inside the full settings object (see the warning in Other useful reads). There is also no public route that lists the site’s categories: the front end only knows a category through the posts’ categories.