Agent integration guide
This page is written for autonomous agents (and the engineers wiring them up). It states the contract with zero ambiguity so a model can call the API correctly on the first try. Everything an agent needs is also available as machine-readable files:
/llms.txt— the full contract in one plain-text file. Fetch this and you can publish.https://api.canverly.com/openapi.json— OpenAPI 3.1 schema for tool/function-calling.
The contract, in brief
Section titled “The contract, in brief”- Endpoint:
POST https://api.canverly.com/v1/posts - Headers:
Authorization: Bearer <CANVERLY_API_KEY>,Content-Type: application/json - Body (minimum):
{ "title": string, "blocks_json": { "v": 1, "blocks": Block[] }, "status": "published" } Block:{ "id": string, "type": "paragraph"|"heading"|"list"|"code"|"quote"|"image", "text"?: string, "attrs"?: object }- Success:
201→{ "id", "slug", "status", "public_url" } - The site is fixed by the key. Never put
site_idanywhere; you cannot target another site.
The whole surface an agent can drive
Section titled “The whole surface an agent can drive”Publishing is the common case, but the API is not publish-only. Every route below is reachable with the same bearer key, subject to that key’s scopes.
| Goal | Call | Scope |
|---|---|---|
| Publish an article | POST /v1/posts |
posts:write |
| Fix or re-date a published article | PATCH /v1/posts/{id} |
posts:write |
| Read what is already published | GET /v1/posts (keyset, ?since= for incremental sync) |
posts:read |
| Read one article with its body | GET /v1/posts/{reference} (id or slug) |
posts:read |
| Avoid duplicating an existing article | GET /v1/posts?q=<title> before writing |
posts:read |
Set title/description/canonical/no_index |
PATCH /v1/posts/{reference}/seo |
seo:write |
| Upload an image to use in a post | POST /v1/media (multipart, field file) |
media:write |
| Check which post types exist | GET /v1/post-types |
post_types:read |
| Resolve the public domain | GET /v1/sites/me |
sites:read |
| Read traffic numbers | GET /v1/analytics/summary |
analytics:read |
| Load many posts at once | POST /v1/import (NDJSON) |
import + posts:write |
| Pull the archive out | GET /v1/export?resource=posts (NDJSON) |
export |
Full parameters and responses: the API reference.
Drop-in system prompt
Section titled “Drop-in system prompt”Give your agent this verbatim (fill in the key via a tool/secret, never inline it in the prompt):
You can publish articles to a Canverly site through its public API.
ENDPOINT: POST https://api.canverly.com/v1/postsHEADERS: Authorization: Bearer ${CANVERLY_API_KEY} Content-Type: application/json
BODY (JSON):- title (string, required)- status (string): "published" to go live, "draft" to stage. Default "draft".- blocks_json (object, required): { "v": 1, "blocks": [ ...blocks ] }- slug (string, optional): omit to auto-generate; for retries send a STABLE slug.- excerpt (string, optional): one-sentence summary.- language (string, optional): BCP-47, e.g. "en" or "pt-BR".- category_slugs (string[], optional), tag_slugs (string[], optional).
BLOCK SHAPES (each needs a unique "id"):- { "id":"p1", "type":"paragraph", "text":"inline HTML allowed (<a>,<strong>,<em>,<code>)" }- { "id":"h1", "type":"heading", "attrs":{"level":2}, "text":"Heading" }- { "id":"l1", "type":"list", "attrs":{"style":"unordered"}, "text":"item one\nitem two" }- { "id":"c1", "type":"code", "attrs":{"language":"python"}, "text":"print('hi')" }- { "id":"q1", "type":"quote", "text":"A quote." }
RULES:- Body content is blocks_json, NOT raw HTML and NOT markdown.- Do NOT send site_id/org_id/author_id; the key fixes the site.- <script>, event handlers and javascript: URLs are stripped; do not rely on them.- For safe retries, send header "Idempotency-Key: <stable id>" (e.g. the source item id). A replay with the same key+body returns the original 201 (no duplicate).- On 429, read Retry-After and wait that many seconds, then retry.- On 201, the response has "slug"; the post is live at https://<site-domain>/<slug>.
OPTIONAL DISCOVERY (each needs its own scope on the key):- GET /v1/post-types -> valid post_type slugs (default "post").- GET /v1/sites/me -> { id, slug, primary_domain, default_language }.- GET /v1/posts?q=... -> check whether you already published this; the response is { "items": [...], "next_cursor": string|null }. Absent/null cursor = end.- GET /v1/posts?since=<RFC3339> -> only what changed since your last run.
EDITING AN EXISTING POST:- PATCH /v1/posts/{id} with only the fields to change: title, blocks_json, excerpt, category_slugs, tag_slugs, published_at. There is no status or slug field: you cannot publish a draft or rename a URL through it.- Do NOT send excerpt/category_slugs/tag_slugs if the post has SEO metadata you need to keep - they overwrite the post's whole SEO object.- Do NOT send published_at unless the date is genuinely wrong; it re-pings IndexNow on every call.
SEO:- GET /v1/posts/{id}/seo, then PATCH the SAME object back with your change. The PATCH REPLACES the SEO object; anything you omit is cleared.Deterministic publish procedure
Section titled “Deterministic publish procedure”- (optional) Resolve the site —
GET /v1/sites/me→ cacheprimary_domainanddefault_language. - (optional) Check post types —
GET /v1/post-types→ confirm yourpost_typeslug exists (defaultpost). - (optional) Check for a duplicate —
GET /v1/posts?q=<title>→ if a close match already exists, patch it instead of publishing a second one. - Publish —
POST /v1/postswith the body above. Expect201. - (optional) Set SEO —
PATCH /v1/posts/{id}/seowith the complete SEO object. - Verify —
GET https://<primary_domain>/<slug>returns200, or re-read the post throughGET /v1/posts/{id}.
Minimal valid request
Section titled “Minimal valid request”{ "title": "Example", "status": "published", "blocks_json": { "v": 1, "blocks": [ { "id": "p1", "type": "paragraph", "text": "Body text." } ] }}Function/tool schema (for tool-calling models)
Section titled “Function/tool schema (for tool-calling models)”{ "name": "canverly_publish_post", "description": "Create and optionally publish a post on the Canverly site bound to the API key.", "parameters": { "type": "object", "required": ["title", "blocks_json"], "properties": { "title": { "type": "string" }, "status": { "type": "string", "enum": ["draft", "published"], "default": "draft" }, "slug": { "type": "string" }, "excerpt": { "type": "string" }, "language": { "type": "string" }, "post_type": { "type": "string", "default": "post" }, "category_slugs": { "type": "array", "items": { "type": "string" } }, "tag_slugs": { "type": "array", "items": { "type": "string" } }, "blocks_json": { "type": "object", "required": ["v", "blocks"], "properties": { "v": { "type": "integer", "const": 1 }, "blocks": { "type": "array", "items": { "type": "object", "required": ["id", "type"], "properties": { "id": { "type": "string" }, "type": { "type": "string", "enum": ["paragraph", "heading", "list", "code", "quote", "image"] }, "text": { "type": "string" }, "attrs": { "type": "object" } } } } } } } }}Failure modes an agent must handle
Section titled “Failure modes an agent must handle”| Status | Meaning | Agent action |
|---|---|---|
401 |
Bad/missing key | Stop; surface a “needs valid API key” message. |
403 |
Missing scope | Stop; the key needs posts:write. |
400 / 422 |
Bad payload (empty title, missing blocks_json, wrong types) |
Fix the body and retry once. |
429 |
Rate limited | Sleep Retry-After seconds, then retry. |
404 |
The post/asset is not on this site (or does not exist — the two are indistinguishable) | Stop; do not enumerate ids looking for one that works. |
5xx |
Transient | Exponential backoff (e.g. 1s, 2s, 4s), max ~3 tries. Reuse the same Idempotency-Key. |
Rate limits differ per route: 60 req/min for writes, 600 req/min for the public read routes, 10 req/min for leads. An agent that reads a lot and writes a little will hit the write bucket first.
See Errors & rate limits for the full table.