Error codes
If you call the Runtime API or the browser SDK from a canvas, or you are an agent
handling its failures, this page is the contract. Branch on the error's code,
never on its message text. Every refusal from /v1/c/{slug}/* is JSON with a
stable code and an HTTP status, and the browser SDK throws typed errors that
extend CanvasdropError, each with a readonly .code and .status (plus .hint
when the server sent a remediation hint).
The browser global is window.canvasdrop, loaded from {base}/sdk/v1.js; there
is no cd alias. The error classes, ERROR_CODES, and errorFromResponse are
named exports of @canvas-drop/sdk for code that imports the package.
try {
await canvasdrop.kv.set("prefs", { theme: "dark" });
} catch (err) {
switch (err.code) {
case "CAPABILITY_DISABLED": // KV is off for this canvas; the hint says how to turn it on
showBanner(err.hint);
break;
case "VALUE_TOO_LARGE":
case "KEY_LIMIT":
showBanner("Storage limit reached.");
break;
default:
throw err;
}
}
kv.get returns null for a missing key instead of throwing, so most reads need
no try/catch.
The codes
Codes, statuses, and meanings below are the SDK's exported ERROR_CODES, in the
same order (each entry is { status, summary }; ErrorCode is its key type).
| Code | Status | Meaning |
|---|---|---|
NOT_AUTHENTICATED |
401 | The viewer is not signed in. |
PASSWORD_REQUIRED |
403 | The canvas is password-protected. |
PERMISSION_DENIED |
403 | The signed-in viewer lacks permission for this operation. The SDK throws PermissionDeniedError. Check me().permissions; requesting the API directly does not bypass role checks. |
CAPABILITY_DISABLED |
403 | Backend or the specific feature is off for this canvas. |
CROSS_CANVAS_FORBIDDEN |
403 | A request targeted another canvas's resources. |
MODEL_NOT_ALLOWED |
403 | The requested AI model is not in the allow-list. |
DISABLED |
403 | The canvas has been disabled by an administrator. |
STATIC_ONLY |
403 | The canvas is a public link (public_link): every backend primitive is refused for non-owners. |
GUEST_AI_DISABLED |
403 | AI is not enabled for a retained legacy guest-session viewer. |
GUEST_AI_CAP |
429 | The canvas reached its retained legacy guest-session AI spend cap. |
NOT_FOUND |
404 | The key, file, or canvas does not exist. |
INVALID_BODY |
400 | The request body failed validation. |
KEY_TOO_LARGE |
413 | The KV key exceeds the size limit. |
VALUE_TOO_LARGE |
413 | The KV value exceeds the size limit. |
FILE_TOO_LARGE |
413 | An uploaded file exceeds the per-file size limit. |
KEY_LIMIT |
409 | The canvas hit its key-count limit. |
NOT_NUMERIC |
409 | increment was called on a non-numeric value, or a collection increment would exceed the finite numeric range. The stored value is unchanged. |
POLICY_CONFLICT |
409 | A management policy save omitted its expected revision or used an old one. Reload the current policy and reconcile the edit before saving again. |
QUOTA_EXCEEDED |
429 | A spend or rate quota was exceeded. |
CONNECTION_LIMIT |
429 | A realtime or outbound connection concurrency limit was reached. |
CONNECTION_RATE_LIMIT |
429 | The outbound connection rate limit was reached. |
CONNECTION_NOT_GRANTED |
404 | The outbound connection is not granted to this canvas. |
CONNECTION_DISABLED |
503 | The outbound connection profile is disabled. |
CONNECTION_KEY_UNAVAILABLE |
503 | The protected-header encryption key is unavailable. |
METHOD_NOT_ALLOWED |
405 | The outbound method is unsupported or not allowed by the profile. |
DESTINATION_BLOCKED |
403 | The outbound origin, DNS answer, redirect, or encoding crossed the approved boundary. |
UPSTREAM_TIMEOUT |
504 | The total outbound deadline expired. |
UPSTREAM_UNAVAILABLE |
502 | DNS or the approved upstream failed. |
RESPONSE_TOO_LARGE |
502 | The upstream response exceeded its limit. |
REQUEST_TOO_LARGE |
413 | The connection URL, headers, or request body exceeded its limit. |
AI_STREAM_TRUNCATED |
502 | An AI stream ended before completion. |
AI_UPSTREAM_ERROR |
502 | The AI provider returned an error. |
PUBLISH_FAILED |
502 | canvasdrop.canvases.publish created the canvas but its deploy or share-config failed; the new canvas's id is returned so the caller can retry or revoke. |
SHARE_REVOKED |
409 | canvasdrop.canvases.update was called on an unpublished share without a bundle; include a bundle to publish it again. |
SHARE_CONFLICT |
409 | canvasdrop.canvases.update sent a stale expectedUpdatedAt: the share changed after it was read. The current state is returned; refresh before retrying. |
UPDATE_PARTIAL |
502 | canvasdrop.canvases.update saved the share settings, but a later stage (the bundle deploy) failed; stage names it and current carries the saved state. |
REQUEST_FAILED |
0 | A request failed without a more specific code. |
Five rows need a note:
NOT_AUTHENTICATEDis what the SDK throws for any 401. The auth gateway's own 401 (indevandproxymodes) has the shape{ "error": "unauthorized" }with nocode; the SDK normalizes it. Inoidcmode the gateway does not return 401 at all: a signed-out request is redirected (302) to the login page. The authoring routes return401 { "code": "NOT_AUTHENTICATED" }to a retained legacy guest session, since only org members can author.STATIC_ONLYapplies to every caller except the canvas's owner and its editors. A signed-in member gets it on a Public link canvas too, not only an anonymous visitor.QUOTA_EXCEEDEDis 429 from AI (the body addsscope:user_dailyorcanvas_monthly) and from authoring (scope:user_dailyoruser_total), but 409 when a file upload would exceed the per-canvas byte quota.err.codeis the same in all three cases;err.statusandscopetell them apart.INVALID_BODYis 400 everywhere except one case: an authoring bundle over 50 MiB returns413 { "code": "INVALID_BODY", "message": "bundle too large" }, and the SDK's any-413 rule puts it onQuotaExceededError(see below).REQUEST_FAILEDis the SDK's fallback when a response carries nocode. The0is nominal;err.statusholds the real HTTP status.
CAPABILITY_DISABLEDis self-repairing. Its 403 body carries extra fields beyondcode, so a caller (or an agent) can fix it without guessing:capability(which one),backendEnabled(the master switch,falseon a new canvas),reason(backend_off,feature_off, oroperator_disabled), and a human-readablehint. The SDK puts the hint on the thrownCapabilityDisabledErroras both its message and.hint. Forbackend_offandfeature_offthe fix is the owner's or an editor's: the canvas's Backend tab, theset_capabilitiesMCP tool, orPATCH /api/canvases/{id}/capabilities {"backendEnabled": true, "kv": true}.operator_disabled(AI, realtime, and authoring only) is a deployment-level setting: no AI provider key,CANVAS_DROP_REALTIME=off, orCANVAS_DROP_AUTHORING=off(the default). See Capabilities.
DISABLEDhas two surfaces. On the Runtime API (viewers, the browser SDK) a canvas an admin has taken down returnsDISABLEDwith status 403, the row above. On the owner management API (/api/canvases/{id}/...), the authoring routes'PUT/DELETE, and over MCP, the same takedown makes the canvas read-only to its owner and editors: reads succeed, but every mutation (settings, sharing, tags, capabilities, slug, preview, deploy, publish, rollback, archive, unpublish, draft edits) is refused with HTTP 409{ "code": "DISABLED", "message": "This canvas has been disabled by an administrator." }, withReason: <text>appended when the admin set one. The MCP tool result isDISABLED: <that message>. The same management surfaces refuse an editor's owner-only act (delete, transfer, the guest-AI opt-in) with 403{ "code": "OWNER_ONLY", "message": "Only the canvas owner can do this." }; do not confuse it with the Runtime API'sOWNER_ONLY, which is a 404 (next section).
Typed SDK errors
The SDK exports the base class and six subclasses. Any code without a dedicated
subclass is thrown as the base CanvasdropError with .code set from the
response.
| Class | .code |
.status |
Extra |
|---|---|---|---|
NotAuthenticatedError |
NOT_AUTHENTICATED |
401 | |
CapabilityDisabledError |
CAPABILITY_DISABLED |
403 | message and .hint from the server's hint; no .capability property |
NotFoundError |
NOT_FOUND |
404 | Every 404 except CONNECTION_NOT_GRANTED. |
QuotaExceededError |
QUOTA_EXCEEDED by default; see below |
429 by default; see below | message is always quota exceeded |
PublishFailedError |
PUBLISH_FAILED |
502 | .id: the created canvas's id, when the failure happened after creation |
UpdatePartialError |
UPDATE_PARTIAL |
502 | .stage: the stage that failed after the settings were saved; .current: the saved share state, when the server returned it |
CanvasdropError (base) |
any | any | .hint when the server sent one |
The SDK picks the class from the HTTP response in this order (errorFromResponse):
- Status 401 →
NotAuthenticatedError(any bodycodeis ignored). - Status 403 with
code: "CAPABILITY_DISABLED"→CapabilityDisabledError. - Status 404, except
code: "CONNECTION_NOT_GRANTED", →NotFoundError(the other body codes are ignored; see the next section). code: "PUBLISH_FAILED"→PublishFailedErrorwith.idfrom the body.code: "UPDATE_PARTIAL"→UpdatePartialErrorwith.stageand.currentfrom the body.codeofQUOTA_EXCEEDED,GUEST_AI_CAP,KEY_LIMIT,CONNECTION_LIMIT, orCONNECTION_RATE_LIMIT, or any 413 →QuotaExceededError, keeping the body'scodeand the response's status.- Anything else—including
CONNECTION_NOT_GRANTEDat 404—→CanvasdropErrorwith the body'scode(orREQUEST_FAILED), the response status, and the body'shintormessageas the message.
So QuotaExceededError is the one limit-shaped class and it is reused: expect
.code values of QUOTA_EXCEEDED, GUEST_AI_CAP, KEY_LIMIT (409),
KEY_TOO_LARGE, VALUE_TOO_LARGE, FILE_TOO_LARGE, BODY_TOO_LARGE,
INVALID_BODY (413), CONNECTION_LIMIT (429, realtime or outbound admission), and
CONNECTION_RATE_LIMIT (429). NOT_NUMERIC
is a 409 but not a limit, so it stays on the base class. Never assume a
QuotaExceededError is literally QUOTA_EXCEEDED; read err.code.
Classes such as PasswordRequiredError, ModelNotAllowedError, or
CrossCanvasForbiddenError do not exist. PASSWORD_REQUIRED, STATIC_ONLY,
DISABLED, MODEL_NOT_ALLOWED, CROSS_CANVAS_FORBIDDEN, GUEST_AI_DISABLED,
INVALID_BODY (at 400), NOT_NUMERIC, SHARE_REVOKED, SHARE_CONFLICT, and
AI_UPSTREAM_ERROR all arrive on the base CanvasdropError. Branching on err.code is the only
reliable check.
Server codes outside the enum
The Runtime API can return a few codes that are not in ERROR_CODES. They are
stable; the table shows how each reaches SDK callers.
| Code | Status | When | In the SDK |
|---|---|---|---|
RATE_LIMITED |
429 | A request bucket is spent. Primitives: per user per canvas, default 120/min. .../ai/*: per user across all canvases, default 10/min. Headers: Retry-After, X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset. |
CanvasdropError, code: "RATE_LIMITED" |
ARCHIVED, NOT_INVITED, OWNER_ONLY, SHARE_EXPIRED |
404 | The canvas resolved but access was denied (archived; guest scoped to another canvas; not on the sharing rung, including a non-owner admin; share expired). | NotFoundError, code: "NOT_FOUND"; the body's code is not preserved |
CROSS_SITE_FORBIDDEN |
403 | Path mode only: Sec-Fetch-Site is present and not same-origin or none. |
CanvasdropError |
BODY_TOO_LARGE |
413 | POST .../ai/chat body over 256 KiB. |
QuotaExceededError, code: "BODY_TOO_LARGE" (every 413 maps there) |
ORG_REQUIRED, SLUG_TAKEN |
409 | Authoring publish/update. | CanvasdropError |
PUBLIC_LINKS_DISABLED, PUBLIC_NOT_ALLOWED, PUBLIC_LINK_OWNER_GATED |
403 | Authoring publish/update asked for a Public link the instance, the actor, or the actor's role (editor) cannot grant. | CanvasdropError |
The full per-route list, including which routes emit INVALID_BODY with a
reason field, is on the Runtime API page.
Client-side codes
The SDK also throws a handful of codes that never come from the server. They are
base CanvasdropErrors and are not in ERROR_CODES, so err.code is typed
string, not ErrorCode.
| Code | Status | When |
|---|---|---|
NO_STREAM |
the HTTP status | The AI response had no body. |
MALFORMED_FRAME |
502 | An SSE data: line was not valid JSON (proxy teardown, partial flush). |
AI_ERROR |
502 | An in-stream error frame arrived without a string code. |
DISCONNECTED |
0 | The realtime socket dropped while a presence() call was in flight; the SDK is reconnecting, retry the call. |
CHANNEL_CLOSED |
0 | publish() or presence() was called on a channel after close(); pending presence() calls also reject with it when close() runs. |
AI stream errors
ai.chat and ai.stream consume a server-sent event stream. Failures surface in
two ways:
- Before the stream starts, as an HTTP error mapped per the tables above, in
the server's check order:
BODY_TOO_LARGE(413),INVALID_BODY(400),MODEL_NOT_ALLOWED(403),GUEST_AI_DISABLED(403),GUEST_AI_CAP(429),QUOTA_EXCEEDED(429), orCAPABILITY_DISABLED(403).RATE_LIMITED(429) fires before any of them. - Mid-stream, as an
errorframe.CAPABILITY_DISABLEDbecomesCapabilityDisabledError;QUOTA_EXCEEDEDandGUEST_AI_CAPbecomeQuotaExceededErrorwith status 429; any other code becomes a baseCanvasdropErrorwith that code and status 502. The server's mid-stream provider failure isAI_UPSTREAM_ERROR; a frame with no code defaults toAI_ERROR.
If the stream ends without a terminal done or error frame, both methods
throw AI_STREAM_TRUNCATED (502). ai.stream yields text only; usage and cost
are available from ai.chat.
Realtime close codes
A terminal WebSocket close maps to a typed error and stops reconnecting. Any
pending presence() call rejects with it, and later publish() or presence()
calls on the same client throw it.
| Close code | Error |
|---|---|
4403 |
CapabilityDisabledError (realtime is off) |
4401 |
NotAuthenticatedError (access lost, canvas gone, canvas turned Public link, password gate newly set, or user inactive) |
4429 |
QuotaExceededError (code: "CONNECTION_LIMIT", status 429; 30 connections per canvas) |
An in-band { "type": "error", "code": "CAPABILITY_DISABLED" } frame is terminal
in the same way. Register channel.onError(handler) to observe errors attributed
to that channel, including PERMISSION_DENIED when publishing or presence is
denied. The handler receives a typed SDK error; permission failures have status
403. Other channel errors have status 400. Errors without a matching channel
are not delivered to these handlers. Stay inside the limits on the
Runtime API page.
Any other close is transient: the SDK reconnects with exponential backoff (500 ms
doubling to a 10 s cap), re-subscribes every channel, and flushes up to 256
buffered frames. An in-flight presence() call rejects with DISCONNECTED
(status 0) so the caller can retry.