Designing Pagination Parameters in OpenAPI
This guide is part of Structuring OpenAPI Paths within OpenAPI & AsyncAPI Schema Authoring. It covers describing pagination in a way that produces usable generated SDKs, consistent documentation, and no surprises for consumers. The symptom that leads here is an API where three collections paginate three different ways, and every generated client returns a raw page that consumers have to loop over themselves.
Problem & Context
Pagination is usually added one endpoint at a time, by whoever built that endpoint. The result is an API where /invoices takes page and per_page, /customers takes offset and limit, and /events takes a cursor — each internally reasonable and collectively a tax on every consumer.
Generated SDKs make the cost concrete. Without an explicit pagination annotation, a generator has no way to know that next_cursor in the response feeds the cursor parameter of the same operation. So it emits listInvoices() returning one page, and every consumer writes the same while-loop, slightly differently, with slightly different bugs.
There is also a correctness dimension that the choice of model decides. Offset pagination over a set that is being written to will skip or repeat records — a row inserted before the current offset shifts everything down, and the consumer receives a duplicate with nothing indicating anything went wrong. For an audit log or an event feed that is a data-integrity problem, not a UX one.
The work is therefore three things: pick one model, express it once as shared components, and annotate it so generators can build real iterators.
Step-by-Step Solution
1. Choose cursor or offset once, for the whole API
CURSOR collections that grow or change while a client is paging
event feeds, audit logs, transactions, anything time-ordered
→ stable under concurrent writes; cannot jump to page 7
OFFSET small, stable collections where a page number is genuinely useful
reference data, a settings list, an admin table with a page picker
→ simple; silently wrong under concurrent inserts
Whichever you pick, the important property is uniformity. A consumer who learns your pagination once should never have to learn it again.
2. Define the parameters as shared components
One definition, referenced everywhere, so no endpoint can quietly disagree:
components:
parameters:
Cursor:
name: cursor
in: query
required: false
description: |
Opaque cursor from `next_cursor` of the previous page.
Omit to start from the first page. Do not construct or parse this value.
schema:
type: string
maxLength: 512
example: eyJvIjoxMjAsInQiOiIyMDI2LTA3LTMwIn0
Limit:
name: limit
in: query
required: false
description: Maximum records to return. Values above 100 are clamped to 100.
schema:
type: integer
minimum: 1
maximum: 100
default: 25
3. Standardise the response envelope
Every paginated response has the same shape, so one generated iterator works across all of them:
components:
schemas:
PageInfo:
type: object
required: [has_more]
properties:
next_cursor:
type: [string, 'null'] # explicitly nullable — the last page has none
description: Pass as `cursor` to fetch the next page. Null on the last page.
has_more:
type: boolean
description: Whether a further page exists.
InvoicePage:
type: object
required: [data, page]
additionalProperties: false
properties:
data:
type: array
items: { $ref: '#/components/schemas/Invoice' }
page: { $ref: '#/components/schemas/PageInfo' }
Declaring next_cursor as [string, 'null'] rather than omitting it on the last page matters: a field that sometimes disappears breaks strictly-typed generated clients, while a nullable field is expressible in every language.
4. Annotate for SDK generators
This is the step that turns a page into an iterator. The extension name depends on your generator; the shape is the same:
paths:
/invoices:
get:
operationId: listInvoices
summary: List invoices
tags: [Invoices]
parameters:
- $ref: '#/components/parameters/Cursor'
- $ref: '#/components/parameters/Limit'
# Tells the generator which parameter advances and which field holds results.
x-pagination:
type: cursor
cursor_param: cursor
next_cursor_field: page.next_cursor
results_field: data
responses:
'200':
description: A page of invoices
content:
application/json:
schema: { $ref: '#/components/schemas/InvoicePage' }
The generated client then offers iteration rather than a raw page:
# with the annotation
for invoice in client.invoices.list(): # transparently pages
print(invoice.id)
# without it
page = client.invoices.list()
while page.page.has_more: # every consumer writes this
page = client.invoices.list(cursor=page.page.next_cursor)
5. Lint for consistency
Uniformity only survives if something checks it:
# .spectral.yaml
rules:
collections-use-shared-pagination:
description: 'A collection endpoint must use the shared Cursor and Limit parameters.'
given: $.paths[?(@property.match(/^\/[a-z-]+$/))].get.parameters
severity: error
then:
function: schema
functionOptions:
schema:
type: array
contains:
type: object
properties:
$ref: { const: '#/components/parameters/Cursor' }
required: ['$ref']
paginated-responses-declare-pagination:
description: 'An operation taking a cursor must declare x-pagination.'
given: $.paths[*].get[?(@.parameters)]
severity: error
then:
field: x-pagination
function: truthy
Complete Working Example
A complete, consistent collection endpoint plus the check that keeps every other collection matching it:
openapi: 3.1.0
info: { title: Acme Billing API, version: 0.0.0-dev }
paths:
/invoices:
get:
operationId: listInvoices
summary: List invoices
tags: [Invoices]
parameters:
- $ref: '#/components/parameters/Cursor'
- $ref: '#/components/parameters/Limit'
- name: status
in: query
required: false
schema: { type: string, enum: [draft, open, paid, overdue, void] }
x-pagination:
type: cursor
cursor_param: cursor
next_cursor_field: page.next_cursor
results_field: data
responses:
'200':
description: A page of invoices
content:
application/json:
schema: { $ref: '#/components/schemas/InvoicePage' }
examples:
firstPage:
summary: First page, more results available
value:
data:
- { id: inv_01H8XKQ2M4, total_cents: 24900, currency: EUR, status: paid }
page: { next_cursor: eyJvIjoyNX0, has_more: true }
lastPage:
summary: Final page — next_cursor is null
value:
data:
- { id: inv_01H8XM7B3P, total_cents: 26400, currency: EUR, status: overdue }
page: { next_cursor: null, has_more: false }
'400':
description: Invalid or expired cursor
content:
application/problem+json:
example:
type: https://acme.com/problems/invalid-cursor
title: The cursor is invalid or has expired
status: 400
components:
parameters:
Cursor:
name: cursor
in: query
required: false
description: Opaque cursor from the previous page's `next_cursor`.
schema: { type: string, maxLength: 512 }
Limit:
name: limit
in: query
required: false
description: Maximum records per page; values above 100 are clamped.
schema: { type: integer, minimum: 1, maximum: 100, default: 25 }
schemas:
PageInfo:
type: object
required: [has_more]
properties:
next_cursor: { type: [string, 'null'] }
has_more: { type: boolean }
InvoicePage:
type: object
required: [data, page]
additionalProperties: false
properties:
data:
type: array
items: { $ref: '#/components/schemas/Invoice' }
page: { $ref: '#/components/schemas/PageInfo' }
Verify that every collection agrees:
# Every list operation must reference the shared Cursor parameter.
yq -r '
.paths | to_entries[] | select(.key | test("^/[a-z-]+$")) |
select(.value.get) |
[.key, ([.value.get.parameters[]?."$ref"] | any(. == "#/components/parameters/Cursor"))] | @tsv
' openapi/api.yaml
/invoices true
/customers true
/events true
A false in that output is a collection that paginates differently from the rest of the API, which is the single most common source of consumer confusion — and it is invisible in a per-endpoint review, because each endpoint looks fine on its own.
Gotchas & Edge Cases
Documenting the cursor’s internals invites consumers to build them. If the description explains that the cursor is base64 of {"o":120}, somebody will construct one, and you can never change the encoding again. Describe it as opaque and mean it.
A cursor that expires needs a documented error. Cursors backed by a snapshot or a sort key often become invalid after some period. Consumers must be able to distinguish “your cursor expired, start again” from “your request was malformed”, which means a distinct problem type rather than a generic 400.
maximum on limit is a promise about clamping, not rejection. Decide whether limit=1000 is a 400 or is silently clamped to 100, and document which. Consumers who assume the wrong one write pagination loops that either fail or run four times longer than expected.
A missing next_cursor is not the same as a null one. Omitting the field on the last page breaks strictly-typed clients that expect it always to be present. Use an explicitly nullable type so every language can model it.
Total counts are expensive and often wrong. A total field on a large, changing collection either costs a full count on every page or is stale by the time it is read. If you must provide one, mark it approximate and document that it may not equal the number of records the consumer eventually receives.
The consistency requirement is what makes any of this pay off. One collection that differs undoes the benefit for every consumer:
FAQ
Cursor or offset pagination?
Cursor, for anything where records are inserted while a client is paging. Offset silently skips or repeats rows when the underlying set changes, and that failure is invisible to the consumer. Offset is acceptable only for small, stable collections where a page number is genuinely useful to a user interface.
Why do my generated SDKs not have pagination helpers?
The generator needs an explicit annotation naming the cursor parameter and the results field. Without it, the operation looks like any other and you get a single page and a raw response — so every consumer writes the paging loop themselves.
Should the envelope go in every response or only paginated ones?
Only paginated ones, and then always. A collection that returns a bare array while every other returns an envelope forces consumers to special-case it, which is exactly the inconsistency this design exists to prevent.
Related
- Structuring OpenAPI Paths — the parent guide on path and collection design.
- Reusing schemas across multiple OpenAPI files — sharing
PageInfoacross services. - Enforcing API style guides with Spectral in CI — where the consistency rules belong.
- SDK Generation & Changelog Automation — the generators that read the pagination annotation.