REST API
Exposed SQLite tables are available under /api/<table>.
Endpoints
| Method | Path | Description |
|---|---|---|
| GET | /api/:table | List rows |
| GET | /api/:table/:id | Get one row |
| POST | /api/:table | Create row |
| PATCH | /api/:table/:id | Update row |
| DELETE | /api/:table/:id | Delete 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 filterfilter[field][operator]=value—eq,ne,lt,lte,gt,gte,in,is_null,contains, orstarts_withselect=id,title— field projectionsort=field,-other— stable multi-column ascending / descending sortinglimit— page size (default 50, max 1000)offset— pagination offsetcursor— signed opaque continuation cursor; pass it alone, without other list parameters
Unknown or malformed list parameters are rejected instead of being silently ignored.
Example:
GET /api/todos?where[completed]=0&sort=-id&limit=20&offset=0
Response shapes
Success (single):
{ "data": { "id": 1, "title": "Ship", "completed": 0 } }
Success (list):
{
"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:
{ "error": { "code": "forbidden", "message": "forbidden" } }
Access rules
Table rules from loomup.toml are applied on every operation. Send:
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>:readpermits list/get, history/state, sync reads, and realtime.resource:<table>:writepermits create/update/delete and sync mutations.project:backendpermits 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:
[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.