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.
Authentication
Section titled “Authentication”Which key to use
Section titled “Which key to use”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:readis enough. Do not requestposts:writefor 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:
curl -s "https://api.canverly.com/v1/posts?limit=10" \ -H "Authorization: Bearer ck_YOUR_KEY"The key stays on the server
Section titled “The key stays on the server”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 with400, because it would show up in access logs, in theRefererand in the browser history.
Why not the public pk_ key
Section titled “Why not the public pk_ key”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.
Restrictions and limits
Section titled “Restrictions and limits”- 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
403with the dimension that blocked it. - The read routes accept 600 requests per minute per key. Above that,
429withRetry-After. Honor the header and cache on your side (see Caching and updates).
The read endpoints
Section titled “The read endpoints”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.
Fields of each post
Section titled “Fields of each post”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 missing post is 404
Section titled “A missing post is 404”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
Section titled “Pagination”Pagination is by cursor, from newest to oldest. There is no
page number, total or has_more.
- Request the first page:
GET /v1/posts?limit=10. - If the response includes
next_cursor, the next page is the same call with&cursor=<next_cursor>. - A missing or
nullnext_cursormeans the end of the archive.
curl -s "https://api.canverly.com/v1/posts?limit=10&cursor=eyJwIjoi..." \ -H "Authorization: Bearer ck_YOUR_KEY"limitranges from 1 to 50 (default 20). Out of range is400: 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.
Filters
Section titled “Filters”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.
Blocks: the post body
Section titled “Blocks: the post body”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.
What each block becomes
Section titled “What each block becomes”| 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.
What never appears in content_html
Section titled “What never appears in content_html”- 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 withGET /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.
Why it is safe to inject content_html
Section titled “Why it is safe to inject content_html”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.
Images
Section titled “Images”- Cover:
cover_urlis an absolute CDN URL, ornull. 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">, withoutwidthandheight. 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-srcof your CSP.
Caching and updates
Section titled “Caching and updates”| 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 inIf-None-Matchand, if nothing changed, the response is304with 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.
Errors
Section titled “Errors”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. |
Other useful reads (with extra scopes)
Section titled “Other useful reads (with extra scopes)”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. |
What your own front end no longer gets
Section titled “What your own front end no longer gets”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.
Platform page cache
Section titled “Platform page cache”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.
Technical SEO
Section titled “Technical SEO”- 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 withsince. - 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
urlfield 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
hreflangtags and the language prefix in URLs. Your front end receives each post’slanguage; 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.
Consent and cookie banner
Section titled “Consent and cookie banner”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.
Other site pieces
Section titled “Other site pieces”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.