Skip to content

Bulk export & import

Two endpoints for moving content in volume: GET /v1/export streams a resource of the site that owns the key, and POST /v1/import creates posts from an NDJSON file. Both work on the key’s own site — there is no site parameter.

Method Path Scope
GET /v1/export export
POST /v1/import import and posts:write
  • Base URL: https://api.canverly.com
  • Auth: Bearer API key (secret ck_… key only) — see Authentication
  • Rate limit: 60 req/min per key (leads exports also draw on the stricter 10 req/min leads bucket)

Streams the requested resource as it is read — the API never buffers the whole archive. NDJSON means one JSON object per line; CSV means flat columns.

Parameter Required Values Notes
resource yes posts | leads | analytics Missing or unknown is a 400 — there is no default.
format no ndjson (default) | csv Unknown value is a 400; it is never silently coerced to NDJSON.
form_id no string leads only — restrict to one form.
status no clean | spam | all leads only — submission status filter.
de no date leads only — start of the period.
ate no date leads only — end of the period.
from no YYYY-MM-DD analytics only — window start.
to no YYYY-MM-DD analytics only — window end.
days no string analytics only — window preset.

Filters that do not belong to the chosen resource are ignored.

resource Formats Extra scope Shape
posts ndjson only — One post object per line, the public projection, paginated internally by cursor.
leads ndjson, csv leads:read Form submissions. CSV is the forms export, with the right per-form columns.
analytics ndjson, csv analytics:read The daily time series. CSV columns are date,views,visitors.

posts + csv is a 400. A post body is a nested block document (see Content blocks); flat columns cannot represent it without mutilating it, so posts export as NDJSON only and the error says so.

leads is personal data. It requires leads:read on top of export, every export is written to the audit trail as leads.export with the API key as the actor, and it is charged against the stricter 10 req/min leads bucket. That scope is opt-in per key — it is never granted by default.

analytics is a single read of a series capped at 366 days, so it is produced in one piece rather than streamed page by page.

Limit Value Why
Rows per export 50 000 (posts, leads) A paginated export is truncated at this many rows; it must not become an endless read holding pool connections. Narrow the window (or the filters) and export in slices.
Upstream page size 200 rows The stream pulls the source in pages of 200 and holds one page at a time, with a bounded channel applying backpressure. Not a client-visible parameter.

Truncation at 50 000 rows is silent in the body — the stream simply ends. If a resource may exceed it, slice the export by period (leads) or window (analytics) instead of asking for everything.

200 OK with the stream:

Header Value
Content-Type application/x-ndjson or text/csv; charset=utf-8
Content-Disposition attachment; filename="<resource>-<YYYY-MM-DD>.<ext>", e.g. posts-2026-08-26.ndjson
X-Content-Type-Options nosniff

The first page is fetched before the 200 is sent, so an authorization or upstream failure arrives as the correct status code rather than as a truncated body. A failure after the stream has started ends the body early — check that you received a complete last line.

Export every post as NDJSON:

Janela do terminal
curl -sS "https://api.canverly.com/v1/export?resource=posts&format=ndjson" \
-H "Authorization: Bearer ck_live_xxx" \
-o posts-export.ndjson

Export the clean leads of one form for a period, as CSV:

Janela do terminal
curl -sS "https://api.canverly.com/v1/export?resource=leads&format=csv&status=clean&de=2026-08-01&ate=2026-08-31" \
-H "Authorization: Bearer ck_live_xxx" \
-o leads-august.csv

Export the analytics series for a window:

Janela do terminal
curl -sS "https://api.canverly.com/v1/export?resource=analytics&format=csv&from=2026-08-01&to=2026-08-26" \
-H "Authorization: Bearer ck_live_xxx" \
-o analytics-august.csv
Status error.code When
400 bad_request resource missing or unknown, format unknown, or resource=posts&format=csv.
401 unauthorized Missing/invalid/revoked key.
403 forbidden Key lacks export — or lacks leads:read for leads, analytics:read for analytics.
404 not_found The resource does not exist for this site (e.g. the site has no forms app installed).
429 rate_limited Over 60 req/min — or over the 10 req/min leads bucket. Honor Retry-After.

Creates posts in bulk from an NDJSON body: one line = one post, each line using exactly the same JSON schema as POST /v1/posts.

  • Content-Type: application/x-ndjson
  • Scopes: import and posts:write — importing is writing.
Parameter Required Values Notes
resource no posts (default) Any other value is a 400. Media import is deliberately not offered yet.

Each line is a full post object: title and blocks_json are required, everything else (status, slug, excerpt, language, post_type, category_slugs, tag_slugs) is optional and behaves exactly as on POST /v1/posts.

posts.ndjson
{"title":"Choosing a static host","status":"published","language":"en","category_slugs":["guides"],"blocks_json":{"v":1,"blocks":[{"id":"p1","type":"paragraph","text":"A short comparison of static hosting options for a docs site."}]}}
{"title":"Migrating from example.com to the new domain","status":"draft","language":"en","blocks_json":{"v":1,"blocks":[{"id":"p1","type":"paragraph","text":"Keep the old URLs alive with redirects before you switch the DNS."}]}}

Lines are separated by \n; blank lines are skipped, and trailing whitespace or \r is trimmed. The file must be UTF-8.

Limit Value Result if exceeded
Body size 8 MiB 413
Lines 1000 400 — too many lines (max 1000 per import)
Empty body (or only blank lines) — 400
Non-UTF-8 body — 400

A bigger migration becomes several calls; pace them at roughly 1 request/second to stay under the rate limit.

Each line’s idempotency key is derived from the content of that line, scoped to your API key, and cached for 24 hours. Re-running the same file therefore does not duplicate posts: a line already seen returns the original id with status: "replayed". Change a line’s content and it counts as a new post.

Janela do terminal
curl -sS -X POST "https://api.canverly.com/v1/import" \
-H "Authorization: Bearer ck_live_xxx" \
-H "Content-Type: application/x-ndjson" \
--data-binary @posts.ndjson

200 OK with a per-line summary:

{
"created": 1,
"failed": 1,
"results": [
{
"line": 1,
"status": "created",
"id": "01K8Z9K3F7T8M2QYV5N6B4WJ8R",
"slug": "choosing-a-static-host",
"post_status": "published"
},
{
"line": 2,
"status": "error",
"error": "title is required"
}
]
}
Field Type Notes
created integer Lines that produced a post — including replayed ones.
failed integer Lines that returned status: "error".
results[].line integer 1-based line number, counting only non-blank lines.
results[].status string "created", "replayed", or "error".
results[].id string Post ULID — on created/replayed.
results[].slug string Final slug — on created/replayed.
results[].post_status string "draft" or "published" — on created/replayed.
results[].error string Why the line failed — on error only.

A 200 can contain failures. A bad line never aborts the import: it becomes one status: "error" entry and the remaining lines still run. Never treat the status code as proof the import succeeded — inspect failed, and re-send only the lines whose results[] entry says error.

Status error.code When
400 bad_request Empty body, non-UTF-8 body, more than 1000 lines, or resource other than posts.
401 unauthorized Missing/invalid/revoked key.
403 forbidden Key lacks import, or lacks posts:write.
413 — Body over 8 MiB (plain-text message, not the JSON envelope).
429 rate_limited Over 60 req/min — honor Retry-After.

Per-line failures are not HTTP errors — see the results[] table above. The full status table lives in Errors & rate limits.