Lists

Lists are structured collections of typed rows. Every list has a schema (a small DSL describing the columns) and a stream of data rows matching that schema. Lists can be public, organised into folders, linked together via connections, watched by other users, or synchronised from a GitHub repository.

All list endpoints accept either a session cookie or a Bearer token, making them fully accessible from native iOS (and other non-browser) clients.

Endpoint table

MethodPathAuthDescription
GET/api/listsSession or BearerYour lists. Query: limit, offset, page.
POST/api/listsSession or BearerCreate a list. Body: title (required), schema (DSL object describing the columns, see Creating a list below), optional description, parentId, isPublic. Subscriber only.
GET/api/lists/:idSession or BearerList metadata and schema.
PUT/api/lists/:idSession or BearerUpdate list metadata (title, description, parentId, isPublic, folderId).
DELETE/api/lists/:idSession or BearerDelete a list.
GET/api/lists/:id/schemaSession or BearerGet list schema (properties).
PUT/api/lists/:id/schemaSession or BearerUpdate list schema.
POST/api/lists/:id/refreshSession or BearerRefresh a GitHub-backed list from source.
GET/api/lists/:id/dataSession or BearerList rows. Query: limit, offset.
POST/api/lists/:id/dataSession or BearerAdd a row. Body: { "data": { "field": "value", ... } } (single) or { "bulk": true, "data": [ ... ] } (bulk).
GET/api/lists/:id/data/:rowIdSession or BearerGet one row.
PUT/api/lists/:id/data/:rowIdSession or BearerUpdate a row.
DELETE/api/lists/:id/data/:rowIdSession or BearerDelete a row.
GET/api/lists/searchSession or BearerSearch your lists by title or description. Query: q (required), limit, offset.
GET/api/lists/:id/watchersSession or BearerUsers watching this list.
POST/api/lists/:id/watchersSession or BearerAdd a watcher to this list.
GET/api/lists/:id/watchers/meSession or BearerWhether the current user is watching.
GET/api/lists/:id/watchers/usersSession or BearerUsers with access (watchers, collaborators, managers).
PUT/api/lists/:id/watchers/:userIdSession or BearerChange a user's watcher role.
DELETE/api/lists/:id/watchers/:userIdSession or BearerRemove a user from list access.
GET/api/lists/:id/share-linksSession or BearerList a list's active tokenized share links. Owner only.
POST/api/lists/:id/share-linksSession or BearerCreate a tokenized share link. Owner only. Subscriber only.
DELETE/api/lists/:id/share-links/:tokenSession or BearerRevoke a share link. Owner only.
GET/api/lists/shared/:tokenOptionalResolve a share link for viewing (anonymous read for Viewer links).
POST/api/lists/shared/:tokenSession onlyClaim an Editor/Admin share link as the signed-in user.
GET/api/lists/connectionsSession or BearerAll connections between your lists.
POST/api/lists/connectionsSession or BearerCreate a directed connection. Body: fromListId, toListId, optional label.
DELETE/api/lists/connections/:idSession or BearerRemove a connection.

Public access to lists you've marked isPublic: true is documented in Public Profiles.

Creating a list

A list is created from a top-level title plus an optional schema that defines its columns. The schema is passed as a structured DSL object under the schema key (it is a JSON object, not a comma-separated string). Each entry in schema.fields becomes one typed column.

The essentials are below; for the complete reference (every field type, validation rule, and conditional-visibility operator) see List Schema DSL.

POST /api/lists
Content-Type: application/json

{
  "title": "Books to Read",
  "description": "My reading backlog.",
  "isPublic": true,
  "schema": {
    "name": "Books to Read",
    "description": "My reading backlog.",
    "fields": [
      { "key": "title",  "type": "text",    "label": "Title",  "required": true },
      { "key": "author", "type": "text",    "label": "Author" },
      { "key": "year",   "type": "number",  "label": "Year" },
      { "key": "read",   "type": "boolean", "label": "Read",   "defaultValue": false }
    ]
  }
}

The schema object

KeyRequiredDescription
nameYesThe schema name (non-empty string). Required even though the list's displayed title comes from the top-level title; a good default is to set both to the same value. Omitting it returns 400 Invalid schema: DSL must have a 'name' property (string).
descriptionNoOptional description of the schema.
fieldsYesArray of column definitions. Must contain at least one column (see Every list has at least one column below).

Defining columns (fields) and their types

Each object in fields describes one column. key, type, and label are always required; the rest are optional.

Field propertyRequiredDescription
keyYesMachine key used in each row's rowData (e.g. author). Must be unique within the schema.
typeYesThe column's data type: one of the values in the table below.
labelYesHuman-readable column header shown in forms and tables.
requiredNotrue makes this column mandatory when a row is added or edited (default false).
defaultValueNoValue pre-filled for new rows.
optionsselect/multiselectArray of allowed values. Required for select and multiselect.
placeholderNoPlaceholder text for the input.
helpTextNoHelp/tooltip text shown beneath the field.
validationNoExtra rules: min, max, minLength, maxLength, pattern, step.
visibleNofalse hides the column by default (default true).
visibilityNoConditional-visibility rule: { "condition": { "field": "<key>", "operator": "equals", "value": ... } }.
displayOrderNoInteger ordering; defaults to the field's position in the array.

Supported column type values:

TypeStoresNotes
textSingle-line text
textareaMulti-line text
numberNumeric valueHonors min / max / step from validation.
booleantrue / false
dateCalendar date
datetimeDate + time
emailEmail address
urlURL
telPhone number
selectOne value from optionsoptions array required.
multiselectMultiple values from optionsoptions array required.
priorityOne of low, medium, high, urgentDefaults to those four options when none are supplied.

Every list has at least one column

The fields array can never be empty: every list must define at least one column. A schema with no columns is rejected:

{ "error": "Invalid schema: DSL must have at least one field", "code": "bad_request" }

The web app enforces the same rule by always seeding a first column when you create a list, so a list always carries at least one entity/column. This is distinct from a column's own required flag: fields must contain at least one column, while each individual column may independently be optional or required: true for row entry.

Response (201):

{
  "message": "List created successfully",
  "data": {
    "id": "lst_abc001",
    "title": "Books to Read",
    "description": "My reading backlog.",
    "isPublic": true,
    "source": "local",
    "createdAt": "2025-06-11T08:30:00.000Z",
    "properties": [
      { "id": "prop_001", "propertyKey": "title",  "propertyName": "Title",  "propertyType": "text",    "isRequired": true,  "displayOrder": 0 },
      { "id": "prop_002", "propertyKey": "author", "propertyName": "Author", "propertyType": "text",    "isRequired": false, "displayOrder": 1 },
      { "id": "prop_003", "propertyKey": "year",   "propertyName": "Year",   "propertyType": "number",  "isRequired": false, "displayOrder": 2 },
      { "id": "prop_004", "propertyKey": "read",   "propertyName": "Read",   "propertyType": "boolean", "isRequired": false, "displayOrder": 3 }
    ]
  }
}

Each fields[] entry is stored as a ListProperty (keypropertyKey, labelpropertyName, typepropertyType). Schema validation errors come back as 400 { "error": "Invalid schema: <reason>", "code": "bad_request" }, where <reason> names the offending column (e.g. Field 'year' has invalid type 'integer').

Adding and reading rows

Row data is passed under the top-level data key, keyed by each column's key (its propertyKey), not its label. Using the schema defined above (title, author, year, read):

POST /api/lists/lst_abc001/data
Content-Type: application/json

{
  "data": {
    "title": "The Dream Machine",
    "author": "M. Mitchell Waldrop",
    "year": 2001,
    "read": false
  }
}

Response (201): the created row is returned under data:

{
  "message": "Row created successfully",
  "data": {
    "id": "row_xyz001",
    "listId": "lst_abc001",
    "rowData": { "title": "The Dream Machine", "author": "M. Mitchell Waldrop", "year": 2001, "read": false },
    "createdAt": "2025-06-11T08:35:00.000Z"
  }
}

To insert many rows at once, send { "bulk": true, "data": [ ... ] } (an array of row objects). The bulk response is { "message": "<N> rows created successfully", "count": N }. Bulk create is not supported for GitHub-backed lists (400).

Any column defined with required: true must be present and non-empty in each row's data, or the request fails validation with the message <Column label> is required.

Reading, updating, and deleting one row

  • GET /api/lists/:id/data/:rowId{ "data": { ...row } }.
  • PUT /api/lists/:id/data/:rowId → body { "data": { ...fields } } (same top-level data key as create). Returns { "message": "Row updated successfully", "data": { ...row } }.
  • DELETE /api/lists/:id/data/:rowId{ "message": "Row deleted successfully" } (soft delete; for GitHub-backed lists this closes the underlying issue).

Fetch rows with pagination:

GET /api/lists/lst_abc001/data?limit=50&offset=0

Updating the schema

PUT /api/lists/:id/schema accepts two body shapes on the same route (there is no separate /schema/structured route); the server dispatches by payload:

  • DSL rebuild (destructive): body { "schema": "Title\nName:text, Done:boolean", "parentId"?, "isPublic"? }. Wipes and recreates all properties.
  • Structured update (non-destructive): body { "properties": [ { "id"?, "propertyKey", "propertyName", "propertyType", "displayOrder"?, "isVisible"?, "isRequired"?, "defaultValue"?, "helpText"?, "placeholder"? } ] }. Items with an existing id are updated in place (row data preserved); items without id are created; existing properties omitted from the array are soft-deleted and their key is stripped from every row. displayOrder is authoritative by array order (renumbered 0..n-1). Allowed propertyType: text, number, boolean, date, url, email.

The structured form returns 400 for a duplicate propertyKey, an unknown propertyType, an unknown id, or an attempt to change propertyKey for an existing id (rename propertyName instead). If a to-be-deleted property still holds non-null row data, the request fails 409 with { error, propertiesWithData: [...] } unless ?force=true is passed. Success returns { "properties": [ ...rows ordered by displayOrder... ] }.

Watchers

A watcher's role is one of watcher, collaborator, or manager.

  • GET /api/lists/:id/watchers (owner only) → { watchers: [ { id, userId, role, createdAt, user } ] }.
  • POST /api/lists/:id/watchers → body { userId?, role?, notify? }. Two modes:
    • Owner grants a named user (userId set): the list owner adds that user at role (default watcher); the list does not need to be public. This is a sharing action and is subscriber-gated: a free owner gets 403 { "error": "Subscribe to share lists." }. On a new grant (201) the recipient gets an in-app notification, plus an email unless notify is false (default true); an idempotent re-add (200) never notifies.
    • Self-subscribe (userId omitted): the caller watches the list themselves. The list must be public and not their own. This branch is free and ignores notify.
    • Idempotent: returns { watching: true } (201 when newly created, 200 if already watching).
  • PUT /api/lists/:id/watchers/:userId (owner only) → body { role, notify? }; invalid/missing role → 400. Returns { role }. Subscriber-gated (403 for free owners). A notification (and email, unless notify: false) fires only when the role actually changes.
  • DELETE /api/lists/:id/watchers/:userId (owner only) → { removed: true }. Not subscriber-gated: a downgraded owner can always remove access.
  • GET /api/lists/:id/watchers/users (owner only): search users to add. Query: search, limit (50), offset (0), excludeWatchers (comma-separated ids; if omitted, current watchers are auto-excluded). Returns { users, total, pagination }.

Besides granting a named user access, an owner can mint a secret tokenized share link that confers a role (watcher/collaborator/manager) on anyone who opens it: POST /api/lists/:id/share-links. Creating a link is subscriber-gated (403 for free owners); listing and revoking are owner-only. Recipients open /lists/shared/:token to view, and (for Editor/Admin links) sign in to claim the grant. A shared list's rows are served read-only at GET /api/lists/shared/:token/data (token-authorized, no account required: this is how a Viewer link renders a private list's rows). The full request/response reference for share links lives on the Sharing & Share Links page.

Connections

Connections create labelled, directed relationships between two of your lists, useful for modelling related datasets, parent/child structures, or any graph of linked lists.

POST /api/lists/connections
Content-Type: application/json

{ "fromListId": "lst_abc001", "toListId": "lst_abc002", "label": "references" }

Response (201): the created connection object.

ErrorCondition
400Missing fromListId or toListId, or both IDs are the same.
403The current user does not own both lists.
409The connection already exists.

Fetch all connections owned by the current user:

GET /api/lists/connections
{
  "connections": [
    { "id": "conn_abc001", "fromListId": "lst_abc001", "toListId": "lst_abc002", "label": "references", "createdAt": "..." }
  ]
}

Searching

GET /api/lists/search?q=books&limit=20&offset=0

Returns lists owned by the current user whose title or description matches q (case-insensitive substring). q is required: an empty q returns 400, as does a q longer than 200 characters. limit defaults to 20 and is capped at 100 (a higher value returns 400); offset defaults to 0. The response is { "lists": [ ... ], "pagination": { "total", "limit", "offset", "hasMore" } }, where each list carries an itemCount.

Organising lists into folders

Lists can be grouped into folders. To move a list into (or out of) a folder, send folderId on PUT /api/lists/:id: a string folder ID to place the list in that folder, or null to move it back to the root. The folder must be one you own, or the request returns 404.

Folders themselves are managed through the list-folder endpoints:

MethodPathAuthDescription
GET/api/foldersSession or BearerYour non-deleted list folders ({ folders: [ { id, name, parentId } ] }).
POST/api/foldersSession or BearerCreate a folder. Body: name (required, ≤ 80 chars), optional parentId. Returns { message, folder }. Subscriber only.

GitHub-backed lists

A list created with source: "github" (and the right configuration) reflects issues from a GitHub repository. Trigger an on-demand sync via:

POST /api/lists/:id/refresh

A cron job (/api/cron/sync-github-lists) also refreshes these lists periodically (see Cron & Webhooks).