Reading
GET /api/{schema}/table/{name}
GET /api/{schema}/view/{name}
GET /api/{schema}/table/{name}/{id}
A read needs READ on the resource. It returns only the rows your row filters allow and only the fields you may read.
Query parameters
| Parameter | Does |
|---|---|
fields | which columns and relation paths to return |
where | which rows: Filtering |
search | rows containing some words |
search_fields | where search looks |
order_by | the order |
limit, offset | pagination |
count | also return the total |
flags | check each row against named conditions |
group_by, having, _sum, _count, _avg, _min, _max | Aggregating |
The order of parameters doesn't matter. The order inside a list (fields, order_by) does.
Fields
fields=number,status,due_at
fields=number,status,site.name,site.customer.name
Without fields, a read returns every column you may read, silently leaving out the others. Asking for a column you may not read is 403 FIELD_PERMISSION_DENIED.
Dotted paths follow foreign keys: see Tables › Relations. A path's output key is the path itself ("site.name"). To name the output, use aliases:
fields[number]=Order&fields[site.customer.name]=Customer&fields[due_at]=Due
{ "data": [ { "Order": "WO-000412", "Customer": "Mercado del Norte S.A.", "Due": "2026-10-15T12:00:00Z" } ] }
A request uses either the list form or the alias form, not both.
How values come back
Values are JSON, following each column's PostgreSQL type:
| PostgreSQL type | JSON | Example |
|---|---|---|
integer, smallint, real, double precision | number | 42 |
bigint, numeric | string, so no precision is lost | "1250.50" |
text, varchar, uuid, enums | string | "ASSIGNED" |
boolean | true / false | true |
date | string, the day as stored | "2026-10-14" |
timestamptz | string, ISO 8601 in UTC | "2026-10-14T13:05:30.000Z" |
time | string | "09:30:00" |
point | object, x as longitude | { "lng": -65.4117, "lat": -24.7821 } |
json, jsonb | the JSON value | { "plan": "PREMIUM" } |
| arrays | array | ["HVAC", "SOLAR"] |
NULL | null | null |
Writes take the same shapes: a point is written as { "lng": …, "lat": … }, and a numeric as a number or a string.
One row
GET /api/public/table/work_orders/8f1c2e0a-…
{ "data": { "id": "8f1c…", "number": "WO-000412", "status": "ASSIGNED" }, "meta": { "version": "48213" } }
A row that doesn't exist, or that your row filter hides, is 404 ROW_NOT_FOUND. meta.version (also sent as the ETag header) lets a later update make sure nobody changed the row in between. See Writing. Reading by key needs a single-column primary key, and takes no query parameters.
Search
search=mercado salta
search=mercado&search_fields=name,site.name
search finds rows where every word appears in some field. By default it looks in the fields the request returns, or in exactly search_fields when given.
| Field type | A word matches when |
|---|---|
| text | the field contains it, ignoring case and accents: perez finds "Pérez" |
| number | the word is that exact number: 412 finds order 412, 41 doesn't |
| uuid | the word is the whole uuid |
| date, timestamp | the word is that day, as YYYY-MM-DD |
| time | the word is that minute, as HH:mm |
Other types (booleans, JSON, arrays) aren't searched. Fields you can't read are skipped. % and _ in a search are literal characters.
LIKE '%word%', which a normal index can't serve. Add a trigram index on the expression TotalBase uses: CREATE INDEX ON customers USING gin (totalbase.search_normalize(name) gin_trgm_ops); (needs the pg_trgm extension).Sorting
order_by=-priority,due_at,site.name
A field sorts ascending, and -field sorts descending. Without order_by, rows come in the resource's default order (set in Database › table › Columns › Order), then by primary key, so pages never overlap.
Pagination
limit=50&offset=100
limit defaults to 50 (api.default_limit) and is capped at 500 (api.max_limit). offset is capped at 100 000. Values over a cap are lowered to it, not rejected.
Counting
count=true adds the number of matching rows, before limit and offset:
{ "data": [ … ], "meta": { "count": 184 } }
The rows and the count come from the same snapshot. Without count=true, no count query runs.
Flags
flags checks each returned row against named conditions, without filtering anything. Each flag uses the where syntax:
flags[overdue][due_at][_lt]=$CURRENT_TIMESTAMP&flags[urgent][priority][_eq]=URGENT
{ "data": [ {…}, {…} ], "meta": { "flags": [ ["overdue", "urgent"], [] ] } }
meta.flags has one list per row, in the same order as data. PostgreSQL evaluates the conditions with the rows. DataViews use this for conditional formatting. At most 32 flags per request.
Reads and the audit log
Reads aren't audited by default. For sensitive tables, turn on Audit reads in the table's Auditing section. Every read then records who read, which fields, with which filter, and how many rows came back, but not the rows themselves.
The SQL it runs
This is the SQL TotalBase sent to PostgreSQL for the example above, as PostgreSQL's own log recorded it - ids shortened, long column lists cut with …. Every value travels as a parameter ($1), never inside the SQL text. A dispatcher reads urgent assigned orders with their site's name:
GET /api/public/table/work_orders?fields=number,status,site.name&where[status][_eq]=ASSIGNED&where[priority][_eq]=URGENT&order_by=-reported_at&limit=20
BEGIN;
SELECT set_config('statement_timeout', '30000', true);
SELECT t0."number" AS "number", t0."status" AS "status", t1."name" AS "site.name"
FROM "public"."work_orders" AS t0
LEFT JOIN "public"."sites" AS t1 ON t1."id" = t0."site" -- site.name follows the foreign key
WHERE (t0."status" = $1 AND t0."priority" = $2) -- the request's where
AND t0."branch" = $3 -- AND the role's row filter: their branch
ORDER BY t0."reported_at" DESC
LIMIT $4 OFFSET $5; -- $1 = 'ASSIGNED', $2 = 'URGENT', $3 = 'de01…', $4 = '20', $5 = '0'
COMMIT;
The row filter is added by TotalBase, never by the request, so no query string can widen it. An administrator's read has no such condition.