Documents API
Find the Cirrux Docs documents a user owns or has been shared, read one as markdown with its comments and suggestions, edit it, suggest changes to it, comment on it, create one, rename it, and move it in and out of Trash. It lives under https://api.cirrux.co/public_api/v1/documents.
Every endpoint acts on one person’s documents, so it needs a user-scoped OAuth token. A workspace API key is refused with 403 forbidden.
Cirrux Docs itself is in preview: the docs.cirrux.co app is only open to accounts that have been given early access.
The document object
| Field | Type | Required | Description |
|---|---|---|---|
| object | string | no | Always document. |
| uuid | string | no | Unique identifier of the document. |
| workspace_uuid | string | no | The workspace the document belongs to. |
| title | string | null | no | null when the document is untitled. |
| url | string | no | Where to open the document in Cirrux Docs. |
| my_role | string | no | Your role on the document: viewer, commenter, editor, manager or owner. |
| shared | boolean | no | Whether the document has been shared with anyone. |
| owner | object | no | Who owns it: self (true when that is you), first_name, last_name and display_name. |
| trashed_at | string | null | no | When the document was moved to Trash. It is deleted for good 30 days later. |
| created_at | string | no | When the document was created. |
| updated_at | string | no | When the title or trash state last changed. Editing the content does not change it. |
Endpoints
List documents
/v1/documents/v1/documents/trashThe documents you own or have been shared, newest first. The first leaves out the ones in Trash, the second lists only those. Scope docs.read.
| Field | Type | Required | Description |
|---|---|---|---|
| q | string | no | Only documents whose title contains this, ignoring case. |
| limit | integer | no | Documents per page, 1 to 100. Default 25. |
| cursor | string | no | The next_cursor of the previous page, while has_more is true. |
curl "https://api.cirrux.co/public_api/v1/documents?q=roadmap" \ -H "Authorization: Bearer $CIRRUX_TOKEN"
{
"object": "list",
"url": "/v1/documents",
"has_more": false,
"data": [
{
"object": "document",
"uuid": "0b6f0d3e-8c1a-4f6e-9a4b-2d1e5c7a9f10",
"workspace_uuid": "b3f1c2a4-3333-4a2b-8c3d-000000000002",
"title": "Q3 roadmap",
"url": "https://docs.cirrux.co/d/0b6f0d3e-8c1a-4f6e-9a4b-2d1e5c7a9f10",
"trashed_at": null,
"created_at": "2026-09-01T10:00:00.000Z",
"updated_at": "2026-09-01T10:00:00.000Z",
"my_role": "editor",
"shared": true,
"owner": { "self": false, "first_name": "Ada", "last_name": "Lovelace", "display_name": "Ada Lovelace" }
}
]
}Get a document
/v1/documents/{uuid}One document. A document you cannot see answers 404, the same as one that does not exist. Scope docs.read.
Read a document's content
/v1/documents/{uuid}/contentThe document as it stands right now, as markdown (CommonMark with GFM strikethrough), with its open comments and suggestions. Scope docs.read; any role can read.
Markdown has no underline or color, so those are inline HTML: <u>underlined</u>, text color as <span data-color="red">red</span> and a highlight as <mark data-color="yellow">marked</mark>. The colors are gray, red, orange, yellow, green, teal, blue, purple and pink. Write them the same way in an edit; any other color is dropped.
Suggestions are marked in the text with CriticMarkup, each followed by a reference to its thread. Comments are not marked in the text: a comment’s quote is the text it is on. Attachments link to attachment:<uuid>.
{++added++}{>>s1<<}is text a suggestion adds.{--removed--}{>>s1<<}is text a suggestion takes out.{~~old~>new~~}{>>s1<<}is text a suggestion replaces.{==text==}{>>s1<<}is text a suggestion formats differently.{++¶++},{--¶--}and{==¶==}at the start of a block are a suggested new paragraph or line break, a removed one, and a changed block type (a paragraph becoming a heading, say).
| Field | Type | Required | Description |
|---|---|---|---|
| revision | integer | no | The revision the document was read at. |
| markdown | string | no | The document. |
| threads | array | no | The open comments and suggestions, oldest first. Each has uuid, kind (comment or suggestion), ref (the reference a suggestion has in the markdown), status, placement, quote, suggested_text, suggested_format, author, created_at and messages, whose body is markdown too. |
| threads[].placement | string | no | attached while its text is in the document, document for a comment on the document as a whole, and detached for a comment whose text was deleted. |
curl "https://api.cirrux.co/public_api/v1/documents/$DOC_UUID/content" \ -H "Authorization: Bearer $CIRRUX_TOKEN"
{
"object": "document_content",
"uuid": "0b6f0d3e-8c1a-4f6e-9a4b-2d1e5c7a9f10",
"revision": 42,
"markdown": "# Launch plan\n\nWe ship on {~~Monday~>Friday~~}{>>s1<<}.\n",
"threads": [
{
"uuid": "5d0c3f8e-1b2a-4c3d-8e9f-0a1b2c3d4e5f",
"kind": "suggestion",
"ref": "s1",
"status": "open",
"placement": "attached",
"quote": "Monday",
"suggested_text": "Friday",
"suggested_format": null,
"author": { "self": false, "first_name": "Ada", "last_name": "Lovelace", "display_name": "Ada Lovelace" },
"created_at": "2026-09-01T10:00:00.000Z",
"messages": [
{
"uuid": "7a6b5c4d-3e2f-4a1b-9c8d-7e6f5a4b3c2d",
"author": { "self": false, "first_name": "Ada", "last_name": "Lovelace", "display_name": "Ada Lovelace" },
"body": "Monday is a holiday.\n",
"created_at": "2026-09-01T10:00:00.000Z",
"edited_at": null
}
]
}
]
}Edit a document's content
/v1/documents/{uuid}/contentEdits the document with operations aimed at text you quote from the markdown the read returns. They apply in order, each to the document the ones before it left, and land as one revision, or nothing changes. People with the document open see the edit at once. Scope docs.write and the editor role. Answers with the document after the edit, exactly as the read does.
| Field | Type | Required | Description |
|---|---|---|---|
| operations | array | yes | Up to 100 operations, described below. |
| base_revision | integer | no | The revision you read. If the document has moved on since, the edit is refused with 409 conflict, and nothing changes. |
| batch_id | string | no | Your own id for the edit, so a retried request is not applied twice. |
{"type": "replace", "find", "with", "occurrence"?, "before"?, "after"?}replaces text inside one paragraph, heading or list item.withis inline markdown, and an empty one removes the text. The replacement keeps the formatting of what it replaces, plus what it writes.{"type": "insert", "markdown", "after" | "before"}inserts blocks beside an anchor: a quote (the top-level block it is in),heading:Title,section:Title(the heading and what follows it, up to the next heading as high or higher),startorend. A list inserted next to a list item joins that list.{"type": "delete", "find", "occurrence"?, "before"?, "after"?}removes text, across blocks if it spans them. A block whose whole text is found goes with it.{"type": "set_document", "markdown"}sets the whole document. Only the blocks that differ change, so the rest keep their place for people working in it.
A quote is matched against the markdown, and failing that against the text as it reads without markup. One that is not there is target_not_found. One that is there more than once is target_ambiguous, with each match in candidates, unless occurrence (a number from 1, first or last) picks one or before and after (the text right before or after it) narrow it down. Both are 422 with retryable: true, name the refused operation by its index, and change nothing. A 409 conflict carries the document as it is now in current, so the edit can be redone without reading again.
curl -X POST "https://api.cirrux.co/public_api/v1/documents/$DOC_UUID/content" \
-H "Authorization: Bearer $CIRRUX_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"base_revision": 42,
"operations": [
{ "type": "replace", "find": "Monday", "with": "Friday" },
{ "type": "insert", "after": "Write the post", "markdown": "- Check the numbers" }
]
}'{
"error": "target_ambiguous",
"error_description": "\"Monday\" is in the document 2 times; quote more of it, or say which with occurrence.",
"operation": 0,
"candidates": [
{ "occurrence": 1, "snippet": "We ship on Monday." },
{ "occurrence": 2, "snippet": "…the Monday after, we…" }
]
}Attachments
/v1/documents/{uuid}/attachments/v1/documents/{uuid}/attachments/{attachment}/v1/documents/{uuid}/attachments/{attachment}/downloadUploads a file to the document. The request body is the file’s raw bytes, and filename in the query string is the name it shows with. Its type is read from the bytes, never from Content-Type: a PNG, JPEG, GIF or WebP image shows in the page (inline: true), and anything else is a file people download. Scope docs.write and the editor role.
Uploading does not place it. The answer carries markdown, which you insert with an edit, alone on its own line:  for an image, [minutes.pdf](attachment:<uuid>) for a file. Reading the document writes it back the same way. Uploading the same bytes again answers 200 with the same attachment instead of 201 with a new one, so a retry stores nothing twice. A missing filename is 422 invalid_filename, an empty body is 422 empty_file, and a file over 100 MB is 413 file_too_large (with the limit in max_bytes). Attachments count towards the workspace’s storage, so one that would take it past its limit is 422 storage_limit_exceeded.
The attachment:<uuid> links in a document’s markdown name its attachments. GET on one returns it as below, and /download returns its file as raw bytes, with its type in Content-Type and its name in Content-Disposition. Scope docs.read and the viewer role. An attachment of another document is 404, and one whose file has not arrived (upload_status other than completed) is 409 not_uploaded.
| Field | Type | Required | Description |
|---|---|---|---|
| object | string | no | Always document_attachment. |
| uuid | string | no | The attachment. |
| document_uuid | string | no | The document it belongs to. |
| filename | string | no | The name it shows with, without any directories. |
| content_type | string | no | An image type, or application/octet-stream for anything else. |
| byte_size | integer | no | Its size in bytes. |
| upload_status | string | no | completed, awaiting_upload or failed. |
| width, height | integer | no | An image's size in pixels, or null. |
| inline | boolean | no | Whether it shows in the page or as a file to download. |
| markdown | string | no | What places it in the document. |
| created_at | string | no | When it was uploaded. |
curl -X POST "https://api.cirrux.co/public_api/v1/documents/$DOC_UUID/attachments?filename=chart.png" \ -H "Authorization: Bearer $CIRRUX_TOKEN" \ -H "Content-Type: application/octet-stream" \ --data-binary @chart.png
curl -X POST "https://api.cirrux.co/public_api/v1/documents/$DOC_UUID/content" \
-H "Authorization: Bearer $CIRRUX_TOKEN" \
-H "Content-Type: application/json" \
-d '{ "operations": [{ "type": "insert", "after": "section:Results", "markdown": "" }] }'curl "https://api.cirrux.co/public_api/v1/documents/$DOC_UUID/attachments/$ATTACHMENT_UUID/download" \ -H "Authorization: Bearer $CIRRUX_TOKEN" \ -o chart.png
Suggest changes
/v1/documents/{uuid}/suggestions/v1/documents/{uuid}/suggestions/{thread}/accept/v1/documents/{uuid}/suggestions/{thread}/reject/v1/documents/{uuid}/suggestions/{thread}Suggests changes instead of making them, so the people working in the document review them first. It takes the replace, insert and delete operations of an edit, aimed at quotes the same way, along with base_revision and batch_id. Each operation becomes a suggestion of its own, which people accept or reject in the editor: text it would take out stays, marked, and what it brings in is added, marked. Give an operation a comment (markdown) to say why. It becomes the first message on that suggestion. Scope docs.comment and the commenter role.
Answers 201 with the document after it, as the read returns it, plus suggestions: the new suggestions’ uuids, one per operation, in order. Quotes are refused as an edit’s are. set_document cannot be suggested (not_suggestible), and text already suggested for removal has nothing_to_suggest.
/acceptmakes the change and/rejecttakes it back, closing the suggestion in the same revision. Scopedocs.writeand the editor role. Both answer with the document. A suggestion that was already decided is422 suggestion_closed.DELETEwithdraws a suggestion of yours (or anyone’s if you manage or own the document): its marks go as rejecting it takes them, and it is deleted with its messages.
curl -X POST "https://api.cirrux.co/public_api/v1/documents/$DOC_UUID/suggestions" \
-H "Authorization: Bearer $CIRRUX_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"operations": [
{ "type": "replace", "find": "Monday", "with": "Friday", "comment": "The invite says Friday." },
{ "type": "delete", "find": "We may slip." }
]
}'Comments
/v1/documents/{uuid}/comments/v1/documents/{uuid}/comments/{thread}/replies/v1/documents/{uuid}/comments/{thread}/v1/documents/{uuid}/comments/{thread}/messages/{message}/v1/documents/{uuid}/comments/{thread}/v1/documents/{uuid}/comments/{thread}/messages/{message}Comments as people make them in Cirrux Docs: a comment on quoted text is marked on those words, so it shows beside them for everyone with the document open. Scope docs.comment and the commenter role. Every body is markdown. Each write answers with the thread, in the shape the read lists threads in; a delete answers 204.
{"body", "on"?, "occurrence"?, "before"?, "after"?}adds a comment on the textonquotes, matched as an edit’s quote is, or on the whole document without it./replies {"body"}replies;{"status": "resolved" | "open"}on the thread resolves or reopens it. A suggestion is accepted or rejected instead, and is422 not_a_comment./messages/{message} {"body"}edits a message you wrote.- Deleting a comment takes its replies and its marks with it. Deleting its opening message does the same; deleting a reply deletes just that. Your own, or anyone’s if you manage or own the document. Deleting a suggestion withdraws it.
curl -X POST "https://api.cirrux.co/public_api/v1/documents/$DOC_UUID/comments" \
-H "Authorization: Bearer $CIRRUX_TOKEN" \
-H "Content-Type: application/json" \
-d '{ "on": "Monday", "body": "Is **Monday** firm?" }'Create a document
/v1/documentsCreates an empty document in the token’s workspace, owned by you, and returns it with 201. The body is optional. Scope docs.write.
| Field | Type | Required | Description |
|---|---|---|---|
| title | string | no | At most 255 characters. Leave it out for an untitled document. |
Update a document
/v1/documents/{uuid}Renames a document and/or moves it in or out of Trash. Fields you leave out are not changed, and a request is checked in full before anything is written, so a refusal changes nothing. Scope docs.write.
| Field | Type | Required | Description |
|---|---|---|---|
| title | string | null | no | The new title. An empty string or null makes it untitled. Needs the editor role. |
| trashed | boolean | no | true moves it to Trash, false restores it. Needs the owner or manager role. |
curl https://api.cirrux.co/public_api/v1/documents/$DOC_UUID \
-H "Authorization: Bearer $CIRRUX_TOKEN" \
-H "Content-Type: application/json" \
-d '{"title": "Q3 roadmap (final)"}'Move a document to Trash
/v1/documents/{uuid}/trashThe same as updating with {"trashed": true}, and it needs the same owner or manager role. Trashing a trashed document changes nothing. Scope docs.write.
Errors
Error codes
| Field | Type | Required | Description |
|---|---|---|---|
| forbidden | 403 | no | Your role on the document does not allow this change. The description names the role it needs. |
| not_found | 404 | no | No such document or attachment, or you cannot see it. |
| comment_not_found | 404 | no | No such comment or message on this document. |
| suggestion_not_found | 404 | no | No such suggestion on this document, when accepting or rejecting. |
| conflict | 409 | no | The document moved past base_revision. The body carries current. |
| document_trashed | 409 | no | The document is in Trash, so its content, comments and suggestions cannot change. |
| not_uploaded | 409 | no | The attachment’s file has not finished uploading. |
| file_too_large | 413 | no | The attachment is over 100 MB. |
| invalid_uuid | 422 | no | A uuid in the path is not a UUID. |
| invalid_title | 422 | no | The title is not a string of at most 255 characters. |
| invalid_trashed | 422 | no | trashed is not a boolean. |
| target_not_found, target_ambiguous | 422 | no | A quote is not in the document, or is there more than once. Carries retryable: true. |
| invalid_operation, invalid_operations, invalid_markdown, target_spans_blocks | 422 | no | An operation is malformed, the edit is too large, its markdown cannot be read, or a replace reaches across blocks. |
| invalid_base_revision, invalid_batch_id | 422 | no | base_revision is not a revision number, or batch_id is not a string of 1 to 64 characters. |
| not_suggestible, nothing_to_suggest | 422 | no | The change cannot be suggested (set_document), or the text is already suggested for removal. |
| suggestion_closed, suggestion_not_in_document, not_a_suggestion | 422 | no | The suggestion was already decided, none of it is left in the document, or the thread is a comment. |
| invalid_body, invalid_status, not_a_comment | 422 | no | A comment body is blank, status is not open or resolved, or the thread is a suggestion. |
| invalid_filename, empty_file, storage_limit_exceeded | 422 | no | An attachment has no filename, holds no bytes, or would take the workspace past its storage. |