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) |
|
|
Export (Markdown out) |
|
|
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}.slugis required and its value may not contain spaces. Without a resolvableslug, the line is not a directive and stays as literal text.versionis optional. Omit it to track the component's current published version.propsis optional and must be the LAST attribute on the line, because its value is JSON and may itself contain spaces and braces.Malformed
propsJSON 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 |
|---|---|---|---|
| string | required | The Markdown source. Capped at 262,144 characters. |
| UUID | optional | The doc to replace. Omit to create a new doc. |
| string | on create | Flat slug, |
| string | on create | Also updates the title when set on an existing doc. |
| UUID | optional | The section this doc belongs under. |
| boolean | optional | Whether this row is a section rather than a page. |
| 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 | Supply |
422 |
| Split the source across several docs. |
422 |
| Lowercase letters, digits and hyphens only; check field names against the table above. |
404 | No doc with that | Confirm the id and that you are authenticated against the right workspace. |
409 / 410 / 403 | A | Re-run with |
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 |
|---|---|---|---|
| string | required | The Markdown source. Capped at 262,144 characters. |
| UUID | optional | The page whose blocks to replace. Omit to create. |
| string | on create | Flat slug, |
| string | on create | Also updates the title when set on an existing page. |
| string | optional | Page template, create only. Defaults to |
| UUID | optional | Parent folder. |
| 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 | Supply both when creating. |
422 |
| Trim the source; check field names against the table above. |
404 | No page with that | Confirm the id and the workspace you authenticated against. |
409 / 410 / 403 | A | Re-run with |
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"Response — text/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"Response — text/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 |
|
|
CLI |
|
|
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.mdKnown constraints
The
propsattribute must come last on a:::component{...}line. Anything after it is read as part of the JSON.Malformed
propsJSON 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
slugon an update does not rename anything — the field is ignored.Import replaces, it does not merge. Setting
doc_idorpage_idoverwrites the whole body or block list with what your Markdown produced.An unrecognized
templateis 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
Write component directives into your manual: Documentation.
Generate a reference section from a spec instead of authoring it: API Reference Generator.
Browse the components you can embed: Components.
Drive the whole loop from an agent: Content Tools.