API
REST API reference
Endpoint contracts for authentication, tasks, Documents, Tables, roles, imports, webhooks, and automation graphs.
v0.3.0 referenceReviewed
On this page
Start with the API quick start for authentication and your first request. This reference describes the pinned v0.3.0 contract; your deployed instance serves its own /api/v1/openapi.json.
| Convention | Value |
|---|---|
| REST base path | /api/v1 on your Hopya instance |
| Workspace prefix | /workspaces/:wid; abbreviated paths below use the stated prefix |
| Success | Direct JSON object or array |
| Error | { "error": "message" } |
| Resource IDs | UUIDs; status IDs are bounded safe strings |
| Dates / timestamps | YYYY-MM-DD / UTC ISO-8601 |
| Mutations | Unknown fields are rejected |
See architecture for the full endpoint inventory and integrations for attachments, OIDC, AI, and MCP.
Authentication
Use Authorization: Bearer $HOPYA_API_TOKEN for programmatic requests. Create a token in Account settings after signing in.
| Credential | Contract |
|---|---|
| Personal token | Revealed once; hashed at rest; expires after 90 days; immediately revocable |
| Token access | Inherits the account’s current workspace permissions; no independent per-token scopes |
| Browser session | HttpOnly cookie; seven days; at most 20 sessions per account |
| Token limit | At most 50 active tokens per account |
| Password | 12–256 characters when set |
Keep secrets private. Use a restricted automation account. Never expose tokens in URLs, client code, shell history, or logs. This example assumes privately supplied environment variables.
curl --fail-with-body "$HOPYA_URL/api/v1/workspaces" \
-H "Authorization: Bearer $HOPYA_API_TOKEN"
| Request | Mutation boundary |
|---|---|
| Unsafe browser request, including login/setup | Origin must exactly match APP_URL |
| Origin-less mutation | Valid bearer token and no cookies |
| Explicit foreign Origin | Rejected, even with a valid bearer |
| CORS | No permissive CORS in normal operation |
Account endpoints
Paths below are relative to /api/v1.
| Method | Path | Input / result |
|---|---|---|
| GET | /config | Public feature booleans and setupRequired; no secrets |
| POST | /auth/setup | {name,email,password,setupToken}; first operator-authorized admin only |
| POST | /auth/register | {name,email,password}; only when enabled after setup |
| POST | /auth/login | {email,password}; session cookie plus public user |
| GET | /auth/me | {id,name,email,isAdmin} |
| POST | /auth/logout | Revoke current browser session |
| POST | /auth/forgot-password | {email}; generic accepted response; delivery when configured |
| POST | /auth/reset-password | {token,password}; consume one-use link and revoke all credentials |
| PATCH | /auth/profile | {name,email?,password?,currentPassword?}; current password required for email/password changes; prior credentials revoked |
| GET | /auth/tokens | Token metadata only |
| POST | /auth/tokens | {name}; raw token once |
| DELETE | /auth/tokens/:id | Revoke caller-owned token |
| PUT | /auth/profile/photo | {contentType,data}; canonical base64 PNG/JPEG/WebP ≤512 KiB; self-only replacement; {photoUrl} |
| DELETE | /auth/profile/photo | Remove own photo; {photoUrl:null} |
| GET | /users/:id/photo?v=:revision | Current private raster for self, current shared-workspace members, or site admins; not a capability URL |
Authentication limits and recovery
| Rule | Limit / behavior |
|---|---|
| Login | 10 requests per normalized account/IP and 100 per verified IP per 15 minutes, including invalid attempts |
| Reset requests | Limited by verified IP and normalized email; same response for existing and missing accounts |
| Reset token | Hashed; single-use; 30-minute lifetime |
| Limiter windows | Login address windows: 1,000; other authentication categories: 10,000 |
| Throttling | 429 includes Retry-After; see limiter boundaries |
| Credential changes | Revalidate the current credential after hashing; expiry/revocation before commit returns 401 |
| Dev-only CORS escape hatch | ALLOW_ANY_ORIGIN=true echoes any Origin with credentials, answers preflights, and logs a warning; never enable on a reachable instance |
MCP transport
MCP paths below use /api/v1; payloads follow MCP, not the REST envelope. The pinned transport implementation confirms that prefix.
| Method | Path | Contract |
|---|---|---|
| GET | /mcp/sse | Administrator-enabled SSE stream; 404 while disabled |
| POST | /mcp/messages?sessionId=... | Client messages for the authenticated session |
- Both require a personal bearer token; cookie-only authentication is rejected.
- Sessions are user-bound, expire after 30 minutes, and are capped at four per user / 32 per instance.
Workspaces And Hierarchy
Prefix: /api/v1. Every resource stays workspace-scoped.
| Method | Path | Input / result |
|---|---|---|
| GET | /workspaces | Membership-scoped workspace array |
| POST | /workspaces | {name}; creates workspace and Owner, Member, Viewer roles |
| GET | /workspaces/:wid | Permission-filtered bootstrap; response keys below |
| PATCH | /workspaces/:wid | {name}; workspace:manage |
| DELETE | /workspaces/:wid | Protected Owner only; permanent deletion of workspace and relational contents |
Hierarchy paths below use /api/v1/workspaces/:wid.
| Method | Path | Input / result |
|---|---|---|
| GET | /nodes | Hierarchy node array |
| POST | /nodes | {name,kind,parentId?,description?,icon?,color?}; project, folder, or list |
| PATCH | /nodes/:id | {name?,description?,parentId?,expectedParentId?,icon?,color?}; rename, style, or move |
| DELETE | /nodes/:id | Empty nodes only; 409 if child nodes, documents, or tasks remain |
| GET | /export | Version 8 JSON: hierarchy, attributed Documents/pages, local Tables, safe live-source descriptors, tasks, comments, reactions, fields, and attachment metadata |
| Structure rule | Contract |
|---|---|
| Roots | Projects and standalone lists may be workspace roots |
| Parents | Folders require a project/folder; nested lists may use either |
| Lists | Contain tasks, not hierarchy nodes |
| Depth | At most 32 path entries, root counted as level 1; includes Document/Table leaves |
| Names | At most 120 characters |
| Mutation access | structure:write |
Export is not a backup or import format. It excludes credentials, audit history, and file bytes. Workspace deletion also removes memberships, roles, fields, and automation records; export needed data first.
Move rules and status inheritance
- Preserved: IDs, child relationships, tasks, attachments, and task statuses. Move/rename and audit commit together.
- Rejected: moving a project under another node; self/descendant/list parents; foreign workspaces; subtree depth overflow.
- Stale placement: send the original
parentIdasexpectedParentIdwith aparentIdmutation; mismatch returns409. Rename-only PATCH preserves the current parent. Empty/unknown-field mutations are rejected. - Across projects: inherited descendant lists must be valid under destination statuses. Overrides retain their own statuses and do not block a move based on project defaults.
- List to root: materializes its inherited statuses. Standalone list into project: keeps its statuses as an override. No move remaps task statuses.
Descriptions, icons, and colors
| Field | Contract |
|---|---|
description | Returned on nodes, default ""; only project POST/PATCH accepts it; ≤50,000 characters; "" clears it |
icon | Nullable allowlisted name; null restores default |
color | Nullable six-digit #RRGGBB; responses normalize to lowercase; null restores default |
Allowed icons:
diamond, briefcase, target, home, star, heart, globe, clock,
mapPin, settings, lock, users, user, folder, archive, bookmark,
list, checklist, calendar, flag, package, shoppingBag, fileText,
inbox, trash, pencil, eye, eyeOff, sparkles, code, link, comment, save
Legacy color names remain accepted:
| Name | Normalized color |
|---|---|
slate | #64748b |
orange | #c45d0a |
amber | #9a7411 |
green | #4d7a47 |
teal | #17776f |
blue | #2563a6 |
violet | #7652a8 |
rose | #a5415b |
Folder/list descriptions remain empty.
Workspace bootstrap shape and visibility
{
"workspace": {}, "role": {}, "permissions": [],
"members": [], "roles": [], "nodes": [], "documents": [],
"tables": [], "documentPages": [], "fields": [],
"projectFields": [], "listStatusConfigs": [], "listTagColorConfigs": []
}
The example shows response keys, not a complete instance response.
| Data | Required access |
|---|---|
| Workspace name, own role/permissions | Any active membership |
| Hierarchy metadata | Task, Document, Table, or structure access |
| Document page links | Both task and Document read access |
| Table metadata, without columns/records | Table read, write, delete, or structure management |
| Field definitions | Task read or structure write |
| Member directory | Task read or member management |
| Full role catalog | Task read, member management, or role management |
Other arrays are empty; the role list retains the caller’s own role. Bootstrap metadata never grants task-body, Document-body, Table-record, or export access. Management-only roles can use Settings without reading tasks.
Documents
Prefix: /api/v1/workspaces/:wid. Documents sit at root or under a project/folder.
| Method | Path | Input / result |
|---|---|---|
| GET | /documents | Document metadata |
| POST | /documents | {title,body?,parentId?,icon?,color?} |
| GET | /documents/:id | One Document |
| PATCH | /documents/:id | At least one mutable field plus exact expectedUpdatedAt; documents:read + documents:write |
| DELETE | /documents/:id | documents:delete; removes nested document-page subtree and page links, preserves linked tasks/subtasks |
| Field | Contract |
|---|---|
title | 1–300 characters |
body | Markdown, ≤50,000 characters |
icon, color | Nullable; same hierarchy allowlist/normalization; may change without editing body |
Task-backed pages
| Method | Path | Input / result |
|---|---|---|
| GET | /documents/:id/pages | {items,total,truncated}; Document + task read; at most 500 rendered tasks, complete totals |
| POST | /documents/:id/pages | {itemId,position?}; link an active top-level task |
| DELETE | /documents/:id/pages/:itemId | Unlink, not delete; Document write + task read |
- One task may belong to at most one Document; its subtasks appear as nested pages.
- A linked root task cannot become a subtask until unlinked.
Document-backed pages
| Method | Path | Input / result |
|---|---|---|
| GET | /documents/:id/subpages | {rootId,documents,total,truncated}; ≤500 complete summaries including pagePlacement; valid from root or page |
| POST | /documents/:id/subpages | {title,placement?}; uses Document permissions and normal editor |
| Placement | Meaning |
|---|---|
page | Root Document only; top-level Pages-rail entry |
subpage (default) | Child of addressed Document/page; at most 31 levels |
Tables
Prefix: /api/v1/workspaces/:wid. Tables are separate from task Lists: root/project/folder leaves, not containers for tasks.
Local Table Resources
| Method | Path | Input / result |
|---|---|---|
| GET | /tables | Table metadata |
| POST | /tables | {name,parentId?,icon?,color?} |
| GET | /tables/:id | One Table |
| PATCH | /tables/:id | Rename/move/style; exact expectedUpdatedAt; tables:read + tables:write |
| DELETE | /tables/:id | tables:delete; permanently removes local columns/records |
| GET | /tables/:id/columns | Ordered columns |
| POST | /tables/:id/columns | {name,type,options?} |
| PATCH | /tables/:id/columns/:columnId | Rename with exact expectedUpdatedAt; tables:read + tables:write |
| DELETE | /tables/:id/columns/:columnId | tables:write + tables:delete; {success,touchedRecords} |
| GET | /tables/:id/records | {records,nextCursor}; limit 1–500 and opaque workspace/Table cursor; tables:read |
| POST | /tables/:id/records | {values?}; tables:write |
| GET | /tables/:id/records/:recordId | One record; tables:read |
| PATCH | /tables/:id/records/:recordId | Replace complete values map with expectedUpdatedAt; tables:read + tables:write |
| DELETE | /tables/:id/records/:recordId | tables:delete |
| Data | Validation / limits |
|---|---|
| Table name / depth | 1–120 characters / at most 32 path entries; a containing project/folder is not empty |
icon, color | Nullable hierarchy allowlist/normalization; included in metadata and exports |
| Columns | At most 100; text, number, date, datetime, checkbox, select |
| Select options | 1–100 unique strings, ≤120 characters each; other column types reject options |
| Record values | Keyed by column UUID; nullable and type-checked; text ≤10,000 characters |
| Complete values map | At most 256 KiB / 100 keys |
| Record page | 2 MiB encoded budget; follow nextCursor until null; pages are not one frozen snapshot |
| Column deletion | Removes its key from every record transactionally and advances touched record revisions |
Query and calculations
| Method | Path | Input / result |
|---|---|---|
| POST | /tables/:id/query | Read-only; tables:read; {records,nextCursor,total,summary?} |
| GET | /tables/:id/summary | tables:read; {recordCount,columns:{[columnId]:{count,empty,...}}} across all records |
Query input:
{
"filters": [{"columnId": "<column UUID>", "operator": "not_empty"}],
"sort": null,
"limit": 100,
"summary": false
}
| Parameter | Contract |
|---|---|
filters? | At most 20 {columnId,operator,value?} conditions combined with AND; columns must belong to this Table |
sort? | {columnId,direction:"asc"|"desc"} or null |
limit? | 1–500; default 100; 2 MiB record budget |
cursor? | At most 8,192 characters; binds workspace/Table, column schema, filters, and sort; not access credentials |
summary? | Default false; true calculates across every matching row |
| Column type | Operators / comparison |
|---|---|
| All | is, is_not, empty, not_empty; empty operators need no value |
| Text / select | Also contains, not_contains; case-insensitive |
| Number / date / datetime | Also gt, gte, lt, lte; numeric values / precise date instants |
| Checkbox | JSON booleans; false sorts before true |
Values must match column types: JSON number/boolean scalars, date strings, or offset date-time strings. Missing/null/empty text match only empty operators and sort last in both directions. Ties/no sort preserve local creation/ID order or live primary-key order.
| Calculation | Result |
|---|---|
| All columns | count, empty; missing/null/empty string are empty, zero/false are present |
| Number | sum, average, min, max; empty: sum 0, other values null; overflow: null sum/average |
| Date / datetime | earliest, latest; precise instants including source fractional precision; empty bounds null |
| Checkbox | checked, unchecked |
Full-dataset results, not loaded-grid totals. Queries scan a consistent local/live snapshot; total and requested calculations cover all matches. Exports remain independent of grid filters/order.
Query cursors, deadlines, and failure handling
- Queries and calculations share four global / two per-account slots and a 30-second checkpoint deadline.
- Processing uses bounded chunks; summaries use constant-sized accumulators. Authentication/permissions are rechecked between chunks and before returning.
- A changed/missing/nonmatching query anchor returns
409: refresh. Pages are not a cross-request snapshot. - Incompatible compared source values return
422, not misleading ordering. Failed, expired, or cancelled work returns no partial totals/results; connections and slots are released. - HEAD on summary validates access without calculating.
Files And Live Connections
| Method | Path | Input / result |
|---|---|---|
| POST | /tables/import | Create a local Table; {format:"csv"|"json",data,name?,parentId?,columns?}; tables:read + tables:write |
| POST | /tables/:id/import | Append to a local Table with the same body/access |
| GET | /tables/:id/export?format=csv|json | All records, including unloaded/live rows; tables:read |
| GET | /tables/:id/source | Local: null; live: {dialect,connectionName,schema,table} |
| GET | /table-connections | Safe metadata; credentials:manage |
| POST | /table-connections | Test/save encrypted connection; credentials:manage; fields below |
| GET | /table-connections/:id/catalog | {tables:[{schema,name}],more}; ≤500 names; credentials:manage |
| DELETE | /table-connections/:id | credentials:manage; 409 if links still use it |
| POST | /tables/connect | {connectionId,schema,table,name,parentId?}; credentials:manage + tables:read + tables:write |
| Transfer rule | Contract |
|---|---|
| Import size | At most 500 records / 1,000,000 UTF-8 bytes; failure rolls back all rows, columns, and new Table |
| Import columns | Exact destination names; empty Table creates definitions; optional {key,name,type,options?} |
| CSV | New columns default to text; typed destinations parse numbers and true/false/1/0; export needs unique headings and flattens null/empty strings |
| JSON | Bare arrays infer number/checkbox types; typed format below preserves values and remaps column keys on import |
| Export snapshot | Separate consistent snapshot; ≤30 seconds; eight global / two per-account slots; 64 KiB chunks |
| Export access | Rechecked before chunks; interruption aborts; HEAD validates without a snapshot |
Typed Table JSON example
{
"format": "hopya-table",
"version": 1,
"name": "Inventory",
"columns": [
{"key":"qty","name":"Quantity","type":"number","options":[]},
{"key":"active","name":"Active","type":"checkbox","options":[]}
],
"records": [
{"qty":12.5,"active":false},
{"qty":null,"active":null}
]
}
Live writes change the source database. Reload before retrying an uncertain commit. Deleting a Hopya live Table removes its link, never source data; workspace export excludes remote rows.
Connection configuration and live write-back
Connection POST accepts {name,dialect,host?,port?,database?,username?,password?,tls?,filename?}.
| Setting | Contract |
|---|---|
dialect | pg, mysql, or sqlite |
| Network source | Host, database, username required; verified TLS by default; operator SQL_ALLOWED_HOSTS |
| SQLite source | Filename required; operator SQL_SQLITE_ROOT |
| Returned metadata | {id,workspaceId,name,dialect,createdAt} only; encrypted configuration never returned |
| Linked source | Text/numeric primary key required; composite keys supported; explicit schema/table linking is not limited by catalog truncation |
| Live record behavior | Contract |
|---|---|
| Shared routes | Column/record GET and record PATCH |
| Column metadata | Adds sourceType, readOnly, primaryKey |
| IDs / cursors | Opaque primary-key tokens ≤4,096 characters |
| Revision | updatedAt is a SHA-256 value revision, not a timestamp; createdAt is empty |
| PATCH | Send exact revision; changed fields apply under a source row lock; omitted fields survive |
| Conflicts | Stale row/changed schema returns 409; schema changes require reconnecting |
| Read-only fields | Primary keys, generated fields, unsupported types |
| Source operations | Insert/delete rows and change columns through database tools, not Hopya |
| Audit | Durable intent before source transaction, applied audit after commit; no distributed transaction |
An uncertain source commit can leave an unresolved intent. Workspace export v8 includes local Table data, appearance, committed-image metadata, and safe tableSources descriptors—not connection secrets, file bytes, or remote rows. Use per-Table export for remote data.
Tasks
Prefix: /api/v1/workspaces/:wid.
| Method | Path | Input / result |
|---|---|---|
| GET | /items/page | Preferred paged read; {items,nextCursor}; query parameters below |
| GET | /items | Complete JSON-array snapshot stream; same node/search/status/archive filters |
| POST | /items | title and list nodeId required; example below |
| GET | /items/:id | One workspace-scoped task |
| PATCH | /items/:id | Preserve omitted fields; items:read + items:write; use expectedUpdatedAt |
| DELETE | /items/:id | items:delete |
| POST | /items/bulk | Atomic archive/delete of 1–100 tasks with exact revisions |
Pagination and bulk reads
| Parameter | Contract |
|---|---|
nodeId? | Hierarchy scope |
search? | Literal title/description substring |
status? | Exact configured status ID |
archived? | exclude (default), include, or only |
limit? | Integer 1–500; default 200; may change between cursor requests |
cursor? | Pass nextCursor unchanged with the same workspace/filters; restart if filters change |
curl --fail-with-body --get \
"$HOPYA_URL/api/v1/workspaces/$WORKSPACE_ID/items/page" \
-H "Authorization: Bearer $HOPYA_API_TOKEN" \
--data-urlencode "limit=200"
For subsequent pages, add --data-urlencode "cursor=$NEXT_CURSOR" using the previous non-null value.
Finish only when nextCursor is null. The 2 MiB encoded budget can return a short page. Cursors are workspace/filter-bound positions, not credentials; every request checks current permissions. Multiple pages are not one frozen snapshot.
| Bulk read rule | Contract |
|---|---|
/items / /export | Consistent SQLite/PostgreSQL snapshot; no silent row cap; workspace export always includes archives |
| Capacity | Eight global / two per-account reads; excess: 429, Retry-After: 1 |
| Lifetime | 30 seconds; released on cancellation, finish, or error |
| Access | Rechecked before every chunk; HEAD checks access without a stream slot |
| Before headers | Sanitized JSON error; bulk deadline: 504 |
| After headers | Revocation, timeout, or corruption aborts the connection; delivered bytes cannot be recalled |
An interrupted export is a failed download, never a complete/importable partial file. Settings downloads stream through the browser without JavaScript buffering/reserialization.
Task page sizing and seek behavior
- A schema-valid item may exceed the soft 2 MiB budget so it remains whole; an encoded stored record above 8 MiB fails rather than truncates.
- The
(createdAt,id)seek avoids offset drift when an earlier item is deleted. Use a bulk stream for one consistent dataset snapshot.
Create and update fields
| Field | Contract |
|---|---|
title | Required on create; 1–300 characters |
nodeId | Required list ID on create |
description | At most 50,000 characters; "" clears |
status | Effective destination-list ID on create/update/move; omitted create defaults to first status |
priority | none, low, medium, high, urgent |
startDate, dueDate | Nullable calendar dates; start cannot follow due |
tags | At most 30 distinct strings, 1–60 characters each |
assigneeId | Active workspace member; null clears |
checklist | At most 100 entries; server-normalized IDs; text 1–200 characters |
parentId | Same-workspace task; no cycles/subtask-depth overflow; null clears |
customFields | Typed values keyed by field UUID; PATCH replaces the complete map |
| Server fields | Responses add id, workspaceId, archivedAt, createdAt, updatedAt; ordinary writes cannot overwrite identity/timestamps/archive state |
List overrides govern status validation; otherwise the root project’s configuration applies. Standalone lists always have explicit statuses. All updates preserve omitted fields.
Create-task example
{
"nodeId": "11111111-1111-4111-8111-111111111111",
"title": "Rehearse backup recovery",
"description": "Verify restored attachments and membership.",
"status": "todo",
"priority": "high",
"startDate": "2026-09-07",
"dueDate": "2026-09-10",
"tags": ["operations"],
"assigneeId": null,
"checklist": [{"text":"Verify task data","done":false}],
"parentId": null,
"customFields": {}
}
Replace illustrative IDs with real resources from your instance.
Concurrent Editing
Send the exact last-read updatedAt as expectedUpdatedAt on PATCH. Preserve unrelated entries when replacing customFields.
{
"status": "done",
"expectedUpdatedAt": "2026-09-06T12:00:00.000Z"
}
| Situation | Result |
|---|---|
| Task changed | 409; no modification or mutation audit; refresh and reconcile the human’s intent before retry |
| Browser edit | Sends the condition and only changed fields |
| REST/MCP omits condition | Explicitly accepts last-write-wins |
| Create | expectedUpdatedAt is not valid |
Archive and delete in bulk
{
"action": "archive",
"items": [{
"id": "11111111-1111-4111-8111-111111111111",
"expectedUpdatedAt": "2026-09-08T10:00:00.000Z"
}]
}
- Archive requires
items:read+items:write; permanent delete requiresitems:delete. - Select every descendant before archiving/deleting a parent.
- Missing, foreign, stale, duplicate, or incomplete selections reject the entire mutation.
Discussion And Notifications
Task-comment paths below use /api/v1/workspaces/:wid/items/:id. Document discussion uses the equivalent /documents/:id/comments routes with documents:read instead of items:read.
| Method | Path | Input / result |
|---|---|---|
| GET | /comments | Complete creation-ordered discussion; parentId, optional anchor, grouped {emoji,count,reactedByMe}; resource read access |
| POST | /comments | {body,parentId?,anchor?}; resource read + comments:create |
| DELETE | /comments/:commentId | First tombstones body; author or comments:manage; repeat DELETE by moderator permanently removes tombstone |
| PATCH | /comments/:commentId/reaction | {emoji,active}; resource read + comments:create; one of each reaction per member |
| Discussion field | Contract |
|---|---|
body | Markdown, 1–10,000 characters |
| Replies | Same resource; depth ≤32; inherit root range and cannot supply a separate anchor |
Root anchor | {revision,start,end,exact,prefix,suffix}; UTF-16 saved-body/description offsets; stale/nonmatching selections rejected |
| Edited quote | Unambiguous range relocates; deleted/ambiguous text retains quote with orphaned anchor |
| Tombstone removal | Direct replies survive, reattached to removed entry’s parent |
| Reactions | 👍, ❤️, 😂, 🎉, 😕, 👀 |
Notification prefix: /api/v1/workspaces/:wid.
| Method | Path | Input / result |
|---|---|---|
| GET | /notifications | Up to 200 newest assignment/user-mention notifications owned by current member |
| GET | /notifications/unread-count | {unread} |
| PATCH | /notifications/:id | {read:boolean} |
| DELETE | /notifications/:id | Remove caller-owned row |
User mentions are editor-generated same-origin Markdown links to active same-workspace members with items:read; at most 20 per body/comment. Unchanged task mentions do not notify twice; self-notifications are skipped. @@ task links and @@@ structure links navigate without notifying.
Fields And Access
Prefix: /api/v1/workspaces/:wid.
Custom field definitions
| Method | Path | Input / result |
|---|---|---|
| GET | /fields | Workspace field catalog |
| POST | /fields | {name,type,options?,settings?,projectId?}; optional project assignment is atomic |
| PATCH | /fields/:id | {name?,options?,settings?}; at least one property; structure:write; last-write-wins |
| DELETE | /fields/:id | Destructive deletion of definition, assignments, and saved task values |
Definitions return {id,workspaceId,name,type,options,settings?}. Omitted projectId creates a reusable catalog entry; type/projectId cannot be patched.
| Type | Value / configuration |
|---|---|
text, number, checkbox | Typed value; options must be empty |
date | Calendar-valid YYYY-MM-DD; optional dateFormat |
datetime | Valid ISO timestamp ≤64 characters with Z/explicit offset; preserved as supplied; optional dateFormat |
select | Dropdown; 1–100 unique trimmed nonempty options, ≤120 characters each |
checklist | Same option constraints; value is unique selected-option array, including [] |
rating | Integer 1–maxRating; default 5, configured maximum 1–10 |
formula | New shared formula defined at creation; calculated task values read-only; legacy behavior below |
All types permit null. Task customFields replaces the complete map on PATCH; preserve unrelated values and send expectedUpdatedAt. Unknown fields/wrong types are rejected.
Field settings, formulas, and mutation integrity
| Setting | Contract |
|---|---|
dateFormat | Date/datetime only: yyyy-MM-dd, MMM d, yyyy, MMMM d, yyyy, dd/MM/yyyy; presentation only |
maxRating | Rating only; integer 1–10 |
formula | Formula only; nonempty expression ≤200 characters; trimmed {{Field name}} references must resolve in workspace |
| Replacement | Supplied options/settings replace that property; omitted ones survive; {settings:{}} restores defaults |
- Field PATCH returns
409if it would invalidate any saved value, including unassigned values. Validation scans one task map at a time. Safe audit and field update commit atomically; task values/versions and assignment revisions stay unchanged. - Field deletion uses indexed single-row value/version reads, removes assignments, and advances affected task/configuration versions.
field.deleterecords exact touched-task count; audit failure rolls everything back. - New formula fields require a shared expression (
=0is the browser starter). Referenced fields cannot be renamed/deleted until shared formulas are updated. Legacy definitions withoutsettings.formulakeep per-task expressions until deliberately configured. - Raw formula strings allow ≤200 characters / 20 references. New/changed references must name exact sibling fields and cannot self-reference; quoted reference-like text is literal. Removing an input does not block unrelated edits to an unchanged formula.
- Display evaluation supports arithmetic, comparisons, quoted strings, and
SUM,AVERAGE,MIN,MAX,ROUND,ABS,IF,CONCAT, with bounded nesting and no JavaScript execution. Referenced formula strings are not recursively evaluated. Raw strings survive REST/export; missing references, malformed expressions, and non-finite results display the raw expression.
Field ownership and statuses
| Method | Path | Input / result |
|---|---|---|
| GET | /projects/:projectId/fields | Root-project or standalone-root-list configuration; membership + items:read or structure:write |
| PATCH | /projects/:projectId/fields | {fieldIds?,builtInFields?,statuses?,dateFormat?,expectedUpdatedAt?}; at least one config property; structure:write |
| GET | /lists/:listId/statuses | Stored {listId,statuses?,updatedAt,inheritedProjectUpdatedAt}; omitted statuses means inheritance |
| PATCH | /lists/:listId/statuses | {statuses,expectedUpdatedAt?,expectedProjectUpdatedAt?}; array overrides, null inherits; structure:write |
| GET | /lists/:listId/tag-colors | {listId,colors,updatedAt}; structure visibility |
| PATCH | /lists/:listId/tag-colors | {colors,expectedUpdatedAt}; complete replacement; structure:write |
| Configuration | Contract |
|---|---|
| Field-owner response | {projectId,fieldIds,builtInFields,statuses,dateFormat?,updatedAt}; historical route/response key retained |
| Field IDs | Unique workspace fields; at most 100 |
| Optional built-ins | priority, startDate, tags, description, nodeId, createdAt, updatedAt |
| Date presentation | Same date-format enum; absence means ISO display; null restores default |
| Assignment removal | Not field deletion; task values/versions survive; display assignment does not restrict REST/MCP permissions |
| Standalone statuses | Mutate through /lists/:listId/statuses, not the project-fields route |
| Tag colors | At most 30 exact nonempty tag names ≤60 characters → six-digit hex; required revision; stale write 409; does not change task tags |
Status schema:
| Field | Contract |
|---|---|
| Array | Ordered, 1–50 strict objects |
id | Unique, 1–64 characters; ^[A-Za-z0-9][A-Za-z0-9_-]*$ |
name | Trimmed, 1–120 characters |
color | Six-digit #RRGGBB |
completed | Explicit boolean, never inferred from ID |
Status changes never rewrite tasks. Removing a status still in use returns 409; renaming an ID is removal plus addition. To enable an override on an inherited list, send both the list revision and its owning project’s inheritedProjectUpdatedAt as expectedProjectUpdatedAt.
Status concurrency, inheritance, and legacy defaults
- Project PATCH replaces statuses, allowing add/reorder/rename/color/completion/removal. Its removal check considers only currently inheriting lists; overridden lists do not block it. Existing request/response/concurrency shape stays intact.
- List GET returns stored configuration, not a materialized effective copy.
inheritedProjectUpdatedAtis always present, even for an overridden list. List GET requires membership +items:readorstructure:write. - Supplied
expectedUpdatedAtmust match the list revision or return409. When enabling an override from inheritance, missingexpectedProjectUpdatedAtreturns400; mismatch returns409. The owner is resolved inside the mutation transaction. - Replacing an existing override/restoring inheritance needs only the list revision. A supplied project revision adds no conflict condition in those transitions. Every list has a stored row from creation/migration; first-write semantics match later writes.
- Invalidating tasks in the list returns
409. Mutation plus safelist.statuses.updateaudit is atomic. Workspace detail and exports carry all field-owner configurations inprojectFields; standalone statuses appear in GET/detail/export. - Legacy projects retain
todo,backlog,in_progress,review,done. Migration 006/new projects use that order, with todo first and only done initially completed. Custom IDs work in page/bulk/MCP filters with unchanged cursor binding and authorization.
List-view settings
| Method | Path | Input / result |
|---|---|---|
| GET | /views/list/settings | Optional UUID projectId; omit for all projects; caller’s current settings |
| PATCH | /views/list/settings | {projectId?:string|null,columnOrder,hiddenColumns,sort,expectedUpdatedAt}; strict full replacement |
| Rule | Contract |
|---|---|
| Response | {view:"list",projectId:string|null,columnOrder:string[],hiddenColumns:string[],sort:{column,direction}|null,updatedAt:string|null} |
| Access | Membership + items:read; viewers manage only their own settings; write-only members/outsiders cannot read/write |
| First save | expectedUpdatedAt:null; then exact timestamp; stale writes return 409 |
| Columns | Unique; always available: title, status, assigneeId, dueDate; optional built-ins and custom:<UUID> |
| Scope | Workspace accepts any configured field; root-project/standalone-list scope only assigned custom fields; project ID must identify a root project |
| Visibility / order | Title must stay ordered and visible; hidden columns must be ordered; sort names a visible ordered column, direction asc/desc |
| Ownership | Authenticated account only; no client user ID; membership removal deletes settings |
| Persistence | Mutation and count-only audit commit together; unsaved defaults have updatedAt:null |
Roles and membership
| Method | Path | Input / result |
|---|---|---|
| GET, POST | /roles | List or create {name,permissions} |
| PATCH, DELETE | /roles/:id | Update or delete; assigned roles cannot be deleted |
| POST | /members | {email,roleId}; existing active account |
| PATCH | /members/:userId | {roleId} |
| DELETE | /members/:userId | Remove member and clear task assignments |
| Permission group | Values |
|---|---|
| Tasks | items:read, items:write, items:delete |
| Documents | documents:read, documents:write, documents:delete |
| Tables | tables:read, tables:write, tables:delete |
| Discussion | comments:create, comments:manage |
| Workspace | structure:write, members:manage, roles:manage, workspace:manage |
| Integrations | automations:manage, credentials:manage, agent:use |
- Owner is immutable and receives all permissions. Role managers cannot delegate/manage privileges above their own.
- Normal membership changes cannot remove the last active Owner. Site-admin security suspension is exempt and retains ownership for recovery.
- Migration 0007 derives existing roles’ Table permissions from task permissions; review delegation after upgrading.
Site Administration
Prefix: /api/v1. Every /admin/* endpoint requires isAdmin, not a workspace role. Site administration does not grant workspace-data access.
| Method | Path | Input / result |
|---|---|---|
| GET | /admin/users | Public user metadata, including disabled state |
| POST | /admin/users | {name,email,password,isAdmin?} |
| PATCH | /admin/users/:id | {isAdmin?,disabled?}; disabling revokes credentials and clears assignments, even for sole workspace owners; last active admin protected |
| GET | /admin/audit | Newest first; optional workspaceId, limit 1–200 (default 50), offset default 0; no secret hashes/keys, bodies, or full AI prompts |
| GET | /admin/status | Counts, migrations, selected integration booleans, storage mode, pending cleanup; no provider credentials |
| GET | /admin/oidc-identities | Optional UUID userId; {id,userId,issuer,subject,createdAt}; unknown/malformed queries rejected |
| POST | /admin/oidc-identities | Explicit {userId,issuer,subject} link; see SSO |
| DELETE | /admin/oidc-identities/:id | Unlink and atomically revoke target sessions/tokens; safe audit counts; recovery rules below |
OIDC unlink and recovery safeguards
- Passwordless unlink returns
409unless another identity remains for the exact configured issuer and SSO has a configured client. Inactive-issuer links are not recovery. - Configuration must exactly match discovery issuer, not a normalized equivalent URL.
- Password-backed admins may unlink themselves, but are signed out. Matching in-flight callbacks cannot restore the removed identity/session.
- Unlink is not a permanent IdP ban: restrict JIT and/or disable the upstream identity when quarantining access.
Integrations And Automations
Prefix: /api/v1/workspaces/:wid. Every ID remains workspace-scoped.
| Operation | Required permission |
|---|---|
| Webhook administration | workspace:manage |
| All automation routes, including legacy CRUD and runs | automations:manage + items:read |
| Credential mutation / OAuth | credentials:manage |
| Credential metadata | automations:manage or credentials:manage; no extra items:read |
Events: item.created, item.updated, item.deleted, node.created, node.updated, node.deleted, field.changed. Matching durable runs enqueue atomically with the event-producing mutation.
Workspace Webhooks
| Method | Path | Input / result |
|---|---|---|
| GET | /webhooks | Safe metadata |
| POST | /webhooks | {name,url,events,enabled?}; maximum 20; raw new secret once; signingVersion:2 |
| PATCH, DELETE | /webhooks/:id | Update name/URL/events/enabled or delete |
| POST | /webhooks/:id/rotate | Replacement secret once; upgrades to version 2 |
| Signing version | Verification of exact JSON body |
|---|---|
| 2 | x-hopya-signature: sha256=<hex HMAC-SHA256(secret, body)> |
| 1 (pre-migration) | Historical sha256(secret + "." + body) until rotation; not HMAC |
Configure the final destination directly. All 3xx responses are rejected for both signing versions and legacy/graph transports. Rotation is not needed for redirect rejection; redirect-dependent destinations are intentionally incompatible. Consumers must choose verification by signingVersion.
Versioned Automation Graphs
| Method | Path | Input / result |
|---|---|---|
| GET, POST | /automations | Legacy list/create; {name,event,steps,enabled?} or singular action; maximum 50 automations / 1–20 steps |
| PATCH, DELETE | /automations/:id | Compatibility updates/delete; graph restrictions below |
| GET | /automations/catalog | Typed trigger outputs, node input/output/config manifests, events, limits |
| GET | /automations/:id/draft | {revision,graph,updatedAt}; revision 0 initialized from published version if unsaved |
| PUT | /automations/:id/draft | {expectedRevision,graph}; saves structurally invalid drafts with validation; stale 409 |
| POST | /automations/:id/validate | Optional {graph}, otherwise saved draft; includes credential/destination checks |
| POST | /automations/:id/publish | {expectedRevision}; validate/publish immutable version, record publisher/credentials, update trigger, retain draft and increment revision |
| GET | /automations/:id/versions | Newest-first {version,format,publisherId,publishedAt} |
| GET | /automations/:id/versions/:version | {version,format,graph,publisherId,publishedAt} |
| POST | /automations/:id/preview | {graph?,event?,mockOutputs?} → {valid,errors,path,uncertain}, effect:"none"; unresolved output branches marked uncertain |
| POST | /automations/:id/test | Queue real published-version run → {ok,event,runId,status:"pending"} |
| GET | /automations/runs | Newest durable runs; limit 1–100, optional automation UUID and pending/running/delivered/failed |
| GET | /automations/runs/:runId | Bounded sanitized steps/graph nodes; raw event/causation omitted |
Preview has no effects; test does. Preview sends no HTTP/email/log/task changes. Test rejects any update_item node with 400 before enqueueing because its synthetic event has no triggering task.
| Graph property | Contract |
|---|---|
| Shape | {nodes,edges} with exactly one trigger |
| Actions | http, webhook, email, log, update_item |
| Controls | condition, switch |
| Condition | equals, not_equals, exists, contains; true/false edges required |
| Switch | 1–20 scalar cases and default branch |
| Topology | Acyclic, fully reachable, exclusively branched; ordinary nodes ≤1 outgoing edge; one selected path, no parallel/join semantics |
| Limits | 50 nodes / 75 edges / 256 KiB graph / 32 KiB per node config / 50 visited execution nodes |
| Templates | Event: {{event.<path>}}; upstream: {{nodes.<nodeId>.output.<path>}}; controls: nodes.<nodeId>.output.<path> |
| Publication | Reject unknown/self/downstream/branch-optional references not guaranteed upstream |
| Transport | Methods, recipients, headers, bodies, outputs, logs, execution time, retention bounded; no automatic redirects |
Version lifecycle, execution security, and legacy compatibility
- Draft revisions rise across saves/publications, never reset on publication. Re-read the retained draft before the next save/publish. Stale requests return
409; queued/active runs keep their immutable version. - Before execution and each node—even read/send-only nodes—the publisher must still exist, belong to the workspace, and have
items:read. Failure is closed. update_itemacts only on triggeringitemIdas the publisher, not event actor/test caller. It rechecksitems:read+items:writeimmediately before mutation; ordinary task validation applies.- Nested causation is limited to depth five; an automation cannot re-enter its own causation chain.
- Legacy create produces a versioned linear graph and preserves
{{steps.N.output}}; existing linear versions keep ordered execution under no-redirect transport. Lists mark graph versions withgraph:true. - Legacy
action/stepsPATCH cannot replace a graph version; graph trigger changes require draft publication. Name/enabled remain patchable; linear trigger PATCH creates a new immutable linear version. - Migration
0002_automation_linear_compatibilitypreserves linear representations larger than the graph-only 256 KiB limit and repairs HTTP-body{{event}}to{{nodes.<triggerId>.output.event}}in derived linear forms. Original steps, queued runs, published graphs, credential bindings, and edited drafts survive. Graph publication retains its own limits.
Automation Credentials
Secrets are write-only: encrypted by the API, never returned/exported/included in runs. Responses contain only {id,name,type,origin,pathPrefix,version,status,createdAt,updatedAt}.
| Method | Path | Input / result |
|---|---|---|
| GET | /automations/credentials | Safe metadata; either management permission |
| GET | /automations/credentials/:id | One safe metadata record |
| POST | /automations/credentials | {name,type,origin,pathPrefix?,secret}; version 1 |
| PUT | /automations/credentials/:id | {expectedVersion,name?,secret}; complete secret replacement; stale version rejected |
| DELETE | /automations/credentials/:id | Revoke; published uses then fail |
| POST | /automations/credentials/:id/oauth/start | {authorizationUrl,expiresAt}; ten-minute authorization-code PKCE S256 flow |
| GET | /automations/credentials/:id/oauth/callback | Authenticated state/code exchange; encrypted tokens versioned; redirect to /integrations?oauth=connected on instance |
| Credential type | Write-only secret shape |
|---|---|
bearer | {token} |
api_key | {name,value}; header only |
basic | {username,password} |
custom_headers | {headers} |
oauth2 | {authorizationUrl,tokenUrl,clientId,clientSecret?,scopes[],accessToken?,refreshToken?,expiresAt?} |
Destination binding always applies. Public credential destinations require HTTPS. A missing/invalid AUTOMATION_KEYRING returns explicit 503 for credential-dependent work; ordinary task APIs remain operational.
OAuth storage and network restrictions
- OAuth state is hash-only; verifier encrypted; refresh advances credential version. This is a generic authorization-code/PKCE client, not verified-provider certification.
originis exact scheme/host/optional port.pathPrefixpermits only its exact path or descendants. DNS/address checks run at validation and dispatch.- Private RFC1918/IPv6 ULA is allowed. Unspecified, loopback, link-local, IPv4 multicast/reserved (
224.0.0.0/4and above), IPv6 multicast, and mapped loopback/link-local forms are blocked. AUTOMATION_NETWORK_EXCEPTIONSbypass address/HTTPS blocks only for exact origins, never credential origin/path binding or redirect rejection.
Task Import And Export
Prefix: /api/v1/workspaces/:wid. Use the migration guide for a reviewed transfer workflow.
| Method | Path | Input / result |
|---|---|---|
| POST | /items/import | {nodeId,format,data}; items:write; success 201 {imported,ids} |
| GET | /items/export | format=json|csv; items:read; attachment response |
| Import rule | Contract |
|---|---|
| Target | One list; strict request; format is json or csv |
| Size | At most 500 rows / 1,000,000 data characters |
| JSON | Array of row objects |
| CSV | Header row with title |
| Validation | Fully checked before write; 400 Row N: reason; all-or-nothing transaction with one count-only audit |
| Column | Mapping / validation |
|---|---|
title | Required; 1–300 characters |
description | Task description |
status | Destination-list ID or exact status name |
priority | Task priority enum |
startDate, dueDate | YYYY-MM-DD; start not after due |
tags | Semicolon-separated; trimmed/deduplicated; ≤30 tags of ≤60 characters |
assignee | Email of active workspace member |
custom:<ExactFieldName> | Existing typed field; JSON may instead use customFields object |
CSV coercion is strict: finite numbers; true/false/yes/no/1/0 checkboxes; in-range integer ratings; semicolon-split checklists.
| Export option | Contract |
|---|---|
format | json or csv |
nodeId? | Any node, expanded to descendant lists |
status?, search? | Exact status ID / literal title-description substring |
limit? | Default 1,000; maximum 5,000; creation order |
| JSON | API-shape items |
| CSV | Standard columns below, plus union of ≤50 custom:<FieldName> columns; semicolon-joined lists, empty nulls |
| Headers | Content-Disposition: attachment; text/csv or application/json |
Standard task CSV export columns
id, title, description, status, priority, startDate, dueDate,
tags, assigneeEmail, nodeId, nodePath, createdAt, updatedAt
Site Settings
Prefix: /api/v1. Settings mutations require site-administrator access and commit with audit.
| Method | Path | Input / result |
|---|---|---|
| GET | /site/settings | landingDisabled, mcpSseEnabled, showSidebarAttribution; also reports operator LANDING_ENABLED=true |
| PATCH | /site/settings | Toggle those booleans; site admin |
| PUT | /site/logo | {contentType,data}; base64 PNG/JPEG/WebP/SVG ≤300 KB; site admin |
| GET | /site/logo | Public bytes; 300-second cache |
| DELETE | /site/logo | Remove; site admin |
showSidebarAttributiondefaults true and controls the instance-wide By WNZN watermark; also exposed safely by/config.- Public
landingEnableddefaults false; stays false when operator/admin disables it. The application’s landing template isapps/web/src/landing.json, separate from this docs site. /configexposes the currentlogoURL when set.
Errors
| Status | Meaning |
|---|---|
| 400 | Validation failure |
| 401 | Missing/expired credentials or login failure |
| 403 | Permission or Origin denial |
| 404 | Missing scoped resource |
| 409 | State/version conflict |
| 413 | Payload too large |
| 429 | Rate/concurrency limit |
| 502 | Invalid/failed AI response |
| 503 | Disabled/unavailable integration |
| 504 | Bulk deadline before headers |
Storage/OIDC errors are sanitized, never raw upstream bodies. Post-header stream failures abort the connection rather than return a successful partial export.