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/json on 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: *. OPTIONS returns 204.
# 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 — every GET the principal can see. Any mutation returns 403 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…' },
})
Tokens are shown exactly once. Copy the value immediately after minting. We store only a SHA-256 hash, so we can revoke a key but never recover one.

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)

  1. 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

    NamePrefixScopeLast usedStatus
    Zapier flow sd_pk_kT4xQp8… write 2 days ago Active Revoke
  2. 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

    Scope
  3. 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
  4. Use it as a Bearer token

    Drop it into the Authorization header 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.

  1. 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

    NamePrefixScopeLast usedStatus
    Acme nightly sync sd_sk_pR9zQ2v… write 17 min ago Active Revoke
  2. Mint a service key

    Same form, different title. The token prefix will be sd_sk_ instead of sd_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

    Scope
Revoking a key. From the list in step 1, click Revoke on the row. The next request using that token returns 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

HTTPCodeWhen
400invalid_requestMalformed JSON, missing required field, unknown query param.
401unauthenticatedMissing or malformed Bearer; key not found, revoked, or principal missing.
403scope_insufficientRead-only key attempting a mutation.
403permission_deniedKey has access at a lower level than required.
403doc_cap_reachedFree-plan owner's 25-doc cap hit (defensive — practically unreachable in v1).
404not_foundResource doesn't exist, or isn't visible to this key.
412etag_mismatchIf-Match header didn't match current updated_at.
422validation_failedBody parsed but semantically invalid (e.g., folder cycle).
429quota_exhaustedMonthly cap hit. Retry-After is seconds until the 1st UTC of next month.
429rate_limitedPer-minute burst hit. Retry-After: 60.
500server_errorUnexpected. Logged with request_id.
503unavailableDatabase 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.

PlanMonthlyBurst (60s)
Pro10,000 requests60 req
Teams100,000 requests60 req
FreeCannot 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.

Caveat. If a dashboard editor has the same document open at the moment of an API write, its next debounced save can overwrite the API content. To guarantee no overwrite, send the 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

FieldTypeDescription
iduuidUnique identifier.
namestringDisplay name. "Personal" for personal libraries.
kindstring"personal" or "org".
org_iduuid · nullableThe owning org, or null for personal libraries.
created_attimestampRFC 3339 in UTC.

List libraries

GET /v1/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

GET /v1/libraries/{id}
{
  "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

FieldTypeDescription
iduuidUnique identifier.
library_iduuidThe library this folder lives in.
parent_iduuid · nullableParent folder, or null if at library root.
namestringFolder name.
positionintegerSort order within parent. Lower = earlier.
created_attimestampRFC 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

GET /v1/folders
Query paramTypeDescription
library_idrequiredThe library to list folders from.
parent_iduuid · optionalDirect children of one folder. Omit for library root.
limitinteger · optional1-100, default 25.
cursorstring · optionalFrom the previous response's next_cursor.

Get a folder

GET /v1/folders/{id}

Create a folder

POST /v1/folders
Body fieldTypeDescription
library_idrequiredThe library to create in.
namerequiredDisplay name.
parent_iduuid · optionalParent 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

PATCH /v1/folders/{id}
Body fieldTypeDescription
namestring · optionalRename the folder.
parent_iduuid · nullable · optionalMove to a different parent. null moves to root.
library_iduuid · optionalMove to a different library (must be accessible to the key).

Delete a folder

DELETE /v1/folders/{id}

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

FieldTypeDescription
iduuidUnique identifier.
library_iduuidOwning library.
folder_iduuid · nullableFolder, or null for library root.
titlestringDisplay title.
contentstringRaw Markdown source. UTF-8.
doc_typestring"md" or "svg".
is_publicbooleanWhether the public-link toggle is on. v1 cannot set this.
share_urlstring · nullableThe shareable URL when is_public is true; otherwise null.
created_byuuidUser id who created the doc.
created_attimestampRFC 3339 in UTC.
updated_attimestampRFC 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

GET /v1/documents
Query paramTypeDescription
library_idrequiredThe library to list from.
folder_iduuid · optionalDirect children of one folder. Omit for library root.
updated_sinceiso8601 · optionalOnly docs with updated_at > updated_since. Polling-friendly.
limitinteger · optional1-100, default 25.
cursorstring · optionalFrom 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

GET /v1/documents/{id}

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

POST /v1/documents
Body fieldTypeDescription
library_idrequiredThe library to create in.
folder_iduuid · optionalFolder to place into. Omit for root.
titlestring · optionalDisplay title. Defaults to "Untitled".
contentstring · optionalInitial 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

PATCH /v1/documents/{id}
Body fieldTypeDescription
titlestring · optionalRename the document.
contentstring · optionalReplace the Markdown body. Atomically clears the Yjs state.
folder_iduuid · nullable · optionalMove to a different folder. null moves to library root.
library_iduuid · optionalMove 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

DELETE /v1/documents/{id}

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.