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 .
fieldsstring—comma-sep field whitelist for the response
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"

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 field; on any other field it’s a 400. 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

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.

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
?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.

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

Returns { data: {...} } or 404. expand and fields work as on a list (since 0.26.1): 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.

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.

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. 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?at=<unix-seconds>

Admin-only. Restores the record to the most recent snapshot at or before at. The live record’s updated_at advances and history records the restore itself as a fresh update row.

409 when the record was deleted (v1 limitation — createRecord mints its own id, so re-creating a deleted record with its original id is unsupported).

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
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
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 restore-of-deleted record
412If-Match precondition failed — response echoes the current ETag
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
  • Rules — gating list/view/create/update/delete
  • Files — uploads attached to records
  • Realtime — live updates over WebSocket
  • Batch API — atomic multi-op transactions