# Pagination and filtering

URL: /code/api/guides/pagination-and-filtering · Id: `page:code/api/guides/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](/code/platform/api-sb) returns one page at a time, newest first, behind a cursor. One `where` grammar filters it, and the same grammar selects [Events](/business/nouns/event) for [Watches](/business/nouns/watch).

## A first query
`paperbill.io` is a [Headless SaaS](/products/templates/headless-saas) that sends and collects [Invoices](/business/nouns/invoice) for freelancers. Its collections screen asks for unpaid Invoices over $500, oldest due date first:

**Ask**

```text
Which unpaid invoices are over $500? Oldest due date first.
```

**API**

```bash
curl -G https://api.sb/paperbill.io/invoices \
  -H "Authorization: Bearer $STARTUP_BUILDER_API_KEY" \
  --data-urlencode "where=paidAt = null and total.amount > 500" \
  --data-urlencode "sort=due" \
  --data-urlencode "limit=50"
```

**SDK**

```ts
import { $ } from 'startup-builder'

const page = await $.Invoice.list({
  where: 'paidAt = null and total.amount > 500',
  sort: 'due',
  limit: 50,
})
```

**CLI**

```bash
npx startup-builder do Invoice.list \
  --json '{"where":"paidAt = null and total.amount > 500","sort":"due","limit":50}'
```

**MCP**

```json
{
  "name": "do",
  "arguments": { "code": "return await $.Invoice.list({ where: 'paidAt = null and total.amount > 500', sort: 'due', limit: 50 })" }
}
```

The response is a list [envelope](/code/api/guides/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](/business/nouns/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, overdue)` |
| `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`](/code/api/errors/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: 500 }`.

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=-total.amount,due`. 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:

```ts
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=true` 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:

```bash
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": 41, "overdue": 9 }`, 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,total,due`. `$id` and `$type` always come back. `include` embeds related records, and `depth` follows them further, as the envelope describes.

[Agents](/business/nouns/agent) pay for every token they read. A list call with `fields` and `Accept: text/markdown` returns the same page at a fraction of the size.

## Search
`q` matches words across a Noun's text properties, ranked by relevance: `q=late fee`. Add `match=meaning` to rank by meaning instead, so `q=client refuses to pay` finds Invoices whose notes say "disputed". Both combine with `where`, and both page with the same cursors.

## Next
- [Envelope](/code/api/guides/envelope): the list shape, `include` and `depth`.
- [Webhooks](/code/api/guides/webhooks): the same `where` clause, on a Watch.
- [Realtime](/code/sdk/guides/realtime): a query that stays open and streams its changes.