Tables Over the API

Read and write tables from your own code: rows keyed by immutable field key, typed property values, hasMore on long lists, and the separate operations for relations and attachments.

9 min read

On this page

A table is a first-class REST resource under /v1/tables, with scoped API keys and the same operations exposed to agents over MCP. See Authentication for keys and scopes.

The short version#

  • Address properties by immutable field key, never by the label a person can rename.
  • Read the value's type instead of guessing from JSON shape.
  • Treat hasMore: true as a required follow-up fetch, not as a complete list.
  • Send row values through row endpoints, relation links through relation endpoints, and file bytes through attachment endpoints.
  • Send expectedVersion when overwriting another editor's change would be unsafe.
  • Ask one question with POST /rows/query — filter, sort, group, calculations and search are answered together, over the same filtered set.
  • Send an Idempotency-Key on writes you may retry.

Properties are keyed by field key#

A row's data arrives as a properties object keyed by each field's key — not its id, and not its label.

The key is minted once, when the field is created, and never changes. Renaming a column does not move it. A key is also never reused: delete a field called amount and add another one labelled "Amount", and the new field gets amount_2, so the deleted field's old values can never resurface as the new field's values.

That is why integration code should be written against keys. Labels are for people.

JSON
{  "id": "row_...",  "tableId": "tbl_...",  "version": 7,  "properties": {    "description": { "type": "text", "value": "Blue Bottle Coffee" },    "amount": { "type": "number", "value": -12.5 },    "posted_at": { "type": "date", "value": "2026-08-14" },    "category": {      "type": "select",      "option": { "id": "opt_...", "label": "Meals", "color": "amber" }    }  },  "provenance": { "...": "..." },  "createdAt": "2026-08-14T04:12:09.113Z",  "updatedAt": "2026-08-14T04:12:09.113Z"}

Property values are typed#

Every property value carries a type that says how to read it, so a client never has to look the schema up to know what it is holding.

typeShape
text, long_text, url, email, phonevalue: string or null
number, ratingvalue: number or null
auto_numbervalue: number or null, text: the formatted form
checkboxvalue: boolean
date, datetimevalue: ISO string or null
select, statusoption: the option, or null
multi_selectoptions: an array of options
userusers: an array of workspace members
jsonvalue: anything
attachmentitems, total, hasMore
relationitems, total, hasMore
formulavalue, resultKind, stale, optional error and preset
created_time, last_edited_timevalue: ISO string
created_by, last_edited_byactor: who, or null

Long values report hasMore#

Relation and attachment properties hold lists that can be long, so a row listing inlines the first 25 items and tells you the truth about the rest:

JSON
"invoices": {  "type": "relation",  "items": [ { "rowId": "row_...", "tableId": "tbl_...", "primary": "INV-1042", "position": 0, "unavailable": false, "preview": [] } ],  "total": 63,  "hasMore": true}

total is the real count, and hasMore says the array you were given is a prefix. Fetch the whole list with GET /v1/tables/{tableId}/rows/{rowId}/properties/{fieldKey}, or a row's files with GET /v1/tables/{tableId}/rows/{rowId}/attachments.

A related row whose target has been trashed comes back with unavailable: true rather than disappearing from the list.

Computed properties say when they are stale#

A formula property — which covers lookups, rollups and counts — carries stale. When it is true, a recalculation is outstanding and the value you are holding is the previous one. It never comes back as a silent blank.

error carries a stable machine code when the calculation could not be produced: division_by_zero, invalid_value, missing_reference, type_mismatch, complexity_limit, depth_limit, link_limit or unavailable. preset is present when the field was built as a lookup, rollup or count, so you can tell one from a hand-written formula.

Asking for rows: one query#

POST /v1/tables/{tableId}/rows/query is the one "rows where …" question in the product — the same shape the grid, the footer, the group headers, the dashboard widgets and the agent tools ask.

JSON
{  "filter": {    "op": "and",    "children": [      { "field": "status", "op": "is", "value": "opt_overdue" },      { "field": "amount", "op": "lt", "value": 0 },      { "field": "vendor.country", "op": "is", "value": "AU" }    ]  },  "search": "coffee",  "sort": [{ "field": "posted_at", "direction": "desc" }],  "groups": { "by": [{ "field": "category" }] },  "calculations": [{ "field": "amount", "calculation": "sum" }],  "limit": 100}
  • filter is a tree: and / or / not over clauses that name a field by key. A clause may reach one hop through a relation as relation.field. Relative dates (last_30_days) and field-to-field comparisons ({"$field": "budget"}) are part of the shape.
  • sort takes up to 5 keys, groups.by up to 3.
  • calculations are computed over the whole filtered set, not the page you were handed — a total beside filtered rows is a total of those rows. Money columns whose currency is bound per row answer per currency rather than adding unlike amounts.
  • viewId asks through a saved view: its filter is merged under yours, and it lends its sort when you send none.
  • rows: false skips the row page entirely when you only want groups or totals.
  • cursor continues the page. Paged follow-ups are cheap and are deliberately not counted as new queries.

A question the schema cannot answer is a 400 QUERY_REFUSED whose details[] name each offending path — never a partial answer presented as a complete one.

Saved views#

A view is the saved arrangement of a table: its query, its field layout, its footer calculations, its row colours and its layout (grid, board, calendar, gallery, timeline, list, form).

  • GET /v1/tables/{tableId}/views — shared views plus the caller's own personal views.
  • POST /v1/tables/{tableId}/views — create one; duplicateOf copies another view's query and layout but not its name — send layout to override it.
  • PATCH /v1/tables/{tableId}/views/{viewId} — update under the view's version.
  • POST /v1/tables/{tableId}/views/reorder — send the complete order.
  • DELETE /v1/tables/{tableId}/views/{viewId} — delete.

scope: "personal" needs a user behind the credential, so an OAuth client sees shared views only and is refused when it tries to create a personal one. A table always keeps at least one shared view: deleting the last one is a 409 VIEW_LAST_SHARED.

Row comments and row history#

  • GET/POST /v1/tables/{tableId}/rows/{rowId}/comments — read a thread oldest-first by cursor, or post to it.
  • PATCH/DELETE /v1/tables/{tableId}/rows/{rowId}/comments/{commentId} — edit, resolve, reopen, delete.
  • GET /v1/tables/{tableId}/rows/{rowId}/history — what happened to one row, comments included, by cursor. A trashed row keeps its history.
  • GET /v1/tables/{tableId}/events — the whole table's event stream by cursor.

A recorded value larger than the log's cap reports truncated: true rather than being silently shortened.

What you can write#

Rows are written by property key:

  • POST /v1/tables/{tableId}/rows — insert, up to 500 rows per call.
  • PATCH /v1/tables/{tableId}/rows/{rowId} — update. Rows carry a version; send expectedVersion if you want the write to fail rather than overwrite a change someone else made. Several cells in one call land as one atomic write.
  • DELETE /v1/tables/{tableId}/rows — move rows to trash.
  • POST /v1/tables/{tableId}/rows/query — the one read (above).
  • POST /v1/tables/{tableId}/rows/aggregate — the older count/sum/group call, still supported and answered by the same engine.

Writing by your key, not ours: mergeOn#

Send mergeOn: ["invoice_number"] on an insert and each row updates the row that key names, or inserts when nothing matches. The response lists which ids were created and which were updated, so you never have to guess.

A merge key matching several rows is a 409 MERGE_AMBIGUOUS unless you send onMany: "first". A merge key that is not a writable field is a 400 VALIDATION_FAILED.

Sending labels instead of ids: typecast#

Without typecast, an unknown select label is a refusal naming the cell — never a silently blank column. With typecast: true, labels resolve case-insensitively to their option, strings coerce into numbers and dates, and a genuinely new option is created when the credential may edit the schema.

Retrying safely: Idempotency-Key#

Send an Idempotency-Key header on a write and a retry within 24 hours replays the first response instead of writing twice; the replay is marked on the response. The same key with a different body is a 409 IDEMPOTENCY_KEY_REUSED. Keys are scoped per workspace and per credential, so two integrations cannot collide.

If the replay store is unavailable the request is executed rather than failed — availability is never traded for the guarantee.

Not every property is writable this way:

  • Computed, system and auto-number properties are read-only. Sending one is a typed refusal, not a silently ignored key.
  • Relations have their own operationPOST /v1/tables/{tableId}/rows/{rowId}/relations/{fieldKey} — and GET /v1/tables/{tableId}/relations/{fieldKey}/targets searches the rows you may link to.
  • Attachments have their own sequence: POST /v1/tables/{tableId}/attachments uploads the bytes, then PUT /v1/tables/{tableId}/rows/{rowId}/attachments/{fieldKey} sets which files the cell holds and in what order. GET /v1/tables/{tableId}/attachments/{assetId} downloads one.

Schema is writable too: POST/PATCH/DELETE on /v1/tables/{tableId}/fields, plus /fields/{fieldId}/move to reorder and /fields/primary to change which field names the rows. A field may be marked unique, which is enforced on the write itself: a colliding value is a 409 UNIQUE_VIOLATION naming the field, and a blank never collides with another blank.

Error codes worth handling#

CodeWhen
QUERY_REFUSEDThe query names a field, operator or path the schema cannot answer
INVALID_CURSORA cursor did not come from this endpoint, or no longer decodes
UNIQUE_VIOLATIONA write would put a duplicate in a unique field
MERGE_AMBIGUOUSA mergeOn key matched several rows and onMany was not first
ROW_IDENTITY_CONFLICTTwo rows in one batch claim the same row identity
IDEMPOTENCY_KEY_REUSEDThe same Idempotency-Key arrived with a different body
VIEW_NOT_FOUNDThe viewId does not exist, or belongs to another user
VIEW_NAME_TAKENAnother view of the same audience already has that name
VIEW_VERSION_CONFLICTThe view moved under you; re-read it and retry
VIEW_LAST_SHAREDDeleting this view would leave the table with no shared view
COMMENT_NOT_FOUNDThe comment does not exist on that row
TABLE_STRUCTURE_LOCKEDThe table's structure is locked; row edits still work

Limits worth coding against#

LimitValue
Rows per batch insert500
Stored size of one row's values64 KB
Items inlined before hasMore25
Relation target search results25
Links per relation cell100
Files per attachment cell10
Bytes per uploaded file25 MB

Every one of these is a typed refusal with a reason, never a truncated result presented as a complete one. Plan row and table quotas behave the same way: a write that would cross one fails loudly.

Where to go next#

Use this page with your AI assistant

Every BankSync doc is available as plain Markdown for agents and LLMs.