DDuvappsDocumentationStudio

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/records

Pagination

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_e4ks9mz2bh7w

Filters 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_4nqw8e2hzk7s

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 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 1

The spec

Every app serves its own OpenAPI 3.1 document, describing only what your credential can reach:

GET /crm/v1/openapi.json

The Studio's builder API document is public, at /v1/openapi.json on the Studio.