Developers
Read and write your cDox data programmatically with the API.
Overview
The cDox API lets you read, create, update and delete your documents and sheets, and create and organize your folders, programmatically. It's great for backups, integrations, or building custom tools around your data.
This is our first public release and things may evolve, but we're committed to not breaking things without notice.
This is an early-access API. The base URL is https://cdox.studio/api/v1/
Quickstart
Three steps to your first API call:
- Generate an API key in My Account > API key (your avatar in the top right of the dashboard).
- Confirm it works by hitting
/me. - Create your first document.
const KEY = 'cdox_your_key_here';
const headers = { 'Authorization': `Bearer ${KEY}`, 'Content-Type': 'application/json' };
// 1. Confirm your key works
const me = await fetch('https://cdox.studio/api/v1/me', { headers }).then(r => r.json());
console.log('Hello,', me.data.first_name);
// 2. Create your first document
const created = await fetch('https://cdox.studio/api/v1/documents', {
method: 'POST',
headers,
body: JSON.stringify({
title: 'Hello from the API',
content: '# It works\n\nWith **markdown** too.'
})
}).then(r => r.json());
console.log('Created:', created.data.id);If the second request returns an object with an id, you're set. The document will show up in your cDox list right away.
Getting your API key
You can generate an API key in My Account > API key. The key starts with cdox_ and is only shown once. Copy it and keep it somewhere safe. Treat it like a password.
If you lose your key, you can regenerate a new one. This will immediately invalidate the old one.
Authentication
Send your key in the Authorization header on every request:
const response = await fetch('https://cdox.studio/api/v1/me', {
headers: {
'Authorization': 'Bearer cdox_your_key_here'
}
});
const { data } = await response.json();
console.log(data);If the key is valid, you'll get your user info back. If not, you'll get a 401 error. Your email must also be verified, or you'll get a 403.
Rate limits
The API is rate-limited to protect the service:
| Plan | Reads / minute | Writes / minute |
|---|---|---|
| Free | 100 | 30 |
| Pro | 300 | 100 |
Writes (POST, PATCH, DELETE) have their own bucket, separate from reads.
Every response includes headers so you can track your usage:
| Header | Description |
|---|---|
X-RateLimit-Limit | Your per-minute limit |
X-RateLimit-Remaining | Requests remaining in the window |
X-RateLimit-Reset | Unix timestamp when the window resets |
If you go over the limit, you'll get a 429 Too Many Requests response with a Retry-After header telling you how many seconds to wait.
Retry example
async function fetchWithRetry(url, options, maxRetries = 3) {
for (let attempt = 0; attempt <= maxRetries; attempt++) {
const res = await fetch(url, options);
if (res.status !== 429) return res;
const wait = parseInt(res.headers.get('Retry-After') ?? '60', 10);
if (attempt === maxRetries) return res;
await new Promise(r => setTimeout(r, wait * 1000));
}
}Pro users get 3x the reads and over 3x the writes per minute.
Storage and quotas
Every account has a storage limit that applies to your stored data, regardless of how it was created (UI or API):
| Plan | Limit |
|---|---|
| Free | 50 MB |
| Pro | 500 MB |
The total includes:
- Document content (converted markdown and rendered HTML)
- Sheet content (JSON cells)
- Uploaded images
If a request that creates or grows content (POST, or a PATCH with bigger content) would push you over the quota, the API returns 403 QUOTA_EXCEEDED and changes nothing. Free up space by deleting items in the UI, or upgrade to Pro.
Documents are typically a few kilobytes each. The 50 MB limit is enough for thousands of typical documents.
Endpoints
All URLs start with https://cdox.studio/api/v1.
| Endpoint | Description |
|---|---|
GET /me | Your user info (good for testing your key) |
GET /documents | List your documents (paginated) |
GET /documents/:id | A single document with its content |
POST /documents | Create a document from markdown |
PATCH /documents/:id | Change the fields you send; everything else is left alone |
DELETE /documents/:id | Delete a document, permanently |
GET /sheets | List your sheets (paginated) |
GET /sheets/:id | A single sheet with its content |
POST /sheets | Create a sheet from CSV |
PATCH /sheets/:id | Change the fields you send; CSV content replaces the sheet |
DELETE /sheets/:id | Delete a sheet, permanently |
GET /folders | Your folder tree |
GET /folders/:id | A single folder with its children |
POST /folders | Create a folder: name, optional parent_id (up to 5 levels deep) |
PATCH /folders/:id | Rename or move a folder: name and/or parent_id |
content is markdown for documents and CSV for sheets. What you read is what you can write back: a GET returns a document's markdown exactly as it was sent, and PATCHing it back changes nothing. Sheets answer with their JSON, which is accepted as-is on write; CSV replaces the whole sheet and cannot express tabs or formatting.
Folders cannot be deleted through the API. Deleting a shared folder revokes every member's access at once, with nothing to confirm it: that one stays in the interface.
The list endpoints (/documents, /sheets) don't include content to keep responses light. Use the single-item endpoint to get the full content.
Query parameters for lists
| Parameter | Default | Description |
|---|---|---|
page | 1 | Page number |
per_page | 50 | Results per page (max 100) |
sort | updated_at | updated_at or created_at |
order | desc | desc or asc |
folder_id | Filter by folder (use `null` for root) |
Response format
All successful responses have the same shape: a wrapper object with a data key. List endpoints add a pagination key.
HTTP statuses
200: Successful read, update or delete201: Resource created- 4xx / 5xx: See the Errors section
User object fields
Returned by GET /me
| Field | Type | Description |
|---|---|---|
id | number | Internal ID |
email | string | |
first_name | string | null | |
last_name | string | null | |
is_pro | boolean | Whether the user is on the Pro plan |
created_at | string (ISO 8601) |
Document object fields
Returned by /documents and /documents/:id
| Field | Type | Description |
|---|---|---|
id | string | Public ID (UUID). This is what you pass to endpoints |
type | string | Always "page" |
title | string | |
folder_id | number | null | `null` if at the root |
owner_id | number | |
is_public | boolean | Whether a public share link exists |
show_author | boolean | Whether the author name appears on the public page |
size | number | Length of the content field (characters) |
content | string (Markdown) | Single-item endpoint only. A few documents made by an early API version hold ProseMirror JSON instead; send it back as-is and it is kept. |
html_content | string | Rendered HTML. Single-item endpoint only |
created_at | string (ISO 8601) | |
updated_at | string (ISO 8601) |
Sheet object fields
Returned by /sheets and /sheets/:id
| Field | Type | Description |
|---|---|---|
id | string | Public ID (UUID) |
type | string | Always "sheet" |
title | string | |
folder_id | number | null | |
owner_id | number | |
is_public | boolean | |
show_author | boolean | |
size | number | Length of the content field (characters) |
content | string (JSON) | Sheet structure (tabs, cells). Single-item endpoint only |
created_at | string (ISO 8601) | |
updated_at | string (ISO 8601) |
Folder object fields
Returned by /folders and /folders/:id
| Field | Type | Description |
|---|---|---|
id | number | Numeric ID. This is what you pass as `folder_id` |
name | string | |
parent_id | number | null | `null` for root-level folders; folders nest at most 5 levels deep |
owner_id | number | |
document_count | number | Items directly inside this folder: documents, sheets and sub-folders |
children | array | Sub-folders (the full tree on GET /folders, direct children on GET /folders/:id) |
created_at | string (ISO 8601) | |
updated_at | string (ISO 8601) |
Pagination
List endpoints return a pagination object with every response:
{
"data": [ ... ],
"pagination": {
"page": 1,
"per_page": 50,
"total": 127,
"total_pages": 3
}
} Single-item endpoints return one object in data:
{
"data": {
"id": "abc-123",
"type": "page",
"title": "My Document",
"content": "...",
...
}
}Folders
To create a document or sheet inside a specific folder, you need the folder's numeric id. Fetch your tree with GET /folders:
const tree = await fetch('https://cdox.studio/api/v1/folders', {
headers: { 'Authorization': 'Bearer cdox_your_key_here' }
}).then(r => r.json());
// Find a folder by name (top level)
const projects = tree.data.find(f => f.name === 'Projects');
console.log(projects.id); // → 42
// Pass it when creating
await fetch('https://cdox.studio/api/v1/documents', {
method: 'POST',
headers: {
'Authorization': 'Bearer cdox_your_key_here',
'Content-Type': 'application/json'
},
body: JSON.stringify({
title: 'New doc',
content: 'Hello',
folder_id: projects.id
})
});The tree is recursive: each folder has a children array of sub-folders. Walk the tree to find a nested folder.
If you omit folder_id or pass null, the new resource lands at the root.
Creating documents
Send a POST /documents with a title and markdown content. The content can be plain text with no formatting at all, since any text is valid markdown. Sprinkle in markdown markers (#, **, -, etc.) if you want structure.
Request body
| Field | Type | Required | Description |
|---|---|---|---|
title | string | Yes | Max 255 characters |
content | string (markdown) | No | Max 256 KB. Empty creates a blank document. |
folder_id | number | null | No | ID of a folder you own or that is shared with you. Defaults to root. |
is_public | boolean | No | Defaults to false. If true, a public share link is created (Free accounts can have 3). |
const response = await fetch('https://cdox.studio/api/v1/documents', {
method: 'POST',
headers: {
'Authorization': 'Bearer cdox_your_key_here',
'Content-Type': 'application/json'
},
body: JSON.stringify({
title: 'Meeting notes',
content: '# Agenda\n\n- Roadmap\n- **Q3 priorities**\n- Open questions'
})
});
const { data } = await response.json();
console.log(data.id);Supported markdown
The following markers are recognized and converted to editable elements:
- Headings:
# H1,## H2,### H3 - Bold and italic:
**gras**,_italique_ - Strikethrough:
~~strikethrough~~ - Links:
[texte](https://...) - Lists:
- itemor1. item - Code:
`inline`or fenced blocks with``` - Blockquotes:
> quote - Horizontal rules:
--- - Images:
 - Tables (GFM pipe tables; the first row is the header):
| A | B |
Not yet supported
- Raw HTML (rendered as plain text)
- Footnotes
- Headings beyond H3 (they open in the editor as plain paragraphs)
The API isn't idempotent. Sending the same request twice creates two different documents. If you need deduplication, track your own IDs client-side and check before sending.
Creating sheets
Send a POST /sheets with a title and CSV content. A single value is valid. A full grid with headers is valid. Empty cells are skipped, numbers are auto-detected, and quoted values can contain commas and newlines.
Request body
| Field | Type | Required | Description |
|---|---|---|---|
title | string | Yes | Max 255 characters |
content | string (CSV) | No | Max 256 KB. Empty creates a blank sheet. |
folder_id | number | null | No | ID of a folder you own or that is shared with you. Defaults to root. |
is_public | boolean | No | Defaults to false. If true, a public share link is created (Free accounts can have 3). |
const csv = `name,email,signups
Alice,alice@x.com,42
Bob,bob@x.com,17`;
const response = await fetch('https://cdox.studio/api/v1/sheets', {
method: 'POST',
headers: {
'Authorization': 'Bearer cdox_your_key_here',
'Content-Type': 'application/json'
},
body: JSON.stringify({ title: 'Weekly signups', content: csv })
});
const { data } = await response.json();
console.log(data.id);CSV format
- First row = headers (row 1, refs A1, B1, etc.)
- Empty cells: skipped (sparse storage)
- Numbers: auto-detected and stored as numbers
- Commas in values: use quotes
"a, b" - Quotes in values: double them
"" - Newlines in values: yes, inside quotes
- Separator: comma, semicolon or tab, detected automatically
- $1,200, 45% and 2026-03-31 become currency, percent and date; values with leading zeros (007) stay text
- The created sheet has at least 100 rows × 26 cols; it expands if your data is larger
- Up to 10,000 rows and columns A to ZZ; anything past that is left out
Not yet supported
- Formulas cDox can't calculate yet (kept as the text you sent)
- Multi-tab input in CSV (one tab per request; send the sheet JSON from a GET for tabs)
- Cell formatting (colors, alignment, borders)
Like documents, the API isn't idempotent. Sending the same CSV twice creates two different sheets.
Errors
Errors follow a consistent format:
{
"error": {
"code": "NOT_FOUND",
"message": "Document not found"
}
} | Status | Code | Meaning |
|---|---|---|
400 | VALIDATION_ERROR | Invalid request body or missing field |
400 | MAX_DEPTH_EXCEEDED | Folders nest at most 5 levels deep |
400 | INVALID_OPERATION | A folder can't move inside itself |
401 | UNAUTHORIZED | Missing or invalid API key |
403 | FORBIDDEN | You can open it but not do this (rename, move, publish, delete) |
403 | PUBLIC_LINK_LIMIT | Free accounts can have 3 public links |
403 | EMAIL_NOT_VERIFIED | Verify your email first |
403 | QUOTA_EXCEEDED | Storage quota exceeded |
404 | NOT_FOUND | Resource doesn't exist or you don't have access to it |
413 | PAYLOAD_TOO_LARGE | Content too large (max 256 KB) |
429 | RATE_LIMITED | Too many requests, slow down |
503 | WRITE_DISABLED | Write API is temporarily disabled |
Versioning
The API is versioned in the URL (/api/v1/). As long as you stay on v1, we won't make breaking changes without notice.
Stable
- URL shape and HTTP methods
- Response shape (
data,pagination,error) - Fields documented above
- Error codes
- Bearer key authentication
May evolve
- New fields added (additive; ignore what you don't recognize)
- New endpoints
- Supported markdown subset (can grow, never shrink)
- Rate limits (can be relaxed, never tightened without notice)
Examples
List your recent documents
const response = await fetch(
'https://cdox.studio/api/v1/documents?per_page=10',
{ headers: { 'Authorization': 'Bearer cdox_your_key_here' } }
);
const { data, pagination } = await response.json();
console.log(`Page ${pagination.page} of ${pagination.total_pages}`);
data.forEach(doc => console.log(doc.title));Get a document with its content
const response = await fetch(
'https://cdox.studio/api/v1/documents/abc-123-def',
{ headers: { 'Authorization': 'Bearer cdox_your_key_here' } }
);
const { data } = await response.json();
console.log(data.title);
console.log(data.content); // Markdown
console.log(data.html_content); // rendered HTMLBack up all your sheets
const API_KEY = 'cdox_your_key_here';
const headers = { 'Authorization': `Bearer ${API_KEY}` };
// Walk every page of the list, 100 sheets at a time
let page = 1;
let totalPages = 1;
do {
const list = await fetch(
`https://cdox.studio/api/v1/sheets?per_page=100&page=${page}`,
{ headers }
).then(r => r.json());
totalPages = list.pagination.total_pages;
// Download each sheet's content
for (const sheet of list.data) {
const full = await fetch(
`https://cdox.studio/api/v1/sheets/${sheet.id}`,
{ headers }
).then(r => r.json());
console.log(`Backed up: ${full.data.title}`);
}
page++;
} while (page <= totalPages);Mirror content from an external system
// Uses fetchWithRetry from the Rate limits section above
const API_KEY = 'cdox_your_key_here';
const headers = { 'Authorization': `Bearer ${API_KEY}`, 'Content-Type': 'application/json' };
// Pretend these came from your CMS / DB / LLM
const articles = [
{ title: 'Welcome', body: '# Hello world\n\nThis is **markdown**.' },
{ title: 'Pricing', body: '# Pricing\n\n- Free: $0\n- Pro: $8/mo' }
];
for (const article of articles) {
// Waits out a 429 and retries this same article
const res = await fetchWithRetry('https://cdox.studio/api/v1/documents', {
method: 'POST',
headers,
body: JSON.stringify({ title: article.title, content: article.body })
});
if (!res.ok) {
const err = await res.json();
console.error(`Failed: ${article.title}: ${err.error.code}`);
continue;
}
const { data } = await res.json();
console.log(`Created: ${data.id}: ${article.title}`);
}Add | python3 -m json.tool to the end of any curl command to pretty-print the JSON response.
The API is in early access and will evolve over time. If you have feedback or requests (webhooks, anything else you need), don't hesitate to reach out.