Overview
The Data API is the REST API over the tables, views and functions you expose. It lives under /api on the instance's address. It has no version prefix, and it's the same API the admin, the App and the MCP server use.
URLs
/api/{schema}/table/{name} rows of a table
/api/{schema}/table/{name}/{id} one row, by its primary key
/api/{schema}/view/{name} rows of a view (read-only)
/api/{schema}/function/{name} call a function
The table, view or function segment is always written out.
| Method | On a table | On a view | On a function |
|---|---|---|---|
GET | read rows, or one row by key | read rows | |
POST | create one row, several (bulk=true), or a row with its children | call it | |
PATCH | update one row by key, rows matching where, or several by key | ||
DELETE | delete one row by key, rows matching where, or several by key |
A method exists for a resource only if it's exposed. Otherwise the answer is 404, as for a table that doesn't exist.
Some routes aren't resources:
| Route | For |
|---|---|
/api/auth/* | signing in, registration, passwords: Authentication |
/api/tokens | your personal access tokens |
POST /api/transactions | several writes in one transaction: Transactions |
GET /api/executions/{id} | the result of an asynchronous call |
/api/totalbase/table/files/* | uploading and serving files |
/api/workflows/{slug}/* | running workflows: Manual trigger |
GET /api/openapi.json | the OpenAPI document for the caller |
Every route, with its method and what it needs: Routes.
Request and response
Bodies use one envelope in both directions. What you send goes in data, what you get back comes in data, and information about the result comes in meta:
POST /api/public/table/customers
Authorization: Bearer <token>
Content-Type: application/json
{ "data": { "name": "Mercado del Norte S.A.", "status": "ACTIVE" } }
{ "data": { "id": "6a0f...", "name": "Mercado del Norte S.A.", "status": "ACTIVE" } }
Reads take their options as query parameters and have no body:
GET /api/public/table/customers?fields=id,name&where[status][_eq]=ACTIVE&count=true
{ "data": [ { "id": "6a0f...", "name": "Mercado del Norte S.A." } ], "meta": { "count": 38 } }
Errors come in errors:
{ "errors": [ { "source": "POSTGRESQL", "code": "UNIQUE_VIOLATION",
"message": "A customer with this tax id already exists.", "field": "tax_id" } ] }
sourceisPOSTGRESQLwhen the database refused, andTOTALBASEfor TotalBase's own checks.codeis stable and safe to branch on. The full list is in Errors.field, when present, names the one column the error is about, so a form can show it there.
Column names keep their PostgreSQL spelling (reported_at). Keys TotalBase defines are camelCase (meta.count).
Status codes
| Status | When |
|---|---|
200 | a read, an update, a delete (returning what it removed), a function call |
201 | a create |
204 | nothing to return |
400 | invalid query or input: an unknown operator, a malformed value, a limit exceeded |
401 | no credential, or an invalid or expired one |
403 | authenticated, but the action or a field isn't allowed |
404 | the resource or row doesn't exist, or isn't visible to you |
409 | a conflict: unique or foreign key violation, an idempotency key in use |
413 | the body is too large |
422 | a rule refused the values: CHECK, NOT NULL, RAISE EXCEPTION, a payload check |
429 | too many requests |
503 | PostgreSQL cancelled the statement (timeout) |
What happens to a request
Raw SQL from clients is never accepted. Every request is processed the same way:
request → limits → parse the query → resolve the resource (exposed?)
→ permissions (action, fields, row filter) → compile to parameterized SQL
→ PostgreSQL: one transaction, with a statement timeout
- Values are always sent as bind parameters, and names come only from PostgreSQL's catalog, so a request can't inject SQL.
- Each request runs in one PostgreSQL transaction, reads included, with
statement_timeoutset toapi.request_timeout_ms(30 s by default). - Writes also record an audit entry and an event in the same transaction. See Audit and events.
What the Data API doesn't do
These are deliberate. Each has a PostgreSQL answer:
| Instead of | Use |
|---|---|
| saved queries | a view |
| computed fields | a generated column, a view, or a function |
| custom actions on a table | a function |
| full-text search | search is a substring match. For more, a view or function using PostgreSQL's full-text search. |
| relations without a foreign key | add the foreign key |
Next steps
- A client, end to end: every step below, in one small TypeScript client.
- Authentication.
- Reading and Filtering.
- Writing.