Skip to content

Records API

Full reference for /api/v1/<collection> — list, get, create, update, delete, with filter, sort, expand, projection, batch.

The records API is the main surface for client apps. Every base/auth/view collection exposes the same shape under /api/v1/<collection_name>. A singleton collection is read and updated at its own path instead; see Singleton collections.

GET /api/v1/<col>

Query params:

ParamTypeDefaultNotes
pageint11-based. Missing, zero, negative or non-numeric → 1
perPageint30Max 500. Missing, zero, negative or non-numeric → 30. A page that skips more than 100,000 records ((page − 1) × perPage) answers 400.
filterexpr—Rule expression language. An expression that doesn’t parse returns 400 (it is never silently ignored)
sortstring-created_atcomma-sep; - prefix = DESC; aliases created/updated
expandstring—comma-sep relation field paths; nested via .. At most 6 levels and 20 paths (400)
fieldsstring—comma-sep field whitelist for the response
excludestring—comma-sep fields to leave out, applied after fields (since 0.31.0)
skipTotal1/truefalseomit count for faster paging
searchstring—Full-text search over the collection’s searchable fields. Words are ANDed, the last is a prefix. Newest first unless sort is set; sort=@rank orders by relevance
nearVectorJSON number[]—Vector similarity search; pair with nearVectorField.
nearVectorFieldstring—Vector field on this collection to search against.
nearLimitint10Top-K results to return (max 1000).
nearMinScorefloat—Drop matches with cosine similarity below this.

Response:

{
"data": [ { "id": "...", ... } ],
"page": 1,
"perPage": 30,
"totalItems": 42, // -1 if skipTotal
"totalPages": 2 // -1 if skipTotal
}
Terminal window
?filter=published=true
?filter=author="u1" || author="u2"
?filter=title~"hello" && created>1730000000
?filter=meta.color="red"
?filter=items:any(sku="A-1" && qty>2)

Quote string literals with double-quotes. Operators: = != > >= < <= ~ (substring), &&, ||, parentheses. The timestamps can be named created / updated or created_at / updated_at, in filters, rules and sort.

A relation field holds the related record’s id, so compare it with an id (author="u1"). A dotted path such as meta.color reads inside a json or group field; on any other field it’s a 400. Group items are filtered with ? operators, :each, :length and :any(...) (syntax). To filter on a related record’s fields, use @collection.

Terminal window
?sort=-created # newest first
?sort=author,-created # by author asc, then newest first
?sort=-updated # last-modified first

A group field isn’t a sort key: sort=items is ignored.

Rows with equal sort values come back in a fixed order (by insertion, in the direction of the last sort field), so tied rows never repeat or go missing across pages.

Every collection has indexes on created_at and updated_at, so a plain list in the default order stays fast at any size, and on each single relation field (since v0.26.5), so post = "<id>" (a post’s comments) finds its records directly: 1.2 ms instead of 56 ms on 265k rows in a load test. Any other filter, a sort on another field, or a list rule such as owner = @request.auth.id reads and sorts the whole table on every request, and gets slower as the table grows. In a local test at 100,000 records:

RequestNo indexWith an index
?filter=owner="u7" (or an owner-only list rule)1.5 ms0.08 ms, index on owner
?filter=published=true && views>100&sort=-views3.4 ms0.02 ms, index on views
  • Index the field you sort on, and fields a filter or rule compares with = that narrow the results a lot (an owner, a status with many values). A yes/no field like published gains little on its own.
  • Add skipTotal=1 when you don’t need totalItems. A list with no filter, no rule condition and no search reads a row count the table keeps up to date (since v0.27.0, SQLite), which costs nothing. Any other list counts every matching record in a second query. Since v0.20.1 that result is cached until the next write to the table, so read-heavy apps pay for it once; on a busy write path it still runs on most requests. An index on the sort field can make that count slower: in the test above it went from 2 ms to 14 ms while the page itself got 150 times faster. With skipTotal, totalItems and totalPages are -1; page until a page comes back shorter than perPage.
  • Deep pages still cost more than early ones. Past 3,000 skipped records a list reads the skipped part from the sort index alone, but it still steps over every one of them. On large tables, narrow the list with a filter (a date range, a status) rather than paging far into it.
  • Filter and sort together need one index over both. A list filtered on a field and sorted on another (?filter=status="published" with the default sort, or &sort=-views) is served by a composite index with the = fields first and the sort field last: (status, created_at), (status, views). Two single-field indexes don’t do it: SQLite picks one and still walks or sorts the rest.
  • A unique field is already indexed.

Add indexes in the admin UI (collection → Indexes) or with the indexes API. The Monitoring page lists every list query’s shape by total time and, where one reads a whole table or sorts in memory, names the index that would serve it with a button to create it.

Inline relation targets nested in the response under expand:

Each related record must pass its own collection’s view_rule for the caller. A related record the caller can’t view is left out of expand (the relation field still holds its id). Admins see every related record.

A request may expand at most 20 paths, each at most 6 levels deep (a.b.c is 3). More answers 400, on lists, get-one and batch.

Terminal window
?expand=author # one hop
?expand=author.profile # two hops
?expand=author,comments # multiple
{
"id": "p1", "title": "...", "author": "u1",
"expand": {
"author": { "id": "u1", "email": "...", "expand": { "profile": { ... } } }
}
}
Terminal window
?fields=id,title

Returns only id and title for each row. id is always included, and so is expand when the request also expands (?fields=id,title&expand=author).

Terminal window
?exclude=body,meta

Returns every field except body and meta (since 0.31.0), for leaving out a few large fields without listing all the others. It applies after fields, so ?fields=title,body&exclude=body returns id and title. id is always returned; exclude=expand drops the expanded relations. Unknown names are ignored, as with fields.

Terminal window
?nearVector=[0.12,-0.05,...]&nearVectorField=embedding&nearLimit=10

The candidate set is filtered by filter + list_rule first (so you can’t read past your access scope), then re-ranked by cosine similarity. Each returned row carries a _score field in [-1, 1]. Ranking runs in-process over the newest 10 000 matching records, using a cache of parsed vectors, so repeat searches only parse new or changed rows. See Vector search.

A view collection with parameters takes their values as params[name], on lists, get-one, _aggregate and the CSV export:

Terminal window
curl "https://api.example.com/api/v1/customer_revenue?params[since]=2026-10-01&params[status]=paid"

An unknown name, a value that doesn’t fit its type, or a missing required parameter answers 400, naming it. A parameter the view fills from the signed-in user can’t be set (400).

GET /api/v1/<col>/<id>
GET /api/v1/<col>/<id>?expand=author&fields=id,title

Returns { data: {...} } or 404. expand, fields and exclude work as on a list (since 0.26.1; exclude since 0.31.0): related records the caller can’t view are left out of expand, and a projection keeps expand when you expand. Responses carry a weak ETag header that changes whenever the record does (its updated_at plus a hash of its content). Treat it as opaque and send back exactly what you received:

ETag: W/"1714502400-1x8k2m4q9z"

Pass it back as If-None-Match for a cheap conditional GET (server returns 304 Not Modified when nothing changed), or as If-Match on the next PATCH / DELETE for optimistic concurrency control (see below).

POST /api/v1/<col>
Content-Type: application/json
{ "title": "hello", "body": "world" }

Returns 200 with the created record (with id, created, updated). Validation errors return 422 with details: { fieldName: message }; a value that breaks a unique field is a 422 too ({ "slug": "slug must be unique" }).

A non-admin can set a file field only to files uploaded to that same record and field; any other filename is a 422. Admins are exempt.

If the collection’s view_rule doesn’t admit the new record for the caller, the response is {"data": null} with no ETag. The record is still created.

view collections return 405. So do auth collections: create users with POST /api/v1/auth/<col>/register, which the error message names.

PATCH /api/v1/<col>/<id>
Content-Type: application/json
{ "title": "new title" }

Partial update — fields not in the body stay unchanged. view collections return 405.

As on create, an update the caller can’t view afterwards (its view_rule denies the updated record) returns {"data": null} with no ETag.

PATCH /api/v1/<col> without an id is only for singleton collections; on any other collection it returns 405.

DELETE /api/v1/<col>/<id>

Returns { data: null }. View collections → 405. Records referenced by restrict-mode relations → 409 with details listing the blockers.

PATCH / DELETE honour If-Match against the record’s current ETag:

PATCH /api/v1/posts/abc123
If-Match: W/"1714502400-1x8k2m4q9z"
{ "title": "new title" }

Because the tag includes a content hash, two writes in the same second get different tags, so a stale If-Match is always caught. Tags of the older form W/"<updated>" (whole seconds only) are still accepted on If-Match for existing clients, with that older precision; on If-None-Match they no longer match, so such clients simply get a fresh 200.

  • Match → proceed; response carries the new ETag.
  • Mismatch → 412 Precondition Failed with the server’s current ETag echoed in the response, if the caller’s view_rule admits the record (otherwise without it). Re-fetch, re-merge, and retry.
  • If-Match: * matches any existing record (useful for “create-if-missing, block-if-changed” flows).

The check holds until the write commits, so of several requests carrying the same ETag only one succeeds; the rest get 412. That holds across several servers on one Postgres database too: the write keeps the record’s row locked from the check to the commit.

Both weak (W/"…") and strong-form ("…") tags compare under RFC 7232’s weak compare semantics, so apps don’t have to track which prefix the server sent.

The official SDK auto-attaches If-Match from a per-record cache filled by every get / update. Disable per call with { ifMatch: false } or override with an explicit string.

A singleton collection has exactly one record, created with the collection. Its path is the record:

GET /api/v1/settings

Returns { data: {...} }, the one record, with an ETag. If-None-Match works as on Get one: 304 when nothing changed.

PATCH /api/v1/settings
If-Match: W/"1714502400-1x8k2m4q9z"
Content-Type: application/json
{ "theme": "dark" }

Partial update, same as PATCH /api/v1/<col>/<id>. A stale If-Match returns 412 with the current ETag.

  • GET and PATCH on /api/v1/settings/<id> also work, with the record’s id.
  • POST /api/v1/settings and DELETE /api/v1/settings/<id> return 405. The record can’t be created or deleted, in a batch either.
  • view_rule gates the GET, update_rule the PATCH. list_rule, create_rule and delete_rule don’t apply.
  • The record’s fields start empty. There are no per-field defaults.
  • If the record is removed with raw SQL, the next read creates it again, empty, with a new id.

When a collection has history_enabled: true, every write produces an append-only audit row. See the Record history concept page for the full overview.

GET /api/v1/<col>/<id>/history?page=1&perPage=50

Returns the change log for one record. Gated by the parent record’s view_rule — if you can view the live record (or the most recent snapshot when it’s been deleted), you can read its history.

{
"data": {
"data": [
{ "id": "...", "op": "update", "snapshot": { ... },
"actor_id": "u-42", "actor_type": "user", "at": 1714502400 }
],
"page": 1, "perPage": 50, "totalItems": 3, "totalPages": 1
}
}

404 when history is disabled on the collection.

POST /api/v1/<col>/<id>/restore?entry=<history-id>
POST /api/v1/<col>/<id>/restore?at=<unix-seconds>

Admin-only. entry restores exactly that history version; at restores the most recent snapshot at or before that second. The live record’s updated_at advances and history records the restore itself as a fresh update row. Each restore is in the audit log as record.restore.

A record whose latest change was a delete comes back under its old id with its original creation time (base collections only; since 0.40.0). 409 for a deleted auth or singleton record. See Undo a delete.

For a non-admin, a collection whose list_rule, view_rule and create_rule are all admin-only ("") answers 404 Collection not found, as a missing one does, rather than 403. That covers records, history and batch requests on it, as long as the rule for the request is admin-only too. A singleton is hidden when its view_rule and update_rule are admin-only, and a view when its list_rule and view_rule are; then every request on it answers 404, whatever its other rules say. A non-admin’s ?filter= that names a hidden collection through @collection or _via_ answers 400, as for an unknown name. File routes and the public OpenAPI spec hide the same collections. Admins see the collection as usual.

Pass a Bearer token (user or admin):

Authorization: Bearer <jwt>

@request.auth.id, @request.auth.email, @request.auth.type are then available in API rules.

CodeWhen
200OK (list, get, create, update, delete)
400Malformed request · filter expression that doesn’t parse · more than 6 expand levels or 20 expand paths
401Unauthorized (missing/invalid token where required)
403Forbidden (rule failed) · the collection’s list_rule can’t be evaluated (fails closed; details in the server log)
404Collection or record not found · a hidden collection for a non-admin
304If-None-Match ETag matched — body omitted
405Write attempted on a view collection · create on an auth collection (use /api/v1/auth/<col>/register) · create or delete on a singleton collection · PATCH /api/v1/<col> on a collection that isn’t a singleton
409Delete blocked by a restrict cascade · or restoring a deleted auth / singleton record
412If-Match precondition failed — response echoes the current ETag when the caller can view the record
422Validation failed (details per field) · unique value already taken · file field set to a file not uploaded to that record and field (non-admins)
429Rate limit exceeded
500Server error: { "error": "Internal error", "code": 500 }, the details in the server log

Every error is JSON, { "error": "…", "code": <status> }, with the usual CORS and security headers; a validation error adds details, one message per field. That includes a request body that doesn’t match the route’s schema (422, details by field path) and one that isn’t JSON (400). Before 0.36.0, an error no handler answered went out as plain text with the raw message and without CORS headers.

  • Rules — gating list/view/create/update/delete
  • Files — uploads attached to records
  • Realtime — live updates over WebSocket
  • Batch API — atomic multi-op transactions