Errors and pagination
Common error shape
Most OpenDocs endpoints return JSON in one of these two forms.
The full shape, for failures that have something meaningful to say:
{
"error": "publish_failed",
"message": "Workspace not found."
}
The minimal shape, for failures where the code alone is self-explanatory:
{
"error": "unauthorized"
}
Structured-failure endpoints (like document_limit) add extra fields on top
of this base shape — see the per-code table below.
Error codes
| Status | Error code | Where it fires | Meaning |
|---|---|---|---|
400 | invalid_request | Any route with body validation | Request body failed Zod validation (missing fields, wrong types, unknown visibility values, etc.) |
400 | publish_failed | POST /publish, POST /publish/batch | Business rule failure during publish (workspace not found, slug collision that couldn't be resolved, markdown too large, etc.) |
400 | update_failed | POST /posts/:id/update | Update succeeded past validation but failed at the service layer |
400 | unpublish_failed | POST /posts/:id/unpublish | Unpublish couldn't complete — usually because the post was already unpublished |
400 | visibility_change_failed | POST /posts/:id/visibility | Visibility change rejected (typically: visibility: "public" without confirmPublic: true) |
400 | invalid_visibility | POST /posts/:id/visibility | visibility was not one of private, workspace, public |
400 | tag_update_failed | PATCH /posts/:id/tags | Tag mutation failed (e.g., exceeding the 20-tag cap) |
400 | onboarding_failed | POST /onboarding | Invalid/reserved username or workspace name couldn't be allocated |
400 | missing_workspace_id | Workspace + team GET routes | workspaceId query param is absent. Every workspace-scoped route requires the caller to name the workspace explicitly. |
401 | unauthorized | All protected routes | Missing, invalid, expired, or revoked API key / session |
403 | forbidden | Workspace, team, storage, api-key routes | Caller is not an active member of workspaceId — or is a member but not an admin/owner where the action requires it |
403 | document_limit | POST /publish, POST /publish/batch | Publishing this document would push the workspace over its plan's document cap |
403 | plan_limit | POST /workspaces | User is at the workspace cap for their account plan |
403 | member_limit | POST /workspace/team/invite | Free/Pro workspace is at its member cap |
402 | billing_failed | POST /workspace/team/accept-invite | Team invite acceptance needed a Paddle seat increase, but billing could not be updated |
404 | not_found | Post routes, api-key delete | Target resource doesn't exist or isn't visible to the caller |
409 | active_plan | DELETE /account | Account deletion is blocked by an active subscription |
409 | team_workspace_with_members | DELETE /account | Account deletion is blocked by an owned Team workspace with other active members |
413 | file_too_large | Avatar / workspace-logo uploads | Upload exceeded the per-file size cap |
429 | rate_limited | All routes (via middleware) | Rate-limit bucket exhausted for this route group |
502 | export_failed | POST /posts/:id/export/:format, POST /posts/batch/export | Upstream converter returned a non-2xx response |
503 | storage_not_configured | Upload endpoints | S3/object-storage env vars aren't set on the server |
The document_limit error
This is the structured 403 emitted when a publish would exceed the workspace's
document cap (Free: 10 docs per workspace). The body always includes plan,
limit, used, and a human-readable message:
{
"error": "document_limit",
"plan": "free",
"limit": 10,
"used": 10,
"message": "Your free plan allows 10 published documents. You currently have 10."
}
The batch variant (POST /publish/batch) adds requested and
workspaceId so you can build a precise error message even when a batch spans
multiple workspaces:
{
"error": "document_limit",
"plan": "free",
"limit": 10,
"used": 9,
"requested": 3,
"workspaceId": "ws_123",
"message": "This batch would publish 3 documents, but your free plan allows 10 total. You have 1 slot left."
}
Batch pre-check is atomic: if the cap would be exceeded, no documents are written — you get one 403 and the whole batch is rejected. See Plans and limits for the full story.
Rate limits
The API enforces rate limits per route group:
| Route group | Limit |
|---|---|
| General API traffic | 120 requests / minute |
POST /publish | 30 requests / minute |
POST /publish/batch | 5 requests / minute |
POST /posts/:id/export/:format | 20 requests / minute |
POST /posts/batch/export | 3 requests / minute |
When a bucket is exhausted:
{
"error": "rate_limited",
"message": "Too many requests. Try again later."
}
Back off for at least the bucket window (usually one minute) before retrying.
Pagination
List endpoints (currently: GET /api/v1/posts) return a pagination block:
{
"posts": [ /* ... */ ],
"pagination": {
"page": 1,
"limit": 20,
"total": 42,
"totalPages": 3
}
}
Defaults:
pagedefaults to1limitdefaults to20, max100
Practical guidance
- Use modest
limitvalues unless you truly need bigger pages. - Prefer
--jsonin the CLI when a script or agent needs to read pagination data. - Call
GET /api/v1/postsbeforePOST /posts/:id/updatewhen your automation needs to discover apostId. - On
403 document_limit: surface themessagefield directly — it's written for end users.