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)
GET /v1/export
Section titled “GET /v1/export”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.
Query parameters
Section titled “Query parameters”| 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.
Resources
Section titled “Resources”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.
Limits
Section titled “Limits”| 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.
Response
Section titled “Response”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.
Examples
Section titled “Examples”Export every post as NDJSON:
curl -sS "https://api.canverly.com/v1/export?resource=posts&format=ndjson" \ -H "Authorization: Bearer ck_live_xxx" \ -o posts-export.ndjsonExport the clean leads of one form for a period, as CSV:
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.csvExport the analytics series for a window:
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.csvErrors
Section titled “Errors”| 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. |
POST /v1/import
Section titled “POST /v1/import”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:
importandposts:write— importing is writing.
Query parameters
Section titled “Query parameters”| 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.
{"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.
Limits
Section titled “Limits”| 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.
Idempotency
Section titled “Idempotency”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.
Example
Section titled “Example”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.ndjsonResponse
Section titled “Response”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.
Errors
Section titled “Errors”| 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.