Database
Tables you design in the console, each with a governed REST API the moment it exists.
Every app has its own database. You design its tables in the console, and each table gets a complete REST API as soon as it is saved. There is nothing to generate and no route to write: the endpoints are derived from the table itself, so they cannot fall behind it.
What the API does not do is decide who may use it. A new table is reachable by nobody until a policy grants it, and every request is checked against the caller's policies, their organization, and the table's column redaction before a row comes back.


Tables
Open the app's Tables section and create a table. You name it, add columns with their types, and declare foreign keys to other tables (with an on-delete rule: Restrict, Cascade, Set null, or No action).
The platform adds six columns to every table. Don't declare them yourself:
| Column | What it is |
|---|---|
id | The primary key. It stays inside the database and never appears in the API. |
uid | The row's handle outside the database: in URLs, responses, references and events. |
org_id | The organization the row belongs to. Set from the caller, never from a request. |
created_at, updated_at | Timestamps. |
deleted_at | The soft-delete marker (used when the table soft-deletes). |
Three settings shape how the table behaves through the API:
- Scope by org (on by default). A scoped table's rows belong to organizations: each caller sees only its own organization's rows. Switch it off for a shared table, such as reference data every organization reads, where every caller sees every row.
- Soft delete. With it on,
DELETEsetsdeleted_atand the row disappears from every read; with it off,DELETEremoves the row. - API operations. Each of the five operations can be switched off for the table. A
switched-off operation answers
423withTABLE_LOCKED, through the REST API and through the handler SDK alike, whatever a policy says.
Mark a column sensitive to redact it on every read. See column redaction.
The five operations
Every table gets the same five endpoints. Each one is its own policy key, so you can grant reading without writing, or listing without deleting.
| Method | Path | Policy key | Does |
|---|---|---|---|
GET | /{appUid}/autocrud/{table} | {table}:read_list | List rows |
POST | /{appUid}/autocrud/{table} | {table}:create | Create a row |
GET | /{appUid}/autocrud/{table}/{rowUid} | {table}:read | Read one row |
PATCH | /{appUid}/autocrud/{table}/{rowUid} | {table}:update | Update a row |
DELETE | /{appUid}/autocrud/{table}/{rowUid} | {table}:delete | Delete a row |
Every request carries a bearer token: one of your app's users, or an agent's API key. For the
example app's invoices table, the generated reference documents each of them field by field:
list,
create,
get,
update and
delete.
Beyond the five, a table can carry custom actions: your own endpoints, backed by a handler, with their own policy keys.
Rows are named by uid
Every row has a uid, minted by the platform when the row is created. It is the only handle the
API speaks. The sequential id your table keys on stays inside the database: it is never
returned, and a number in a URL names nothing (it is the same 404 as a row that does not exist).
The same rule covers references between rows:
- A foreign key goes out under a
_uidname, holding the referenced row's uid. Oninvoices,vendor_idis served asvendor_uidandpurchase_order_idaspurchase_order_uid. - A write sends the
_uidname back with a uid, and the platform resolves it. A uid that names no row in the referenced table is a400withROW_REFERENCE_NOT_FOUND, naming the field. org_idnever appears in a request or a response. Neither does an integer actor column (created_by,updated_by,deleted_by,user_id) without a declared foreign key.- A request that sends
id,uid,org_id, an actor column or a raw foreign key such asvendor_idis a400withROW_FIELD_NOT_ACCEPTED. The message names the field, anddetails.usenames the one to send instead. Nothing is silently dropped.
{
"uid": "0199b8e4-2a6f-7c31-8d5e-41f0a9c37b12",
"invoice_number": "MS-88461",
"vendor_uid": "0199b8e1-9c04-7a8b-b2d3-6e5f1c8a4d07",
"purchase_order_uid": "0199b8e2-4b7e-7f10-a6c9-2d8e3f5b1a64",
"amount": "8760.00",
"currency": "USD",
"status": "received",
"memo": "Sterile kits",
"created_at": "2026-10-23T14:02:11.000Z",
"updated_at": "2026-10-23T14:02:11.000Z"
}Why not just return the id?
Ids are per database. Invoice #7 exists in every app, and in every copy of an app you restore or move. A uid names exactly one row anywhere, so an export re-imports cleanly, a link stays valid, and nothing outside your database can count your rows by guessing numbers.
A table whose derived _uid name collides with a real column (a vendor_id foreign key on a
table that also has a vendor_uid column) cannot be served at all: every request answers 409
with ROW_EDGE_COLLISION until you rename one of them.
Who sees which rows
Two separate questions decide what a request returns, and they never stand in for each other.
Policy decides whether the caller may perform the operation on the table at all. A caller
without invoices:read_list gets a 403.
Organization decides which rows. Inside your app, every caller acts in one of the app's
organizations. In a scoped table it sees, updates and deletes only that organization's rows,
and a row it creates belongs to that organization. Another organization's row is the same 404
as a row that does not exist, so a uid from elsewhere confirms nothing. In a shared table, every
caller sees every row.
You, the app's owner, see every organization's rows in the console. What the owner's agents and scheduled jobs may reach is set separately, by their org reach.
Other things worth knowing
- Retry-safe writes. Send an
Idempotency-Keyheader (a UUID per logical operation) on aPOST,PATCHorDELETEand the write runs once. A retry with the same key and body gets the stored response for 24 hours. The same key with a different body, or while the first request is still running, is a409. - Deleting a referenced row. A delete that would orphan rows under a Restrict or No action
foreign key is refused with
409andDELETE_BLOCKED_BY_DEPENDENCIES.details.blockerslists the child tables and how many rows hold the reference. Cascade and Set null rules are left to the database. - Unique columns. A create that collides with a unique column is a
409withCREATE_DUPLICATE. - Changes are events. Every write through the API or the handler SDK is recorded and can be streamed to subscribers. See Realtime.
Beyond the generated endpoints
A list takes a filter tree, a sort on up to three columns, a choice of columns and referenced rows to include, and pages by offset or by cursor; an aggregate counts and totals in the database (see Querying). A batch writes up to 500 rows at once (see Batch writes). For a workflow across tables, a join or a write by condition, write a handler and expose it as a custom action.
Next
Reading and writing rows
Create, read, update and delete, with the errors you will meet.
Querying
Filtering, sorting, and paging lists.
Batch writes
Up to 500 rows in one all-or-nothing request.
Realtime
Subscribe to a table's changes over SSE.
Importing CSV
Load a spreadsheet into a table, validated cell by cell.
AI-derived columns
Columns the platform fills in with AI.
Custom actions
Your own endpoints on a table.