sweetdocs API
A REST API over HTTPS, JSON in and JSON out. Authenticate with a Bearer token, hit the endpoints below, build whatever you need on top of your documents.
Base URL
All requests go to https://api.sweetdocs.app/v1.
The v1 prefix is part of the URL, not a header, so
older client code keeps working when newer versions ship.
Conventions
- JSON in, JSON out. Send
Content-Type: application/jsonon writes. - Single-object responses:
{ "data": { … } }. - List responses:
{ "data": [ … ], "next_cursor": "…" | null }. - Every response includes
X-Request-Id,X-RateLimit-Limit,X-RateLimit-Remaining,X-RateLimit-Reset. - Request body size cap: 1 MB.
- CORS:
Access-Control-Allow-Origin: *.OPTIONSreturns204.
# Confirm your key works — list libraries
curl https://api.sweetdocs.app/v1/libraries \
-H "Authorization: Bearer sd_pk_YOUR_KEY" // Confirm your key works — list libraries
const res = await fetch('https://api.sweetdocs.app/v1/libraries', {
headers: { Authorization: `Bearer ${KEY}` },
})
const { data: libraries } = await res.json() Authentication
Every request needs an Authorization header with a
Bearer token. Tokens come in two types and carry one scope and,
optionally, a single-library restriction.
Personal keys: sd_pk_…
Acts as the user who minted it and inherits that user's ACL. If the user can read a folder, the key can read that folder. If the user loses access, the key loses access. Mint from Settings → Developer.
Org service keys: sd_sk_…
Acts as the org itself. Carries permissions equivalent to org
manage on every library in the org, and survives
membership changes. Only org Owners can mint these, from
Org settings → API keys.
Scopes
Each key has a single scope, set at mint time and immutable afterward.
read— everyGETthe principal can see. Any mutation returns403 scope_insufficient.write— full CRUD within the principal's permission envelope.
Library restriction
A key can optionally be bound to one library at mint time. The key sees and writes only that library, regardless of what the principal could otherwise access. Useful for narrow Zapier flows or library-scoped automations.
Header format
curl https://api.sweetdocs.app/v1/libraries \
-H "Authorization: Bearer sd_pk_3f9a…" const res = await fetch('https://api.sweetdocs.app/v1/libraries', {
headers: { Authorization: 'Bearer sd_pk_3f9a…' },
}) Plan eligibility
API key minting is available on Pro and Teams. Free users can't mint a key. If a Pro user downgrades to Free, their existing personal keys are automatically revoked at the downgrade moment.
Mint a key
Keys live in the app, not in this docs site. Open my.sweetdocs.app and follow the steps below — the screenshots are the same dialogs you'll see in the dashboard.
For your own use (personal key)
-
Open Profile → API keys
In the app sidebar, click your name + avatar at the bottom-left to open the profile menu, then choose Profile. The page header reads "Profile"; scroll past Account and Appearance to find the API keys section. If you don't see it, you're on the Free plan — upgrade to Pro first.
API keys
Name Prefix Scope Last used Status Zapier flow sd_pk_kT4xQp8…write 2 days ago Active Revoke -
Click "New key" and fill out the form
Three fields. Name is for your eyes only — call it something like "Zapier flow" so you remember where it's used. Scope defaults to read & write; pick Read only if the integration only needs to list / get things. Restrict to library binds the key to a single library — useful for narrow automations, leave on "All libraries" otherwise.
New API key
-
Copy the token. Now.
After you click Mint, the full token appears once. We hash it and throw the plaintext away — if you close this dialog without copying, you'll need to mint a new key. Hit Copy, paste it into your secret store, then I've saved it.
Save your token
This token will never be shown again. Copy it now and store it securely.
sd_pk_kT4xQp8mN2vR7yL9wA3jZ6bH5cF1dY0eS -
Use it as a Bearer token
Drop it into the
Authorizationheader on every request. The smoke test from the Introduction is a good first call.curl https://api.sweetdocs.app/v1/libraries \ -H "Authorization: Bearer sd_pk_kT4xQp8mN2vR7yL9wA3jZ6bH5cF1dY0eS"const res = await fetch('https://api.sweetdocs.app/v1/libraries', { headers: { Authorization: 'Bearer sd_pk_kT4xQp8mN2vR7yL9wA3jZ6bH5cF1dY0eS' }, }) const { data: libraries } = await res.json()
For an org integration (service key)
Service keys act as the org and survive membership changes — use these for anything production. Only org Owners and Admins can manage them. The flow mirrors the personal one, except you start from the org page and the modal title reads New service key.
-
Open the org page, then the Keys tab
From the profile menu choose Manage [org name], or click the gear next to the upload button in the sidebar while an org library is open. On the org page, the tabs across the top read Members · Groups · Libraries · Audit · Settings · Keys. Click Keys.
Service keys
Name Prefix Scope Last used Status Acme nightly sync sd_sk_pR9zQ2v…write 17 min ago Active Revoke -
Mint a service key
Same form, different title. The token prefix will be
sd_sk_instead ofsd_pk_. Library restriction is even more useful here: bind a service key to one team library so an integration can't accidentally read another team's docs.New service key
401 unauthenticated
within seconds. Revocation is permanent — mint a new key if
you want access back.
Errors
Errors return a standard HTTP status code plus a JSON body
with a stable code, a human-readable
message, and the request_id echoed
from the X-Request-Id header.
{
"error": {
"code": "permission_denied",
"message": "Key does not have write scope for this resource.",
"request_id": "req_01HQ8Z…"
}
} Error codes
| HTTP | Code | When |
|---|---|---|
| 400 | invalid_request | Malformed JSON, missing required field, unknown query param. |
| 401 | unauthenticated | Missing or malformed Bearer; key not found, revoked, or principal missing. |
| 403 | scope_insufficient | Read-only key attempting a mutation. |
| 403 | permission_denied | Key has access at a lower level than required. |
| 403 | doc_cap_reached | Free-plan owner's 25-doc cap hit (defensive — practically unreachable in v1). |
| 404 | not_found | Resource doesn't exist, or isn't visible to this key. |
| 412 | etag_mismatch | If-Match header didn't match current updated_at. |
| 422 | validation_failed | Body parsed but semantically invalid (e.g., folder cycle). |
| 429 | quota_exhausted | Monthly cap hit. Retry-After is seconds until the 1st UTC of next month. |
| 429 | rate_limited | Per-minute burst hit. Retry-After: 60. |
| 500 | server_error | Unexpected. Logged with request_id. |
| 503 | unavailable | Database reachability failure. |
Pagination
List endpoints use keyset (cursor) pagination ordered by
updated_at. Pass limit (default 25,
max 100) and follow next_cursor until it comes
back null. The cursor is an opaque base64 string;
don't try to construct one yourself.
# First page
curl 'https://api.sweetdocs.app/v1/documents?library_id=4d5e8c1f-…&limit=50' \
-H "Authorization: Bearer sd_pk_…"
# Follow next_cursor on the response
curl 'https://api.sweetdocs.app/v1/documents?library_id=4d5e8c1f-…&limit=50&cursor=eyJ1cGRhdGVkX2F0IjoiMjAyNi0wNS0yNlQwOTowMDowMFoiLCJpZCI6ImFiYy0xMjMifQ==' \
-H "Authorization: Bearer sd_pk_…" async function listAll(libraryId) {
let cursor = null, out = []
do {
const qs = new URLSearchParams({ library_id: libraryId, limit: '100' })
if (cursor) qs.set('cursor', cursor)
const r = await fetch(`https://api.sweetdocs.app/v1/documents?${qs}`, { headers })
const page = await r.json()
out.push(...page.data)
cursor = page.next_cursor
} while (cursor)
return out
} List response shape
{
"data": [ /* … objects … */ ],
"next_cursor": "eyJ1cGRhdGVkX2F0IjoiMjAyNi0wNS0yNlQwOTowMDowMFoiLCJpZCI6ImFiYy0xMjMifQ=="
} Rate limits
Limits are tracked per key. Bursts let you batch work; the
monthly cap is the real ceiling. 429s still count
against the burst window so retry loops are predictable.
| Plan | Monthly | Burst (60s) |
|---|---|---|
| Pro | 10,000 requests | 60 req |
| Teams | 100,000 requests | 60 req |
| Free | Cannot mint keys. | |
When you hit a limit you get a 429 with a
Retry-After header (seconds). Quota windows reset
on the 1st of each month at UTC midnight.
Headers on every response
X-RateLimit-Limit— your monthly cap.X-RateLimit-Remaining— requests left this month.X-RateLimit-Reset— Unix timestamp of the next month rollover.X-Request-Id— id for this request (req_…). Include in support emails.
You'll get email warnings at 80% and 100% of the monthly cap (once per threshold per month). Personal keys notify the owning user; org service keys notify the org Owner.
Concurrent writes
Documents are also edited from the in-app editor, which writes
both content (Markdown) and a Yjs CRDT state
blob. API writes only set content and clear the
Yjs blob, which forces the next editor session to rehydrate
from your Markdown.
If-Match header with the
updated_at value from your last read. A peer write
since then returns 412 etag_mismatch.
Libraries
A library is the top-level container of folders and documents. Every user has one personal library; an org can have any number of shared team libraries. Library membership is set in the app, not via the API. Libraries are read-only via the API.
The library object
| Field | Type | Description |
|---|---|---|
| id | uuid | Unique identifier. |
| name | string | Display name. "Personal" for personal libraries. |
| kind | string | "personal" or "org". |
| org_id | uuid · nullable | The owning org, or null for personal libraries. |
| created_at | timestamp | RFC 3339 in UTC. |
List libraries
Returns every library the key can access. A library-restricted key returns exactly that one library.
curl https://api.sweetdocs.app/v1/libraries \
-H "Authorization: Bearer sd_pk_…" const r = await fetch('https://api.sweetdocs.app/v1/libraries', { headers })
const { data: libraries } = await r.json() Get a library
{
"id": "4d5e8c1f-…",
"name": "Acme Co.",
"kind": "org",
"org_id": "8aa1f9c2-…",
"created_at": "2026-04-12T19:04:11Z"
} Folders
Folders are the second level of the hierarchy: every folder
belongs to exactly one library, and folders can nest. A
document sits at the library root (folder_id: null)
or inside a folder. Folder cycles are rejected by a
server-side trigger and surface as
422 validation_failed.
The folder object
| Field | Type | Description |
|---|---|---|
| id | uuid | Unique identifier. |
| library_id | uuid | The library this folder lives in. |
| parent_id | uuid · nullable | Parent folder, or null if at library root. |
| name | string | Folder name. |
| position | integer | Sort order within parent. Lower = earlier. |
| created_at | timestamp | RFC 3339 in UTC. |
{
"id": "f0a2c9e8-…",
"library_id": "4d5e8c1f-…",
"parent_id": null,
"name": "Trip planning",
"position": 0,
"created_at": "2026-05-10T14:22:18Z"
} List folders
| Query param | Type | Description |
|---|---|---|
| library_id | required | The library to list folders from. |
| parent_id | uuid · optional | Direct children of one folder. Omit for library root. |
| limit | integer · optional | 1-100, default 25. |
| cursor | string · optional | From the previous response's next_cursor. |
Get a folder
Create a folder
| Body field | Type | Description |
|---|---|---|
| library_id | required | The library to create in. |
| name | required | Display name. |
| parent_id | uuid · optional | Parent folder. Omit for root. |
curl -X POST https://api.sweetdocs.app/v1/folders \
-H "Authorization: Bearer sd_pk_…" \
-H "Content-Type: application/json" \
-d '{"library_id":"4d5e8c1f-…","name":"Trip planning"}' const r = await fetch('https://api.sweetdocs.app/v1/folders', {
method: 'POST',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({
library_id: '4d5e8c1f-…',
name: 'Trip planning',
}),
})
const { data: folder } = await r.json() Update a folder
| Body field | Type | Description |
|---|---|---|
| name | string · optional | Rename the folder. |
| parent_id | uuid · nullable · optional | Move to a different parent. null moves to root. |
| library_id | uuid · optional | Move to a different library (must be accessible to the key). |
Delete a folder
Cascades through the existing in-app trash behavior. Child folders and documents are soft-deleted with the parent.
Documents
Documents are where the content lives. The
content field is the raw Markdown source that
you'd see in the editor when "Edit raw" is on.
The document object
| Field | Type | Description |
|---|---|---|
| id | uuid | Unique identifier. |
| library_id | uuid | Owning library. |
| folder_id | uuid · nullable | Folder, or null for library root. |
| title | string | Display title. |
| content | string | Raw Markdown source. UTF-8. |
| doc_type | string | "md" or "svg". |
| is_public | boolean | Whether the public-link toggle is on. v1 cannot set this. |
| share_url | string · nullable | The shareable URL when is_public is true; otherwise null. |
| created_by | uuid | User id who created the doc. |
| created_at | timestamp | RFC 3339 in UTC. |
| updated_at | timestamp | RFC 3339 in UTC. Doubles as the ETag for If-Match. |
{
"id": "8f4a8b2c-…",
"library_id": "4d5e8c1f-…",
"folder_id": "f0a2c9e8-…",
"title": "Kyoto trip",
"content": "# Day 1\n\nLand in Kyoto.",
"doc_type": "md",
"is_public": false,
"share_url": null,
"created_by": "8aa1f9c2-…",
"created_at": "2026-05-12T08:21:55Z",
"updated_at": "2026-05-26T11:30:00Z"
} List documents
| Query param | Type | Description |
|---|---|---|
| library_id | required | The library to list from. |
| folder_id | uuid · optional | Direct children of one folder. Omit for library root. |
| updated_since | iso8601 · optional | Only docs with updated_at > updated_since. Polling-friendly. |
| limit | integer · optional | 1-100, default 25. |
| cursor | string · optional | From the previous response. |
updated_since is designed for Zapier-style
polling triggers. Poll every minute with
updated_since set to the last-seen
updated_at and you'll have near-realtime sync.
Get a document
Returns the full document including content. Use
the updated_at from the response as the ETag for
any subsequent PATCH with If-Match.
Create a document
| Body field | Type | Description |
|---|---|---|
| library_id | required | The library to create in. |
| folder_id | uuid · optional | Folder to place into. Omit for root. |
| title | string · optional | Display title. Defaults to "Untitled". |
| content | string · optional | Initial Markdown. Defaults to empty. |
is_public is always false on a
newly-created document. Share-link toggling is not exposed in
v1.
curl -X POST https://api.sweetdocs.app/v1/documents \
-H "Authorization: Bearer sd_pk_…" \
-H "Content-Type: application/json" \
-d '{
"library_id": "4d5e8c1f-…",
"title": "Kyoto trip",
"content": "# Day 1\n\nLand in Kyoto."
}' const r = await fetch('https://api.sweetdocs.app/v1/documents', {
method: 'POST',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({
library_id: '4d5e8c1f-…',
title: 'Kyoto trip',
content: '# Day 1\n\nLand in Kyoto.',
}),
})
const { data: doc } = await r.json() Update a document
| Body field | Type | Description |
|---|---|---|
| title | string · optional | Rename the document. |
| content | string · optional | Replace the Markdown body. Atomically clears the Yjs state. |
| folder_id | uuid · nullable · optional | Move to a different folder. null moves to library root. |
| library_id | uuid · optional | Move to a different library (must be accessible to the key). |
Pass If-Match: <updated_at> to guarantee no
overwrite. Mismatch returns 412 etag_mismatch.
See Concurrent writes for why.
curl -X PATCH https://api.sweetdocs.app/v1/documents/8f4a8b2c-… \
-H "Authorization: Bearer sd_pk_…" \
-H "Content-Type: application/json" \
-H "If-Match: 2026-05-26T11:30:00Z" \
-d '{"content":"# Updated\n\n…"}' await fetch(`https://api.sweetdocs.app/v1/documents/${id}`, {
method: 'PATCH',
headers: {
...headers,
'Content-Type': 'application/json',
'If-Match': lastUpdatedAt,
},
body: JSON.stringify({ content: '# Updated\n\n…' }),
}) Delete a document
Soft-deletes the document. It moves to the in-app trash and is purged on the standard schedule. Restore-from-trash is not exposed in v1.
Search
Full-text search across documents in a single library. Backed
by the same Postgres tsvector index the in-app
search uses, so ranking and stemming match what the dashboard
surfaces. Returns documents in the standard list response
shape.
Search documents
| Query param | Type | Description |
|---|---|---|
| q | required | Query string. Matches title and content. |
| library_id | required | Library to search inside. |
| limit | integer · optional | 1-100, default 25. |
curl 'https://api.sweetdocs.app/v1/documents/search?q=arashiyama&library_id=4d5e8c1f-…' \
-H "Authorization: Bearer sd_pk_…" const qs = new URLSearchParams({ q: 'arashiyama', library_id })
const r = await fetch(`https://api.sweetdocs.app/v1/documents/search?${qs}`, { headers })
const { data: matches } = await r.json()