API Reference

Base URL: https://easymarkdown.io/api/v1

All requests must be authenticated with a Bearer token. Tokens are created in Settings → API keys inside the web app.

Machine-readable spec (OpenAPI 3.1): /docs/api/openapi.json

Command line

emd wraps this API for terminals, scripts and agents: one static binary, exit codes per error class, --json on every command.

brew install imgerson/emd/emd
emd login                 # paste a key from Settings → API keys
emd tree                  # projects, folders and documents with IDs
emd get 42 > plan.md && emd put 42 plan.md   # put never overwrites newer browser edits

AI agents

The easymarkdown-plans skill teaches an agent to keep the main plan for a piece of work here: saved when you approve it or it really changes (never per step), one project per repository, folders only once a project grows, never as a scratchpad, never with secrets. It replies with the document's link.

In Claude Code, as a plugin:

/plugin install emd --marketplace imgerson/emd

On Claude Code before 2.1.275, run /plugin marketplace add imgerson/emd, then /plugin install emd@easymarkdown. With emd installed, emd skill install adds it as a personal skill instead. Other agents: emd skill prints it, and /llms.txt summarises the API and the same rules.

The agent reads its key from EMD_TOKEN. Give it its own key with documents:write and an expiry, so you can revoke it alone.


Authentication

Include the token in every request:

Authorization: Bearer <your-token>

Keys look like 12|emd_… (keys made before October 2026 have no emd_; they keep working). Treat them like passwords: the emd_ prefix lets secret scanners spot a leaked key.

Every token expires: 30 days, 90 days (the default) or 1 year, chosen at creation. Tokens made before expiry existed stop working a year after they were created. A token can be revoked at any time from the Settings page. A missing, invalid or expired token gets 401 Unauthorized.

Each token has abilities: documents:read for GET requests and documents:write for everything else. A read-only key gets 403 Forbidden on writes. Without an active subscription, a lapsed account can still read (writes get 402) and an account that never paid gets 402 everywhere.


Rate limits

Scope Limit
Each API key 60 requests / minute
All of an account's API keys together 300 requests / minute
POST /documents/{id}/snapshots 10 requests / minute

The web editor has its own separate limit, so a script or agent using a key never slows down your browser. Every response carries X-RateLimit-Limit and X-RateLimit-Remaining; past a limit you get 429 with Retry-After (seconds).

Storage limits

Limit Value
One document's content 512 KB (bytes, not characters)
Everything in an account (documents, their history and snapshots) 500 MB
Documents / folders / unexpired API keys per account 10,000 / 2,000 / 50

Past a limit, the write gets 422 with a message saying which one. Deleting documents or snapshots frees space; reading always works.


Errors

Every error has a human-readable message and a machine-readable code. Branch on code (it is stable); message may be reworded.

{ "message": "Document was modified elsewhere. Reload to continue.", "code": "conflict" }

Validation errors also carry an errors object:

{
  "message": "The selected status is invalid.",
  "code": "validation_failed",
  "errors": { "status": ["The selected status is invalid."] }
}
Status code Meaning
401 unauthenticated Missing, invalid, revoked or expired key: create a new one in Settings
402 subscription_required The account has never had a plan
402 subscription_ended The plan ended: reads still work, writes don't
403 missing_ability The key lacks documents:read (reads) or documents:write (writes)
404 not_found Not found, or it belongs to another account
409 conflict The document changed since the updated_at you sent: re-read, merge, retry
409 folder_not_empty Move or delete the folder's contents first
422 validation_failed See errors
422 content_too_large Content over 512 KB
422 storage_limit The account's 500 MB is full
422 row_limit Too many documents, folders or API keys
429 rate_limited Wait Retry-After seconds
5xx server_error Our fault; retry later

IDs, statuses and order


Documents

List documents

GET /api/v1/documents

Query parameters

Name Type Description
q string Filter by title (case-insensitive, partial match). Use /search for content.
folder_id integer or empty Only documents in this folder; ?folder_id= (empty) for unfiled ones
status string Only documents with this status
limit integer, 1–500 Page size. Without it you get every document in one response
cursor string The meta.next_cursor from the previous page

Documents come newest first (updated_at, then id). Scripts should page with limit:

curl -H "Authorization: Bearer $EMD_TOKEN" "https://easymarkdown.io/api/v1/documents?limit=100"
# then again with &cursor=<meta.next_cursor> until it is null

Response 200 OK

{
  "data": [
    {
      "id": 42,
      "folder_id": 5,
      "title": "My document",
      "slug": "my-document",
      "status": "review",
      "position": 0,
      "word_count": 412,
      "updated_at": "2026-06-07T14:32:00.000000Z",
      "last_accessed_at": "2026-06-07T14:32:00.000000Z",
      "url": "https://easymarkdown.io/dashboard?doc=42"
    }
  ],
  "meta": {
    "total": 1,
    "next_cursor": null
  }
}

With limit, meta.total is the number of documents in this page and meta.next_cursor is the cursor for the next one (null on the last page). Without limit, meta.total counts them all.


Create a document

POST /api/v1/documents

Request body (JSON, all fields optional)

Field Type Description
title string (max 255) Document title. Defaults to "Untitled" if omitted.
content string (max 512 KB) Initial markdown. Non-empty content is recorded as the first revision.
folder_id integer or null One of your folders. Omit or null for unfiled.
status string Defaults to draft.

New documents go to the top of their folder.

Response 201 Created

{
  "id": 43,
  "folder_id": null,
  "title": "My document",
  "slug": "my-document",
  "status": "draft",
  "position": 0,
  "content": "",
  "word_count": 0,
  "updated_at": "2026-06-07T14:32:00.000000Z",
  "url": "https://easymarkdown.io/dashboard?doc=43"
}

Get a document

GET /api/v1/documents/{id}

Response 200 OK

{
  "id": 42,
  "folder_id": 5,
  "title": "My document",
  "slug": "my-document",
  "status": "review",
  "position": 0,
  "content": "# Hello\n\nFull markdown content here.",
  "word_count": 412,
  "updated_at": "2026-06-07T14:32:00.000000Z",
  "last_accessed_at": "2026-06-07T14:32:00.000000Z",
  "url": "https://easymarkdown.io/dashboard?doc=42"
}

Update a document

PATCH /api/v1/documents/{id}

Request body (JSON, all fields optional)

Field Type Description
title string (max 255) New title. Regenerates the slug.
content string (max 512 KB) Full markdown content.
status string New status. Does not change updated_at.
updated_at ISO 8601 datetime Client's last-known timestamp for optimistic locking.

A change to title or content records a revision. content is stored exactly as sent (leading indentation and trailing newlines are kept; "" clears the document). Titles are trimmed.

Optimistic locking

If updated_at is provided and the server's copy is more than 2 seconds newer, the request is rejected with 409 Conflict:

{ "message": "Document was modified elsewhere. Reload to continue." }

Response 200 OK

{
  "id": 42,
  "folder_id": 5,
  "title": "My document",
  "slug": "my-document",
  "status": "review",
  "position": 0,
  "word_count": 412,
  "updated_at": "2026-06-07T14:35:00.000000Z",
  "url": "https://easymarkdown.io/dashboard?doc=42"
}

Read or replace the raw Markdown

For scripts and agents that work with files: no JSON escaping, byte for byte.

GET /api/v1/documents/{id}/content
PUT /api/v1/documents/{id}/content

GET returns the Markdown as text/markdown, with the lock value in the X-Updated-At header. PUT takes the Markdown as the request body and replaces the content (a new history entry, same limits as PATCH). The whole body is the content: to change the title or status, use PATCH. Send the X-Updated-At you read to get 409 conflict instead of overwriting edits made since. Like updated_at in PATCH, it has one-second precision and two seconds of tolerance.

# Pull, edit locally, push back safely
curl -s -D headers.txt -H "Authorization: Bearer $EMD_TOKEN" \
  https://easymarkdown.io/api/v1/documents/42/content > plan.md
LOCK=$(grep -i '^x-updated-at:' headers.txt | cut -d' ' -f2 | tr -d '\r')
# ... edit plan.md ...
curl -s -X PUT -H "Authorization: Bearer $EMD_TOKEN" -H "Content-Type: text/markdown" \
  -H "X-Updated-At: $LOCK" --data-binary @plan.md \
  https://easymarkdown.io/api/v1/documents/42/content

Response 200 OK: the same JSON as PATCH (id, title, slug, status, word_count, updated_at, url).


Move or reorder a document

POST /api/v1/documents/{id}/move

Request body (JSON)

Field Type Description
folder_id integer or null Required (may be null). Target folder; the current one to just reorder.
place string Required. top, bottom, before or after.
anchor_id integer Required for before/after: another document in the target folder.

Response 200 OK

{ "id": 42, "folder_id": 5, "position": 0, "updated_at": "2026-06-07T14:35:00.000000Z" }

Delete a document

DELETE /api/v1/documents/{id}

Permanently deletes the document, its snapshots and its history.

Response 204 No Content


Snapshots and history

Every row here is a full copy of the document. kind tells them apart:

source is web (the editor) or api (a token).

Because editor saves fold together, the newest web row can change in place for up to 10 minutes; api rows never change once written.

Behaviour change (October 2026): without ?kind=, this list now includes automatic history as well as named snapshots. Clients that only want named snapshots should send ?kind=manual.

List snapshots and history

GET /api/v1/documents/{id}/snapshots

Query parameters

Name Type Description
kind string manual or auto. Omit for both.

Response 200 OK, newest first

{
  "data": [
    {
      "id": 311,
      "kind": "auto",
      "name": "Relaunch plan",
      "title": "Relaunch plan",
      "word_count": 412,
      "source": "api",
      "token_name": "claude-skill",
      "created_at": "2026-06-07T14:35:00.000000Z"
    },
    {
      "id": 7,
      "kind": "manual",
      "name": "Before refactor",
      "title": "Relaunch plan",
      "word_count": 398,
      "source": "web",
      "token_name": null,
      "created_at": "2026-06-07T13:00:00.000000Z"
    }
  ]
}

Create a snapshot

POST /api/v1/documents/{id}/snapshots

Captures the current content of the document. If the document already has 50 snapshots the oldest one is silently deleted before the new one is created.

Request body (JSON, all fields optional)

Field Type Description
name string (max 255) Snapshot name. Defaults to "Snapshot — HH:mm" if omitted.

Response 201 Created

{
  "id": 8,
  "kind": "manual",
  "name": "Before refactor",
  "title": "Relaunch plan",
  "word_count": 412,
  "source": "api",
  "token_name": "my-cli",
  "created_at": "2026-06-07T14:32:00.000000Z"
}

Get a snapshot

GET /api/v1/documents/{id}/snapshots/{snapshot_id}

Works for both kinds. Response 200 OK: the list fields plus content.

{
  "id": 7,
  "kind": "manual",
  "name": "Before refactor",
  "title": "Relaunch plan",
  "word_count": 398,
  "source": "web",
  "token_name": null,
  "content": "# Full markdown content at the time of the snapshot.",
  "created_at": "2026-06-07T13:00:00.000000Z"
}

Restore a snapshot

POST /api/v1/documents/{id}/snapshots/{snapshot_id}/restore

Restores the document's content to the snapshot. Before restoring, the API automatically creates a backup snapshot of the current content named "Before restore" (its created_at says when). Works for both kinds; the restore itself is recorded in history.

Response 200 OK

Returns the updated document:

{
  "id": 42,
  "content": "# Restored content.",
  "word_count": 198,
  "updated_at": "2026-06-07T14:40:00.000000Z"
}

Delete a snapshot

DELETE /api/v1/documents/{id}/snapshots/{snapshot_id}

Response 204 No Content


Folders

Folders nest through parent_id. A top-level folder (parent_id: null) is a project.

List folders

GET /api/v1/folders

A flat list; nest it by parent_id.

{
  "data": [
    { "id": 5, "parent_id": null, "name": "Website relaunch", "status": "review", "position": 0,
      "created_at": "…", "updated_at": "…" },
    { "id": 6, "parent_id": 5, "name": "Plans", "status": "draft", "position": 0,
      "created_at": "…", "updated_at": "…" }
  ],
  "meta": { "total": 2 }
}

Create a folder

POST /api/v1/folders
Field Type Description
name string (max 255) Required. Names need not be unique.
parent_id integer or null One of your folders. Omit for a project.
status string Defaults to draft.

New folders go to the bottom of their parent. Response 201 Created: the folder.

Update a folder

PATCH /api/v1/folders/{id}

name and/or status. Response 200 OK: the folder.

Move or reorder a folder

POST /api/v1/folders/{id}/move

Same body as moving a document, with parent_id in place of folder_id. Moving a folder into itself or one of its subfolders is a 422. Response 200 OK: the folder.

Delete a folder

DELETE /api/v1/folders/{id}

Only empty folders can be deleted; otherwise 409 Conflict. Moving documents is POST /documents/{id}/move (PATCH does not move). Move or delete the contents first, or set the folder's status to archived. Response 204 No Content


Search

GET /api/v1/search?q={text}

Case-insensitive match on folder names, document titles and document content (% and _ are literal). Up to 50 of each. path is the folder trail ("" at the top level or unfiled); snippet is ~120 characters around the first content match, null when only the title matched.

{
  "folders": [
    { "id": 6, "parent_id": 5, "name": "Plans", "status": "draft", "path": "Website relaunch" }
  ],
  "documents": [
    { "id": 42, "folder_id": 6, "title": "Relaunch plan", "status": "review",
      "updated_at": "…", "path": "Website relaunch / Plans", "snippet": "…new pricing page…" }
  ]
}

Examples

Push a local file to a document by title (create or update)

API=https://easymarkdown.io/api/v1
AUTH="Authorization: Bearer $EMD_TOKEN"

# 1. Find the document by its exact title (?q= matches any part of a title)
DOC=$(curl -s -H "$AUTH" "$API/documents?q=README" \
  | jq -c '[.data[] | select(.title == "README")][0]')

if [ "$DOC" != "null" ]; then
  # 2a. Found: update it, sending the updated_at you read so edits made in the
  #     browser since then are never overwritten (you get 409 instead).
  jq -n --rawfile c README.md --argjson d "$DOC" '{content: $c, updated_at: $d.updated_at}' \
    | curl -s -X PATCH -H "$AUTH" -H "Content-Type: application/json" -d @- \
        "$API/documents/$(jq -r .id <<< "$DOC")"
else
  # 2b. Not found: create it
  jq -n --rawfile c README.md '{title: "README", content: $c}' \
    | curl -s -X POST -H "$AUTH" -H "Content-Type: application/json" -d @- "$API/documents"
fi

Create a snapshot before making changes

curl -s -X POST \
  -H "Authorization: Bearer $EMD_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"name": "before refactor"}' \
  "https://easymarkdown.io/api/v1/documents/$DOC_ID/snapshots"

Pull a document by ID to a local file

curl -s \
  -H "Authorization: Bearer $EMD_TOKEN" \
  "https://easymarkdown.io/api/v1/documents/$DOC_ID" \
  | jq -r '.content' > README.md