Help
Help
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
| Method | Path | Auth | Description |
|---|---|---|---|
| GET | /api/lists | Session or Bearer | Your lists. Query: limit, offset, page. |
| POST | /api/lists | Session or Bearer | Create 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/:id | Session or Bearer | List metadata and schema. |
| PUT | /api/lists/:id | Session or Bearer | Update list metadata (title, description, parentId, isPublic, folderId). |
| DELETE | /api/lists/:id | Session or Bearer | Delete a list. |
| GET | /api/lists/:id/schema | Session or Bearer | Get list schema (properties). |
| PUT | /api/lists/:id/schema | Session or Bearer | Update list schema. |
| POST | /api/lists/:id/refresh | Session or Bearer | Refresh a GitHub-backed list from source. |
| GET | /api/lists/:id/data | Session or Bearer | List rows. Query: limit, offset. |
| POST | /api/lists/:id/data | Session or Bearer | Add a row. Body: { "data": { "field": "value", ... } } (single) or { "bulk": true, "data": [ ... ] } (bulk). |
| GET | /api/lists/:id/data/:rowId | Session or Bearer | Get one row. |
| PUT | /api/lists/:id/data/:rowId | Session or Bearer | Update a row. |
| DELETE | /api/lists/:id/data/:rowId | Session or Bearer | Delete a row. |
| GET | /api/lists/search | Session or Bearer | Search your lists by title or description. Query: q (required), limit, offset. |
| GET | /api/lists/:id/watchers | Session or Bearer | Users watching this list. |
| POST | /api/lists/:id/watchers | Session or Bearer | Add a watcher to this list. |
| GET | /api/lists/:id/watchers/me | Session or Bearer | Whether the current user is watching. |
| GET | /api/lists/:id/watchers/users | Session or Bearer | Users with access (watchers, collaborators, managers). |
| PUT | /api/lists/:id/watchers/:userId | Session or Bearer | Change a user's watcher role. |
| DELETE | /api/lists/:id/watchers/:userId | Session or Bearer | Remove a user from list access. |
| GET | /api/lists/:id/share-links | Session or Bearer | List a list's active tokenized share links. Owner only. |
| POST | /api/lists/:id/share-links | Session or Bearer | Create a tokenized share link. Owner only. Subscriber only. |
| DELETE | /api/lists/:id/share-links/:token | Session or Bearer | Revoke a share link. Owner only. |
| GET | /api/lists/shared/:token | Optional | Resolve a share link for viewing (anonymous read for Viewer links). |
| POST | /api/lists/shared/:token | Session only | Claim an Editor/Admin share link as the signed-in user. |
| GET | /api/lists/connections | Session or Bearer | All connections between your lists. |
| POST | /api/lists/connections | Session or Bearer | Create a directed connection. Body: fromListId, toListId, optional label. |
| DELETE | /api/lists/connections/:id | Session or Bearer | Remove 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
| Key | Required | Description |
|---|---|---|
name | Yes | The 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). |
description | No | Optional description of the schema. |
fields | Yes | Array 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 property | Required | Description |
|---|---|---|
key | Yes | Machine key used in each row's rowData (e.g. author). Must be unique within the schema. |
type | Yes | The column's data type: one of the values in the table below. |
label | Yes | Human-readable column header shown in forms and tables. |
required | No | true makes this column mandatory when a row is added or edited (default false). |
defaultValue | No | Value pre-filled for new rows. |
options | select/multiselect | Array of allowed values. Required for select and multiselect. |
placeholder | No | Placeholder text for the input. |
helpText | No | Help/tooltip text shown beneath the field. |
validation | No | Extra rules: min, max, minLength, maxLength, pattern, step. |
visible | No | false hides the column by default (default true). |
visibility | No | Conditional-visibility rule: { "condition": { "field": "<key>", "operator": "equals", "value": ... } }. |
displayOrder | No | Integer ordering; defaults to the field's position in the array. |
Supported column type values:
| Type | Stores | Notes |
|---|---|---|
text | Single-line text | |
textarea | Multi-line text | |
number | Numeric value | Honors min / max / step from validation. |
boolean | true / false | |
date | Calendar date | |
datetime | Date + time | |
email | Email address | |
url | URL | |
tel | Phone number | |
select | One value from options | options array required. |
multiselect | Multiple values from options | options array required. |
priority | One of low, medium, high, urgent | Defaults 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 (key → propertyKey, label → propertyName, type → propertyType). 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-leveldatakey 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 existingidare updated in place (row data preserved); items withoutidare created; existing properties omitted from the array are soft-deleted and their key is stripped from every row.displayOrderis authoritative by array order (renumbered 0..n-1). AllowedpropertyType: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 (
userIdset): the list owner adds that user atrole(defaultwatcher); the list does not need to be public. This is a sharing action and is subscriber-gated: a free owner gets403 { "error": "Subscribe to share lists." }. On a new grant (201) the recipient gets an in-app notification, plus an email unlessnotifyisfalse(defaulttrue); an idempotent re-add (200) never notifies. - Self-subscribe (
userIdomitted): the caller watches the list themselves. The list must be public and not their own. This branch is free and ignoresnotify. - Idempotent: returns
{ watching: true }(201when newly created,200if already watching).
- Owner grants a named user (
PUT /api/lists/:id/watchers/:userId(owner only) → body{ role, notify? }; invalid/missing role →400. Returns{ role }. Subscriber-gated (403for free owners). A notification (and email, unlessnotify: 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 }.
Share links
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.
| Error | Condition |
|---|---|
400 | Missing fromListId or toListId, or both IDs are the same. |
403 | The current user does not own both lists. |
409 | The 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:
| Method | Path | Auth | Description |
|---|---|---|---|
| GET | /api/folders | Session or Bearer | Your non-deleted list folders ({ folders: [ { id, name, parentId } ] }). |
| POST | /api/folders | Session or Bearer | Create 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).
Related
- Lists (Help Center): the user-facing guide to creating lists, columns, rows, and sharing.
- List Schema DSL: the full reference for the
schemaobject and column types. - List Folders: folder hierarchy for organising lists.
- Sharing & Share Links: tokenized share links and per-person roles for lists.
- API overview: base URL, authentication at a glance, and response conventions.
- API explorer: try list endpoints live with the interactive Swagger console.