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.

Cursor versus offset under concurrent writes Offset pagination skips or repeats records when rows are inserted between page requests, while a cursor anchors to a position in the result set. a record is inserted between page 1 and page 2 offset=20&limit=20 page 1: rows 1–20 insert shifts everything down by one page 2 repeats row 20 silent duplicate — consumer never knows cursor=eyJvIjoyMH0 page 1: rows 1–20 + next cursor insert does not move the anchor page 2 continues after row 20 no duplicate, no skip

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
From annotation to iterator The pagination extension names the cursor parameter, the next-cursor field and the results field, which is exactly what a generator needs to emit an iterator. x-pagination cursor_param: cursor next_cursor_field: … results_field: data three facts, in the spec generator reads the annotation a real iterator for x in client.invoices.list() paging handled once, correctly in every language at once

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:

Envelope consistency When every collection returns the same envelope a consumer learns pagination once; a single inconsistent collection forces a special case everywhere. one inconsistent collection costs more than three consistent ones save all collections agree data + page envelope everywhere one iterator works for all one returns a bare array no envelope, no cursor every consumer special-cases it

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.