Importing documentation
Four ways in, all of them landing pages in a space you already have.
Call | Takes |
|---|---|
| JSON: |
| multipart, field |
| JSON: |
| multipart, one Word file |
All four need the docs.write capability. /import/url fetches a GitHub archive and then runs the ZIP importer, so everything below about ZIP applies to it too.
The options
Field | Default | What it does |
|---|---|---|
| detected | Which tool the export came from. Detected from signature files; override it if detection gets it wrong. |
| the default collection | Where pages land. |
|
|
|
|
| Soft-delete every live page in scope the run did not touch. |
|
| Return a per-file |
onExisting defaults to replace on purpose: rename is the only value that auto-suffixes, and a page at a URL nobody authored is the worst outcome for a docs-as-code pipeline.
Run with dryRun: true first. It costs one call and tells you exactly which slug every file is about to take.
What a ZIP import skips
Archive junk, by path segment or filename:
Pattern | What it is |
|---|---|
| macOS resource-fork directory. |
| AppleDouble sidecars. The real file is in the archive too, so skipping these loses nothing. |
| Repository internals. |
| macOS. |
| Windows. |
| Windows. |
Two more are skipped inside the import loop — on every door (ZIP, URL, and the JSON files[] body of POST …/docs/import):
Pattern | Why |
|---|---|
| It is read as navigation and then not imported as a page. Its ordering becomes your tree. |
| Notion database exports. They are tables of rows, not pages. |
It is a segment match, not a dotfile rule. .gitbook is deliberately not caught by the .git test, because .gitbook.yaml is how a GitBook export is recognised at all, and GitBook keeps every image under .gitbook/assets/. Skipping all dotfiles would make a GitBook export unimportable.
What it imports
Extension | Becomes |
|---|---|
| A page. Front matter is stripped and its |
| A page, converted from HTML. |
| A page, converted from Word, in a second pass. |
| An asset. Re-hosted on our storage, and every reference to it in your pages is rewritten to the new URL. |
anything else | Silently dropped. |
.gitbook/assets/* imports as images, which is what you want. .gitbook.yaml itself is read for flavour detection and then dropped — it is configuration, not content.
An image is uploaded once however many pages reference it.
Limits
Upload size | 64 MiB. Over that is a 413, refused before the handler runs. |
Files per request | One. |
Fetched archive ( | 64 MiB. Over that is a 413 naming the limit. A |
Whole-import deadline | 45 seconds for fetch, unzip, image uploads and writes together. Exceeding it answers a retryable 503. |
Blocks per page | 100,000 nodes, nesting depth 64. |
Errors you may see:
Status | Meaning |
|---|---|
400 | No file in the request. |
404 | The |
413 | Over 64 MiB. |
422 |
|
503 | Over the 45-second deadline. Retry, or split the import. |
Reading the result
{
"files": 40,
"created": 31,
"replaced": 7,
"skipped": 2,
"failed": 0,
"imported": [ … ],
"brokenLinks": [ … ],
"pruned": 0,
"prunedSlugs": [],
"collisions": [],
"collection": "…"
}Two fields mean something other than what they look like, and both have cost people time:
skipped is not "files we ignored". It is the count of existing pages left untouched because you passed onExisting: "skip". Junk and unknown extensions are not counted here.
files is the count after the junk filter. A ZIP of 100 entries where 60 were __MACOSX sidecars reports files: 40. The invariant created + replaced + skipped + failed === files holds over the surviving entries only.
Nothing in the response names what was skipped as junk. There is no field for it — not for the archive junk, not for SUMMARY.md, not for .csv, not for an unrecognised extension. If your page count is lower than your file count, run with dryRun: true and compare the plan[] against your own listing of the archive. failed only counts files that reached the writer and could not be converted.
Markdown flavour: the same on every /docs/import door
Every Markdown door converts through one function. The JSON /docs/import route, /docs/import/zip, /docs/import/url and the .docx door all share it, so the same file lands as the same blocks whichever one you hand it. That includes the normaliser that translates {% hint %} blocks, :::note containers, GitBook tabs, Mintlify/MDX components such as <Note>, HTML <details> and reference footnotes ([^1] with its [^1]: … definition).
The JSON route is not the weak one. If you have read anywhere — including an older copy of this page — that /docs/import does not normalise and that you should send a ZIP instead, that is out of date: the normaliser was once wired to the ZIP reader only, and it has not been for some time. You do not have to pre-translate anything for any of the four doors, and there is no reason to prefer one of them for conversion fidelity.
The container admonitions have moved further in still: :::note, :::warning Some title, ::::warning and :::details Some title are read by the Markdown converter itself, not by the importer, so they behave the same through every door there is — the GitHub/GitLab sync, PUT …/sections/:key and the editor's Markdown source view included.
The GitHub/GitLab sync still differs on a competitor's dialect (POST …/github/import and the synced connection): it does not translate {% hint %}, <Note>, MDX import lines or YAML front matter. That is deliberate rather than missing — a sync is a two-way mirror and writes Markdown back to your repository, so rewriting your files into our spelling is something an import may do and a mirror may not. If your repo is a GitBook or Mintlify export, import it once through /docs/import/zip or /docs/import/url rather than connecting it as a synced repo.
It does write one line of its own: a page's title goes out as the file's leading # …, because a title is a column rather than a block and a file without it came back named after its filename. The sync reads that line back as the title on the next pull, so the mirror round-trips in both directions.
Code groups and code-sample comments. MDX <CodeGroup> (Mintlify) and <CodeBlocks> (Fern) become a code group — tabs in one code header — through every import door; inside one, Mintlify's ```bash npm (a bare word after the language) is read as the tab's title. A Mintlify export also has two or more adjacent titled fences grouped the same way. Docusaurus // highlight-next-line / highlight-start and Shiki-style // [!code ++], [!code focus], [!code word:x] comments become highlighted, inserted, focused and marked lines and are removed from the code; see "Magic comments" in the Markdown guide.
Importing a page you exported from here
Export and import agree about the two things a stored page does not keep in its body:
the title is the first
# …line. An export writes it; an import reads it and takes it out of the body. A body that opens with its own copy of the title is not written twice and comes back as the title.the
## For AI agentsfooter is ours. The default form of every.mdaddress ends in one, and an import recognises and drops it rather than publishing it as four blocks of your content. A section you wrote yourself with that heading is left alone.
Ask for ?form=source when the result will be written back: that form resolves no {{variables}}, inlines no snippets and carries no footer. Forgetting it is no longer expensive, but it is still the right form for a pipeline.
Markdown in Forumulate has the full construct-by-construct matrix and the round-trip rules, including the two things Markdown cannot spell.
Other importers
Call | What it brings in |
|---|---|
| A repository path, on demand. |
| A connection that keeps syncing. |
| A whole organization, from another Forumulate export. |
| An OpenAPI document, as a reference section. |
| Merges pages whose titles collide after an import. Dry-run by default — send |
Importing a forum
POST /orgs/{orgId}/import restores community spaces as well as docs. Rows are matched by slug and skipped when they already exist, so a re-run never duplicates. Everything skipped or changed comes back in summary.warnings (path + message, at most 200), never only in a log.
Level | Fields carried |
|---|---|
Document |
|
Space |
|
Topic |
|
Post |
|
Authors resolve in this order: an authorId that is a member here; the member whose address members[].email gives; for an address with no account, a staged placeholder (no password, not a member, claimed when that person signs up); and an account that exists but is outside this organization is credited by name only, with a warning — an import never puts words in the mouth of somebody who is not here. A post with no authorId keeps its authorName as a guest post.
Dates. Each post keeps its createdAt (clamped to now; an unreadable date is a warning and becomes now). The topic is dated by its opening post, its last activity by its newest post, and posts are numbered by date.
Bodies. A post whose content is not a block document and that sends no markdown is skipped with a warning — never imported as an empty post. Pages follow the same rule.
Reactions are rows. A counts-only map such as {"heart": 12} is refused with a warning: the next live reaction recounts from rows and would wipe a bare number.
Poll votes. Each poll may carry votes[], one entry per voter (or per choice):
Field | Meaning |
|---|---|
| The voter, resolved exactly like a post author. A vote by an account outside this organization is skipped with a warning, never attributed to that person. |
| The chosen option's text (a number poll's value, such as |
| Several choices at once: a multi-select ballot, or a whole ranking in order. |
| The 0-based position of the option, instead of its text. |
| On a ranked poll, this choice's place (1 = first). |
Votes are restored as they were, even on a poll that is closed, past its close date or limited to a group. A voter is counted once per poll. A single-choice poll keeps the first choice of a voter who sent several. A ranking is renumbered 1, 2, 3… with no gaps. A choice that matches no option is skipped, and the summary reports how many. summary.pollVotesCreated counts the votes that were written. GET /orgs/{orgId}/export writes votes[] in this shape, by userId and option text.
Large imports run in the background. POST /orgs/{orgId}/import runs the import while you wait, so it only suits documents that finish within about 30 seconds (roughly 200 topics). For anything larger, queue it:
POST /orgs/{orgId}/imports # same body → 202 { "job": { "id", "status": "queued", "progress": { "done": 0, "total": 3120 } } }
GET /orgs/{orgId}/imports/{id} # { "job": { "status": "running", "progress": { "done": 1450, "total": 3120 } } }
GET /orgs/{orgId}/imports # the 50 most recent jobstotal counts one unit per page and per topic in the document. status moves from queued to running to succeeded (with summary, the same summary the synchronous call returns) or failed (with error). The worker runs the job, so it keeps going if the browser is closed, and the stored document is deleted when the job ends. The console's Settings → Your data → Import from file always queues the import and shows its progress.
GET /orgs/{orgId}/export produces exactly this shape, so an export of one organization is an import into another. For seeding a new forum, see Seeding a forum with real authors.
The forum's look comes with it
A space the import creates gets its look back from spaces[].settings: theme, chrome, announcement and readerThemes (never readerAuth, whose secrets an export redacts). A space that already existed keeps its own look.
The theme is restored as one composition of keys — list, topic, profile and per-category keys alike; a look (forum.look) inside it is only a patch of those same keys, so what you exported is what renders. Per-category entries (forum.categoryOverrides, forum.topic.byCategory) are keyed by category slug and are checked after the categories land: a slug no imported category has (renamed in the file, or kept out by the plan limit) comes back in summary.warnings — for example spaces[0].settings.theme.forum.categoryOverrides.anouncements — instead of being stored and silently never applied. On a live theme write the same slug is a 422 (see Per-category overrides name real categories).
Mix and match on import
The theme in an import file is an ordinary theme body, so the same building blocks combine there. Scopes: list, topic and profile keys are theme-wide; categoryOverrides and byCategory entries are category-scoped. Two compositions that no built-in look uses:
A forum whose home is the dense table, whose ideas category reads as cards, and whose profiles show one line of numbers and a Badges tab:
{
"format": "forumulate-org-export",
"spaces": [
{
"slug": "community",
"name": "Community",
"type": "FORUM",
"visibility": "PUBLIC",
"settings": {
"theme": {
"forum": {
"list": { "anatomy": "table" },
"categoryOverrides": { "ideas": { "list": { "anatomy": "cards" } } },
"profile": { "statsStyle": "inline", "tabs": ["summary", "badges", "activity"] }
}
}
},
"categories": [
{ "slug": "help", "name": "Help" },
{ "slug": "ideas", "name": "Ideas" }
],
"topics": []
}
]
}The same file with Q&A help topics — a solid green "Solved" header on help only — and dated top lists on profiles; the qa look patches the list, the category entry patches only help topics:
{
"forum": {
"look": "qa",
"topic": {
"answerPlacement": "excerpt",
"byCategory": { "help": { "answerStyle": "header", "answerColor": "success" } }
},
"profile": { "itemMeta": ["date", "likes"] }
}
}(That second block is the settings.theme value only; put it where the first file has its theme.)
Importing an OpenAPI document
The URL may serve JSON or YAML, under any of the usual content types (application/json, application/yaml, text/yaml, application/x-yaml). An inline spec field and a multipart import-file upload take either dialect too. The document itself must look like an OpenAPI one: openapi (or swagger) plus at least one of paths, components or webhooks.
includeTags and excludeTags filter by tag. Matching is case-insensitive and * is the only wildcard, so internal:* and *:webhook both work.
An
includeTagsvalue that matches none of the spec's tags is refused with422, naming the value and listing the tags the spec does have — an allowlist that matches nothing would import a landing page and no reference under it.An
excludeTagsvalue that matches none of them is only a warning; the import proceeds, so a standing exclude list reused across several specs is fine.Filters that match but leave zero operations are a warning too, not a refusal.
dryRun: true gives the same answer the real import would: the same refusals, the same warnings, and the slugs it would write. That agreement is the point — a dry run that warns where the real run refuses is worse than no dry run at all, because it tells you to go ahead.
Existing pages. onExisting: "replace" publishes over a page at a planned slug only when that page is in the target collection, or is one the registered spec (specId) already owns — wherever an author has since moved it. A page filed in any other collection is a 409 page.slug_taken naming every such slug, and nothing is written. The answer's replacedSlugs lists each page that was replaced, and a warning names them too (up to 20). skip is never refused: skipping somebody else's page is harmless. POST …/docs/openapi/generate applies the same rule: a page outside its collection that an earlier generation did not write is a 409 before anything is published.
Option | Values | Unset means | What it does |
|---|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
A fresh import puts the tags at the top (R5-018). When a spec's tags would all sit under one page named for the spec ("Endpoints › Emails › Send email"), the API collection opens on a lone group that holds everything. So an import with landing unset lays the tags out at the collection's top level ("Emails › Send email") whenever it is fresh: no page the spec already owns and no page at a slug the landing plan would write. A re-import over an existing reference keeps its landing page, because landing is an address rule; use relayout: true (below) to move an existing reference to the flat layout. To keep a landing page on a fresh import, send "landing": true. When the import decided this on its own, the answer carries "landing": false and a registered spec records it, so every resync plans the same tree.
All three are recorded on a registered spec, so a bodiless resync plans the same addresses. The collections tagPages: "collection" used are recorded too, and the resync's prune covers exactly those collections and the target.
Re-lay-out an existing reference (relayout). Changing landing, tagPages or slugStyle on a plain re-import plans NEW addresses and writes a second reference beside the first. To change the shape of the one you have, send the same import with relayout: true (mode: "per-operation" only; unset = a normal import):
POST /orgs/{orgId}/spaces/{spaceId}/docs/openapi/import
{ "sourceUrl": "https://example.com/openapi.json", "collectionId": "…",
"mode": "per-operation", "landing": false, "relayout": true, "specId": "…" }Operation pages are matched by what they document (method + path, read from their own operation block) and moved and renamed in place: same page id, same body, new parent, collection, position and slug. An operation body is never rewritten.
Only tag and landing pages are created or deleted. A surviving one gets its index regenerated (it lists the pages that moved). A hand-written page filed under a container that goes moves to the top of the reference.
Every slug that changes, and every container that goes, leaves an automatic
308redirect (old path → new; a removed container → the new top page).The pages are found through
specIdwhen given (the pages that spec owns), else as the generated pages in the target collection.An operation in the spec with no page yet is not created (a warning names it; import with
onExisting: "skip"to create it), and a page for an operation the spec no longer has is kept at the top of the reference (a resync prunes it).A planned slug held by a page outside the reference is a
409naming it, and nothing moves — one transaction, like every import.The answer is the usual import summary plus
moved(pages whose parent or collection changed) andrenamed(pages whose address changed). WithdryRun: truenothing is written and the counts say what would happen.The new
landing/tagPages/slugStyleare recorded on the registered spec, so the next resync plans the new layout.
Pages it writes. Every page an import or a generation writes carries metadata.apiPage (landing, tag or operation) and, on an operation page, metadata.apiMethod (GET … TRACE, WEBHOOK, or SEND/RECEIVE for an AsyncAPI channel). The nav draws its method pill from apiMethod, and the SDK panel's landing mode reads apiPage. A reference written before this gets both keys on its next resync — and migration 0374_backfill_api_method stamped every existing page whose body is exactly one operation block, so older references show their method pills without one.
What a reference page shows. Request and response bodies are walked into a field tree — nested objects and arrays of objects to three levels, at most 200 fields per operation — with each field's type, required flag, enum, default, example and deprecation. A response with a schema gets Example and Schema tabs; a oneOf/anyOf with more than one option gets one tab per option (titled by its title, then its $ref name); named content[…].examples get a picker (at most five, 8 KB each). Descriptions are CommonMark: backticks, links and lists render as markdown.
Servers. When an operation (or its path, or the spec) declares more than one server, or a server with {variables}, "Try it" offers a server picker and an input per variable — a select when the variable has an enum. A free-text Custom base URL is always there. The proxy and the reader's connect-src accept every server the picker can produce: each enum value of each variable, else its default.
Webhooks and callbacks. OpenAPI 3.1 webhooks become operations of their own, badged WEBHOOK, grouped under their first tag or under "Webhooks", with the body shown as Payload and no samples or "Try it" (you do not call a webhook; it calls you). Their slug is the operationId, else webhook-<event>. An operation's callbacks are drawn inside it, one level deep.
Re-sync changelog. Register or resync with changelog: { pageId } and every resync that changes the spec prepends a dated update entry (tag api) to that page's first updates block — creating the block if the page has none — listing the operations added, removed and changed (and what changed: params, body, responses, deprecated). The resync's answer carries the same diff as changelog, or null when none is configured. The page id is recorded, so a deploy hook's bodiless resync keeps writing it.
Exports. GET /public/orgs/{orgId}/spaces/{spaceId}/api-export/spec downloads the spec document itself, as last synced (?format=yaml for YAML, ?specId= to pick one) — public spaces only, like the Postman, Insomnia, Bruno and mock-server exports beside it. Set theme.docs.api.specLink: true to link it from every operation.
"Try it" on a public reference
A reference page's Try it console sends the reader's request through a relay, because a browser cannot call your API directly across origins. The relay is unauthenticated and only answers for public spaces:
POST /public/orgs/{orgId}/spaces/{spaceId}/openapi-proxy
{
"targetUrl": "https://api.example.com/v1/widgets",
"method": "GET",
"headers": { "Authorization": "Bearer <the reader's own key>" }
}The response is { "response": { "status", "headers", "body" } }, with only the content-type header returned and the body capped at 1,000,000 bytes. You do not call this yourself; the console does. What matters when you author a reference is what it refuses:
The target origin must be one the space declares as an API server: the
serversof an imported spec or the address it was imported from, the servers of a hand-written OpenAPI block on a published page, or an origin listed in the space'stheme.apiOrigins. Anything else is a 403 "not a registered API server", so add the server to your spec if Try it says that.Private, loopback and link-local addresses are refused (403).
The space must be public (a private space is a 404), the method must be one of
GET,POST,PUT,PATCH,DELETE,HEADorOPTIONS(anything else is a 422), and a request body over 1,000,000 bytes is a 413.Redirects are never followed, and the upstream gets 12 seconds before the relay answers 504 (502 if it cannot be reached).
The reader's own Authorization or Cookie header is forwarded to your API and nowhere else.
AsyncAPI
An AsyncAPI 2.x or 3.x document is accepted by the same doors (URL, inline spec, import-file, register and resync). Each channel operation becomes an operation page badged SEND or RECEIVE, with the message payload as its Payload (several messages become one tab each) and no "Try it". Planning, ownership, prune and redirects work exactly as for OpenAPI.
api: in front matter
A markdown file whose front matter says api: POST /v1/refunds (or Mintlify's openapi: "openapi.json POST /v1/refunds") gets that operation's block as its first block, resolved from a spec registered in the space, and metadata.api set. Setting metadata.api on any page does the same to its draft (replacing a leading operation block rather than adding a second). An operation no registered spec declares is an X-Field-Warnings entry, not a refusal.
Docs blocks (wave D): the MDX map
Every import door converts these MDX components (a tag opens and closes on its own line; code is never touched). Anything else capitalised stays as text and the import says so on X-Field-Warnings.
MDX | Becomes |
|---|---|
| a |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| the picture inside, with the caption as its title |
| annotation, badge, icon, kbd marks |
|
|
GitBook: {% tabs %} / {% tab title %} become a real tab group (they were flattened to bold titles), and {% code title="x" lineNumbers="true" %} keeps its title and line numbers on the fence inside it.
Importers for every other space type
This page covers docs. Every other importer is collected in Importers: help desks (Zendesk, Intercom, Help Scout), status pages (Statuspage, Instatus, Better Stack), blogs (WordPress, Ghost, Substack, Medium), changelogs, roadmaps (Canny, Featurebase), forums (Discourse, phpBB, vBulletin, Flarum, Discord), events, courses, and whole sites from Webflow or Framer. Each section gives the endpoint, the dry run, what maps to what, the limits and what a second run does.
In the API
Every operation below is also reachable from the other surfaces without a dedicated command. forumulate api <Operation> calls it from the CLI, the JavaScript SDK has a typed method for each one (the last column names it; the Python and Go clients are generated from the same spec), and an agent reaches the organization-scoped ones through the MCP call_api tool. Where a CLI command or MCP tool exists too, the last column names it. Operation names link to the API reference.
Import
Operation | Request | What it does | Shortcuts |
|---|---|---|---|
| Create the helpdesk in a space |
| |
| Create a dedupe title in a space |
| |
| Import the doc |
| |
| Create the zip in a space (docs import) |
| |
| Create the URL in a space (docs import) |
| |
| Create the docx in a space |
|
Open Api
Operation | Request | What it does | Shortcuts |
|---|---|---|---|
| List specs in a space |
| |
| Create a spec in a space |
| |
| Resync the spec |
| |
| Get the resync |
| |
| Get the reference in a space |
| |
| Delete the spec |
| |
| List languages in a space (docs OpenAPI) |
| |
| Get the SDK (docs OpenAPI specs) |
| |
| Get the package in a space (docs OpenAPI specs SDK) |
| |
| Get the lint in a space |
| |
| Create the lint in a space |
| |
| Create an operation in a space |
| |
| Resolve the OpenAPI |
| |
| Generate the OpenAPI |
| |
| Get the generate |
| |
| Import the OpenAPI |
| |
| Import file in a space |
|
Open Api Export Public
Operation | Request | What it does | Shortcuts |
|---|---|---|---|
| Get the spec in a space |
| |
| Get the collection (public reader API export) |
| |
| Get the mock server in a space |
|
Open Api Proxy
Operation | Request | What it does | Shortcuts |
|---|---|---|---|
| Create the OpenAPI proxy in a space |
|
Org
Operation | Request | What it does | Shortcuts |
|---|---|---|---|
| Update the email from in an organization |
| |
| Import an export document into this organization |
| |
| Rename the organization’s slug |
|
Org export
Operation | Request | What it does | Shortcuts |
|---|---|---|---|
| Export the whole organization as one document |
|
Org Import Job
Operation | Request | What it does | Shortcuts |
|---|---|---|---|
| List imports in an organization |
| |
| Queue an import of an export document |
| |
| Queue a Canny or Featurebase roadmap import |
| |
| Get the import |
|