Records API
REST endpoints for listing, creating, reading, updating, and deleting records with filtering, sorting, and pagination.
#Records API
Records are data rows within a collection. All record endpoints are scoped to a specific collection and require collections/{name} permissions.
Base path: /api/v1/collections/:name/records
#List records
GET /api/v1/collections/:name/records
Query records with filtering, sorting, pagination, search, and relation expansion.
Permission required: collections/{name}:read
#Query parameters
| Parameter | Type | Default | Description |
|---|---|---|---|
page |
integer |
1 |
Page number (1-indexed) |
limit |
integer |
25 |
Records per page (max: 100) |
sortBy |
string |
created_at |
Field to sort by. Must be a valid field or system column. A - prefix (-created_at) is shorthand for descending and overrides order |
order |
asc|desc |
desc |
Sort direction |
filter |
string |
— | JSON-encoded filter (see filtering) |
search |
string |
— | Full-text search across text-like fields |
expand |
string |
— | Comma-separated relation fields to expand inline |
fields |
string |
— | Comma-separated field projection. System columns id, created_at, updated_at are always included |
locale |
string |
project default | For translatable fields: resolve values to this BCP-47 locale (with fallback), or * for the full { locale: value } map. Also selects the locale that sortBy and filter compare against on a translatable field (by that locale's value, with no fallback). On a single-record read, meta=i18n additionally returns per-locale status. |
cursor |
string |
— | Opaque cursor for cursor-based pagination |
include_deleted |
boolean |
false |
Include soft-deleted records. Requires the view_deleted permission |
path |
string |
— | JSON path projection (returns { id, path, value } per record) |
#Request
#HTTP
GET /api/v1/collections/products/records?filter={"status":"published"}&sortBy=price&order=asc&limit=10&expand=category
Authorization: Bearer sk-your-api-key
#SDK
import { EmuView } from '@emuview/sdk';
const sdk = new EmuView({
url: 'https://your-api.example.com',
auth: { apiKey: 'sk-your-api-key' }
});
const results = await sdk.collection('products').list({
filter: { status: 'published' },
sortBy: 'price',
order: 'asc',
limit: 10,
expand: 'category'
});
#Response
200 OK
{
"data": [
{
"id": "b10b01964f804a55b140be7dad738c8f",
"title": "Widget Pro",
"price": 29.99,
"status": "published",
"category": {
"id": "cat_electronics",
"name": "Electronics"
},
"created_at": 1718900000,
"updated_at": 1718905000,
"created_by": "usr_01HXK5M9ABCDEF",
"updated_by": "usr_01HXK5M9ABCDEF"
}
],
"total": 142,
"page": 1,
"limit": 10,
"hasMore": true
}
#Cursor-based pagination
For large datasets, pass a cursor parameter instead of page. This uses keyset pagination, which avoids expensive COUNT queries:
GET /api/v1/collections/products/records?cursor=eyJ2IjoxNzE4OTA1MDAwLCJpZCI6ImFiYyJ9&limit=20
Authorization: Bearer sk-your-api-key
The response includes cursor tokens:
{
"data": [],
"total": -1,
"page": 1,
"limit": 20,
"hasMore": true,
"cursors": {
"next": "eyJ2IjoxNzE4OTA1MDAwLCJpZCI6ImExYjJjM2Q0In0=",
"previous": "eyJ2IjoxNzE4OTEwMDAwLCJpZCI6InoweXh3dnV0In0="
}
}
Pass cursors.next as the cursor parameter to fetch the next page.
#Get record
GET /api/v1/collections/:name/records/:id
Retrieve a single record by ID.
Permission required: collections/{name}:read
#Path parameters
| Parameter | Type | Description |
|---|---|---|
name |
string |
Collection name |
id |
string |
Record identifier |
#Query parameters
| Parameter | Type | Default | Description |
|---|---|---|---|
expand |
string |
— | Comma-separated relation fields to expand |
include_deleted |
boolean |
false |
Return the record even if soft-deleted |
#Request
#HTTP
GET /api/v1/collections/products/records/b10b01964f804a55b140be7dad738c8f?expand=category
Authorization: Bearer sk-your-api-key
#SDK
const product = await sdk.collection('products').get('b10b01964f804a55b140be7dad738c8f', {
expand: 'category'
});
#Response
200 OK
{
"data": {
"id": "b10b01964f804a55b140be7dad738c8f",
"title": "Widget Pro",
"price": 29.99,
"status": "published",
"category": {
"id": "cat_electronics",
"name": "Electronics"
},
"created_at": 1718900000,
"updated_at": 1718905000,
"created_by": "usr_01HXK5M9ABCDEF",
"updated_by": "usr_01HXK5M9ABCDEF"
}
}
#Errors
| Status | Code | Description |
|---|---|---|
401 |
unauthorized |
Missing or invalid authentication |
403 |
forbidden |
User's role lacks read permission on this collection |
404 |
record_not_found |
No record with this ID exists (or is hidden by row-level security) |
#Create record
POST /api/v1/collections/:name/records
Create a new record. System fields id, created_at, updated_at, created_by, and updated_by are set automatically.
owned_by is a system column too, but it is not set on create. It stays
NULL, and NULL means "nobody has moved this record", which reads as
created_by. It becomes a real value only when somebody hands the record over —
see Transfer ownership below. The column is added to a
collection lazily, so it appears in responses only once that collection has used
owner scoping or had a record transferred.
Migrations & imports: a privileged caller (
super_admin, or a role with admin access) may include explicitid,created_at, and/orupdated_at(unix seconds) and they are persisted, preserving record identity across a migration. A duplicateidreturns409. For all other callers these keys are ignored and stripped — the response always shows the values that were actually stored. For large loads use the NDJSON import endpoint (POST /collections/:name/records/import-ndjson, SDKimportNdjson()).
Permission required: collections/{name}:create
#Request
#HTTP
POST /api/v1/collections/products/records
Content-Type: application/json
Authorization: Bearer sk-your-api-key
{
"title": "Widget Pro",
"price": 29.99,
"status": "published"
}
#SDK
const product = await sdk.collection('products').create({
title: 'Widget Pro',
price: 29.99,
status: 'published'
});
// product.id → "a1b2c3d4..."
#Response
201 Created
{
"id": "a1b2c3d4e5f6a7b8c9d0e1f2",
"title": "Widget Pro",
"price": 29.99,
"status": "published",
"created_at": 1718900000,
"updated_at": 1718900000,
"created_by": "usr_01HXK5M9ABCDEF",
"updated_by": "usr_01HXK5M9ABCDEF"
}
#Errors
| Status | Code | Description |
|---|---|---|
400 |
missing_field |
A required field is missing from the request body |
400 |
invalid_field |
A field value does not match the expected type |
422 |
validation_error |
One or more fields failed validation rules |
409 |
already_exists |
Unique constraint violation |
#Update record
PATCH /api/v1/collections/:name/records/:id
Update specific fields on a record. Supports two modes: field-level updates and operator-based JSON mutations.
Permission required: collections/{name}:update
#Field-level update
Send the fields you want to change:
#HTTP
PATCH /api/v1/collections/products/records/b10b01964f804a55b140be7dad738c8f
Content-Type: application/json
Authorization: Bearer sk-your-api-key
{
"title": "Widget Pro Max",
"price": 39.99
}
#SDK
const updated = await sdk.collection('products').update('b10b01964f804a55b140be7dad738c8f', {
title: 'Widget Pro Max',
price: 39.99
});
#Operator-based update (JSON fields)
Use MongoDB-style operators for fine-grained mutations on JSON fields:
PATCH /api/v1/collections/products/records/b10b01964f804a55b140be7dad738c8f
Content-Type: application/json
Authorization: Bearer sk-your-api-key
{
"$set": {
"metadata.category": "electronics",
"metadata.tags": ["sale", "featured"]
},
"$push": {
"metadata.history": { "action": "price_change", "date": "2026-01-15" }
},
"$pull": {
"metadata.tags": "clearance"
},
"$unset": {
"metadata.legacy_field": true
}
}
#Update operators
| Operator | Description |
|---|---|
$set |
Set a value at a JSON path |
$push |
Append a value to an array at a JSON path |
$pull |
Remove a value from an array at a JSON path |
$unset |
Delete a key at a JSON path |
#Optimistic concurrency control
To prevent lost updates, send the record's _version back with your update.
_version is a counter the server increments on every write. It is returned on
create, read and update responses. Do not use updated_at — it has second
precision and was the version source in an older design; sending it now produces
a permanent conflict.
Which channel you use decides the status code on a mismatch:
| Method | Example | Status on mismatch |
|---|---|---|
| Header | If-Match: 4 |
412 Precondition Failed |
| Query param | ?version=4 |
409 Conflict |
| Body field | "_version": 4 |
409 Conflict |
OCC is opt-in: an update that sends no version is applied unconditionally.
A supplied version is re-checked as part of the write itself, so when several clients hold the same version exactly one update commits and the rest are told. A rejected update changes nothing.
# Read the current version, then update conditionally
VERSION=$(curl -s -H "Authorization: Bearer $TOKEN" \
"$BASE/api/v1/collections/products/records/rec_123" | jq -r '.data._version')
curl -X PATCH "$BASE/api/v1/collections/products/records/rec_123" \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d "{\"price\": 29.99, \"_version\": $VERSION}"
On a 409/412: re-read the record, re-apply your change to the fresh copy, and
retry with the new _version.
#Response
200 OK — returns the full updated record.
#Errors
| Status | Code | Description |
|---|---|---|
404 |
record_not_found |
Record does not exist |
409 |
conflict |
Version mismatch, where the version came from the body or ?version= |
412 |
precondition_failed |
Version mismatch, where the version came from If-Match |
422 |
validation_error |
Updated values fail field validation rules |
#Delete record
DELETE /api/v1/collections/:name/records/:id
Delete a record. Defaults to soft delete (sets deleted_at), with an option for permanent deletion.
Deleting an id that does not exist, is hidden by row-level security, or was
already soft-deleted returns 404 record_not_found (not a silent 200), so
deletes are safely idempotency-checkable. A permanent delete may still purge an
already-soft-deleted row.
Permission required: collections/{name}:delete
#Query parameters
| Parameter | Type | Default | Description |
|---|---|---|---|
permanent |
boolean |
false |
If true, permanently removes the record and any associated R2 files |
#Request
#HTTP
DELETE /api/v1/collections/products/records/b10b01964f804a55b140be7dad738c8f
Authorization: Bearer sk-your-api-key
#SDK
// Soft delete (default)
await sdk.collection('products').delete('b10b01964f804a55b140be7dad738c8f');
// Permanent delete
await sdk.collection('products').delete('b10b01964f804a55b140be7dad738c8f', {
permanent: true
});
#Response
200 OK
{ "success": true, "permanent": false }
#Restore record
POST /api/v1/collections/:name/records/:id/restore
Restore a soft-deleted record, clearing deleted_at so it reappears in normal
reads and lists.
Restore is an explicit route, not a deleted_at write on update — writing
deleted_at in a PATCH body is silently ignored. This keeps restore behind the
same permission as the action that deleted the record, rather than granting it
to everyone holding update.
Restoring an id that does not exist, is hidden by row-level security, or is not
soft-deleted returns 404 — the three cases are indistinguishable, so the
route reveals nothing about hidden records.
Permission required: collections/{name}:delete
#Request
#HTTP
POST /api/v1/collections/products/records/b10b01964f804a55b140be7dad738c8f/restore
Authorization: Bearer sk-your-api-key
#Response
200 OK
{ "success": true }
#Bulk delete
DELETE /api/v1/collections/:name/records/bulk
Delete multiple records in a single request.
Permission required: collections/{name}:delete
#Request
#HTTP
DELETE /api/v1/collections/products/records/bulk
Content-Type: application/json
Authorization: Bearer sk-your-api-key
{
"ids": ["id_001", "id_002", "id_003"]
}
#Response
200 OK
{ "deleted": 3 }
Constraints:
- Maximum 200 records per bulk delete
- Respects row-level security (RBAC item filters)
#Export records
GET /api/v1/collections/:name/records/export
Export records as CSV or JSON.
Permission required: collections/{name}:read
#Query parameters
| Parameter | Type | Default | Description |
|---|---|---|---|
format |
csv|json |
csv |
Export format |
ids |
string |
— | Comma-separated record IDs (exports all if omitted) |
#Request
GET /api/v1/collections/products/records/export?format=json
Authorization: Bearer sk-your-api-key
#Response
File download with appropriate Content-Disposition header.
#Import records
POST /api/v1/collections/:name/records/import-ndjson
Bulk-load records from newline-delimited JSON — one JSON object per line. This is the endpoint for
migrations and large loads; POST /records one row at a time is far slower.
Do not confuse this with POST /collections/:name/records/import, which is the geospatial
importer (GeoJSON, CSV, KML, GPX). Sending NDJSON there fails with a GeoJSON parse error.
Permission required: collections/{name}:create
#Query parameters
| Parameter | Type | Default | Description |
|---|---|---|---|
dryRun |
boolean |
false |
Validate and report without writing anything |
jobId |
string |
— | Attribute this chunk to an import job (see below) |
A single request accepts at most 1000 records. For a larger load, send sequential chunks under one import job.
#Request
#HTTP
POST /api/v1/collections/posts/records/import-ndjson
Content-Type: application/x-ndjson
Authorization: Bearer sk-your-api-key
{"title":"First post","body":"…"}
{"title":"Second post","body":"…"}
#SDK
await sdk.collection('posts').importNdjson(rows);
#Response
{
"imported": 2,
"failed": 0,
"total": 2,
"truncated": false,
"records": [
/* the stored rows */
],
"errors": [],
"staleSearchIndexes": ["forum_posts"]
}
staleSearchIndexes names every compiled search index whose sources include this collection. They
are marked stale automatically — see Search indexes after an import.
#Import jobs
For a load larger than 1000 records, an import job gives you one handle and aggregate progress across all its chunks, instead of tracking counts client-side.
const { jobId } = await sdk.collection('posts').startImportJob({ total: rows.length });
for (const chunk of chunks(rows, 1000)) {
await sdk.collection('posts').importNdjson(chunk, { jobId });
}
const job = await sdk.collection('posts').finalizeImportJob(jobId, { rebuild: true });
console.log(`${job.imported}/${job.total} imported`);
Progress is a read-modify-write on a single job record, so drive a job's chunks sequentially.
Parallel chunks against one
jobIdcan lose counts.
#Create a job
POST /api/v1/collections/:name/records/import-jobs
Body takes an optional { "total": <number> } — the row count you expect, used only to report
progress against. Returns { "jobId", "status": "pending", "total" }.
#Read progress
GET /api/v1/collections/:name/records/import-jobs/:jobId
{
"jobId": "4c4e6c7d-…",
"collection": "posts",
"status": "pending",
"total": 438000,
"imported": 12000,
"failed": 3,
"chunks": 12,
"errors": [
/* capped sample */
],
"createdAt": "2026-07-27T03:27:36.856Z",
"updatedAt": "2026-07-27T03:29:01.980Z"
}
Counts accrue even when individual rows fail; errors holds a capped sample rather than every
failure. Job records expire after a TTL, so read progress while the load is running.
#Finalize
POST /api/v1/collections/:name/records/import-jobs/:jobId/finalize
Marks the job complete and returns the final tallies. Pass ?rebuild=true to also kick off the
search-index rebuild described below; the response then carries rebuildingSearchIndexes.
#Transfer ownership
POST /api/v1/collections/:name/records/:id/transfer
Hand a record to a new owner. created_by is deliberately untouched — who ADDED
a record is history and never changes; who OWNS it is an access fact and can
move. See Ownership for the model.
curl -X POST https://your-api.example.com/api/v1/collections/photos/records/abc123/transfer \
-H "Authorization: Bearer sk-your-api-key" \
-H "Content-Type: application/json" \
-d '{ "userEmail": "ana@example.com" }'
Body
| Field | Type | Notes |
|---|---|---|
userId |
string |
The new owner. Exactly one of userId or userEmail. |
userEmail |
string |
The same, by address. Matched case-insensitively. |
keepAccess |
boolean |
Default true — leave the previous owner an edit share. |
Response
{
"success": true,
"changed": true,
"owner": "usr_ana",
"previousOwner": "usr_priya",
"previousOwnerKeptAccess": true
}
changed is false when the record already belonged to that person — a retry
is safe and is not an error. previousOwnerKeptAccess is false when you asked
for a clean break, when there was no previous owner, or when the collection has
sharing turned off, in which case no demotion share could be written.
Who may call it
The record's effective owner, or a role granted transfer on the collection
(manage includes it). update deliberately does not — it is the permission
handed out most freely, and a handover riding inside it would let every editor
reassign anybody's records.
The caller must also be able to READ the record: the row lookup composes the
caller's row filter, so a record outside their scope answers 404 exactly as a
missing one does.
Errors
| Status | When |
|---|---|
400 |
Both userId and userEmail, or neither. |
403 |
Not the owner, and no transfer grant. |
404 |
No such record — or none the caller can reach. |
404 |
Person not found. — deliberately the same message either way, so this cannot be used to test whether somebody has an account here. |
#Search indexes after an import
Bulk-loading data invalidates any compiled search index built from that collection. EmuView handles this in two steps, deliberately kept separate:
| Step | When | What happens |
|---|---|---|
| Mark stale | Automatically, on every import | Affected indexes are flagged; the response lists them in staleSearchIndexes |
| Rebuild | Only when you ask | finalize?rebuild=true, or POST /search-indexes/:name/rebuild |
Rebuilds are not triggered per chunk on purpose. A 438k-row migration sent as 1000-row chunks would otherwise queue several hundred rebuilds of the same index. Load everything, then rebuild once at finalize.
#Filtering
Filters are passed as the filter query parameter (JSON-encoded string). EmuView supports two filter formats.
#MongoDB-style object filter
A nested object with operator keys:
{
"status": { "_eq": "published" },
"price": { "_gte": 10, "_lte": 100 }
}
Shorthand equality — { "status": "published" } is equivalent to { "status": { "_eq": "published" } }.
#Filter operators
| Operator | SQL equivalent | Description |
|---|---|---|
_eq |
= |
Equal |
_neq |
!= |
Not equal |
_gt |
> |
Greater than |
_gte |
>= |
Greater than or equal |
_lt |
< |
Less than |
_lte |
<= |
Less than or equal |
_in |
IN (...) |
Value in array |
_nin |
NOT IN (...) |
Value not in array |
_contains |
LIKE '%val%' |
String contains |
_ncontains |
NOT LIKE '%val%' |
String does not contain |
_starts_with |
LIKE 'val%' |
String starts with |
_nstarts_with |
NOT LIKE 'val%' |
String does not start with |
_ends_with |
LIKE '%val' |
String ends with |
_nends_with |
NOT LIKE '%val' |
String does not end with |
_null |
IS NULL |
Field is null (pass true) |
_nnull |
IS NOT NULL |
Field is not null (pass true) |
#UserFilter array format
A flat array of filter objects — an alternative to the object format:
[
{ "field": "status", "op": "eq", "value": "published" },
{ "field": "price", "op": "gte", "value": 10 }
]
| Operator | Description |
|---|---|
eq |
Equal |
neq |
Not equal |
gt, gte |
Greater than / or equal |
lt, lte |
Less than / or equal |
contains |
String contains |
starts_with |
String starts with |
ends_with |
String ends with |
between |
Value between range (use value and valueTo) |
in |
Value in comma-separated list |
empty |
Field is NULL or empty string |
not_empty |
Field is NOT NULL and not empty |
#Logical combinators
Use _and and _or to build complex filters:
{
"_or": [
{ "status": { "_eq": "published" } },
{
"_and": [{ "status": { "_eq": "draft" } }, { "created_by": { "_eq": "$CURRENT_USER" } }]
}
]
}
#Dynamic variables
These variables are resolved server-side in filter values:
| Variable | Resolves to |
|---|---|
$CURRENT_USER |
Authenticated user's ID |
$CURRENT_ROLE |
Authenticated user's role name |
$PROJECT_ID |
Current project ID |
$NOW |
Current Unix timestamp (seconds) |
$NOW_DATE |
Current date as ISO string (YYYY-MM-DD) |
#Expand (relations)
The expand parameter resolves relation fields from foreign keys to full records.
#Single expansion
GET /api/v1/collections/posts/records?expand=author
Without expand, author returns the raw ID: "usr_abc123". With expand, it returns the full record:
{
"author": {
"id": "usr_abc123",
"name": "Jane Doe",
"email": "jane@example.com"
}
}
#Multiple expansions
Comma-separate field names:
GET /api/v1/collections/posts/records?expand=author,category
#Nested expansions
Use dot notation:
GET /api/v1/collections/posts/records?expand=author,author.organization
#Relation types
| Type | Storage | Expanded value |
|---|---|---|
belongsTo |
Single ID string | Single object or null |
hasMany |
JSON array of IDs | Array of objects |
Expansion respects RBAC permissions. If you lack read permission on the target collection, the expanded field returns null (belongsTo) or [] (hasMany).