Automation recipes¶
Everything the web UI does, the API does — it is the same surface, not a subset. This page walks one
document through its whole lifecycle with curl, using a personal access token.
Where the API reference lives¶
These recipes are worked examples, not a reference. The reference is generated from the running build:
- API reference on this site — a committed snapshot of the document below, guarded by CI so it cannot drift from what the application serves.
- Interactive docs:
/docson your install — e.g. http://localhost:8080/docs. Self-contained, no external CDN, and "try it out" works there. - OpenAPI 3.1 document:
/openapi/v1.json— feed it to a client generator, Bruno, Insomnia, or anything else that speaks OpenAPI.
Authentication¶
Two credentials, same surface:
Authorization: Bearer ed_…— a personal access token. Use this for automation.- the
ed_sessioncookie — what the browser uses, including for server-sent events, since a browser'sEventSourcecannot set headers. (Outside a browser, SSE takes theAuthorizationheader like everything else.)
A token never exceeds the role of the user who minted it. There is no way to create a token more powerful than yourself.
Mint a token¶
Tokens are minted by an authenticated user, so start with a login to get a session cookie, then use it once to create the token.
BASE=http://localhost:8080
# 1. Log in, keeping the session cookie.
curl -sS -c cookies.txt -X POST "$BASE/api/v1/auth/login" \
-H 'Content-Type: application/json' \
-d '{"email":"you@example.com","password":"your-password"}'
# 2. Mint a PAT. The raw token is returned EXACTLY ONCE.
curl -sS -b cookies.txt -X POST "$BASE/api/v1/tokens" \
-H 'Content-Type: application/json' \
-d '{"name":"release-bot","scopes":[],"expiresAt":null}'
# {"id":"…","token":"ed_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"}
export ED_TOKEN=ed_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
Only a SHA-256 hash of the token is stored, so it cannot be recovered. Lost it? Revoke
(DELETE /api/v1/tokens/{id}) and mint another.
scopes is recorded but not enforced in v1
You must send the field, and it is stored and echoed back by GET /api/v1/tokens — but nothing
checks it. A token's authority is exactly its owner's per-document role, no more and no less.
Sending ["read"] does not produce a read-only token. Do not build a permission model on it.
Send [] and treat every token as full-authority-of-its-owner until scope enforcement lands.
POST /api/v1/tokens is rate-limited to 20 per 60 seconds per user.
From here on, every call uses the token:
AUTH="Authorization: Bearer $ED_TOKEN"
The full lifecycle¶
1. Create a document¶
DOC=$(curl -sS -H "$AUTH" -H 'Content-Type: application/json' \
-X POST "$BASE/api/v1/documents" \
-d '{"name":"Supply Agreement","folderId":null}' | jq -r .id)
Body is { "name": string?, "folderId": uuid? }. Pass folderId to file it; null leaves it at the
root. (Create folders with POST /api/v1/folders, body { "name": string?, "parentId": uuid? }.)
2. Upload the first version¶
Multipart, with the file in a field named file:
V1=$(curl -sS -H "$AUTH" \
-X POST "$BASE/api/v1/documents/$DOC/versions" \
-F 'file=@./supply-agreement.docx' | jq -r .versionId)
Response is { "versionId": …, "major": 0, "minor": 0, "revision": 1 } — the first version is always
0.0.1.
POST /api/v1/documents/{id}/versions:import is the same call with a different provenance recorded on
the version.
Retrying an upload is safe
A sessionless upload de-duplicates against the current main-line head's sha256. So immediately
re-POSTing the same file — the timeout-and-retry case — returns the existing version rather than
creating a duplicate, and the response carries the same versionId.
Note the exact scope: it compares against the head only, not the whole history. Upload A, then B, then A again and you do get a new version, because A is no longer the head. That is intended — re-instating earlier content is a real change.
3. Upload a second version and read the change summary¶
V2=$(curl -sS -H "$AUTH" \
-X POST "$BASE/api/v1/documents/$DOC/versions" \
-F 'file=@./supply-agreement-rev2.docx' | jq -r .versionId)
curl -sS -H "$AUTH" \
"$BASE/api/v1/documents/$DOC/compare?from=$V1&to=$V2&format=summary"
# {"insertions":14,"deletions":3,"moves":0,"formatChanges":0}
No polling needed — and moves/formatChanges are always 0
A background worker computes the summary eagerly after each save, but format=summary computes it
inline if the cache is not populated yet. One call always returns real numbers; there is no
pending state and nothing to poll for.
moves and formatChanges are hard-coded to 0. The comparison engine classifies insertions
and deletions only: a move is reported as a deletion plus an insertion, and formatting-only edits are
not counted. Do not treat those two fields as measurements.
The same endpoint gives you the redline itself:
# Redline as HTML (computed on first request, then cached permanently)
curl -sS -H "$AUTH" \
"$BASE/api/v1/documents/$DOC/compare?from=$V1&to=$V2&format=html" -o redline.html
# Redline as a .docx with tracked changes
curl -sS -H "$AUTH" \
"$BASE/api/v1/documents/$DOC/compare?from=$V1&to=$V2&format=docx" -o redline.docx
format defaults to summary. If the comparison engine cannot handle the documents, summary and
docx return 422 Comparison unavailable and html returns a plain "Comparison unavailable." message
(it is meant to be displayed, not parsed) — never a 500.
0/0 means no changes, and only that
A 200 with {"insertions":0,"deletions":0,…} means the two versions were compared successfully and
their text is identical — a common result when a file is re-saved without an edit. A comparison that
failed is the 422 above, never zeros. Check the status code, not the counts.
4. Publish¶
curl -sS -H "$AUTH" -H 'Content-Type: application/json' \
-X POST "$BASE/api/v1/versions/$V2/publish" \
-d '{"kind":"minor","name":"Counterparty draft"}'
kind must be minor or major — anything else is a 400. name is an optional publish
label. Minor takes the counter to X.(Y+1).0, major to (X+1).0.0, and the version you selected is
renumbered; it does not have to be the newest one.
Publishing also queues a PDF render. It is asynchronous, so poll for it if you need the file:
until curl -fsS -H "$AUTH" \
"$BASE/api/v1/versions/$V2/download?format=pdf" -o released.pdf; do
echo "PDF not ready yet…"; sleep 2
done
?format=pdf on an unpublished version returns 409 — no PDF exists, and easydocs will not render
one on the fly and imply it is a release artifact. ?format=docx (the default) always works.
List everything published with GET /api/v1/documents/{id}/publications.
5. Request approval¶
Approvers must already be members of the document, so add them first if needed. Note that
POST …/members takes an email, not a user id, and requires that you are an Owner of the
document:
curl -sS -H "$AUTH" -H 'Content-Type: application/json' \
-X POST "$BASE/api/v1/documents/$DOC/members" \
-d '{"email":"reviewer@example.com","role":"Viewer"}'
# {"userId":"…","email":"reviewer@example.com","role":"Viewer"}
role must be Owner, Editor or Viewer. If that email already belongs to a member of your
organization the membership is granted immediately and the response carries their userId; if not, an
invitation is created instead. Adding someone who is already a member returns 409.
The approval request itself takes user ids:
# NB: the members list is a bare JSON array, not a paginated {items:[…]} envelope.
REVIEWER=$(curl -sS -H "$AUTH" "$BASE/api/v1/documents/$DOC/members" \
| jq -r '.[] | select(.email=="reviewer@example.com") | .userId')
curl -sS -H "$AUTH" -H 'Content-Type: application/json' \
-X POST "$BASE/api/v1/versions/$V2/approvals" \
-d '{"approverIds":["'"$REVIEWER"'"],"dueAt":"2026-08-15T17:00:00Z"}'
Body is { "approverIds": uuid[], "dueAt": timestamp? }. One row is created per approver, each
returned with its own id. Requirements, all enforced:
- the version must be published (
400 Not publishedotherwise); approverIdsmust be non-empty;- every id must already be a member of the document (
400otherwise).
The approver then decides, once and immutably:
curl -sS -H "$AUTH_REVIEWER" -H 'Content-Type: application/json' \
-X POST "$BASE/api/v1/approvals/$APPROVAL_ID:respond" \
-d '{"decision":"approved","comment":"Clause 7 is fine as drafted."}'
Poll a worklist with GET /api/v1/approvals?filter=assigned&status=open (filter is assigned or
requested; status is open or closed). The requester can withdraw an undecided request with
POST /api/v1/approvals/{id}:cancel.
6. Share a version¶
curl -sS -H "$AUTH" -H 'Content-Type: application/json' \
-X POST "$BASE/api/v1/versions/$V2/share-links" \
-d '{"expiresAt":"2027-01-01T00:00:00Z"}'
# {"token":"kQ8…","url":"/s/kQ8…"}
Body is just { "expiresAt": timestamp? } — null for no expiry; a time already in the past is a
400. The raw token is returned once;
only its hash is stored, so it cannot be recovered later. Prefix url with your PUBLIC_BASE_URL to get
something sendable.
The recipient needs no account. GET /s/{token} returns JSON to an API client and the SPA landing page
to a browser, and GET /s/{token}/download streams the .docx. Both are rate-limited per client IP
(120/min and 30/min respectively). Every view increments a counter and writes an audit row.
To revoke, you need the link's id, which the create call does not return — list them:
curl -sS -H "$AUTH" "$BASE/api/v1/documents/$DOC/share-links"
# {"items":[{"id":"…","versionId":"…","versionNumber":"0.1.0","createdByName":"…",
# "createdAt":"…","expiresAt":"…","revokedAt":null,"viewCount":3}],"nextCursor":null}
curl -sS -H "$AUTH" -X DELETE "$BASE/api/v1/share-links/$LINK_ID"
The list includes revoked and expired links, flagged by revokedAt / expiresAt — "did I revoke that?"
is one of the questions it exists to answer. It never returns the token or its hash. Either the creator
or an Editor of the document may revoke.
Other useful calls¶
From an AI agent instead of curl¶
The MCP server exposes the read side of
this API — every GET below — as eighteen tools for Claude Code, Claude Desktop, Cursor, Codex and
Gemini CLI, authenticated with the same ed_ token and running on your own machine. It cannot write.
With uv installed:
claude mcp add easydocs \
-e EASYDOCS_URL=https://docs.example.com -e EASYDOCS_TOKEN=ed_... \
-- uv run https://raw.githubusercontent.com/AptsNY/easydocs/main/packages/mcp/easydocs_mcp.py
Other clients take the same command / args / env in their JSON config — the
package README shows each. Then ask
"what changed in the lease between 0.0.4 and 0.1.0?"
# Who am I, and which org is this session bound to?
curl -sS -H "$AUTH" "$BASE/api/v1/me"
# Version history, newest first, cursor-paginated. Rows carry branch identity
# (branchId, branchKind, branchOrdinal, branchMergedIntoVersionId).
curl -sS -H "$AUTH" "$BASE/api/v1/documents/$DOC/versions?order=desc&limit=50"
# Search documents by name or content. `%` and `_` are literal characters, not wildcards.
curl -sS -H "$AUTH" "$BASE/api/v1/documents?q=supply"
# Move a document to another folder, or back to the top level with an explicit null
# (leaving folderId out of the body leaves the folder alone)
curl -sS -H "$AUTH" -H 'Content-Type: application/json' \
-X PATCH "$BASE/api/v1/documents/$DOC" -d '{"folderId":null}'
# A version's plain text, to read or summarize. .docx only — a PDF or legacy .doc
# version answers 409 naming what its bytes actually are, never an empty string.
curl -sS -H "$AUTH" "$BASE/api/v1/versions/$V1/text"
# Revert: appends a new version equal to an older one (201); history is untouched. If that
# content is already the head, nothing is written: 200 with the existing head.
curl -sS -H "$AUTH" -X POST "$BASE/api/v1/versions/$V1/revert"
# Merge a concurrent branch into main
curl -sS -H "$AUTH" -H 'Content-Type: application/json' \
-X POST "$BASE/api/v1/documents/$DOC/merges" \
-d '{"left":"'"$MAIN_HEAD"'","right":"'"$BRANCH_HEAD"'"}'
# Fork a version into an isolated copy for external review
curl -sS -H "$AUTH" -H 'Content-Type: application/json' \
-X POST "$BASE/api/v1/versions/$V2/copies" \
-d '{"name":"Counterparty review copy"}'
# Manual version-counter override (R5) — note the field is `rev`, not `revision`.
# Any values are accepted: winding it below the history makes the next save reuse a number.
curl -sS -H "$AUTH" -H 'Content-Type: application/json' \
-X PUT "$BASE/api/v1/documents/$DOC/version-counter" \
-d '{"major":2,"minor":4,"rev":0}'
# The audit trail
curl -sS -H "$AUTH" "$BASE/api/v1/documents/$DOC/audit"
Live updates¶
curl -N -b cookies.txt "$BASE/api/v1/documents/$DOC/events"
Server-sent events, authorized like every other route: the session cookie or an
Authorization: Bearer header (an ed_… token works). There is no ?token= query parameter. A
browser's native EventSource cannot set headers, so in a browser the cookie is the credential;
from a script, curl -N -H "$AUTH" … or any SSE client that sends headers works as well.
Events: version.created, version.published, merge.completed, diff.ready, member.added,
push.requested, push.reviewed, approval.responded, pdf.ready, version.named,
version.reverted.
If you proxy easydocs, make sure response buffering is off or this stream will stall — see Self-hosting.
Conventions¶
- Pagination is cursor-based: list endpoints return
{ "items": [...], "nextCursor": … }. Pass?cursor=to continue,?limit=to size the page. AnullnextCursormeans the end. - Errors are RFC 7807
application/problem+json, everywhere, including rate-limit rejections. - Rate limits return
429with aRetry-Afterheader. Honour it. - No
Idempotency-Keysupport. Version upload is naturally idempotent via sha256 de-duplication; other mutations are low-frequency. Do not blind-retry an approval request. A retried publish is refused with409rather than renumbering the version again. - Timestamps are ISO 8601, UTC. Send UTC — a non-UTC offset is normalized, but do not rely on it.
Error responses worth handling¶
| Status | When |
|---|---|
400 Not published |
Requesting approval on an unpublished version. |
400 |
kind not minor/major; empty approverIds; an approver who is not a document member; a negative version counter; a non-multipart or empty upload body. |
403 |
You are not a member of the document. Organization role grants no document access. |
404 |
Also returned instead of 403 where existence itself is sensitive, and for an unknown, revoked, or expired share token. |
409 |
PDF requested for an unpublished version; publishing a version that is already published (other than promoting a minor to major); a merge the comparison engine could not produce. |
422 |
A redline .docx could not be produced. |
429 |
Rate limited. Check Retry-After. |