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.

A schema diagram showing tables as nodes and their foreign keys as connecting edges
The console reads your schema back to you, foreign keys included. This is derived from the database, not maintained by hand.

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:

ColumnWhat it is
idThe primary key. It stays inside the database and never appears in the API.
uidThe row's handle outside the database: in URLs, responses, references and events.
org_idThe organization the row belongs to. Set from the caller, never from a request.
created_at, updated_atTimestamps.
deleted_atThe 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, DELETE sets deleted_at and the row disappears from every read; with it off, DELETE removes the row.
  • API operations. Each of the five operations can be switched off for the table. A switched-off operation answers 423 with TABLE_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.

MethodPathPolicy keyDoes
GET/{appUid}/autocrud/{table}{table}:read_listList rows
POST/{appUid}/autocrud/{table}{table}:createCreate a row
GET/{appUid}/autocrud/{table}/{rowUid}{table}:readRead one row
PATCH/{appUid}/autocrud/{table}/{rowUid}{table}:updateUpdate a row
DELETE/{appUid}/autocrud/{table}/{rowUid}{table}:deleteDelete 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 _uid name, holding the referenced row's uid. On invoices, vendor_id is served as vendor_uid and purchase_order_id as purchase_order_uid.
  • A write sends the _uid name back with a uid, and the platform resolves it. A uid that names no row in the referenced table is a 400 with ROW_REFERENCE_NOT_FOUND, naming the field.
  • org_id never 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 as vendor_id is a 400 with ROW_FIELD_NOT_ACCEPTED. The message names the field, and details.use names 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-Key header (a UUID per logical operation) on a POST, PATCH or DELETE and 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 a 409.
  • Deleting a referenced row. A delete that would orphan rows under a Restrict or No action foreign key is refused with 409 and DELETE_BLOCKED_BY_DEPENDENCIES. details.blockers lists 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 409 with CREATE_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

On this page