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:
| Param | Type | Default | Notes |
|---|---|---|---|
page | int | 1 | 1-based. Missing, zero, negative or non-numeric → 1 |
perPage | int | 30 | Max 500. Missing, zero, negative or non-numeric → 30. A page that skips more than 100,000 records ((page − 1) × perPage) answers 400. |
filter | expr | — | Rule expression language. An expression that doesn’t parse returns 400 (it is never silently ignored) |
sort | string | -created_at | comma-sep; - prefix = DESC; aliases created/updated |
expand | string | — | comma-sep relation field paths; nested via . |
fields | string | — | comma-sep field whitelist for the response |
skipTotal | 1/true | false | omit count for faster paging |
search | string | — | 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 |
nearVector | JSON number[] | — | Vector similarity search; pair with nearVectorField. |
nearVectorField | string | — | Vector field on this collection to search against. |
nearLimit | int | 10 | Top-K results to return (max 1000). |
nearMinScore | float | — | Drop matches with cosine similarity below this. |
Response:
{ "data": [ { "id": "...", ... } ], "page": 1, "perPage": 30, "totalItems": 42, // -1 if skipTotal "totalPages": 2 // -1 if skipTotal}Filter examples
Section titled “Filter examples”?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.
?sort=-created # newest first?sort=author,-created # by author asc, then newest first?sort=-updated # last-modified firstRows 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.
Indexes
Section titled “Indexes”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:
| Request | No index | With an index |
|---|---|---|
?filter=owner="u7" (or an owner-only list rule) | 1.5 ms | 0.08 ms, index on owner |
?filter=published=true && views>100&sort=-views | 3.4 ms | 0.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 likepublishedgains little on its own. - Add
skipTotal=1when you don’t needtotalItems. 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. WithskipTotal,totalItemsandtotalPagesare-1; page until a page comes back shorter thanperPage. - 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.
Expand
Section titled “Expand”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.
?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": { ... } } } }}Field projection
Section titled “Field projection”?fields=id,titleReturns 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).
Vector similarity search
Section titled “Vector similarity search”?nearVector=[0.12,-0.05,...]&nearVectorField=embedding&nearLimit=10The 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 one
Section titled “Get one”GET /api/v1/<col>/<id>GET /api/v1/<col>/<id>?expand=author&fields=id,titleReturns { 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).
Create
Section titled “Create”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.
Update
Section titled “Update”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
Section titled “Delete”DELETE /api/v1/<col>/<id>Returns { data: null }. View collections → 405. Records referenced by
restrict-mode relations → 409 with details listing the blockers.
Optimistic concurrency (If-Match)
Section titled “Optimistic concurrency (If-Match)”PATCH / DELETE honour If-Match against the record’s current ETag:
PATCH /api/v1/posts/abc123If-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 Failedwith 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.
Singleton collections
Section titled “Singleton collections”A singleton collection has exactly one record, created with the collection. Its path is the record:
GET /api/v1/settingsReturns { data: {...} }, the one record, with an ETag. If-None-Match
works as on Get one: 304 when nothing changed.
PATCH /api/v1/settingsIf-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.
GETandPATCHon/api/v1/settings/<id>also work, with the record’s id.POST /api/v1/settingsandDELETE /api/v1/settings/<id>return405. The record can’t be created or deleted, in a batch either.view_rulegates theGET,update_rulethePATCH.list_rule,create_ruleanddelete_ruledon’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.
Record history
Section titled “Record history”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.
List history
Section titled “List history”GET /api/v1/<col>/<id>/history?page=1&perPage=50Returns 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.
Restore
Section titled “Restore”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.
Status codes
Section titled “Status codes”| Code | When |
|---|---|
| 200 | OK (list, get, create, update, delete) |
| 400 | Malformed request · filter expression that doesn’t parse |
| 401 | Unauthorized (missing/invalid token where required) |
| 403 | Forbidden (rule failed) · the collection’s list_rule can’t be evaluated (fails closed; details in the server log) |
| 404 | Collection or record not found |
| 304 | If-None-Match ETag matched — body omitted |
| 405 | Write 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 |
| 409 | Delete blocked by a restrict cascade · or restore-of-deleted record |
| 412 | If-Match precondition failed — response echoes the current ETag |
| 422 | Validation failed (details per field) · unique value already taken · file field set to a file not uploaded to that record and field (non-admins) |
| 429 | Rate limit exceeded |