Skip to main content
Documentation menu

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.

Request header
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, or delete. A token without it gets 403 insufficient_scope.
  • Access checks return 404 not_found when 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 envelope
{ "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.

200 OK
{
  "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 paramTypeDescription
limitnumber?How many diagrams to return. Default 50, clamped to the range 1 to 100.
cursorstring?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.
200 OK
{
  "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.

FieldTypeDescription
titlestring?Diagram title. The stored, sanitized value is echoed back in the response.
folderIdstring | null?UUID of a folder you own, or null for the root. A non-UUID string is rejected.
specDiagramSpec?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.
sceneScene?A raw Excalidraw scene, preserved as-is. Provide this OR spec, never both. Any appState you include is dropped.
POST /api/v1/diagrams
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" }
  }
}
201 Created
{
  "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.

200 OK
{
  "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.

FieldTypeDescription
titlestring?New title. Renames the diagram.
folderIdstring | 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
PATCH /api/v1/diagrams/<id>
Authorization: Bearer <token with write capability>
Content-Type: application/json

{
  "title": "Architecture v2",
  "folderId": "<folder-id>"
}
200 OK
{
  "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 Content
204 No Content

POST /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 Content
204 No Content

GET /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 paramTypeDescription
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:

200 OK (format=json)
{
  "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.

FieldTypeDescription
enabled*booleanTurn the public link on or off.
passwordstring | null?A string sets a password, null clears it, and omitting the key leaves the existing password unchanged.
200 OK
{
  "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.

EditFragment shape
{
  "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.

200 OK
{ "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.

FieldTypeDescription
specDiagramSpec?A high-level node and edge spec, auto-laid-out server-side. Provide this OR scene, never both.
sceneScene?A raw Excalidraw scene, swapped in as-is. Provide this OR spec, never both.
titlestring?Optional rename applied on success. A title that sanitizes to empty is rejected.
expectedUpdatedAtstring?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
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"
}
200 OK
{
  "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.

FieldTypeDescription
ops*PatchOp[]An ordered array of element-addressable operations. The op grammar, the update allowlist, and the size caps live in the diagram DSL.
expectedUpdatedAtstring?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
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"
}
200 OK
{
  "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.

200 OK
{
  "items": [
    { "id": "<id>", "name": "Architecture", "parentId": "<id>" | null }
  ]
}

POST /folders

Capability: write. Creates a folder.

FieldTypeDescription
name*stringFolder name. Must be non-empty.
parentIdstring | null?UUID of a parent folder you own to nest under, or null for a top-level folder.
201 Created
{ "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.

200 OK
{
  "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:

CodeStatusDescription
unauthorized401Missing, malformed, unknown, expired, or revoked token.
insufficient_scope403A read token was used on a write endpoint.
email_unverified403The token is valid but the account's email is not verified.
not_found404The target does not exist, or is not yours. The two are indistinguishable on purpose.
bad_request400A field is the wrong type, or required fields are missing or conflicting.
invalid_json400The request body is not parseable JSON.
payload_too_large413The request body exceeds the roughly 1 MiB cap.
rate_limited429Over the rate limit. The response carries a Retry-After header.
method_not_allowed405An unsupported HTTP verb for the route.
stale_read409On 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_quality400On render, ?quality= was neither "faithful" nor "fast".
api_disabled503The API is temporarily disabled.
render_failed503A PNG render failed or timed out.
upstream_unavailable502An edit merge could not be applied because the backend was unreachable.
quota_exceeded403Creating 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_forbidden403On 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.

CodeStatusDescription
INVALID_SPEC422The spec, or an edit-fragment node patch, is malformed or uses a disallowed field.
UNSUPPORTED_VERSION422A version was given and it is not "1".
MISSING_NODE_ID422A node is not an object, or its id is missing or empty.
DUPLICATE_NODE_ID422Two nodes share the same id.
UNKNOWN_SHAPE422A node's shape is not one of rectangle, ellipse, diamond, or text.
UNKNOWN_ICON422A node's icon (or an image element's icon-* fileId) is not a known curated catalog id. List valid ids with GET /icons.
DANGLING_EDGE422An edge's from or to names a node id that does not exist.
MALFORMED_SHORTHAND422A shorthand edge string is missing an endpoint or its arrow.
MALFORMED_EDGE422An edge object is missing from or to, or has a bad arrowhead or label.
SPEC_TOO_LARGE413The spec exceeds the node, edge, or string-length caps.
ID_COLLISION422A node id collides with a generated label or arrow id.
PROTO_KEY422The payload contains a prototype-polluting key (__proto__, constructor, prototype).
NOT_A_SCENE422The scene is not an object, or its elements are not a valid array.
BAD_LINK_SCHEME422A link value uses a scheme that is not allowed.
SCENE_TOO_LARGE413The resulting scene exceeds the 5000-element cap.

Limits and fidelity

What to expect from a rendered diagram

  • appState is always empty. Only elements round-trip through the API. A supplied appState is 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" and note: "faithful_unavailable" rather than failing. Pass ?quality=fast to 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.