REST API
Boring on purpose, because automation tools break on cleverness.
The envelope
Success and failure have one shape each, and a list looks like a single object with an array in it.
{ "data": … , "meta": { "nextCursor": null, "hasMore": false } }
{ "error": { "code": "forbidden", "message": "…" } }Addresses
An app answers under its suite's address, at its own path: the CRM of the suiteacme is https://acme.gtable.app/crm, and its API is under/crm/v1. A suite on its own domain works the same way.
GET https://acme.gtable.app/crm/v1/tables/tbl_8fj3kq2wmv5d/recordsPagination
Opaque cursors, limit capped at 200. Never offsets: an offset over a table people are writing to silently skips and duplicates rows. Pass meta.nextCursor back ascursor until meta.hasMore is false. A few lists are short by nature (your saved views of a table, a record's comments) and take a limit with no cursor; the reference says which.
GET /crm/v1/tables/tbl_8fj3kq2wmv5d/records?limit=100
GET /crm/v1/tables/tbl_8fj3kq2wmv5d/records?limit=100&cursor=eyJ…Views
Ask for a saved view instead of rebuilding its filters. It is one parameter, it stays correct when someone edits the view, and it is usually a much smaller answer.
GET /crm/v1/tables/tbl_8fj3kq2wmv5d/records?viewId=viw_e4ks9mz2bh7wFilters and order
filter takes a filter tree as JSON, sorts an array of levels, andwhere a SQL-style clause. They combine, and all of them go through your permissions. Anything the server does not understand is refused with a 422 that names it, never ignored: an unknown query parameter, an unknown key in a body, a limit that is not a whole number written in digits.
GET /crm/v1/tables/tbl_8fj3kq2wmv5d/records?sorts=[{"fieldId":"fld_7qk2mv9xp4ht","direction":"desc"}]
GET /crm/v1/tables/tbl_8fj3kq2wmv5d/records?where=Stage = 'won' AND Amount > 50000
GET /crm/v1/tables/tbl_8fj3kq2wmv5d/records?filter={"id":"root","kind":"group","mode":"and","children":[
{"id":"c1","kind":"condition","fieldId":"fld_7qk2mv9xp4ht","op":"gt","value":50000}]}Writes
A record is keyed by field id. A field's exact name works too, but the id survives a rename. A key that is neither is refused and nothing is saved, so a typo never becomes an empty row.
POST /crm/v1/tables/tbl_8fj3kq2wmv5d/records
{ "records": [ { "fld_3ns8wq5ke2pb": "Acme renewal", "Amount": 42000 } ] }Send Idempotency-Key on every write, to an app or to the Studio. The key and its response are kept for 24 hours, so a retry returns the first answer (markedIdempotency-Replayed: true) rather than doing the work twice. The same key on a different request is refused with 409.
If your update moves a row outside what you can see (reassigning an owner, say), the response says so with stillVisible: false rather than pretending the record vanished.
Files
Upload the bytes as multipart/form-data, with the file under fileand the record it belongs to as recordId. The answer is a descriptor; writing it into an attachment field is an ordinary record update.
curl -X POST https://acme.gtable.app/crm/v1/tables/tbl_8fj3kq2wmv5d/files \
-H "Authorization: Bearer $DUVAPPS_TOKEN" \
-F file=@contract.pdf -F recordId=rec_4nqw8e2hzk7sReplacing 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 asbaseRevision (or baseVersion) and a save made after somebody else changed it is refused with 409 instead of erasing their change.
Deleting
Deleting an organisation, a suite, an app, a table or a field takes its current name asconfirm, the same proof the Studio asks you to type; closing your account takes its email address. A call without it is refused and deletes nothing.
DELETE /v1/suites/ste_thr6fncvdx2v?confirm=Suite 1The spec
Every app serves its own OpenAPI 3.1 document, describing only what your credential can reach:
GET /crm/v1/openapi.jsonThe Studio's builder API document is public, at /v1/openapi.json on the Studio.