Create a document, write content into it, and read it back. Three calls, from a server. For the credential these calls need, see Authentication.
This page uses the service-role key, because writing content requires it. Run these from a backend, never from a browser.
You need two values.
<BASE_URL>— where the REST API answers. Locally that ishttp://localhost:4000. On a server, use yourPUBLIC_RESTAPI_URL.<SUPABASE_SERVICE_ROLE_KEY>— the service-role key from your Supabase project settings.
curl -X POST '<BASE_URL>/api/documents' \
-H 'Authorization: Bearer <SUPABASE_SERVICE_ROLE_KEY>' \
-H 'Content-Type: application/json' \
-d '{
"title": "Quarterly report",
"slug": "quarterly-report"
}'title and slug are required. The slug is normalised, and the server generates a 19-character documentId.
{
"success": true,
"data": {
"documentId": "kR4pZ2mQ7tY1nB8xW3v",
"slug": "quarterly-report"
}
}Keep that documentId. Every content call addresses a document by id, never by slug. A taken slug returns 409 rather than being renamed quietly.
You can also seed content in this one call, by adding a content field holding a Tiptap document. That field is accepted only under the service-role key, and it lands as version 1.
curl -X PATCH '<BASE_URL>/api/documents/<DOCUMENT_ID>/content?mode=replace' \
-H 'Authorization: Bearer <SUPABASE_SERVICE_ROLE_KEY>' \
-H 'Content-Type: application/json' \
-d '{
"content": {
"type": "doc",
"content": [
{
"type": "heading",
"attrs": { "level": 1 },
"content": [{ "type": "text", "text": "Quarterly report" }]
},
{
"type": "paragraph",
"content": [{ "type": "text", "text": "Revenue grew 12%." }]
}
]
}
}'Replace <DOCUMENT_ID> with the id from step 1.
Two modes exist. replace swaps the whole body, and it is the default. append adds your top-level nodes after the existing ones.
A 200 means the content reached the live document, and its save was handed off. People with the document open see the change at once, without reconnecting.
{
"success": true,
"data": {
"documentId": "kR4pZ2mQ7tY1nB8xW3v",
"mode": "replace",
"version": 1
}
}version is the saved version that holds your write. It is missing when the 200 came before the save, as step 3 explains.
curl '<BASE_URL>/api/documents/<DOCUMENT_ID>/content?format=json' \
-H 'Authorization: Bearer <SUPABASE_SERVICE_ROLE_KEY>'{
"success": true,
"data": {
"documentId": "kR4pZ2mQ7tY1nB8xW3v",
"version": 1,
"format": "json",
"content": {
"type": "doc",
"content": [
{
"type": "heading",
"attrs": { "level": 1, "toc-id": "a1B2c3D4e5F6g7H8" },
"content": [{ "type": "text", "text": "Quarterly report" }]
},
{
"type": "paragraph",
"content": [{ "type": "text", "text": "Revenue grew 12%." }]
}
]
}
}
}A document created without content has no saved version, so your first write is version 1. Each later saved version adds one.
Notice the toc-id the server added. That is covered below.
Pass format=text instead to get plain text, one line per block.
To read the document as Markdown, call the export route with format=md. It returns the raw .md file, not the JSON envelope. It takes the service-role key or a user token, and it always needs one, even on a public document.
curl '<BASE_URL>/api/documents/<DOCUMENT_ID>/export?format=md' \
-H 'Authorization: Bearer <SUPABASE_SERVICE_ROLE_KEY>'This read returns the stored copy, not the live one. It can trail an actively edited document by up to 60 seconds. Your own write can trail too. On a document nobody has open, the PATCH 200 carries version once the write is saved. On an open document that 200 has no version.
A document that exists with no content yet returns version: 0 and an empty document. That is not an error.
A 422 here usually means valid JSON that breaks a document rule, not a malformed body.
The first node must be a level-1 heading. A document without one is refused. Otherwise the editor would invent a title on first open and save a heading you never wrote.
An image is inline. Put it inside a paragraph or a heading. An image at the top level is refused.
The root array is flat. A heading holds inline text only. The blocks that follow a heading are its siblings, not its children.
Three caps also apply: 5 MiB per payload, 50,000 nodes, and a nesting depth of 100.
Headings and tables carry a toc-id. The server assigns one wherever it is missing. That id is the address a heading's chat thread, its fold state, and its ?id= deep link all hang off.
Round-trip the ids. When you GET a document, edit it, and replace it, send the existing toc-id values back. Drop them and every heading gets a new address, which detaches its chat thread.
On append, a duplicate id is permanent. The server de-duplicates within one payload only, and never reads the live document's ids. So if you send an id a heading already owns, both headings end up sharing one chat thread and one anchor. Next step: omit toc-id on append and let the server mint one, or GET first and avoid the ids already in use.
Every PATCH stores a full version of the document. So 500 small writes leave roughly 500 stored versions.
The rate limit is per address, and your server sets its own value. Read X-RateLimit-Limit on a response that passes through a limiter, which means one that came through your proxy. See Rate limiting.
Next step: prefer one replace, or a few large append calls, over many small ones. If you are importing a long document, build the whole Tiptap document first and send it once.
replace is safe to retry when every heading in it carries its toc-id. Sending it twice then leaves the same document. Without the ids, each retry mints new ones, so a heading's chat thread detaches again. Each retry also stores one more version.
append is not. A timeout may mean the write landed anyway, so a blind retry can append twice. Next step: wait about a minute, then GET the document and check before retrying an append. 503 SAVE_NOT_CONFIRMED means the write was applied. 503 DOCUMENT_BUSY applied nothing; retry after a few seconds. Send writes to one document in sequence.
A repeated 500 on the same document means server-side persistence is stuck for it. Next step: stop retrying and tell an operator, because each attempt keeps changing what people see without saving it.
- Authentication — the other two credential types, and what each one can call.
- WebSocket — to watch a document change in real time instead of polling.
apps/hocuspocus.server/API.md— versions, export and import, media, and link metadata.