Markdown Import & Export

Every doc and every page on your site can be written as Markdown, pushed in over the API, and pulled back out as Markdown that survives the round trip unchanged. This is the bridge between a Markdown file in your repository and the Tiptap body the renderer serves.

Four endpoints, two directions, two targets:

Direction

Docs

Pages

Import (Markdown in)

POST /dashboard/content/docs/import

POST /dashboard/content/pages/import

Export (Markdown out)

GET /dashboard/content/docs/{doc_id}/markdown

GET /dashboard/content/pages/{page_id}/markdown

All four require dashboard auth — a Bearer PAT or a dashboard session — and are also served under the project-scoped prefix /api/v1/dashboard/projects/{project_id}/content/....

Embedding a component in Markdown

The converter understands one directive beyond standard Markdown. A line of this form:

:::component{slug=pricing-table version=1.2.0 props={"tier":"pro","currency":"EUR"}}

becomes a live component in the body — the same component the page editor inserts, rendered through the same path. This is what lets a Markdown file carry an interactive block instead of only prose.

The rules the parser enforces:

  • The directive must be the whole line, opening with :::component{ and closing with }.

  • slug is required and its value may not contain spaces. Without a resolvable slug, the line is not a directive and stays as literal text.

  • version is optional. Omit it to track the component's current published version.

  • props is optional and must be the LAST attribute on the line, because its value is JSON and may itself contain spaces and braces.

  • Malformed props JSON is dropped, not fatal. The component still renders — with its default props — rather than failing the whole import.

In a doc, the directive becomes an embedded component node inside the body. In a page, it becomes a first-class component block, and the prose around it becomes rich_text blocks.

POST /dashboard/content/docs/import

Create a new doc from Markdown, or replace an existing doc's body.

Parameters (JSON body; unknown fields are rejected)

Name

Type

Required

Meaning

markdown

string

required

The Markdown source. Capped at 262,144 characters.

doc_id

UUID

optional

The doc to replace. Omit to create a new doc.

slug

string

on create

Flat slug, ^[a-z0-9][a-z0-9-]*$. Ignored on update — a slug is immutable once set.

title

string

on create

Also updates the title when set on an existing doc.

parent_id

UUID

optional

The section this doc belongs under.

is_section

boolean

optional

Whether this row is a section rather than a page.

sort_order

integer

optional

Position within the parent.

The standard SEO fields (seo_title, seo_description and the rest of the set) are accepted here too, and are applied exactly as they are on a normal doc create or update.

A leading # H1 is consumed as the title. Export prepends # {title}, so if the importer kept that H1 in the body the title would accumulate on every round trip. An explicit title argument wins; the H1 only fills the gap. This is why you can export a doc, edit it, and import it back without the file growing a heading each cycle.

curl -X POST "https://spideriq.ai/api/v1/dashboard/content/docs/import" \
  -H "Authorization: Bearer $CLIENT_ID:$API_KEY:$API_SECRET" \
  -H "Content-Type: application/json" \
  -d '{
    "slug": "rate-limits",
    "title": "Rate limits",
    "parent_id": "6f1caa20-9b47-4d0e-9f2a-71c3e58ab104",
    "markdown": "Every endpoint is rate limited.\n\n## Defaults\n\n- 100 requests per minute\n"
  }'

Response — the created or updated doc, including its id, slug, full_path and the converted Tiptap body. On a preview call (?dry_run=true) you get the preview envelope and a confirm_token instead, and nothing is written.

Gating is asymmetric here, and it is the one thing to know before scripting against it. Creating a doc delegates to the ungated create path — it happens immediately. Replacing a doc's body delegates to the gated update path: pass ?dry_run=true to receive a confirm_token plus a preview without mutating anything, then repeat the call with ?confirm_token=… to apply it. Omit both and the update applies directly.

Errors:

Status

When

Resolution

422

Creating with no title argument and no leading # H1

Supply title, or start the Markdown with a single # Heading.

422

markdown empty, or over 262,144 characters

Split the source across several docs.

422

slug does not match ^[a-z0-9][a-z0-9-]*$, or an unknown field is present

Lowercase letters, digits and hyphens only; check field names against the table above.

404

No doc with that doc_id in this tenant

Confirm the id and that you are authenticated against the right workspace.

409 / 410 / 403

A confirm_token already consumed, expired, or issued for a different action

Re-run with ?dry_run=true for a fresh token and apply it promptly.

POST /dashboard/content/pages/import

Create a new page from Markdown, or replace an existing page's blocks.

Parameters (JSON body; unknown fields are rejected)

Name

Type

Required

Meaning

markdown

string

required

The Markdown source. Capped at 262,144 characters.

page_id

UUID

optional

The page whose blocks to replace. Omit to create.

slug

string

on create

Flat slug, ^[a-z0-9][a-z0-9-]*$. Ignored on update.

title

string

on create

Also updates the title when set on an existing page.

template

string

optional

Page template, create only. Defaults to default.

parent_id

UUID

optional

Parent folder.

sort_order

integer

optional

Position within the parent.

Prose runs become rich_text blocks and each :::component{...} directive becomes a component block, so a Markdown file maps onto the same block structure the visual editor produces.

Unlike the doc importer, both paths are gated here. Create and replace both delegate to the gated page service, so dry_run and confirm_token apply to either.

curl -X POST "https://spideriq.ai/api/v1/dashboard/content/pages/import?dry_run=true" \
  -H "Authorization: Bearer $CLIENT_ID:$API_KEY:$API_SECRET" \
  -H "Content-Type: application/json" \
  -d '{
    "slug": "pricing",
    "title": "Pricing",
    "markdown": "Simple pricing.\n\n:::component{slug=pricing-table props={\"currency\":\"EUR\"}}\n"
  }'

Response — with dry_run=true, a preview envelope carrying the planned blocks and a confirm_token. Without it, the created or updated page including its id, slug and the converted blocks array.

Errors:

Status

When

Resolution

422

Creating without slug, or without a title from either the argument or a leading # H1

Supply both when creating.

422

markdown empty, over 262,144 characters, or an unknown field is present

Trim the source; check field names against the table above.

404

No page with that page_id in this tenant

Confirm the id and the workspace you authenticated against.

409 / 410 / 403

A confirm_token already consumed, expired, or issued for a different action

Re-run with ?dry_run=true for a fresh token.

GET /dashboard/content/docs/{doc_id}/markdown

Return one doc's body as pure Markdown, with the title prepended as an H1.

Embedded component nodes are serialized back to :::component{...} directives, so the output round-trips through the doc importer unchanged.

curl "https://spideriq.ai/api/v1/dashboard/content/docs/$DOC_ID/markdown" \
  -H "Authorization: Bearer $CLIENT_ID:$API_KEY:$API_SECRET"

Responsetext/markdown, not JSON. The Content-Disposition header names the file {slug}.md.

This is a different endpoint from GET /docs/{id}/export?format=md, which wraps the same body in a JSON envelope with export metadata. Use /markdown when you want a file to write to disk; use /export when you want the envelope.

Errors:

Status

When

Resolution

404

No doc with that id in this tenant

Check the id; list your docs to confirm it exists in this workspace.

401 / 403

Missing or wrong-workspace credentials

Authenticate with a PAT scoped to the workspace that owns the doc.

GET /dashboard/content/pages/{page_id}/markdown

Return one page's blocks as pure Markdown. Component blocks are serialized back to :::component{...} directives.

curl "https://spideriq.ai/api/v1/dashboard/content/pages/$PAGE_ID/markdown" \
  -H "Authorization: Bearer $CLIENT_ID:$API_KEY:$API_SECRET"

Responsetext/markdown, not JSON, with the title as a leading H1 and one Markdown run per block.

Component serialization is on for this endpoint specifically. Elsewhere on the platform — the .md mirror of a public URL, and llms-full.txt — it stays off, so that what an LLM crawler reads is prose rather than directive syntax. That is a deliberate split: this endpoint exists to round-trip, those exist to be read.

Errors:

Status

When

Resolution

404

No page with that id in this tenant

Check the id; list your pages to confirm it exists in this workspace.

401 / 403

Missing or wrong-workspace credentials

Authenticate with a PAT scoped to the workspace that owns the page.

Agent and CLI surface

The same four operations, through the two agent surfaces:

Surface

Import

Export

MCP

content_import_markdown

content_export_markdown

CLI

spideriq content markdown:import

spideriq content markdown:export

Both agent tools take a target of doc or page rather than splitting into separate tools, and both carry the same create-or-replace semantics as the REST endpoints.

spideriq content markdown:import doc --slug rate-limits --title "Rate limits" --file ./rate-limits.md
spideriq content markdown:export doc "$DOC_ID" > rate-limits.md

Known constraints

  • The props attribute must come last on a :::component{...} line. Anything after it is read as part of the JSON.

  • Malformed props JSON is silently dropped. The import succeeds and the component renders with defaults. If props do not take effect, validate the JSON before assuming the component is at fault.

  • A slug is immutable. Passing a different slug on an update does not rename anything — the field is ignored.

  • Import replaces, it does not merge. Setting doc_id or page_id overwrites the whole body or block list with what your Markdown produced.

  • An unrecognized template is not rejected. It is accepted at import and then falls back to the default page template when the page renders, so a typo shows up as a page that looks plain rather than as an error.

  • The size cap counts characters, not bytes. Multi-byte text reaches the 262,144 limit later than an equivalent byte count would suggest.

Next steps

Publish