# Lists, Pagination and Filters

<Lead>

Every list in v4 has the same shape: one envelope, one way to page, one way to
filter. A client, an SDK or an MCP tool writes that code once.

</Lead>

## <N n="1" />Every list paginates

<Rule id="V4-COLL-1" lint="v4-list-envelope" shared="COLL-1 COLL-2">

Every `GET` on a collection paginates with `limit` and `offset`, and returns
exactly `{ data, limit, offset, total, hasMore, next }` as a `{Resource}List`.

</Rule>

That includes collections that are small today (a gateway's user apps, a pool's
members). The shared guidelines exempt "bounded" collections. v4 does not:
adding pagination later is a breaking change (VER-3), and one shape means one code
path in every client, SDK and MCP tool.

<DoDont>
<Do title="The envelope, on every list">

```json
{
  "data": [{ "id": "config_IM1F…", "name": "Engineering" }],
  "limit": 2,
  "offset": 2,
  "total": 3,
  "hasMore": false,
  "next": null
}
```

</Do>
<Dont title="A bare array, no paging">

```json
[
  { "id": "config_IM1F…", "name": "Engineering" },
  { "id": "config_9xQa…", "name": "Support" }
]
```

</Dont>
</DoDont>

```http
GET /v4/buckets/bckt_Y40ypGODCDP3S7iZxNwCAbIe/ai/pools?limit=2&offset=2
```

<Fields title="The envelope">
<Field name="data" type="array">

The items on this page, always an array of the resource schema.

</Field>
<Field name="limit" type="integer">

The page size that was applied.

</Field>
<Field name="offset" type="integer">

How many items were skipped.

</Field>
<Field name="total" type="integer">

How many items the whole collection has.

</Field>
<Field name="hasMore" type="boolean">

Whether another page follows.

</Field>
<Field name="next" type="string | null">

The **relative** URL of the next page, or `null` on the last page.

</Field>
</Fields>

This is the shared envelope (COLL-2) exactly. In the spec it is a component named
`{Resource}List` (`AiPoolList`) with exactly these six properties, all required.

<Fields title="Query parameters">
<Field name="limit" type="integer">

Minimum 1, default **10**, maximum **100**. Referenced from the shared `Limit`
component.

</Field>
<Field name="offset" type="integer">

Minimum 0, default 0. Referenced from the shared `Offset` component.

</Field>
</Fields>

## <N n="2" />Order

Every list has one **stable, documented order**, stated in the operation
description. Ties are broken by ID, so pages never overlap or skip items while the
collection is unchanged.

<Tiles>
<Tile label="Default" title="Oldest first">

Resources are listed oldest first, unless there is a reason otherwise.

</Tile>
<Tile label="Exception" title="API keys and deployments: newest first">

API keys and deployments are listed newest first: a client almost always wants
the latest.

</Tile>
</Tiles>

## <N n="3" />Filters

A filter is a query parameter named after the **field** it filters, in camelCase,
and it matches exactly.

<DoDont>
<Do title="Name the filter after the field">

```http
GET /v4/buckets/{bucketId}/ai/pools?parentPoolId=config_IM1F…
```

```http
GET /v4/accounts/{accountId}/buckets?environmentType=production
```

```http
GET /v4/buckets/{bucketId}/consumers?managerEmail=dev@acme.com
```

</Do>
<Dont title="Generic filter syntax">

```http
GET /v4/buckets/{bucketId}/ai/pools?filter=parent:config_IM1F…
```

```http
GET /v4/accounts/{accountId}/buckets?filter[environmentType]=production
```

</Dont>
</DoDont>

<DoDont>
<Do title="A non-owner relation is a filter">

```http
GET /v4/buckets/{bucketId}/ai/pools?parentPoolId=…
```

</Do>
<Dont title="Nest it in the path">

```http
GET /v4/buckets/{bucketId}/ai/pools/{poolId}/pools
```

</Dont>
</DoDont>

See [the owner is a property](/guidelines/urls#the-owner-is-a-property).

<DoDont>
<Do title="Your own userId">

```http
GET /v4/buckets/{bucketId}/ai/user-apps/{userAppId}/users/user_Jm5tR8wQ…/consumers
```

A person lists their own consumers with their own `userId`. User app admins use
anyone's.

</Do>
<Dont title="A /me route">

```http
GET /v4/buckets/{bucketId}/ai/user-apps/{userAppId}/users/me/consumers
```

`me` is not a `userId`, and a `/me` route is never added.

</Dont>
</DoDont>

<Rule id="COLL-6" review>

Unknown query parameters are rejected with `400`. A typo in a filter must not
silently return the unfiltered collection.

</Rule>

## <N n="4" />Reserved parameters

These names are reserved across v4 and mean the same everywhere. Their synonyms
are rejected by lint.

<Specimens cols={2}>
<Specimen label="Pagination · every list" sample="limit offset" not="page pageSize perPage" />
<Specimen label="Sorting · reserved (COLL-4)" sample="sort=-createdOn,name" not="orderBy sortBy order sortOrder" />
<Specimen label="Search · reserved (COLL-5)" sample="search" not="q query" />
<Specimen label="Filtering · every list" sample="parentPoolId=…" not="filter">

A filter is named after its field.

</Specimen>
<Specimen label="Append-only collections · reserved (COLL-3)" sample="cursor">

For logs, audit events and analytics.

</Specimen>
<Specimen label="Including more · reserved (V4-COLL-2)" sample="include[]=consumers" not="includeKeys withApiKey expand[]" />
</Specimens>

Lint also rejects `fields`: every response always has every property.

Every other collection uses `limit` and `offset`
([decided on 2026-11-05](/guidelines/decisions#open-questions), question 10). When
v4 gets its first append-only collection (build logs, request logs, audit
events), it uses an opaque `cursor` with the same envelope minus `offset` and
`total` (COLL-3), and `v4-list-envelope` gets that variant in the same pull
request. A collection's pagination is chosen when it is introduced and never
switched.

## <N n="5" />Including more with include[]

<Rule id="V4-COLL-2" lint="v4-parameters v4-enum-values" shared="EXP-1">

`include[]` is the one query parameter that asks for more in a response. Its
values are the names of response properties that cost an extra lookup, listed as
an enum. Each one is always present and `null` unless it was asked for. There are
no flags of their own (`includeKeys`, `withApiKey`, `expand[]`).

</Rule>

A new flag for every extra means a new parameter for every client, SDK and MCP
tool to learn. `include[]` is one parameter that works the same everywhere, as
Stripe's [`include[]`](https://docs.stripe.com/api/include_dependent_response_values)
does. A value is the property's name (`consumers`, `apiKey`), and the property's
description says when it is not `null`.

| Operation | `include[]` | Adds |
| --- | --- | --- |
| Get, list, create and update an app | `consumers` | the consumers linked to the app |
| List and get the people in a user app | `budget` | each person's limits, as `…/users/{userId}/budget` returns them |
| Create a consumer | `apiKey` | its first API key, with the secret, which is shown only then |

On a create, asking for something the new resource does not have yet creates it:
`include[]=apiKey` issues the consumer's first key. The body stays the new resource
alone ([bodies](/guidelines/methods#bodies)).

Related resources are otherwise a filtered list
(`/v4/buckets/{bucketId}/ai/apps?poolId=…`), or for credentials a nested one
(`…/users/{userId}/consumers`), and references are `…Id` fields. Data that is
only ever read and changed together with its owner is part of the owner's body,
not a sub-collection: a provider assignment's models are its `models` array,
written in the same `PATCH`. A provider's own models are the exception, a
read-only list at `…/ai/providers/{providerId}/models`: a built-in provider's
models are Zuplo's catalog for it, too many to send in every provider.

An app's consumers have no list of their own: an app has a few, one per client or
environment, and `include[]=consumers` returns them with the app, with the same
schema as `…/consumers/{consumerId}`. A consumer is created in the bucket and linked
with a `POST` of its `consumerId` to `…/ai/apps/{appId}/consumers`, and it is
changed and deleted at `…/consumers/{consumerId}`, never in the app's body.

<DoDont>
<Do title="Ask with include[]">

```http
GET /v4/buckets/{bucketId}/ai/apps/{appId}?include[]=consumers
```

```http
POST /v4/buckets/{bucketId}/consumers?include[]=apiKey
Content-Type: application/json

{ "name": "acme-corp" }
```

</Do>
<Dont title="A flag per extra">

```http
GET /v4/buckets/{bucketId}/ai/apps/{appId}?includeConsumers=true
```

```http
POST /v4/buckets/{bucketId}/consumers?withApiKey=true
```

</Dont>
<Do title="Null until included">

```json
{
  "id": "config_Kx7gDiGsr1c2tOOUW5kpmXZn",
  "name": "Support bot",
  "consumers": null,
  …
}
```

</Do>
<Dont title="The key in the consumer's body">

```http
POST /v4/buckets/{bucketId}/consumers
Content-Type: application/json

{ "name": "acme-corp", "initialApiKey": {} }
```

</Dont>
</DoDont>

<EnforcedBy
  lint="v4-list-envelope v4-parameters"
  checks="Parameters, the envelope and {Resource}List naming; reserved names, camelCase, no include*, with* or expand* flags, and include[] values that are nullable properties of every success response."
>

Rejecting unknown query parameters (COLL-6), which request validation also does
at runtime.

</EnforcedBy>
