Loomup Docs
Guide /docs/rest

REST API

Exposed SQLite tables are available under /api/<table>.

Endpoints

MethodPathDescription
GET/api/:tableList rows
GET/api/:table/:idGet one row
POST/api/:tableCreate row
PATCH/api/:table/:idUpdate row
DELETE/api/:table/:idDelete row
GET/api/:table/_loomup/key?<field>=<value>Get a row by composite primary key
PATCH/api/:table/_loomup/key?<field>=<value>Update a row by composite primary key
DELETE/api/:table/_loomup/key?<field>=<value>Delete a row by composite primary key

For a composite primary key, supply every key field exactly once in the query string, for example /api/project_members/_loomup/key?project_id=p1&user_id=u1. Single-column primary keys continue to use /api/:table/:id.

List query parameters

  • where[field]=value — equality filter
  • filter[field][operator]=value — eq, ne, lt, lte, gt, gte, in, is_null, contains, or starts_with
  • select=id,title — field projection
  • sort=field,-other — stable multi-column ascending / descending sorting
  • limit — page size (default 50, max 1000)
  • offset — pagination offset
  • cursor — signed opaque continuation cursor; pass it alone, without other list parameters

Unknown or malformed list parameters are rejected instead of being silently ignored.

Example:

http
GET /api/todos?where[completed]=0&sort=-id&limit=20&offset=0

Response shapes

Success (single):

json
{ "data": { "id": 1, "title": "Ship", "completed": 0 } }

Success (list):

json
{
  "data": [ { "id": 1, "title": "Ship" } ],
  "meta": { "limit": 50, "offset": 0, "total": 80, "next_cursor": "…" }
}

meta.total is the count of authorized rows for the query (rule-filtered when row rules apply). When a safety scan limit is reached before the end of the table, meta.truncated: true is included and total is a lower bound. The accompanying cursor carries opaque raw-scan progress, so authorized rows beyond the safety window remain reachable even when the current page contains no authorized rows.

When meta.next_cursor is present, request the next page with GET /api/todos?cursor=<value>. The cursor contains the signed original filter, projection, sort, limit, authorized offset, and internal scan progress, so clients cannot alter query state between pages.

Constraint failures (NOT NULL, UNIQUE, foreign key) return 400 or 409 with structured error.code values (bad_request / conflict) — not raw SQLite 500s.

Error:

json
{ "error": { "code": "forbidden", "message": "forbidden" } }

Access rules

Table rules from loomup.toml are applied on every operation. Send:

http
Authorization: Bearer <access_token>

Server-side integrations may instead use a project API key created in Studio or with loomup admin create-service-key. Resource keys use explicit scopes:

  • resource:<table>:read permits list/get, history/state, sync reads, and realtime.
  • resource:<table>:write permits create/update/delete and sync mutations.
  • project:backend permits schema deployment plus every current and future Resource and named operation in this project. Use it for a long-lived backend or CI credential, not for an untrusted or narrowly scoped integration.

Resource scopes are machine permissions and bypass user-oriented row rules only for the named table and action. A write-only key can receive its mutation result but cannot independently list or fetch rows. Legacy * and operations:* service-key scopes do not grant Resource access. Expired, revoked, or malformed keys return 401; valid keys with insufficient scope return 403.

System tables (_*) are never exposed via /api.

Row usage metadata

Versioned customer-row accounting is enabled by default. Its explicit configuration is:

toml
[usage]
enabled = true
row_metering_enabled = true

Set row_metering_enabled = false to opt out. Successful list, get, create, update, and delete responses (including composite-key CRUD) include meta.rows_read, meta.rows_written, meta.rows_returned, and meta.metering with version: 1, a server-generated attempt_id, and status: "recorded" | "unrecorded". Existing pagination fields and data are unchanged. Every CRUD caller sees the counts; they can reveal work on records hidden by row permissions. Error responses keep their existing shape, while their database work is metered.

A read is a logical source-row examination: rejected predicates, count queries, offset traversal, relationship permission queries, customer triggers, and repeated visits contribute. An index lookup plus its corresponding table fetch is one visit. Fast COUNT(*) includes the rows counted. A failed lookup examining no matching candidate contributes zero. Temporary/intermediate rows, index maintenance, SQLite system tables, and Loomup bookkeeping are excluded. Virtual tables count logical rows at the module boundary, excluding shadow storage.

Writes count committed customer-row insert/update/delete operations, including customer triggers and cascades. Repeated changes count separately; rolled-back writes do not count. Returned rows count response records, including a minimal acknowledgement object; null data and empty lists return zero.

Unknown counts are null, not zero. recorded means durable recovery evidence exists, not that export has finished. unrecorded indicates a journal outage; CRUD continues and the service retries recording. Process crashes can leave explicit gaps; missing usage is never estimated. Metrics alone do not change prices or enforce quotas. New HTTP attempts incur their actual work; receipt export retries are deduplicated.

In the JavaScript SDK, select() and resource.find() already expose meta. resource.list() continues to return rows only. Single-record calls accept { withMeta: true } to return { data, meta }; without it they keep their existing row return value. Older servers and disabled projects omit metering metadata. See Observability for journal and recovery details.