Skip to main content
CMDashCybersecurity Maturity Dashboard
DocsDevelopersList endpoints — pagination, sort and search

List endpoints — pagination, sort and search

Every list endpoint in the internal API takes the same five query parameters. Before I10 there were six competing conventions, measured across the controllers:

spellingfiles
limit41
offset8
page5
pageSize2
cursor1
sort + dir1
q7
search2

A shared DTO existed in the /admin/* slice only. A front-end developer moving between two screens had to open the controller to learn what the second one called its page size, and the shared DataTable could not send paging parameters at all without per-endpoint special-casing.


The convention

code
GET /controls?limit=50&offset=100&sort=updatedAt&dir=desc&q=mfa
parametermeaning
limitPage size. 1..200, default 50. An endpoint may set a lower ceiling (/admin-access-log caps at 100).
offsetRows to skip. >= 0, default 0.
sortField name. Each endpoint declares which names it accepts; an unknown one falls back to that endpoint's default rather than erroring.
dirasc | desc. Anything else reads as the endpoint's default.
qFree-text search.
cursorKeyset pagination, for the endpoints that genuinely need it (GET /records/:type/:id/activity). An alternative to offset, never combined with it.

Out-of-range values clamp, they do not 400: these are read endpoints whose callers already handle "fewer rows than I asked for", and a 400 on ?limit= (an empty form field) punishes a client for nothing.

The response envelope

json
{
  "items": [/* … */],
  "totalCount": 412, // rows matching the filters, ignoring limit/offset
  "hasMore": true, // offset + items.length < totalCount
  "limit": 50,
  "offset": 100,
}

totalCount and hasMore are spelled to match ROADMAP-V100 I11's truncation signal on purpose: a client should not have to learn one word for "there is more" on a capped list and a different one on a paged list. X-List-Truncated (wave 2's B7) stays what it is — the disclosure for endpoints that return a bare array and so have no envelope to put a field in.

Endpoints that already shipped a different envelope add these fields and keep their old ones. GET /controls is the worked example: it returns total/page/pageSize (deprecated) alongside totalCount/hasMore/limit/offset, so the API and the UI never had to deploy on the same commit.


Compatibility — nothing was removed

Every older spelling is still accepted and resolved onto the canonical one:

legacycanonical
pageSizelimit
pageoffset, as (page - 1) * limit
searchq
sortBysort
sortDirdir

A canonical parameter always wins when both are present. So ?page=4&pageSize=25&search=acme and ?offset=75&limit=25&q=acme return the same rows, and every bookmark, saved report URL and customer script written against the old names keeps working. Retiring an alias is a separate, announced decision — not a side effect of this change.

What the guard prevents is a new endpoint being written in the old dialect, which is the only way six conventions could grow back.


The public API is exempt, permanently

The public API (/v1/* and /v2/*) is not covered by any of this.

It is the documented, versioned, SDK-consumed surface: published in an OpenAPI spec, called by the JavaScript and Python SDKs, the Terraform provider and customers' own automation, and v1 carries a real Sunset header with a date attached (see public-api-versioning.md). Renaming a query parameter there is a breaking change to customers' code no matter how many aliases accompany it, and quietly changing a surface under an active deprecation contract would make that contract worthless.

It already uses limit/offset on its paginated route — which is exactly why those two were chosen as canonical here.


Writing a new list endpoint

typescript
import { normalizeListQuery, paginatedList } from '../common/list-query';

@Get()
@ApiQuery({ name: 'limit', required: false })
@ApiQuery({ name: 'offset', required: false })
@ApiQuery({ name: 'sort', required: false })
@ApiQuery({ name: 'dir', required: false })
@ApiQuery({ name: 'q', required: false })
async list(@TenantId() tenantId: string, @Query() query: Record<string, unknown>) {
  const list = normalizeListQuery(query, {
    allowedSorts: ['name', 'createdAt'],
    defaultSort: 'name',
    defaultDir: 'desc',
  });
  const [rows, totalCount] = await this.service.list(tenantId, list);
  return paginatedList(rows, totalCount, list);
}

Take the whole query object rather than one @Query('name') per parameter: the normalizer is what knows the alias set, and a per-name list in the handler would be a second copy of it that drifts.

For a zod-validated endpoint (/admin/*, usage-metering), keep the schema exactly as it is and change the parse call:

typescript
const result = parseListQuery(SomeQuerySchema, query);

parseListQuery resolves the aliases before zod sees them. It is a pre-processor rather than a .transform() because a transform returns a ZodEffects, which has no .extend() — and seven admin DTOs extend PaginationQuerySchema.

This page is the product manual, published from the repository at docs/developers/list-conventions.md. Customers read the same text inside CMDash under Help. Last updated October 9, 2026.

List endpoints — pagination, sort and search — CMDash Docs