Developer platform
REST API
The hosted REST API lets people and agents create, read, render, and edit diagrams over plain HTTP, without the browser. It is the same surface the CLI and MCP server speak. Everything lives under the base path /api/v1.
Authentication
Every request carries a Personal Access Token in the standard bearer header. Mint a token in your account settings and send it with each call.
Authorization: Bearer <your-token>See Authentication for token capabilities, rate limits, and the auth error codes.
Conventions
- Base path is
/api/v1. All request and response bodies are JSON. - Every endpoint declares the capability it requires —
read,write,publish, ordelete. A token without it gets403 insufficient_scope. - Access checks return
404 not_foundwhen a target is missing OR is not yours. The two cases are indistinguishable, so the API never leaks whether an id exists.
Every failure shares one envelope shape:
{ "error": "<machine_code>", "message"?: "<human-readable detail>" }The error value is a stable machine code. The optional message adds human detail and is omitted entirely when there is none.
Endpoints
GET /me
Capability: none — any valid token answers. Returns the identity and grants behind the calling token. Useful as a quick check that a token is valid.
{
"id": "<userId>",
"email": "user@example.com",
"capabilities": ["read", "write", "publish", "delete"],
"scope": "read" | "read-write"
}scope is a legacy coarse summary — read-write only when the token holds every capability. New integrations should read capabilities.
GET /diagrams
Capability: read. Lists the diagrams you own, newest first, with cursor pagination. List items do not include the scene; fetch a single diagram to read it.
| Query param | Type | Description |
|---|---|---|
limit | number? | How many diagrams to return. Default 50, clamped to the range 1 to 100. |
cursor | string? | Opaque pagination cursor from a previous page's nextCursor. Treat it as a black box and round-trip it unchanged. Omit for the first page. |
filter | "active" | "trash" | Which set to list. Default "active" (not trashed); "trash" returns trashed diagrams. |
{
"items": [
{
"id": "<id>",
"title": "Architecture",
"folderId": "<id>" | null,
"starred": false,
"updatedAt": "2026-06-02T12:00:00.000Z",
"createdAt": "2026-06-01T09:00:00.000Z"
}
],
"nextCursor": "<cursor>" | null
}When nextCursor is non-null, pass it back as the cursor query param to fetch the next page. A null cursor means there are no more pages.
POST /diagrams
Capability: write. Creates a diagram from exactly one of spec or scene. Sending both, or neither, is rejected.
| Field | Type | Description |
|---|---|---|
title | string? | Diagram title. The stored, sanitized value is echoed back in the response. |
folderId | string | null? | UUID of a folder you own, or null for the root. A non-UUID string is rejected. |
spec | DiagramSpec? | A high-level node and edge spec that the server auto-lays-out. Provide this OR scene, never both. See the diagram DSL for the spec shape. |
scene | Scene? | A raw Excalidraw scene, preserved as-is. Provide this OR spec, never both. Any appState you include is dropped. |
POST /api/v1/diagrams
Authorization: Bearer <token with write capability>
Content-Type: application/json
{
"title": "Architecture",
"spec": {
"version": "1",
"nodes": [
{ "id": "client", "label": "Client", "shape": "rectangle" },
{ "id": "api", "label": "API Server", "shape": "rectangle" },
{ "id": "db", "label": "Database", "shape": "diamond" },
{ "id": "cache", "label": "Cache", "shape": "ellipse" }
],
"edges": [
{ "from": "client", "to": "api", "label": "request" },
{ "from": "api", "to": "db" },
{ "from": "api", "to": "cache" }
],
"layout": { "direction": "TB" }
}
}{
"id": "<id>",
"title": "Architecture",
"folderId": "<id>" | null,
"ownerId": "<userId>",
"createdAt": "2026-06-02T12:00:00.000Z",
"updatedAt": "2026-06-02T12:00:00.000Z",
"elementCount": 11
}See the diagram DSL for the full spec grammar, including shorthand edge strings.
GET /diagrams/:id
Capability: read. Returns the full scene. A trashed diagram is still returned, with deletedAt set, so it can be inspected or restored.
{
"id": "<id>",
"title": "Architecture",
"folderId": "<id>" | null,
"ownerId": "<userId>",
"createdAt": "2026-06-02T12:00:00.000Z",
"updatedAt": "2026-06-02T12:00:00.000Z",
"deletedAt": "2026-06-02T13:00:00.000Z" | null,
"docUpdatedAt": "2026-06-02T12:30:00.000Z" | null,
"scene": { "elements": [ /* ... */ ], "appState": {} }
}docUpdatedAt is the live-doc revision clock. Echo it back as expectedUpdatedAt on regenerate or patch to make the write conditional on nobody else having edited since (see those endpoints).
PATCH /diagrams/:id
Capability: write. Renames and/or moves a diagram. At least one field is required. The response does not include the scene.
| Field | Type | Description |
|---|---|---|
title | string? | New title. Renames the diagram. |
folderId | string | null? | Including this key MOVES the diagram: a UUID you own moves it into that folder, null moves it to the root. Omitting the key entirely leaves the location unchanged. |
PATCH /api/v1/diagrams/<id>
Authorization: Bearer <token with write capability>
Content-Type: application/json
{
"title": "Architecture v2",
"folderId": "<folder-id>"
}{
"id": "<id>",
"title": "Architecture v2",
"folderId": "<folder-id>" | null,
"ownerId": "<userId>",
"createdAt": "2026-06-02T12:00:00.000Z",
"updatedAt": "2026-06-02T14:00:00.000Z",
"deletedAt": null
}DELETE /diagrams/:id
Capability: delete. Soft-deletes a diagram to the trash. The call is idempotent: deleting an already-trashed diagram still succeeds. Returns no body.
204 No ContentPOST /diagrams/:id/restore
Capability: delete. The inverse of DELETE — brings a trashed diagram back by clearing its deletedAt. Restore is part of the delete lifecycle, so it is owner-only and needs the same delete capability. It is idempotent: restoring an already-active diagram is a harmless no-op. Takes no body and returns none.
204 No ContentGET /diagrams/:id/render
Capability: read. Renders the diagram's saved scene. By default the render is faithful — the editor's own exporter, with real hand-drawn strokes, editor fonts, and embedded images — falling back gracefully to a fast preview if the render service is unavailable. The output format is chosen with ?format= and the fidelity with ?quality=.
| Query param | Type | Description |
|---|---|---|
format | "json" | "png" | "svg" | Output format. Default "json" returns an envelope with the SVG and base64 PNG; "png" returns image/png bytes; "svg" returns image/svg+xml. |
quality | "faithful" | "fast" | Render fidelity. Default faithful runs the editor’s own exporter — real hand-drawn strokes, editor fonts, and embedded images. fast forces the lightweight preview path. A value other than these two returns 400 invalid_quality. |
The default json format returns an envelope:
{
"isEmpty": false,
"elementCount": 11,
"svg": "<svg ...>",
"png": "<base64, no data: prefix>" | null,
"quality": "faithful" | "fast",
"note"?: "faithful_unavailable" | "images_truncated"
| "large_diagram_placeholder" | "render_failed"
}
// empty scene
{ "isEmpty": true, "elementCount": 0, "svg": null, "png": null, "quality": "fast" }quality reports the fidelity you actually got — faithful or, when the render service could not serve it, fast with note: "faithful_unavailable". An agent that needs production-quality bytes should read quality to confirm it. images_truncated means some embedded images were dropped to stay within limits.
The png format returns image/png bytes (the rendered PNG is 1024 pixels wide, roughly 1024 by 640), and svg returns image/svg+xml. For either image format an empty scene returns 204. Raw image responses carry the fidelity in an x-render-quality header (faithful or fast) plus an x-render-note header when a note applies. Faithful renders are served from a content-keyed cache, so re-rendering an unchanged scene returns the cached bytes; any edit changes the key and re-renders.
PUT /diagrams/:id/public-link
Capability: publish. Turns the anonymous public link on or off and optionally sets or clears its password.
| Field | Type | Description |
|---|---|---|
enabled* | boolean | Turn the public link on or off. |
password | string | null? | A string sets a password, null clears it, and omitting the key leaves the existing password unchanged. |
{
"slug": "<slug>",
"enabled": true,
"hasPassword": false,
"version": 1,
"url": "<absolute public link url>" | null
}url is an absolute URL when the link is enabled, and null when it is disabled.
POST /diagrams/:id/edit
Capability: write. Additively merges an edit fragment into a diagram. Existing elements are never moved, resized, or deleted; the fragment only adds nodes and edges and patches a small allowlist of node properties. The change is reflected immediately in a subsequent read or render.
{
"addNodes": [ /* new nodes, explicit x/y honored, else auto-placed */ ],
"addEdges": [ /* from/to resolve against existing or added node ids */ ],
"updateNodes": [ /* { id } plus the allowed properties below */ ]
}Only non-reflow properties can be patched on existing nodes: strokeColor, backgroundColor, fillStyle, and label (plus the required id). Geometry fields like x, y, width, height, and shape are rejected, so an already-placed node never starts overlapping a neighbor.
{ "added": ["<id>"], "updated": ["<id>"], "merged": 2 }See the diagram DSL for the full edit-fragment shape and field rules.
POST /diagrams/:id/regenerate
Capability: write. The destructive sibling of /edit: it replaces the whole scene in place, keeping the same id and URL. Send exactly one of spec or scene (same XOR rule as create), plus an optional title rename and an optional expectedUpdatedAt precondition. Comments bound to elements the new scene no longer contains are system-resolved; comments bound to ids the new spec reuses stay anchored.
| Field | Type | Description |
|---|---|---|
spec | DiagramSpec? | A high-level node and edge spec, auto-laid-out server-side. Provide this OR scene, never both. |
scene | Scene? | A raw Excalidraw scene, swapped in as-is. Provide this OR spec, never both. |
title | string? | Optional rename applied on success. A title that sanitizes to empty is rejected. |
expectedUpdatedAt | string? | Optimistic-concurrency precondition. Echo the docUpdatedAt you read from GET /diagrams/:id; if the live doc has moved since (a co-editor persisted a change), the write is refused with 409 stale_read before it lands. Opt-in — omit it to force the write. |
POST /api/v1/diagrams/<id>/regenerate
Authorization: Bearer <token with write capability>
Content-Type: application/json
{
"spec": { "nodes": [ /* ... */ ], "edges": [ /* ... */ ] },
"title": "Architecture v2",
"expectedUpdatedAt": "2026-06-02T12:30:00.000Z"
}{
"id": "<id>",
"replaced": 11,
"orphanedComments": 0,
"title": "Architecture v2"
}replaced is the new element count and orphanedComments how many comments were resolved by the swap. title is echoed only when a rename was applied. Content-model failures return the same 422/413 codes as create.
POST /diagrams/:id/patch
Capability: write. The surgical sibling of /edit and /regenerate: it applies an ordered list of element-addressable ops (add, update, move, resize, restyle, delete) in place, keeping the same id and URL. Send only the deltas. An optional expectedUpdatedAt precondition is supported. Comments bound to an element a delete op removes are system-resolved.
| Field | Type | Description |
|---|---|---|
ops* | PatchOp[] | An ordered array of element-addressable operations. The op grammar, the update allowlist, and the size caps live in the diagram DSL. |
expectedUpdatedAt | string? | Optimistic-concurrency precondition. Echo the docUpdatedAt you read from GET /diagrams/:id; if the live doc has moved since (a co-editor persisted a change), the write is refused with 409 stale_read before it lands. Opt-in — omit it to force the write. |
POST /api/v1/diagrams/<id>/patch
Authorization: Bearer <token with write capability>
Content-Type: application/json
{
"ops": [ /* ordered element-addressable operations */ ],
"expectedUpdatedAt": "2026-06-02T12:30:00.000Z"
}{
"id": "<id>",
"applied": 3,
"elements": 12,
"deleted": 1,
"orphanedComments": 0
}applied is the number of ops applied, elements the resulting scene element count, and deleted how many elements the patch removed. The full op grammar and update allowlist live in the diagram DSL reference.
GET /folders
Capability: read. Returns a flat list of every folder you own.
{
"items": [
{ "id": "<id>", "name": "Architecture", "parentId": "<id>" | null }
]
}POST /folders
Capability: write. Creates a folder.
| Field | Type | Description |
|---|---|---|
name* | string | Folder name. Must be non-empty. |
parentId | string | null? | UUID of a parent folder you own to nest under, or null for a top-level folder. |
{ "id": "<id>", "name": "Architecture", "parentId": "<id>" | null }PATCH and DELETE /folders/:id
Capability: write for PATCH, delete for DELETE. PATCH renames a folder with a { "name": "<new name>" } body and returns the updated folder. DELETE removes the folder and returns 204. Member diagrams are not deleted; they become unfiled.
GET /icons
Capability: read. Returns the curated server-side icon catalog. Embed one by adding an image element to a scene with its fileId set to an icon id (e.g. icon-database). Only ids returned here resolve; the response omits the raw bytes.
{
"items": [
{
"id": "icon-database",
"label": "Database",
"category": "data",
"width": 128,
"height": 128,
"mimeType": "image/png"
}
]
}Error codes
Transport and authentication codes, returned by any endpoint:
| Code | Status | Description |
|---|---|---|
unauthorized | 401 | Missing, malformed, unknown, expired, or revoked token. |
insufficient_scope | 403 | A read token was used on a write endpoint. |
email_unverified | 403 | The token is valid but the account's email is not verified. |
not_found | 404 | The target does not exist, or is not yours. The two are indistinguishable on purpose. |
bad_request | 400 | A field is the wrong type, or required fields are missing or conflicting. |
invalid_json | 400 | The request body is not parseable JSON. |
payload_too_large | 413 | The request body exceeds the roughly 1 MiB cap. |
rate_limited | 429 | Over the rate limit. The response carries a Retry-After header. |
method_not_allowed | 405 | An unsupported HTTP verb for the route. |
stale_read | 409 | On regenerate or patch, the expectedUpdatedAt you sent no longer matches the diagram's current revision — someone edited it since you read it. Re-read and retry, or omit expectedUpdatedAt to force the write. |
invalid_quality | 400 | On render, ?quality= was neither "faithful" nor "fast". |
api_disabled | 503 | The API is temporarily disabled. |
render_failed | 503 | A PNG render failed or timed out. |
upstream_unavailable | 502 | An edit merge could not be applied because the backend was unreachable. |
quota_exceeded | 403 | Creating the diagram would exceed your diagram quota. Free accounts start with 3 diagrams (see Settings → Plan & usage for your live count and cap). Diagrams you move to trash stop counting immediately, and inviting teammates or sharing your work earns more. |
folder_forbidden | 403 | On create, folderId names a folder you do not own. |
Content-model codes, returned when a spec, scene, or edit fragment fails validation (422) or exceeds a size cap (413). The accompanying message names the offending element.
| Code | Status | Description |
|---|---|---|
INVALID_SPEC | 422 | The spec, or an edit-fragment node patch, is malformed or uses a disallowed field. |
UNSUPPORTED_VERSION | 422 | A version was given and it is not "1". |
MISSING_NODE_ID | 422 | A node is not an object, or its id is missing or empty. |
DUPLICATE_NODE_ID | 422 | Two nodes share the same id. |
UNKNOWN_SHAPE | 422 | A node's shape is not one of rectangle, ellipse, diamond, or text. |
UNKNOWN_ICON | 422 | A node's icon (or an image element's icon-* fileId) is not a known curated catalog id. List valid ids with GET /icons. |
DANGLING_EDGE | 422 | An edge's from or to names a node id that does not exist. |
MALFORMED_SHORTHAND | 422 | A shorthand edge string is missing an endpoint or its arrow. |
MALFORMED_EDGE | 422 | An edge object is missing from or to, or has a bad arrowhead or label. |
SPEC_TOO_LARGE | 413 | The spec exceeds the node, edge, or string-length caps. |
ID_COLLISION | 422 | A node id collides with a generated label or arrow id. |
PROTO_KEY | 422 | The payload contains a prototype-polluting key (__proto__, constructor, prototype). |
NOT_A_SCENE | 422 | The scene is not an object, or its elements are not a valid array. |
BAD_LINK_SCHEME | 422 | A link value uses a scheme that is not allowed. |
SCENE_TOO_LARGE | 413 | The resulting scene exceeds the 5000-element cap. |
Limits and fidelity
What to expect from a rendered diagram
appStateis always empty. Onlyelementsround-trip through the API. A suppliedappStateis dropped on create, and reads always return{}.- Render reads the saved snapshot of the diagram. An edit merged through the API is reflected immediately, so you can edit and then re-read or re-render and see your own change.
- Renders are faithful by default — the editor's own exporter, with real hand-drawn strokes, editor fonts, and embedded images. If the render service is unavailable the call degrades gracefully to a fast preview (system fonts, no sketchy strokes, images as placeholders) and reports it via
quality: "fast"andnote: "faithful_unavailable"rather than failing. Pass?quality=fastto force the fast path. - Very large diagrams (over 5000 elements) render a placeholder image instead of the full scene.
- Hard caps apply: 1000 nodes, 2000 edges, and 5000 total scene elements. Exceeding a cap returns a size error.
Prefer a higher-level client? The CLI and MCP server wrap this same surface.