Skip to content

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.
  • 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_id anywhere; you cannot target another site.

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.

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/posts
HEADERS: 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.
  1. (optional) Resolve the site — GET /v1/sites/me → cache primary_domain and default_language.
  2. (optional) Check post types — GET /v1/post-types → confirm your post_type slug exists (default post).
  3. (optional) Check for a duplicate — GET /v1/posts?q=<title> → if a close match already exists, patch it instead of publishing a second one.
  4. Publish — POST /v1/posts with the body above. Expect 201.
  5. (optional) Set SEO — PATCH /v1/posts/{id}/seo with the complete SEO object.
  6. Verify — GET https://<primary_domain>/<slug> returns 200, or re-read the post through GET /v1/posts/{id}.
{
"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" }
}
}
}
}
}
}
}
}
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.