Zuplo API v4
Designing Resources

Lists, Pagination and Filters

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.

Every list paginates

V4-COLL-1

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

Lintv4-list-envelopeShared rules: COLL-1, COLL-2

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.

Do
The envelope, on every list
JSONCode
{ "data": [{ "id": "config_IM1F…", "name": "Engineering" }], "limit": 2, "offset": 2, "total": 3, "hasMore": false, "next": null }
Don't
A bare array, no paging
JSONCode
[ { "id": "config_IM1F…", "name": "Engineering" }, { "id": "config_9xQa…", "name": "Support" } ]
Code
GET /v4/buckets/bckt_Y40ypGODCDP3S7iZxNwCAbIe/ai/pools?limit=2&offset=2
The envelope
data
array

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

limit
integer

The page size that was applied.

offset
integer

How many items were skipped.

total
integer

How many items the whole collection has.

hasMore
boolean

Whether another page follows.

next
string | null

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

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.

Query parameters
limit
integer

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

offset
integer

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

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.

Default
Oldest first

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

Exception
API keys and deployments: newest first

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

Filters

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

Do
Name the filter after the field
Code
GET /v4/buckets/{bucketId}/ai/pools?parentPoolId=config_IM1F…
Code
GET /v4/accounts/{accountId}/buckets?environmentType=production
Code
GET /v4/buckets/{bucketId}/[email protected]
Don't
Generic filter syntax
Code
GET /v4/buckets/{bucketId}/ai/pools?filter=parent:config_IM1F…
Code
GET /v4/accounts/{accountId}/buckets?filter[environmentType]=production
Do
A non-owner relation is a filter
Code
GET /v4/buckets/{bucketId}/ai/pools?parentPoolId=…
Don't
Nest it in the path
Code
GET /v4/buckets/{bucketId}/ai/pools/{poolId}/pools

See the owner is a property.

Do
Your own userId
Code
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.

Don't
A /me route
Code
GET /v4/buckets/{bucketId}/ai/user-apps/{userAppId}/users/me/consumers

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

COLL-6

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

Review

Reserved parameters

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

Pagination · every list
limit offset
Incorrect: pageIncorrect: pageSizeIncorrect: perPage
Sorting · reserved (COLL-4)
sort=-createdOn,name
Incorrect: orderByIncorrect: sortByIncorrect: orderIncorrect: sortOrder
Search · reserved (COLL-5)
search
Incorrect: qIncorrect: query
Filtering · every list
parentPoolId=…
Incorrect: filter

A filter is named after its field.

Append-only collections · reserved (COLL-3)
cursor

For logs, audit events and analytics.

Including more · reserved (V4-COLL-2)
include[]=consumers
Incorrect: includeKeysIncorrect: withApiKeyIncorrect: expand[]

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

Every other collection uses limit and offset (decided on 2026-11-05, 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.

Including more with include[]

V4-COLL-2

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[]).

Lintv4-parametersv4-enum-valuesShared rule: EXP-1

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[] does. A value is the property's name (consumers, apiKey), and the property's description says when it is not null.

Operationinclude[]Adds
Get, list, create and update an appconsumersthe consumers linked to the app
List and get the people in a user appbudgeteach person's limits, as …/users/{userId}/budget returns them
Create a consumerapiKeyits 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).

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.

Do
Ask with include[]
Code
GET /v4/buckets/{bucketId}/ai/apps/{appId}?include[]=consumers
Code
POST /v4/buckets/{bucketId}/consumers?include[]=apiKey Content-Type: application/json { "name": "acme-corp" }
Don't
A flag per extra
Code
GET /v4/buckets/{bucketId}/ai/apps/{appId}?includeConsumers=true
Code
POST /v4/buckets/{bucketId}/consumers?withApiKey=true
Do
Null until included
JSONCode
{ "id": "config_Kx7gDiGsr1c2tOOUW5kpmXZn", "name": "Support bot", "consumers": null, … }
Don't
The key in the consumer's body
Code
POST /v4/buckets/{bucketId}/consumers Content-Type: application/json { "name": "acme-corp", "initialApiKey": {} }
Lint
Fails CI on any finding
v4-list-envelopev4-parameters

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.

Review
Checked by people in the pull request

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

Every rule ID and what enforces it: the rule index
Last modified on