Skip to content

Keys, scopes & restrictions

There are two classes of API key, a fixed list of scopes, and four optional restrictions that a key’s owner can attach to it. All three are set when the key is minted, in the admin under Configurações → Integrações API.

Secret key Public read key
Prefix ck_ pk_
Where it lives Server-side only — environment variable, secret manager In the HTML of the page that embeds a widget
Can write Yes No, ever
Sent as Authorization: Bearer ck_… Authorization: Bearer pk_… or ?key=pk_…
Usable from a browser No — read routes deliberately return no CORS headers for a ck_ key Yes, from registered origins only
Origin allowlist Not applicable (rejected at mint time) Required, 1–20 origins

Both are the prefix followed by 32 base62 characters (≈190 bits of entropy) and both are shown once, at creation. Canverly stores only a SHA-256 hash. Keys are opaque: do not parse or split them.

A public read key may only be minted with read scopes — asking for posts:write on one is a 400 at creation. In practice its reachable surface is the two public read routes:

Every other route authenticates ck_ tokens only and answers 401 to a pk_ key, even when the key carries the matching scope. Treat post_types:read and sites:read on a public key as inert today.

Scopes are opt-in per key: a key gets exactly what was requested at creation, and nothing by default. These are the fourteen the API recognizes — anything else is rejected when the key is issued.

Scope Grants
posts:write POST /v1/posts, PATCH /v1/posts/{id}
posts:read GET /v1/posts, GET /v1/posts/{reference}
post_types:read GET /v1/post-types
sites:read GET /v1/sites/me
site:read GET /v1/sites/me/settings
site:write PATCH /v1/sites/me/settings
seo:read GET /v1/posts/{reference}/seo, GET /v1/sites/me/seo
seo:write PATCH /v1/posts/{reference}/seo, PATCH /v1/sites/me/seo
media:read GET /v1/media, GET /v1/media/{id}
media:write POST /v1/media, DELETE /v1/media/{id}
analytics:read GET /v1/analytics/summary, GET /v1/analytics/posts
leads:read GET /v1/leads, GET /v1/leads/{id}
export GET /v1/export
import POST /v1/import

Notes that catch people out:

  • sites:read ≠ site:read. The plural is the identity call; the singular is the settings blob. The difference is not a typo.
  • Read and write are always separate. A key with seo:read can never write SEO. This is per pair, deliberately.
  • Some routes need two scopes. POST /v1/import needs import and posts:write. GET /v1/export?resource=leads needs export and leads:read; ?resource=analytics needs export and analytics:read.
  • leads:read is personal data. It is never granted by default, every read is written to the audit trail with the key as the actor, and it carries its own 10 req/min bucket on top of the general limit.

A request to a route whose scope the key lacks is a 403 naming the scope:

{ "error": { "code": "forbidden", "message": "scope `posts:write` not granted" } }

Beyond scopes, a key can carry up to four restrictions. They are evaluated per request, on both ck_ and pk_ keys, across all of /v1 — and before the rate-limit bucket is charged, so a blocked request does not consume quota. A key with no restrictions skips the whole check.

An empty dimension does not restrict. A dimension that is set and cannot be evaluated denies — a restriction that fails open is a restriction that does not exist at the moment it matters most.

Restriction Accepts Max entries
IP allowlist Exact addresses or CIDR ranges — 203.0.113.4, 2001:db8::/32 64
Region allowlist ISO 3166-1 alpha-2 country codes, uppercase — BR, US 250
Time zone One IANA zone — America/Sao_Paulo 1
Time windows Weekday set + HH:MM–HH:MM range, interpreted in that zone 32
  • IP — if the client address cannot be determined, an active IP allowlist denies.
  • Region — resolved by GeoIP from the client address. An indeterminate country (private address, unknown range, lookup failure) denies when a region allowlist is set. It is never read as “no restriction”.
  • Time windows — the request passes if it falls inside any window. Days are the lowercase three-letter forms mon … sun; end must be strictly later than start. Windows need a time zone to mean anything: without one, they do not restrict.

A blocked request is a 403 that names the dimension and never the list:

{ "error": { "code": "forbidden",
"message": "request blocked by this key's IP restriction" } }

The other two messages are request blocked by this key's region restriction and request blocked by this key's time-window restriction.

A pk_ key is checked against the browser’s Origin header, and the match is byte-for-byte on a normalized form. The rules, each one there because of a known way to get it wrong:

  • https only, except http://localhost, http://127.0.0.1 and http://[::1] — you have to be able to prove the widget on your machine.
  • No wildcards. https://*.example.com would delegate to any subdomain, including one an attacker registers on a third-party pages service under the customer’s domain.
  • No path, query, fragment or userinfo. An “origin” with a path never matches what the browser sends, and you would debug a list that looks right.
  • Default ports are dropped from the stored form, because that is how a browser serializes Origin (RFC 6454): a page on https://x.example:443 sends Origin: https://x.example.
  • Origin: null (sandboxed frames, file://) is rejected as input and never matches.
  • Up to 20 origins per key, deduplicated after normalization.

An unregistered origin gets 403: the key is valid, the page is not.

Bucket Limit Applies to
Write / general 60 req/min per key Every route except the two public read routes
Read 600 req/min per key GET /v1/posts, GET /v1/posts/{reference}
Read per (key, IP) 60 req/min The same two routes, for pk_ keys only
Leads 10 req/min per key GET /v1/leads, GET /v1/leads/{id}, leads export — on top of the general bucket

Reading is ten times cheaper than writing on purpose: sharing one bucket would let a busy widget grid eat the owner’s publishing quota. See Errors & rate limits.

  • One key per integration. Revoke a single consumer without breaking the others; last_used_at tells you which keys are still alive.
  • Never commit a ck_ key or ship it in client-side code. If one leaks, revoke it immediately — revocation takes effect on the next request.
  • Rotate without downtime: mint a new key with the same scopes, switch the integration, confirm, then revoke the old one.
  • Ask for the smallest scope set that works. leads:read and site:write in particular should be on their own key, not bundled into the publisher key.

The admin UI is backed by four routes — POST and GET /admin/api-keys, PATCH and DELETE /admin/api-keys/{id} — which set exactly what this page describes: name, class, scopes, origins and the four restrictions.

What a PATCH can change: the policy — scopes, origins, and the IP, region and time-window restrictions. What it cannot change: the secret. The platform stores only a SHA-256 of your key, so nobody — not even the admin UI — can re-display or re-issue it. That asymmetry is the point: fixing a missing scope should not force you to swap the secret in every integration that uses it. Changing what a key may do does not change which key it is.

One rule survives editing: a pk_ key can never be granted a write scope, the same refusal you get at creation. Otherwise the read-only guarantee would be a formality — mint a reader, promote it later.

They are excluded from /openapi.json’s /v1 contract for the same reason.