Data API
HTTP contract for workspace tables — records, schema types, and FILE uploads. Not SQL, not Prisma.
Data API
The Data API is the public HTTP contract for workspace tables. External apps read and write live Onnie records with a Bearer key. Rows show up in the grid. Automations on record.created / record.updated can mint Work.
It is not SQL, not Prisma, and not a second Postgres. There is no published npm client. Types come from live columns via GET /schema.ts.
Catalog, schema, CRUD, FILE upload. JSON over HTTPS. The write gate stays Onnie.
Rules on record created or updated mint Work. This API has no mint_work and no task routes.
What it is not
You never get a connection string, PostgREST, GraphQL, or Prisma against the cluster. Computed cells (SERIAL, FORMULA, AI, LOOKUP, ROLLUP) are read-only. Schema mutation (create table, add column) is not on this API — change columns in Onnie.
v1 also does not expose pages, skills, or tasks. Keys minted in Settings only offer tables:read and tables:write.
Prerequisites
- Workspace Database is on. If it is off, every Data route returns
403database.disabled. Keys are not auto-revoked. - A workspace admin mints a key: Settings → API Keys → Create new key. Copy it once.
- Use
tables:readand/ortables:write. Write implies read.
See Authentication for the Bearer header, key format, and revoke behavior.
Base URL
https://api.onnie.ai/api/public/v1JSON. Version is in the path. Table and record ids in the path are UUIDs (GET /tables returns id plus alias_id). Unknown table in this workspace → 404 table.notFound.
Surface
| Job | Method | Path | Scope |
|---|---|---|---|
| List tables | GET | /tables | read |
| Get table + columns | GET | /tables/{tableId} | read |
| Schema JSON | GET | /schema | read |
| Generated TypeScript | GET | /schema.ts | read |
| List records | GET | /tables/{tableId}/records | read |
| Get record | GET | /tables/{tableId}/records/{recordId} | read |
| Create record(s) | POST | /tables/{tableId}/records | write |
| Patch record | PATCH | /tables/{tableId}/records/{recordId} | write |
| Delete record | DELETE | /tables/{tableId}/records/{recordId} | write |
| FILE presign | POST | …/files/{columnName}/presign | write |
| FILE register | POST | …/files/{columnName}/register | write |
| FILE sign (GET URL) | GET | …/files/{columnName}/sign | read |
Canonical first write:
curl -X POST https://api.onnie.ai/api/public/v1/tables/$TABLE_ID/records \
-H "Authorization: Bearer onn_live_..." \
-H "Content-Type: application/json" \
-d '{"fields":{"Title":"Inbound lead"}}'200 with { "record": { … } }. Create is not 201. Then an Automation on record.created can mint Work. Five-minute walkthrough: Quickstart.
Record shape
Every public record looks like:
{
"id": "…",
"table_id": "…",
"created_at": "2026-09-12T12:00:00.000Z",
"updated_at": "2026-09-12T12:00:00.000Z",
"fields": { "Title": "Inbound lead" }
}fields uses column names and the public codec (REFERENCE ids, FILE metadata, ISO datetimes). The API omits created_by, updated_by, and form-submit bookkeeping. Inserts from a key leave created_by null — a key is not a member.
Field types
Keys in fields must be real column names. Unknown key → 422 fields.unknown.
| Column | Read | Write | Notes |
|---|---|---|---|
| TEXT, LONGTEXT, EMAIL, PHONE, URL | string | string | |
| NUMBER | number | number | JSON number, not a string |
| BOOLEAN | boolean | boolean | |
| DATETIME | ISO-8601 string | ISO-8601 string | Not Date, not unix ms. Other shapes → 422 datetime.invalid |
| SELECT | option string | same | Options come from column settings { value } |
| MULTISELECT | string[] | string[] | |
REFERENCE, max is 1 or omitted | string | null | string | null | Public id. Stored internally as a pointer |
REFERENCE, max > 1 | string[] | string[] | |
| FILE | FileValue[] | omitted | Bytes via presign → PUT → register |
| SERIAL, FORMULA, AI, LOOKUP, ROLLUP | present, read-only | stripped | Sending them does not persist |
Optional columns (required: false) are T | null on write. Omit a key on PATCH to leave it unchanged; send null to clear an optional cell. Required write columns must be present on create. PATCH only validates keys you send.
v1 writes only entity_type = records (table → table ids). Other entity types may appear as
ids on read; writing them is 422 reference.unsupported. No nested expand.
GET /tables/{tableId} returns a client-needed settings subset: SELECT options, REFERENCE max / entity_type / target_table_id, FILE limits. Use GET /schema.ts for the typed form of the same columns.
Writes
All record writes go through one kernel. Formula / lookup / rollup recompute via the existing database trigger — public writes do not HMAC a second recompute.
Create
Body is { "fields": { … } } or bulk { "records": [ { … }, … ] }.
- Single →
200{ "record": … } - Bulk →
200{ "records": [ … ] } - Bulk cap 50; over →
422bulk.tooMany - Required FILE is not required on create (attach after the row exists)
- Computed keys are stripped; FILE JSON →
422file.use_upload - Enqueues
record.createdwithsource: "api"(fire-and-forget; enqueue failure does not fail HTTP)
Patch
Body { "fields": { … } } is partial. Empty diff → 200 with the current record and no automation. Otherwise record.updated with changed_columns.
Delete
200 { "ok": true, "id": "…" }. Purges that record’s table files, then deletes the row. No record.deleted automation (v1 Automations have no delete trigger).
FILE cells
The record must already exist. Do not POST or PATCH FileValue JSON to attach (422 file.use_upload).
POST …/presignwith{ "files": [{ "filename", "mime_type", "size_bytes" }] }— does not enqueue AutomationsPUTbytes to eachpresignedUrlPOST …/registerwith{ "keys": ["…"] }— patches the cell through the same write kernel and can firerecord.updated
Reads return FileValue[] metadata, not bytes. GET …/sign mints short-lived GET URLs for keys already on that cell.
type FileValue = {
id: string
key: string
filename: string
mime_type: string
size_bytes: number
uploaded_at: string // ISO
storage_id?: string
}MIME, size, and count follow the FILE column (and workspace storage headroom). Failures: file.mimeNotAllowed, file.tooLarge, file.tooMany, file.malformed, 413 storage.limitReached.
Clearing files
PATCH{ "fields": { "Attachment": [] } }clears that FILE cell. Objects still referenced by other FILE cells on the same record are kept. Unreferenced objects for that record are purged. Required FILE cannot be cleared (422fields.required).DELETEon the record purges that record’s table files, then removes the row.
Schema and types
GET /schema.ts is generated at request time from live table_columns for the key’s workspace. Content-Type: text/plain; charset=utf-8.
Per table (export name = sanitized table name):
export type OrdersRecord = {
id: string
table_id: string
created_at: string
updated_at: string
fields: OrdersFields
}
export type OrdersFields = { /* read: includes computed + FILE */ }
export type OrdersWrite = { /* create/patch: no computed, no FILE */ }PATCH bodies are Partial<NameWrite>. Collision on sanitized names suffixes _ + short alias_id. The header comment in the file is the contract: regenerate after a column add, rename, or type change.
GET /schema is the structured JSON equivalent (tables, columns, tsKind) if you generate another language. OpenAPI in these docs is generic (Record / FileValue) — per-table unions live in schema.ts, not in the YAML.
Automations and Work
| Event | When |
|---|---|
record.created | After a successful create (including each bulk row) |
record.updated | After a patch or FILE register that actually changed columns |
| (none on delete) | Delete does not enqueue |
Work stays on Automations. Design the table and the rule in Onnie; the external app only writes records.
List, filters, pagination
GET /tables/{tableId}/records returns { "records", "next_cursor" }. Order is always created_at desc, id desc. Default limit 50, max 100 (higher values clamp). GET /tables is the full catalog — no cursor.
Equality filters only: ?{exactColumnName}=eq.{urlencodedValue}, ANDed. TEXT / EMAIL / PHONE / URL / SELECT / BOOLEAN / NUMBER / DATETIME. Computed, FILE, REFERENCE, MULTISELECT, LONGTEXT → 400 filter.unsupported. No gt / like / in / OR, no sort parameter.
Depth: Pagination & filters.
Errors and rate limits
Every error uses the same envelope:
{ "error": { "code": "record.notFound", "message": "Record not found", "details": {} } }Per-key RPM bucket (default 120). Exhausted → 429 rate.limited. There is no separate API-call meter in v1.
Full code table: Errors. Bucket: Rate limits.
v1 does not include
- SQL, Prisma, connection strings, GraphQL, PostgREST
mint_work/ task, page, or skill routes- Schema mutation (create table / add column)
- Nested expand, realtime, CSV import,
Idempotency-Key - Multipart create-with-files (create the row first)
- OAuth / user-delegated keys / per-table ACL
- A published
@onnie/data-client - Auto-revoke when Database is turned off