Designing API List Endpoints: Filters, Sorting, and the Patterns That Stay Maintainable
The most-used endpoint shape in any API and the one that most often grows unmaintainable through accumulated filter parameters and ordering options. The patterns that hold up across years of customer integration evolution.
Every REST API has list endpoints, and the list endpoint for the most-used resource will be the endpoint that accumulates the largest number of optional parameters over the life of the product. The discipline that prevents this accumulation from turning into an unmaintainable mess is mostly about deciding what shape the parameters take before customers start depending on the wrong shape.
The minimum viable list endpoint
The smallest useful list endpoint takes pagination (cursor or limit/offset), one or two filters relevant to the resource, and one sort order with a tiebreaker. Everything else is optional and should stay optional until customer demand justifies it.
The pagination choice has been written about extensively elsewhere; the short version is that cursor pagination is the right default for any list endpoint that returns ordered results from a mutable collection, and offset pagination is acceptable only for human-browsed UI cases where page-number jumps are part of the experience. Most B2B SaaS APIs should default to cursor pagination and never offer offset.
The filter set should start small. Customers will ask for more filters as they build integrations, and adding filters is easier than removing them. Starting with three filters and adding two more in response to documented customer use is better than starting with fifteen filters and discovering that thirteen of them produce zero queries in the logs.
The filter grammar question
The two main approaches to filter syntax are flat parameters (status=active, created_after=2026-01-01) and structured query languages (filter[status]=active, filter[created_at][gte]=2026-01-01, or full GraphQL-like grammars).
Flat parameters are easy for customers to type, easy to document, easy to log and analyze. They become awkward when the same field needs both equality and range operators, or when boolean combinations get more complex than implicit AND.
Structured grammars handle the complex cases well but cost customers learning time and produce URLs that are unpleasant to debug. They also tend to accumulate features over time as customers discover edge cases, ending up as small query languages that nobody fully implements.
The pattern that holds up best is flat parameters with suffix conventions for common operators: status=active for equality, created_after and created_before for range, tags_includes for set membership. This covers most customer needs without introducing a query grammar, and the few cases that genuinely need a grammar (free-form search, complex boolean combinations) can be handled by a separate search endpoint with its own conventions.
Sorting and tiebreakers
The sort parameter should accept a small enumerated set of valid sort fields, with a default that makes sense for the resource. Allowing arbitrary fields tempts customers into queries that depend on indexes that may not exist; restricting to enumerated fields makes the API a contract about what is fast.
The unique-tiebreaker discipline is non-negotiable. Every sort order has to have a deterministic last column, which is almost always the primary key. Without a tiebreaker, two records with identical sort-field values can appear in different positions across requests, which breaks cursor pagination in subtle ways: a cursor positioned between two such records can either skip rows or duplicate them on the next page.
The descending-vs-ascending question is usually best handled with a sort_direction parameter (asc or desc) rather than negative-sign prefixes (sort=-created_at). The prefix syntax is concise but unfamiliar to many developers and harder to validate; the separate parameter is verbose but obvious.
Including related resources
The expand pattern (include=author,tags) saves customers a round-trip when they need related data that would otherwise require a follow-up query. It is also one of the easiest features to misuse, because each new included relation multiplies the query complexity and can cause N+1 problems if implemented naively.
The right pattern is to allow a small whitelist of expansions, implement each with a single additional query (not per-row), and document the response shape with each expansion enabled. Allowing arbitrary expansion paths (include=author.organization.billing_address) is a recipe for slow queries and expensive support.
The alternative is to require customers to follow the related-resource links in the response, which is cleaner architecturally but produces slower customer integrations. The trade-off favors expansion for high-traffic relations and link-following for everything else.
Total counts
Returning a total count by default is almost always wrong. The count query is often as expensive as the actual list query, so customers who do not need a count are paying for one anyway. And on tables with concurrent inserts the count is stale by the time the response arrives.
The right pattern is to return has_more (via the limit+1 trick where the API requests one more record than the requested page size and indicates more pages are available without giving a total) and offer count via a separate endpoint or an explicit include_count parameter that customers opt into when they actually need it.
Three patterns that hurt
The first is allowing every field to be both a filter and a sort dimension. This sounds flexible and produces a quadratic explosion of index requirements, where every combination of filter and sort needs its own composite index to perform well. Most APIs end up with a small set of supported (filter, sort) combinations and an unspecified-behavior penumbra around the rest.
The second is mutable sort columns without stable secondary ordering. A sort by updated_at puts rows in a position that changes whenever they are updated, which breaks pagination cursors as soon as concurrent updates happen. The fix is either to require the sort column to be immutable (sort by created_at instead) or to require a stable secondary order using the primary key.
The third is silently truncating filter results. An endpoint that says "returns matching records" and silently caps at 1000 will produce mysterious behavior when customers exceed the cap. The right pattern is to either paginate (always) or explicitly document the cap and reject queries that would exceed it.
Our use across products
Across DocuMint, CronPing, FlagBit, and WebhookVault, we converged on the same list-endpoint shape: cursor pagination, two or three filters per resource, one or two sort fields with explicit direction parameter, no expansion (we require follow-up requests for related resources), no total count. The shape is identical because the resource patterns are similar, and the convergence has paid back in shared client SDK code and consistent customer mental models.
Deeper observation
The list endpoint is the part of an API where customer integration patterns are most visible in the logs. The filters that get used heavily in production are not always the ones that seemed obvious during design, and the filters that seemed obvious sometimes turn out to be unused. The discipline of starting with a minimum viable set and adding only in response to documented use is the way to keep the endpoint maintainable over years of evolution.