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 scopes, rate limits, and the auth error codes.
Conventions
- Base path is
/api/v1. All request and response bodies are JSON. - Every endpoint declares a required scope. A read-write token satisfies a read requirement.
- 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
Scope: read. Returns the identity behind the calling token. Useful as a quick check that a token is valid.
{
"id": "<userId>",
"email": "[email protected]",
"scope": "read" | "read-write"
}GET /diagrams
Scope: 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
Scope: read-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 <read-write token>
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
Scope: 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,
"scene": { "elements": [ /* ... */ ], "appState": {} }
}PATCH /diagrams/:id
Scope: read-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 <read-write token>
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
Scope: read-write. Soft-deletes a diagram to the trash. The call is idempotent: deleting an already-trashed diagram still succeeds. Returns no body.
204 No ContentGET /diagrams/:id/render
Scope: read. Renders the diagram's saved scene to a preview. The output format is chosen with ?format=.
| 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. |
The default json format returns an envelope:
{
"isEmpty": false,
"elementCount": 11,
"svg": "<svg ...>",
"png": "<base64, no data: prefix>" | null,
"note"?: "large_diagram_placeholder" | "render_failed"
}
// empty scene
{ "isEmpty": true, "elementCount": 0, "svg": null, "png": null }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. Each call renders fresh; there is no PNG cache.
PUT /diagrams/:id/public-link
Scope: read-write. 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
Scope: read-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.
GET /folders
Scope: read. Returns a flat list of every folder you own.
{
"items": [
{ "id": "<id>", "name": "Architecture", "parentId": "<id>" | null }
]
}POST /folders
Scope: read-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
Scope: read-write. 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.
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. |
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. |
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. |
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.
- PNG output is an approximate preview: no sketchy strokes, system fonts instead of the editor font, and image elements drawn as placeholder rectangles. Good enough for previews, not pixel-parity with the editor.
- 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.