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.
| 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. Two body shapes: { data } (whole-row) or { changes, expect } (per-field delta, requires If-Match). See Optimistic concurrency. |
| POST | /api/lists/:id/data/versions | Session or Bearer | Freshness poll + presence heartbeat. |
| 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 or Bearer | 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.
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 }
]
}
}
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). |
fields) and their typesEach 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. |
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').
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.
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 } }. This is the whole-row form, and it replaces every column — anything you omit is erased, not preserved. If you are saving one cell at a time, use the delta form in Optimistic concurrency instead; a whole-row payload asserts columns the user never touched, which is how concurrent edits get lost.DELETE /api/lists/:id/data/:rowId → { "message": "Row deleted successfully" } (soft delete; for GitHub-backed lists this closes the underlying issue).Every row carries a version integer that increments on each write. Send it back
as an If-Match header and the server stops a write that would overwrite someone
else's, instead of silently replacing it. This mirrors the documents equivalent
(PATCH /api/documents/:id), which uses the same version_conflict code so a
client can switch on one constant across both.
Rows then go one step further, and the step is the part clients get wrong. Documents ask "did the resource move?" A row asks "did the resource move in a way that collides with what I touched?" — so a stale version is not, by itself, a conflict. See The rule that surprises people below before you write any retry logic.
If-Match is optional on the whole-row form. Omit it and the write applies
unconditionally, exactly as before — nothing existing breaks by not adopting it,
which is what keeps the web form path, the CLI and the mobile app working
unchanged. But an unguarded write is last-write-wins, so a collaborative client
should send one.
Both request shapes are modelled in the OpenAPI spec, with worked examples for the clean merge and the real collision.
They are mutually exclusive, discriminated by which key is present. The server
branches on changes !== undefined and ignores data entirely when both are sent.
Per-field delta (recommended — this is what the grid sends). Only the fields you changed, plus the values you believed they held:
PUT /api/lists/lst_abc001/data/row_xyz
If-Match: 7
Content-Type: application/json
{ "changes": { "status": "shipped" }, "expect": { "status": "in review" } }
The server reconciles per field, which matters because it means a field you did
not send can never conflict. Two people editing different columns of the same
row both succeed. Only a genuine disagreement — you and someone else both writing
the same field to different values — is refused. Re-sending a value the row
already holds counts as applied, not as a conflict, so a retry after a lost
response is safe. A key omitted from expect means "I believed this was empty";
absent and null are treated as the same empty value, because JSON.stringify
drops undefined and a strict comparison would otherwise report a conflict
between two spellings of nothing. The empty string "" is deliberately not
folded in with them — it is a value a person can type.
A delta write requires If-Match (400 without one): with no version there
is nothing to reconcile against, only a partial overwrite.
Whole-row. Send { "data": { ...fields } }. Without If-Match it applies
unconditionally. With one, conflict is row-level: because you asserted the entire
object, any change since your version refuses the write and conflicts comes back
as a single entry with key "*". This is simpler but far more likely to refuse,
which is why the grid uses the per-field shape.
Validation always runs on the merged row, never on the delta — required
columns and visibilityCondition are whole-row predicates, so validating a
one-key change would reject every partial write. A consequence worth handling: a
422 from a one-field delta can name a column you did not touch. Surface the
field values from details, not the keys you sent.
Returns the stored row plus:
| Field | Meaning |
|---|---|
data.version | The new version. Quote this on your next write. |
applied | The field keys that landed, including ones the row already agreed with. |
merged | true when your change was folded into a row that had moved since you read it. |
applied and merged appear only on a guarded write. An unguarded whole-row
write and a GitHub-backed list both return the bare { message, data }.
merged: true carries an obligation: repaint from data, not from what you
sent. The server folded your change into a row another writer had already
changed, and their columns are invisible to you until you do. Ignoring it loses no
data, but it leaves the user looking at a row that is silently out of date until
the next reload.
A stale If-Match whose keys all merge cleanly is accepted, not refused.
A client written as "the version was old, so expect a 409" is wrong and will
raise conflicts the server already resolved. The correct reading is the other
direction: a 409 always means the version was old, but an old version does not
mean a 409. Test both paths:
| You sent | Row moved? | Same field touched? | Result |
|---|---|---|---|
If-Match: 7, row at 7 | no | — | 200, merged: false |
If-Match: 7, row at 8 | yes | no | 200, merged: true — repaint from data |
If-Match: 7, row at 8 | yes | yes | 409, one named conflict |
The server also retries its own read once: a row can legitimately move between the server's read and its conditional write, so it re-reads and tries a second time rather than bouncing a conflict that no longer exists. A second failure is reported, not looped on — a row under sustained contention should surface rather than spin.
Returns 409:
{
"error": "This row changed while you were editing it",
"code": "version_conflict",
"currentVersion": 8,
"serverRow": { "id": "row_xyz", "version": 8, "rowData": { "status": "blocked" } },
"conflicts": [
{ "key": "status", "base": "in review", "mine": "shipped", "theirs": "blocked" }
]
}
Nothing is applied when a write is refused — not even the fields that would have
merged cleanly. conflicts carries enough to offer a choice without a re-fetch:
base is what you expected, mine is what you sent, theirs is what is stored.
On the whole-row shape, conflicts carries a single entry with key "*".
serverRow is a projection, not a full row object: it omits listId,
rowNumber and deletedAt, and it expands createdByUser / lastEditedByUser
into { id, username, displayName, avatar } objects so you can name the other
writer without another request. Decode it as its own type.
conflicts can legitimately be empty. That is the sustained-contention case
above — the row is still moving after the server's retry. Render an empty
conflicts as "try again", not as "nothing collided".
GitHub-backed lists do not support this. Their rows are GitHub issues with no
local row to version, and GitHub's own issue API is last-write-wins. Sending
If-Match or changes to one returns 400 with code unsupported_for_source
rather than accepting a header it cannot honour. Detect source: "github" on the
list, send { data } with no If-Match, and disable per-cell conflict UI there.
POST /api/lists/:id/data/versions is a combined freshness check and presence
heartbeat. One request answers three things: which of the rows you hold have
changed (and what they now say), who else is in the list, and whether it is worth
asking again.
Send the versions you are holding; you get back only what moved.
POST /api/lists/lst_abc001/data/versions
Content-Type: application/json
{ "rowVersions": { "row_a": 4, "row_b": 7 }, "focusedRowId": "row_a" }
{
"changed": [{ "id": "row_b", "version": 9, "rowData": { "status": "blocked" }, "lastEditedByUser": { "username": "casey" } }],
"deleted": ["row_a"],
"users": [{ "userId": "usr_2", "name": "Casey", "username": "casey", "color": "#…", "focusedRowId": "row_b" }],
"collaborative": true
}
| Field | Meaning |
|---|---|
changed | Full rows whose stored version differs from the one you sent. Empty is the normal answer. |
deleted | Ids you sent that no longer resolve. A missing id means gone, not "unchanged". |
users | Others currently present, never including you. |
collaborative | false when nobody else is involved with the list — stop polling. |
It is a POST rather than a GET because it writes your heartbeat, and because
rowVersions belongs in a body. Any role with access may call it, watchers
included: seeing someone else's edit arrive is a read concern. At most 500 rows
per request (400 beyond that).
Please respect collaborative: false. The database behind this bills for
being awake and does not autosuspend while anything polls it. The first-party
grid polls every 10s while the tab is visible and something is changing, backs
off to 60s when nothing is, stops after 10 minutes without interaction, and stops
entirely on a list nobody shares. A client that ignores those is a standing cost
on someone else's bill.
Fetch rows with pagination:
GET /api/lists/lst_abc001/data?limit=50&offset=0
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:
{ "schema": "Title\nName:text, Done:boolean", "parentId"?, "isPublic"? }. Wipes and recreates all properties.{ "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... ] }.
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:
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.userId omitted): the caller watches the list themselves. The list must be public and not their own. This branch is free and ignores notify.{ 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 (matches username and displayName), limit (50, capped at 50 server-side), offset (0), excludeWatchers (comma-separated ids; if omitted, current watchers are auto-excluded). Returns { users, total, pagination }; each user carries id, username, displayName, avatar. email is neither returned nor searchable — an address as search matches nothing and yields the same body whether or not it belongs to an account.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 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": "..." }
]
}
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.
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. |
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).
GitHub-backed lists have no user-defined columns; their schema is a fixed set of GitHub issue fields. GET /api/lists/:id returns this synthetic schema in properties, in the same shape as a local list's properties, so the same client code renders both. The nine columns, in displayOrder:
propertyKey | propertyName | propertyType | Notes |
|---|---|---|---|
number | Issue # | number | Read-only (assigned by GitHub). |
title | Title | text | Required. |
body | Body | textarea | |
state | State | select | validationRules.options = ["open", "closed"]; defaultValue = "open". |
labels | Labels | multiselect | Options loaded from the repository. |
assignees | Assignees | multiselect | Options loaded from the repository. |
url | Link | url | Read-only. |
created_at | Created | datetime | Read-only. |
updated_at | Updated | datetime | Read-only. |
Each entry carries the same keys as a stored ListProperty (id, propertyKey, propertyName, propertyType, displayOrder, isRequired, isVisible, defaultValue, validationRules, helpText, placeholder, visibilityCondition) plus isReadOnly (boolean). Because these columns are not persisted as rows, id is a stable synthetic value of the form gh_<propertyKey> (e.g. gh_title), always a non-empty string. The read-only columns (number, url, created_at, updated_at) reject writes through the row endpoints.
Example (GET /api/lists/lst_gh01, abbreviated to three of the nine columns):
{
"data": {
"id": "lst_gh01",
"title": "Repo issues",
"source": "github",
"githubMeta": { "lastRefreshedAt": "2025-06-11T08:30:00.000Z", "refreshStatus": "idle", "refreshError": null },
"properties": [
{ "id": "gh_number", "propertyKey": "number", "propertyName": "Issue #", "propertyType": "number", "displayOrder": 0, "isRequired": false, "isVisible": true, "isReadOnly": true, "defaultValue": null, "validationRules": null },
{ "id": "gh_title", "propertyKey": "title", "propertyName": "Title", "propertyType": "text", "displayOrder": 1, "isRequired": true, "isVisible": true, "isReadOnly": false, "defaultValue": null, "validationRules": null },
{ "id": "gh_state", "propertyKey": "state", "propertyName": "State", "propertyType": "select", "displayOrder": 3, "isRequired": false, "isVisible": true, "isReadOnly": false, "defaultValue": "open", "validationRules": { "options": ["open", "closed"] } }
]
}
}
Local (non-GitHub) lists are unaffected: they return their stored ListProperty rows as before.
schema object and column types.