Import the doc
https://forumulate.com/api /orgs/{orgId}/spaces/{spaceId}/docs/import Writes a batch of pages from Markdown or block JSON in one transaction, creating the collections they need. The door a migration or a git sync drives; it reports what it would do first if you ask for a dry run.
Requires token scope: write (the coarse method gate) for Atlas key (ak_), Personal Access Token (cft_) or OAuth (cfo_) auth — in addition to any catalog scope (GET /pats/scopes) the route’s capability needs. Session/cookie auth is governed by RBAC capabilities instead.
onExisting is fail | replace | skip | rename | duplicate and defaults to replace (this importer syncs authored files per file, and the first import of a repo routinely lands on the starter page a new space ships with). An unrecognised value is now a 422 — it used to fall through to replace, so a typo chose the destructive option. fail refuses to write over an existing page, counts it in failed, lists its slug in refused[], and keeps prune from deleting it.
On the multipart doors, dryRun and the rest are read exactly as strictly as the JSON fields of the same name: true/false/1/0/yes/no/on/off, and anything else is a 422. dryRun=maybe used to run a REAL import.
Parameters
orgIdstringpathrequiredspaceIdstringpathrequiredforumulate-versionstringheaderforumulate-version: 2026-12-01 on every request from a new integration; it is optional only so that integrations written before versioning keep working. Each version includes everything before it:Idempotency-Keystringheader409. The key is scoped to the method and path, so the same key on a different endpoint is a different request. Error responses of 5xx are never cached — those retries re-run. A key is bound to the BODY it was first used with: reusing it with a different body is a 409 idempotency.key_reused, not a replay, because replaying would tell you a write landed that never ran. Generate one key per request, never per process or per session.Request body application/json
A Markdown file may declare its own address. Two frontmatter keys are read, and unknown keys are still ignored:
slug:— the address this file lands at, instead of the one its filename spells. Slugified, soBilling & Plansandbilling-and-plansare the same address; a value with nothing a URL can hold in it is refused with the file named, never quietly renamed. Two files declaring one slug is a422.redirect_from:— the addresses this page REPLACES, comma-separated, each rooted and relative to the space's mount (/old-page, never/docs/old-page— the reader prepends the mount itself). The import writes a redirect rule for each one and moves the page that answered there to the trash, because a rule alone leaves the old page in the sitemap,llms.txtand search. Both are reported on the response asredirectsandretired. A self-redirect, a duplicate or a loop is a422, exactly as it is onPUT …/redirects; an operator's own manual rule for that address is left alone.
filesobject[]requiredShow child parameters
namestringrequiredcontentstringrequiredcollectionstringcollectionIdstringreplaceCollectionbooleanprune for sync.onExistingstringreplace (the default) updates the existing page in place; fail writes nothing over it and lists its slug under refused (the default of POST …/openapi/import, offered here so one explicit value makes both importers behave identically); skip leaves it untouched; rename is the ONLY value that auto-suffixes to a free slug like getting-started-2 (opt-in — a page at a URL nobody wrote is the worst docs-as-code outcome); duplicate is the legacy spelling of rename. An unrecognised value is a 422, never a silent replace.prunestringcollection scopes to the target collection; space to the whole space.dryRunbooleanimported counts creates AND replaces, so imported+skipped+failed == files.length and replaced is a subset of imported), a per-file plan, cross-collection collisions, and the prunedSlugs a prune would remove — and writes nothing. dryRun: true sets dryRun on the response.{
"files": [
{
"name": "string",
"content": "string"
}
],
"collection": "string",
"collectionId": "string",
"replaceCollection": true,
"onExisting": "fail",
"prune": "space",
"dryRun": true
}cURL
curl -X POST "https://forumulate.com/api/orgs/{orgId}/spaces/{spaceId}/docs/import" \
-H "forumulate-version: <value>" \
-H "Idempotency-Key: <value>" \
-H "Content-Type: application/json" \
-d '{"files":[{"name":"string","content":"string"}],"collection":"string","collectionId":"string","replaceCollection":true,"onExisting":"fail","prune":"space","dryRun":true}'TypeScript
const res = await fetch('https://forumulate.com/api/orgs/{orgId}/spaces/{spaceId}/docs/import', {
method: 'POST',
headers: {
'forumulate-version': '<value>',
'Idempotency-Key': '<value>',
'Content-Type': 'application/json',
},
body: JSON.stringify({"files":[{"name":"string","content":"string"}],"collection":"string","collectionId":"string","replaceCollection":true,"onExisting":"fail","prune":"space","dryRun":true}),
});
const data = await res.json();Python
import requests
res = requests.request(
"POST",
"https://forumulate.com/api/orgs/{orgId}/spaces/{spaceId}/docs/import",
headers={
"forumulate-version": "<value>",
"Idempotency-Key": "<value>",
"Content-Type": "application/json",
},
json={"files": [{"name": "string", "content": "string"}], "collection": "string", "collectionId": "string", "replaceCollection": True, "onExisting": "fail", "prune": "space", "dryRun": True},
)
data = res.json()Go
package main
import (
"fmt"
"io"
"net/http"
"strings"
)
func main() {
req, _ := http.NewRequest("POST", "https://forumulate.com/api/orgs/{orgId}/spaces/{spaceId}/docs/import", strings.NewReader(`{"files":[{"name":"string","content":"string"}],"collection":"string","collectionId":"string","replaceCollection":true,"onExisting":"fail","prune":"space","dryRun":true}`))
req.Header.Set("forumulate-version", "<value>")
req.Header.Set("Idempotency-Key", "<value>")
req.Header.Set("Content-Type", "application/json")
res, _ := http.DefaultClient.Do(req)
defer res.Body.Close()
body, _ := io.ReadAll(res.Body)
fmt.Println(string(body))
}PHP
<?php
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, 'https://forumulate.com/api/orgs/{orgId}/spaces/{spaceId}/docs/import');
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'POST');
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_HTTPHEADER, ['forumulate-version: <value>', 'Idempotency-Key: <value>', 'Content-Type: application/json']);
curl_setopt($ch, CURLOPT_POSTFIELDS, '{"files":[{"name":"string","content":"string"}],"collection":"string","collectionId":"string","replaceCollection":true,"onExisting":"fail","prune":"space","dryRun":true}');
$response = curl_exec($ch);
curl_close($ch);
echo $response;Ruby
require 'net/http'
require 'uri'
uri = URI('https://forumulate.com/api/orgs/{orgId}/spaces/{spaceId}/docs/import')
req = Net::HTTP::Post.new(uri)
req['forumulate-version'] = '<value>'
req['Idempotency-Key'] = '<value>'
req['Content-Type'] = 'application/json'
req.body = '{"files":[{"name":"string","content":"string"}],"collection":"string","collectionId":"string","replaceCollection":true,"onExisting":"fail","prune":"space","dryRun":true}'
res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == 'https') do |http|
http.request(req)
end
puts res.bodyJava
import java.net.URI;
import java.net.http.*;
import java.net.http.HttpRequest.BodyPublishers;
HttpClient client = HttpClient.newHttpClient();
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("https://forumulate.com/api/orgs/{orgId}/spaces/{spaceId}/docs/import"))
.header("forumulate-version", "<value>")
.header("Idempotency-Key", "<value>")
.header("Content-Type", "application/json")
.method("POST", BodyPublishers.ofString("{\"files\":[{\"name\":\"string\",\"content\":\"string\"}],\"collection\":\"string\",\"collectionId\":\"string\",\"replaceCollection\":true,\"onExisting\":\"fail\",\"prune\":\"space\",\"dryRun\":true}"))
.build();
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
System.out.println(response.body());Responses
files is how many files the request carried — the denominator. created (new pages), replaced (existing pages rewritten in place), skipped (left untouched) and failed (could not be turned into a page) are DISJOINT and satisfy created + replaced + skipped + failed === files. imported is NOT "files processed": it is a convenience total meaning created + replaced — the pages this run actually wrote — so it EXCLUDES skipped and failed, and it OVERLAPS replaced. That overlap is why three files all rewritten answer files: 3, created: 0, replaced: 3, imported: 3: three pages, not six. Sum the disjoint four; never add imported to replaced. (An earlier version of this page documented imported + replaced + skipped + failed === files.length; that invariant is withdrawn — it stopped being true once a replace began counting as an import.) collisions/prunedSlugs/plan/dryRun appear when the request asks for them. redirects and retired appear when a file DECLARED an address change: see the request body for slug: and redirect_from:. redirects lists the rules the run wrote into the space's redirect table; retired lists the slugs it moved to the trash because a rule took over their address (reversible with POST …/{pageId}/restore). On a dry run both say what WOULD happen and nothing is written.Example
{
"brokenLinks": [
{
"pageSlug": "string",
"url": "string"
}
],
"collection": {
"created": true,
"exists": true,
"id": "string",
"name": "string"
},
"collisions": [
"string"
],
"created": 0,
"dryRun": true,
"failed": 0,
"failures": [
{
"file": "string",
"reason": "string"
}
],
"files": 0,
"imported": 0,
"plan": [
{
"action": "create",
"collision": true,
"slug": "string",
"title": "string"
}
],
"pruned": 0,
"prunedSlugs": [
"string"
],
"redirects": [
{
"from": "string",
"to": "string"
}
],
"refused": [
"string"
],
"replaced": 0,
"retired": [
"string"
],
"skipped": 0,
"warnings": [
"tags: \"Launch Day\" is stored as \"launch-day\""
]
}Schema
brokenLinksobject[]Show child parameters
pageSlugstringrequiredurlstringrequiredcollectionobjectShow child parameters
createdbooleanrequiredexistsbooleanrequiredidstringnamestringrequiredcollisionsstring[]creatednumberrequireddryRunbooleanfailednumberrequiredfailuresobject[]Show child parameters
filestringrequiredreasonstringrequiredfilesnumberrequiredimportednumberrequiredplanPlanEntry[]Show child parameters
actionstringrequiredcollisionbooleanrequiredslugstringrequiredtitlestringrequiredprunednumberprunedSlugsstring[]redirectsobject[]Show child parameters
fromstringrequiredtostringrequiredrefusedstring[]replacednumberrequiredretiredstring[]skippednumberrequiredwarningsstring[][]. The same sentences are on the X-Field-Warnings response header. If this is present, something you sent is not what is stored; read the resource back rather than assuming your own value.request.malformed). A value that was understood but is not acceptable is a 422 validation.failed.Example
{
"error": {
"code": "string",
"message": "string",
"details": {},
"docs": "string",
"reason": "string",
"slugs": [
"string"
],
"conflict": {
"etag": "string",
"ops": [
null
]
}
},
"requestId": "string"
}Schema
errorobjectrequiredShow child parameters
codestringrequiredrbac.forbidden.messagestringrequireddetailsobjectdocsstringGET /space-types/{type}/presets. Present on errors over a controlled list, so the answer to "what am I allowed to send?" is in the error itself.reasonstringiat_too_old, iat_in_future, bad_signature, bad_issuer, bad_audience, expired and more. Branch on this, not on message.slugsstring[]space.slug_taken, page.slug_taken, collection.slug_taken, site.slug_taken or badge.slug_taken.conflictobjectPATCH …/entries/{entryId}/fields) whose If-Match was stale.Show child parameters
etagstringrequiredopsarrayrequiredIf-Match that touch a path your own operations touch. Empty: none did — retry the batch unchanged under etag. null: the history no longer reaches your tag — re-read the entry first.requestIdstringx-request-id and x-forumulate-request-id response headers. Quote it in a bug report: it is the only thing that finds your exact request in our logs. It is in the body because that is the part that survives — an SDK that throws the parsed body, a serverless log that keeps the body and drops the headers, a ticket with the JSON pasted in.auth.required). Send a Bearer token or the session cookie.Example
{
"error": {
"code": "string",
"message": "string",
"details": {},
"docs": "string",
"reason": "string",
"slugs": [
"string"
],
"conflict": {
"etag": "string",
"ops": [
null
]
}
},
"requestId": "string"
}Schema
errorobjectrequiredShow child parameters
codestringrequiredrbac.forbidden.messagestringrequireddetailsobjectdocsstringGET /space-types/{type}/presets. Present on errors over a controlled list, so the answer to "what am I allowed to send?" is in the error itself.reasonstringiat_too_old, iat_in_future, bad_signature, bad_issuer, bad_audience, expired and more. Branch on this, not on message.slugsstring[]space.slug_taken, page.slug_taken, collection.slug_taken, site.slug_taken or badge.slug_taken.conflictobjectPATCH …/entries/{entryId}/fields) whose If-Match was stale.Show child parameters
etagstringrequiredopsarrayrequiredIf-Match that touch a path your own operations touch. Empty: none did — retry the batch unchanged under etag. null: the history no longer reaches your tag — re-read the entry first.requestIdstringx-request-id and x-forumulate-request-id response headers. Quote it in a bug report: it is the only thing that finds your exact request in our logs. It is in the body because that is the part that survives — an SDK that throws the parsed body, a serverless log that keeps the body and drops the headers, a ticket with the JSON pasted in.rbac.forbidden, whether what is missing is the capability, the token scope, or a resource the token is pinned away from. The message names the capability and, for a scope refusal, the scopes that would grant it. A refusal because of the PLAN is a 402 with quota.exceeded, not this.Example
{
"error": {
"code": "string",
"message": "string",
"details": {},
"docs": "string",
"reason": "string",
"slugs": [
"string"
],
"conflict": {
"etag": "string",
"ops": [
null
]
}
},
"requestId": "string"
}Schema
errorobjectrequiredShow child parameters
codestringrequiredrbac.forbidden.messagestringrequireddetailsobjectdocsstringGET /space-types/{type}/presets. Present on errors over a controlled list, so the answer to "what am I allowed to send?" is in the error itself.reasonstringiat_too_old, iat_in_future, bad_signature, bad_issuer, bad_audience, expired and more. Branch on this, not on message.slugsstring[]space.slug_taken, page.slug_taken, collection.slug_taken, site.slug_taken or badge.slug_taken.conflictobjectPATCH …/entries/{entryId}/fields) whose If-Match was stale.Show child parameters
etagstringrequiredopsarrayrequiredIf-Match that touch a path your own operations touch. Empty: none did — retry the batch unchanged under etag. null: the history no longer reaches your tag — re-read the entry first.requestIdstringx-request-id and x-forumulate-request-id response headers. Quote it in a bug report: it is the only thing that finds your exact request in our logs. It is in the body because that is the part that survives — an SDK that throws the parsed body, a serverless log that keeps the body and drops the headers, a ticket with the JSON pasted in.*.not_found).Example
{
"error": {
"code": "string",
"message": "string",
"details": {},
"docs": "string",
"reason": "string",
"slugs": [
"string"
],
"conflict": {
"etag": "string",
"ops": [
null
]
}
},
"requestId": "string"
}Schema
errorobjectrequiredShow child parameters
codestringrequiredrbac.forbidden.messagestringrequireddetailsobjectdocsstringGET /space-types/{type}/presets. Present on errors over a controlled list, so the answer to "what am I allowed to send?" is in the error itself.reasonstringiat_too_old, iat_in_future, bad_signature, bad_issuer, bad_audience, expired and more. Branch on this, not on message.slugsstring[]space.slug_taken, page.slug_taken, collection.slug_taken, site.slug_taken or badge.slug_taken.conflictobjectPATCH …/entries/{entryId}/fields) whose If-Match was stale.Show child parameters
etagstringrequiredopsarrayrequiredIf-Match that touch a path your own operations touch. Empty: none did — retry the batch unchanged under etag. null: the history no longer reaches your tag — re-read the entry first.requestIdstringx-request-id and x-forumulate-request-id response headers. Quote it in a bug report: it is the only thing that finds your exact request in our logs. It is in the body because that is the part that survives — an SDK that throws the parsed body, a serverless log that keeps the body and drops the headers, a ticket with the JSON pasted in.Example
{
"error": {
"code": "string",
"message": "string",
"details": {},
"docs": "string",
"reason": "string",
"slugs": [
"string"
],
"conflict": {
"etag": "string",
"ops": [
null
]
}
},
"requestId": "string"
}Schema
errorobjectrequiredShow child parameters
codestringrequiredrbac.forbidden.messagestringrequireddetailsobjectdocsstringGET /space-types/{type}/presets. Present on errors over a controlled list, so the answer to "what am I allowed to send?" is in the error itself.reasonstringiat_too_old, iat_in_future, bad_signature, bad_issuer, bad_audience, expired and more. Branch on this, not on message.slugsstring[]space.slug_taken, page.slug_taken, collection.slug_taken, site.slug_taken or badge.slug_taken.conflictobjectPATCH …/entries/{entryId}/fields) whose If-Match was stale.Show child parameters
etagstringrequiredopsarrayrequiredIf-Match that touch a path your own operations touch. Empty: none did — retry the batch unchanged under etag. null: the history no longer reaches your tag — re-read the entry first.requestIdstringx-request-id and x-forumulate-request-id response headers. Quote it in a bug report: it is the only thing that finds your exact request in our logs. It is in the body because that is the part that survives — an SDK that throws the parsed body, a serverless log that keeps the body and drops the headers, a ticket with the JSON pasted in.validation.failed). error.details names the field paths.Example
{
"error": {
"code": "string",
"message": "string",
"details": {},
"docs": "string",
"reason": "string",
"slugs": [
"string"
],
"conflict": {
"etag": "string",
"ops": [
null
]
}
},
"requestId": "string"
}Schema
errorobjectrequiredShow child parameters
codestringrequiredrbac.forbidden.messagestringrequireddetailsobjectdocsstringGET /space-types/{type}/presets. Present on errors over a controlled list, so the answer to "what am I allowed to send?" is in the error itself.reasonstringiat_too_old, iat_in_future, bad_signature, bad_issuer, bad_audience, expired and more. Branch on this, not on message.slugsstring[]space.slug_taken, page.slug_taken, collection.slug_taken, site.slug_taken or badge.slug_taken.conflictobjectPATCH …/entries/{entryId}/fields) whose If-Match was stale.Show child parameters
etagstringrequiredopsarrayrequiredIf-Match that touch a path your own operations touch. Empty: none did — retry the batch unchanged under etag. null: the history no longer reaches your tag — re-read the entry first.requestIdstringx-request-id and x-forumulate-request-id response headers. Quote it in a bug report: it is the only thing that finds your exact request in our logs. It is in the body because that is the part that survives — an SDK that throws the parsed body, a serverless log that keeps the body and drops the headers, a ticket with the JSON pasted in.Example
{
"error": {
"code": "string",
"message": "string",
"details": {},
"docs": "string",
"reason": "string",
"slugs": [
"string"
],
"conflict": {
"etag": "string",
"ops": [
null
]
}
},
"requestId": "string"
}Schema
errorobjectrequiredShow child parameters
codestringrequiredrbac.forbidden.messagestringrequireddetailsobjectdocsstringGET /space-types/{type}/presets. Present on errors over a controlled list, so the answer to "what am I allowed to send?" is in the error itself.reasonstringiat_too_old, iat_in_future, bad_signature, bad_issuer, bad_audience, expired and more. Branch on this, not on message.slugsstring[]space.slug_taken, page.slug_taken, collection.slug_taken, site.slug_taken or badge.slug_taken.conflictobjectPATCH …/entries/{entryId}/fields) whose If-Match was stale.Show child parameters
etagstringrequiredopsarrayrequiredIf-Match that touch a path your own operations touch. Empty: none did — retry the batch unchanged under etag. null: the history no longer reaches your tag — re-read the entry first.requestIdstringx-request-id and x-forumulate-request-id response headers. Quote it in a bug report: it is the only thing that finds your exact request in our logs. It is in the body because that is the part that survives — an SDK that throws the parsed body, a serverless log that keeps the body and drops the headers, a ticket with the JSON pasted in.Example
{
"error": {
"code": "string",
"message": "string",
"details": {},
"docs": "string",
"reason": "string",
"slugs": [
"string"
],
"conflict": {
"etag": "string",
"ops": [
null
]
}
},
"requestId": "string"
}Schema
errorobjectrequiredShow child parameters
codestringrequiredrbac.forbidden.messagestringrequireddetailsobjectdocsstringGET /space-types/{type}/presets. Present on errors over a controlled list, so the answer to "what am I allowed to send?" is in the error itself.reasonstringiat_too_old, iat_in_future, bad_signature, bad_issuer, bad_audience, expired and more. Branch on this, not on message.slugsstring[]space.slug_taken, page.slug_taken, collection.slug_taken, site.slug_taken or badge.slug_taken.conflictobjectPATCH …/entries/{entryId}/fields) whose If-Match was stale.Show child parameters
etagstringrequiredopsarrayrequiredIf-Match that touch a path your own operations touch. Empty: none did — retry the batch unchanged under etag. null: the history no longer reaches your tag — re-read the entry first.requestIdstringx-request-id and x-forumulate-request-id response headers. Quote it in a bug report: it is the only thing that finds your exact request in our logs. It is in the body because that is the part that survives — an SDK that throws the parsed body, a serverless log that keeps the body and drops the headers, a ticket with the JSON pasted in.