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
- IDs are stable. A document's or folder's
idnever changes and is never reused. Address things byid; a document'sslugis regenerated on every rename. - Statuses (documents and folders):
draft(default),review,approved,done,archived. Anything else is a422. - Order is manual:
positioncounts from0among siblings (documents in the same folder, or folders under the same parent). Change it with themoveendpoints. updated_atmeans "content last changed". Moving a document or changing its status or folder leavesupdated_atalone, so those never cause a409on the next content save.url(read-only, in list, get, create,PATCHandPUT contentresponses) opens the document in the workspace:https://easymarkdown.io/dashboard?doc={id}. Hand it to a person: it asks them to sign in first if needed, and shows nothing to anyone who doesn't own the document.
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:
manual: a named snapshot you took (max 50 per document; the oldest is pruned).auto: history, written on every change totitleorcontent, holding the state after the change. API writes always add a row and record the key's name intoken_name. Editor autosaves within 10 minutes of each other fold into one row. Max 200 per document; the oldest is pruned.
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