Pagination and filtering
Every list takes one where grammar, a sort and a keyset cursor, so a query pages the same way on every Interface and never skips a record.
Every list on api.sb returns one page at a time, newest first, behind a cursor. One where grammar filters it, and the same grammar selects Events for Watches.
A first query
paperbill.io is a Headless SaaS that sends and collects Invoices for freelancers. Its collections screen asks for unpaid Invoices over $500, oldest due date first:
Which unpaid invoices are over $500? Oldest due date first.The response is a list envelope: the page under invoices, the cursor in meta.after, and the next page in links.next.
The where grammar
A filter is one expression over the Noun's properties. Dotted paths reach into objects and one level into related records: total.amount, customer.country.
| Operator | Example |
|---|---|
=, != | status = open |
>, >=, <, <= | due < 2026-11-01 |
in | status in (open, |
contains | memo contains "retainer" |
= null, != null | paidAt = null |
and, or, not, parentheses | (status = open or status = overdue) and total.amount > 500 |
Values take the property's type: money compares on amount in its own currency, and dates accept ISO 8601. A filter on a property the Noun does not have fails with invalid-input, naming the property.
The SDK accepts the string or an object. An object's keys join with and, and each value is either a literal or an operator map such as { gt:.
The grammar is the platform's one selector language. A Watch selector Invoice.paid where total.amount > 500 uses the same clause, so a filter tested on a list behaves identically on a live feed.
Sorting
sort takes a comma-separated list of properties. A leading minus sorts descending: sort=. Without sort, a list returns newest first.
Every sort ends with the record id as a tiebreaker, added for you. Two Invoices due the same day always come back in the same order.
Cursors
Lists page by keyset. The cursor encodes the last record's position in the sort, not an offset, so a record written mid-scan never shifts later pages or appears twice.
To read the next page, pass after from the previous one, or follow links.next. A page without after is the last. Cursors are opaque and never expire, and each is bound to its query: reusing one with another where or sort returns invalid-input.
limit sets the page size: 20 by default, 100 at most. To walk a whole collection, the SDK iterates pages for you:
for await (const invoice of $.Invoice.list({ where: 'paidAt = null' }).all()) {
await $.Invoice.collect({ invoice: invoice.$id })
}Totals and facets
A list returns no count unless asked, because counting a large collection costs a full scan. Add total= and meta.total holds the count. It is exact up to 10,000 matches; above that it is an estimate, and meta.totalExact is false.
facets returns a count per value for the properties you name, from the same snapshot as the total:
curl -G https: //api.sb/ paperbill.io/ invoices \
-H "Authorization: Bearer $STARTUP_BUILDER_API_KEY" \
--data-urlencode "where= paidAt = null" \
--data-urlencode "total= true" \
--data-urlencode "facets= status, customer.country"meta.facets.status might read { "open":, summing to meta.total. A filter panel built on facets never shows a count the list disagrees with.
Shaping the result
fields trims each record to the properties named: fields=. $id and $type always come back. include embeds related records, and depth follows them further, as the envelope describes.
Agents pay for every token they read. A list call with fields and Accept: returns the same page at a fraction of the size.
Search
q matches words across a Noun's text properties, ranked by relevance: q=. Add match= to rank by meaning instead, so q= finds Invoices whose notes say "disputed". Both combine with where, and both page with the same cursors.