---
title: "Conventions"
description: "The response envelope, how a record looks on the wire, cursor pagination, strict inputs, writes by field name, and replacing a whole document safely."
canonical: "https://gtable.app/docs/api/conventions"
updated: "2026-10-05"
---

# Conventions

## The envelope

Success and failure have one shape each, and a list is an object with an array in it:

```json
{ "data": [], "meta": { "nextCursor": null, "hasMore": false } }
```

```json
{ "error": { "code": "validation_failed", "message": "…", "details": {} } }
```

Switch on `error.code`, never on the message. See [Errors](/docs/api/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.

```json
{
  "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.

```http
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.

> **Note**
>
> Sorting and paging do not yet agree. The cursor follows creation order, so paging a list you
> also **sort** can skip or repeat rows at page edges. If you need a sorted, complete list, fetch
> the pages unsorted and sort them yourself, or ask for a saved view.

## 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.

```http
DELETE /v1/apps/{appId}/tables/{tableId}?confirm=Old%20deals
```

Deleting 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`.
