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.
Two classes of key
Section titled “Two classes of key”| 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.
What a pk_ key can actually reach
Section titled “What a pk_ key can actually reach”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
Section titled “Scopes”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:readcan never write SEO. This is per pair, deliberately. - Some routes need two scopes.
POST /v1/importneedsimportandposts:write.GET /v1/export?resource=leadsneedsexportandleads:read;?resource=analyticsneedsexportandanalytics:read. leads:readis 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" } }Restrictions
Section titled “Restrictions”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;endmust be strictly later thanstart. 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.
Origin allowlist (public keys only)
Section titled “Origin allowlist (public keys only)”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:
httpsonly, excepthttp://localhost,http://127.0.0.1andhttp://[::1]— you have to be able to prove the widget on your machine.- No wildcards.
https://*.example.comwould 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 onhttps://x.example:443sendsOrigin: 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.
Rate limits by class
Section titled “Rate limits by class”| 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.
Hygiene
Section titled “Hygiene”- One key per integration. Revoke a single consumer without breaking the
others;
last_used_attells 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:readandsite:writein particular should be on their own key, not bundled into the publisher key.
Managing keys over HTTP
Section titled “Managing keys over HTTP”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.