Conventions
The response envelope, how a record looks on the wire, cursor pagination, strict inputs, writes by field name, and replacing a whole document safely.
The envelope
Success and failure have one shape each, and a list is an object with an array in it:
{ "data": [], "meta": { "nextCursor": null, "hasMore": false } }{ "error": { "code": "validation_failed", "message": "…", "details": {} } }Switch on error.code, never on the message. See Errors.
Records on the wire
A record is keyed by field id, because an id survives a rename and a name does not. The
id-to-name map travels with every list, in meta.fields: use it to show a name, never to key on
one.
{
"data": [{ "id": "rec_4nqw8e2hzk7s", "created_at": "2026-09-05T16:53:39.058Z", "fld_dd7kbtsjuhdc": "First record", "fld_jx99fwfvhv3x": 42 }],
"meta": { "hasMore": false, "nextCursor": null, "fields": { "fld_dd7kbtsjuhdc": "Name", "fld_jx99fwfvhv3x": "Margin" } }
}Anything that points at a record or a person reads back with both halves, so nobody needs a second request to show it:
| Field type | Reads back as | Written as |
|---|---|---|
| link | [{ "id", "label" }], the label being the linked record’s primary field | ids, or the same objects |
| user, created by, modified by | [{ "id", "label" }], the label being a name or an email | ids |
| attachment | [{ "id", "name", "size", "mime", "url", … }] | the descriptor an upload returned |
Two values catch people: a percent holds the fraction (0.15 is 15%), and a duration
holds milliseconds (one hour is 3600000). A select holds an option id, which is what lets
its label be renamed. Dates are ISO 8601 in UTC.
Writing by name
On records.create and records.update, a key may be a field’s id or its exact name,
resolved before anything is stored. A key that is neither is refused with a 422 that names it,
so a typo is loud rather than an empty row. id and created_at are ignored, so echoing a
record back is safe.
Email, URL and phone fields keep the text you send. A suspect format is still saved, and the
response lists it under warnings (code: "suspect_format"). A warning is a successful save:
do not retry it.
Pagination
Opaque cursors, limit capped at 200, never offsets: an offset over a table people are writing
to skips and repeats rows silently. Pass meta.nextCursor back as cursor until
meta.hasMore is false. A cursor the list did not give out is a 422, not a silent restart.
GET /v1/tables/tbl_8fj3kq2wmv5d/records?limit=100
GET /v1/tables/tbl_8fj3kq2wmv5d/records?limit=100&cursor=eyJ…Lists that are short by nature (a record’s comments, a page’s versions, your views of a table)
take a limit and have no cursor; the reference says which.
Inputs are strict
An unknown query parameter or body key is a 422 that names it, never silently ignored. A
limit must be a whole number written in digits.
Replacing a whole document
A permission document, one group’s permissions and a page’s code are replaced whole. Reading one
gives you its revision (or a page’s version); send it back as baseRevision (or
baseVersion) and a save made after somebody else changed it is refused with 409 instead of
erasing their change. Without it, the last write wins. records.update takes an optional
baseVersion too: the changesetId of the last write you saw on that record.
Deleting
Deleting an organisation, a suite, an app, a table or a field takes its current name as
?confirm=, the same proof the Studio asks a person to type; closing an account takes its email
address. A call without it is refused and deletes nothing.
DELETE /v1/apps/{appId}/tables/{tableId}?confirm=Old%20dealsDeleting a table or a field is also refused, with the list of what reads it (rules, views,
pages, other fields), until you send acknowledge=true.