Skip to content

List & read posts

Two routes read the public archive of the site that owns the key: a paginated listing and a single-post detail.

  • Auth: Bearer API key — scope posts:read. These are the only two routes that also accept a public read key (pk_…), including as a ?key= query parameter. See Keys, scopes & restrictions.
  • Rate limit: 600 req/min per key, plus 60 req/min per (key, IP) when the caller used a pk_ key.

Paginated archive, newest first.

  • URL: https://api.canverly.com/v1/posts
  • Method: GET
Parameter Type Default Notes
limit integer 20 1–50. Out of range returns 400 — the value is never silently clamped.
cursor string — The next_cursor from the previous page. An invalid cursor returns 400.
category string — Category slug. Comma-separate for match-any (category=news,opinion).
tag string — Tag slug. Comma-separate for match-any.
q string — Full-text search over title and excerpt.
since string — RFC-3339 timestamp. Returns only posts with updated_at >= since — the incremental-sync hook.
language string — BCP-47 tag, e.g. pt-BR.
key string — A pk_… public read key, for browser callers. A ck_ key here returns 400. Ignored when an Authorization header is present.
Header Required Value
Authorization yes¹ Bearer ck_… or Bearer pk_…
If-None-Match no A previously returned ETag. A match returns 304 with no body.

¹ Unless the request carries ?key=pk_… instead.

Janela do terminal
curl -s "https://api.canverly.com/v1/posts?limit=5&category=news" \
-H "Authorization: Bearer ck_live_xxx"

Paginate by following the cursor until it is absent or null:

Janela do terminal
curl -s "https://api.canverly.com/v1/posts?limit=50&cursor=eyJwIjoi…" \
-H "Authorization: Bearer ck_live_xxx"

Incremental sync — poll only what changed since your last run:

Janela do terminal
curl -s "https://api.canverly.com/v1/posts?since=2026-08-01T00:00:00Z" \
-H "Authorization: Bearer ck_live_xxx"
{
"items": [
{
"id": "01J8ZQ7X4K2N5M9P0R3T6V8W1Y",
"slug": "como-fazer-x",
"title": "Como fazer X",
"excerpt": "A one-sentence summary.",
"published_at": "2026-08-01T12:00:00Z",
"updated_at": "2026-08-02T09:30:00Z",
"author": { "name": "Ana Prado", "slug": "ana-prado", "avatar_url": "https://cdn.example.com/a.png" },
"categories": [{ "slug": "financas", "name": "Finanças" }],
"tags": [{ "slug": "juros", "name": "Juros" }],
"cover_url": "https://cdn.example.com/cover.jpg",
"url": "https://blog.example.com/como-fazer-x",
"reading_time": 4,
"language": "pt-BR",
"post_type": "post"
}
],
"next_cursor": "eyJwIjoi…"
}
Field Type Notes
id string 26-character Crockford base32 ULID.
slug string URL slug.
title string
excerpt string
published_at / updated_at string RFC-3339.
author object | null Public byline (a persona), not a platform user. null when no persona is assigned — handle that case.
categories / tags array { slug, name }. name is never empty: an unregistered slug is humanized (juros-compostos → Juros Compostos), so you never need two display paths.
cover_url string | null Absolute CDN URL; null/absent when there is no resolvable image.
url string Canonical public URL, already carrying the language prefix when the post is not in the site’s default language.
reading_time integer Minutes, max(1, ceil(words / 200)) — the same formula the site uses.
language string BCP-47.
post_type string e.g. post, page.
next_cursor string | null Opaque cursor for the next page. Absent or null means the end. There is no total and no has_more.

The listing body does not include the post body. Fetch the detail route for that.

Responses carry a strong ETag and Cache-Control: public, max-age=60, stale-while-revalidate=300. Send the ETag back as If-None-Match and you get a 304 with no body when nothing changed — the cheapest possible poll.

One published post, with its rendered body.

  • URL: https://api.canverly.com/v1/posts/{reference}
  • Method: GET
Path segment Notes
reference A ULID (26 chars), a UUID (36 chars), or a slug. Anything that is not id-shaped is treated as a slug.

Same query/header options as the listing for key and If-None-Match. Cache-Control here is public, max-age=300, stale-while-revalidate=600.

Janela do terminal
# By slug
curl -s https://api.canverly.com/v1/posts/como-fazer-x \
-H "Authorization: Bearer ck_live_xxx"
# By id
curl -s https://api.canverly.com/v1/posts/01J8ZQ7X4K2N5M9P0R3T6V8W1Y \
-H "Authorization: Bearer ck_live_xxx"

Every field of the listing item, plus:

Field Type Notes
content_html string The rendered body, allowlist-sanitized on the way out.

What sanitization means concretely, so you can plan around it:

  • No <script>, <iframe>, <form> and no event-handler attributes. Ever.
  • Ad blocks, product cards and story pages are omitted from the output.
  • Video embeds are downgraded to a link.
  • An image stored as a library id (attrs.media_id) comes out with its URL already resolved. If the asset was deleted or is unavailable, the block is omitted rather than emitted as a broken <img>.

Envelope: { "error": { "code", "message" } }. Full table in Errors & rate limits.

Status error.code When
304 — If-None-Match matched; no body.
400 bad_request Invalid limit or cursor; or a ck_ key passed in ?key=.
401 unauthorized Missing/invalid/revoked key.
403 forbidden Scope posts:read missing — or, for a pk_ key, the request Origin is not in the key’s allowed origins. The key is valid; the page it runs on is not. Add the origin under Configurações → Integrações API.
404 not_found Detail route: no such post, or not public.
429 rate_limited Over 600 req/min per key, or 60 req/min per (key, IP) on a pk_ key. Honour Retry-After.
502 upstream Transient upstream error; retry with backoff.