Everything we have designed so far works just as well with 5 coffees as with 5,000… until the first GET /v1/coffees hits a real catalogue and the response weighs 40 MB. Collections are the point where a well-designed API separates itself from one that only holds up in the development environment. This lesson designs Aroma Store's large collections: how they are filtered, how they are sorted, how they are split into pages —with the three existing models and their real consequences— and how text is searched. This is contract design, not implementation: here we decide which parameters exist and what they promise, and module 3 will implement them exactly as they stand.

Contents

  1. Why an unbounded collection is a problem
  2. Filtering: query param conventions
  3. Range filters and multi-value filters
  4. Why not to invent a query language in the URL
  5. Sorting
  6. Offset pagination
  7. Page pagination
  8. Cursor pagination
  9. Comparison and deep paging
  10. Where the pagination metadata travels
  11. Text search
  12. Defaults, limits and documentation

  1. Why an unbounded collection is a problem

GET /v1/coffees with no restrictions looks harmless. With 137 coffees it is. With 50,000 lines, or with GET /v1/orders over the complete history, it stops being harmless for four simultaneous reasons:

Problem What happens
Database A SELECT without a LIMIT scans and materialises the whole table
Server memory Serialising 50,000 objects to JSON can consume hundreds of MB; with several requests at once, the process dies
Network and client 40 MB for a screen that shows 20 rows; on mobile, unacceptable
Availability Anyone can bring the API down by repeating that call: it is a free denial of service

The last point is the important one and the one usually overlooked: a collection with no maximum limit is an attack vector, and it does not take bad intentions —a partner's script with a badly written loop is enough. That is why the first decision is not "how do I paginate" but "pagination is mandatory and the server imposes it even if the client does not ask for it".

  1. Filtering: query param conventions

As we established in 02-02, selection criteria go in the query string. Aroma Store's baseline convention is the simplest possible: one parameter per field, exact equality.

# Coffees from Colombia
curl "https://api.aromastore.example/v1/coffees?origin=Colombia"

# Colombian coffees with a medium roast (filters combine with a logical AND)
curl "https://api.aromastore.example/v1/coffees?origin=Colombia&roast=medium"

# A customer's paid orders
curl "https://api.aromastore.example/v1/orders?customerId=cus_842&status=paid"

Rules of the contract:

  • The parameter's name is the field's name in the representation (origin, roast, status, customerId). Predictability: whoever has seen the JSON already knows how to filter.
  • Several filters combine with a logical AND. Never with OR; the multi-value form in section 3 is there for that.
  • An unknown filter returns 400 with invalid_parameter. Ignoring it silently is worse: the client thinks it has filtered and receives the whole catalogue.
  • An invalid value returns 400: ?roast=super-roasted is not a valid enumeration value.
  • No results is 200 with {"data": [], "total": 0}, never 404 (02-03).
  • Values are URL-encoded: ?origin=Costa%20Rica.

Filters published in Aroma Store's v1:

Collection Filters
/coffees origin, roast, priceMin, priceMax, available, q
/orders customerId, status, dateFrom, dateTo
/reviews coffeeId, customerId, status, ratingMin
/customers q (name or email)

The list of filters is closed and forms part of the contract. You do not filter by any old field "because the ORM allows it": every published filter has to be documented, validated, tested, indexed and maintained forever.

  1. Range filters and multi-value filters

3.1. Ranges

Aroma Store's convention is two parameters with a suffix, both inclusive:

# Coffees between 10 and 15 euros, both included
curl "https://api.aromastore.example/v1/coffees?priceMin=10&priceMax=15"

# Orders from March 2026
curl "https://api.aromastore.example/v1/orders?dateFrom=2026-03-01&dateTo=2026-03-31"

# Reviews of 4 stars or more
curl "https://api.aromastore.example/v1/reviews?ratingMin=4"

Suffix convention: Min/Max for numbers, From/To for dates. The two ends are optional and independent: ?priceMin=10 means "from €10 upwards".

Alternatives that exist and that Aroma Store does not use, so that you recognise them:

Style Example Comment
Suffixes (Aroma Store) ?priceMin=10&priceMax=15 Readable, easy to validate and document
Operators inside the value ?price=gte:10,lte:15 Compact, but the value has to be parsed
Brackets ?price[gte]=10&price[lte]=15 JSON:API style; ugly to URL-encode
Range with a hyphen ?price=10-15 Ambiguous with negatives and with decimals

3.2. Multiple values

For "this or that" on the same field, a comma-separated list:

# Coffees with a light or medium roast
curl "https://api.aromastore.example/v1/coffees?roast=light,medium"

# Paid or shipped orders
curl "https://api.aromastore.example/v1/orders?status=paid,shipped"

The alternative —repeating the parameter, ?roast=light&roast=medium— is equally valid and many APIs use it, but its behaviour depends on the framework and on the client's library (some keep only the last value). The comma is explicit and allows no interpretation. An accepted limitation: the values cannot contain commas; in Aroma Store multi-value is only allowed on enumerations and identifiers, where that does not happen.

Summarising the complete semantics, which have to be documented explicitly:

?roast=light,medium&origin=Colombia
   →  (roast = light OR roast = medium)  AND  (origin = Colombia)

  1. Why not to invent a query language in the URL

Sooner or later somebody will propose something like this:

GET /v1/coffees?filter=(origin eq 'Colombia' and price gt 10) or roast eq 'light'
GET /v1/coffees?where={"$or":[{"price":{"$gt":10}},{"roast":"light"}]}

It is tempting: it solves any future query without touching the API. And it is almost always a mistake:

  • You have to write a parser and an evaluator, with their syntax errors, their messages and their edge cases. That is a project, not a parameter.
  • Injection risk: passing the expression to the data engine without carefully translating it is a direct route to NoSQL injection or SQL injection (04-02).
  • Impossible to bound: the client can build queries of arbitrary cost. Goodbye to indexes and to capacity planning.
  • Impossible to document properly in OpenAPI: the parameter is a free-form string, so there is no automatic validation, no autocompletion and no useful mocks (02-08).
  • Caching suffers: infinite URL combinations, none reusable.

There are serious standards for this —OData and GraphQL (01-07)— and the lesson is that if you genuinely need arbitrary queries, you adopt one of them with your eyes open, you do not invent a dialect. Aroma Store sticks with explicit filters: they cover the real use cases, they document themselves and their cost is predictable. If a legitimate query turns up that does not fit, a new filter is added (a backwards-compatible change, 02-07) or a specific resource is created.

  1. Sorting

A single parameter, sort, with the field's name and a leading hyphen for descending order:

# From cheapest to most expensive
curl "https://api.aromastore.example/v1/coffees?sort=priceEuros"

# From most expensive to cheapest
curl "https://api.aromastore.example/v1/coffees?sort=-priceEuros"

# Most recent orders first
curl "https://api.aromastore.example/v1/orders?sort=-createdAt"

# Multiple sorting: by roast ascending and, within each roast, by price descending
curl "https://api.aromastore.example/v1/coffees?sort=roast,-priceEuros"

Details of the contract:

  • A closed, documented set of sortable fields per collection. /coffees: name, priceEuros, stock, createdAt, averageRating. A field that is not allowed returns 400 with invalid_parameter. The reason is performance: every sortable field needs its index.
  • A default order, also documented: /coffees by name ascending, /orders by -createdAt, /reviews by -createdAt.
  • Multiple sorting with commas, from highest to lowest priority.

Stable ordering, or why this matters more than it seems

An ordering is stable when two identical requests return the elements in the same order. It sounds obvious, but it is not if you sort by a field with repeated values: the database may return ties in any order from one query to the next.

The direct and very real consequence when paginating:

# 40 coffees cost exactly €12.90
curl "https://api.aromastore.example/v1/coffees?sort=priceEuros&limit=20&offset=0"
curl "https://api.aromastore.example/v1/coffees?sort=priceEuros&limit=20&offset=20"

If the engine resolves the ties differently on each query, some coffees appear on both pages and some appear on neither. The user sees duplicates and loses elements, and the bug is intermittent and impossible to reproduce in development with 10 rows.

The fix, and it is contract: the server always appends a unique tie-breaking criterion at the end of the requested order, typically the id.

?sort=priceEuros   →   ORDER BY priceEuros ASC, id ASC   (implicit)

It does not appear in the URL, but it is documented: "ties in the ordering are always broken by id ascending". Without this, no pagination is reliable.

  1. Offset pagination

The most widespread model: "skip N elements and give me M".

# First page
curl "https://api.aromastore.example/v1/coffees?limit=20&offset=0"

# Second page
curl "https://api.aromastore.example/v1/coffees?limit=20&offset=20"

# Page 7
curl "https://api.aromastore.example/v1/coffees?limit=20&offset=120"
{
  "data": [ { "id": "cof_001", "name": "Ethiopia Yirgacheffe", "priceEuros": 14.50 } ],
  "total": 137
}

In favour: it is trivial to understand, it lets you jump to any page (offset = (page - 1) * limit) and it gives the total, so the client can render "137 results, page 3 of 7".

Against, two serious problems we will see in section 9: it degrades with large offsets and it is not stable in the face of insertions.

  1. Page pagination

It is the same model with different arithmetic, more convenient for anyone rendering page controls:

curl "https://api.aromastore.example/v1/coffees?page=3&perPage=20"
{
  "data": [ ],
  "total": 137,
  "page": 3,
  "perPage": 20,
  "totalPages": 7
}

Advantage: the client calculates nothing. Drawback: it is exactly as fragile as offset, because underneath it translates into an offset; only the way of asking changes. And it adds a classic ambiguity: is the first page 1 or 0? Either is fine as long as it is documented; getting it wrong costs you a 20-element bug that nobody spots.

Aroma Store's decision: page/perPage are not offered. A single mechanism (limit/offset) is more consistent than two equivalent ones, and the client works out the page in one line.

  1. Cursor pagination

Instead of "skip 5,000", the server hands over an opaque pointer to the position where it left off.

# First page: no cursor
curl "https://api.aromastore.example/v1/orders?limit=20&sort=-createdAt"
HTTP/1.1 200 OK
Link: <https://api.aromastore.example/v1/orders?limit=20&cursor=eyJmIjoiMjAyNi0wMy0xNCIsImkiOiJvcmRfNTAwMSJ9>; rel="next"
Content-Type: application/json

{
  "data": [ ],
  "nextCursor": "eyJmIjoiMjAyNi0wMy0xNCIsImkiOiJvcmRfNTAwMSJ9"
}
# Next page: the received cursor is sent back
curl "https://api.aromastore.example/v1/orders?limit=20&cursor=eyJmIjoiMjAyNi0wMy0xNCIsImkiOiJvcmRfNTAwMSJ9"

How it works internally: the cursor encodes (normally in Base64) the values of the last row delivered according to the current ordering —here, createdAt and id. The next query does not say "skip 5,000 rows", it says "give me the rows after (2026-03-14, ord_5001)", which the index resolves instantly whatever the depth.

Aroma Store's rules for cursors:

  • They are opaque. The documentation expressly forbids decoding or building them: their content can change without notice.
  • They include the ordering. A cursor obtained with ?sort=-createdAt is not valid for ?sort=priceEuros: mixing them gives 400 with invalid_parameter.
  • There is no total in cursor-paginated collections: calculating it requires counting the whole table, which is exactly the cost we wanted to avoid. The absence is documented.
  • You cannot jump to page N: there is only "next" and "previous". That is the price of the model.

  1. Comparison and deep paging

Criterion Offset (offset) By page (page) Cursor
Ease for the client High Very high Medium
Jump to page N Yes Yes No
Total number of elements Yes Yes No (expensive)
Database cost Grows with depth Grows with depth Constant
Stable against insertions No No Yes
Scalability Low on large collections Low High
Cacheability Good (stable URLs) Good Medium
Typical use Catalogues, back-office Sites with a classic pager Feeds, history, exports

9.1. Deep paging

Asking for page 5,000 with an offset forces the engine to read and discard 100,000 rows before returning 20:

-- What ?limit=20&offset=100000 really does
SELECT * FROM coffees ORDER BY name ASC, id ASC LIMIT 20 OFFSET 100000;

The cost grows linearly with the offset: page 1 takes milliseconds and page 5,000 can take seconds and punish the whole database. With a cursor, the cost is the same on page 1 as on page 5,000.

Aroma Store's mitigation: a maximum offset of 10,000. Exceeding it returns 400 with invalid_parameter and a message pointing at the right mechanism: "to walk the complete collection, use cursor pagination".

9.2. Duplicated and skipped elements

The other problem with offset, illustrated with /orders sorted by -createdAt:

sequenceDiagram
    participant C as Internal panel
    participant A as API
    C->>A: GET /orders?limit=3&offset=0
    A-->>C: [ord_5010, ord_5009, ord_5008]
    Note over A: A new order comes in: ord_5011<br/>Everything shifts one position
    C->>A: GET /orders?limit=3&offset=3
    A-->>C: [ord_5008, ord_5007, ord_5006]
    Note over C: ord_5008 appears TWICE<br/>and no order has been lost... this time

With an insertion, an element is duplicated; with a deletion, one is skipped, which is worse because it is invisible. In a catalogue that barely changes, it is tolerable; in an accounting export, it is unacceptable.

Aroma Store's decision:

Collection Model Reason
/coffees Offset (limit + offset) A small, stable catalogue; the panel needs to jump to a page and see the total
/reviews Offset The same, with moderate volume
/customers Offset The same
/orders Cursor, with offset supported up to offset=10000 It grows without limit and receives constant insertions
/customers/{id}/orders Offset There are few per customer

Coexistence is legitimate as long as each collection documents which one it uses and the parameters are not mixed: sending cursor and offset at the same time returns 400.

  1. Where the pagination metadata travels

Three possible places, and they are not mutually exclusive.

a) In the body, making use of the envelope decided in 02-05:

{ "data": [ ], "total": 137 }

b) In custom headers, GitHub style:

X-Total-Count: 137
X-Page: 3

c) In the Link header (RFC 8288), the web's standard mechanism:

Link: <https://api.aromastore.example/v1/coffees?limit=20&offset=40>; rel="next",
      <https://api.aromastore.example/v1/coffees?limit=20&offset=0>; rel="first",
      <https://api.aromastore.example/v1/coffees?limit=20&offset=0>; rel="prev",
      <https://api.aromastore.example/v1/coffees?limit=20&offset=120>; rel="last"
Criterion Body X- headers Link (RFC 8288)
Standard No No (X- discouraged by RFC 6648) Yes
Visible with HEAD No Yes Yes
Convenient in JavaScript Very Medium (headers have to be read) Medium (it has to be parsed)
Ready-built links No No Yes
Works with non-JSON responses No Yes Yes

Aroma Store's decision —consistent with what was set out in module 1—: total in the body and navigation in the Link header.

HTTP/1.1 200 OK
Content-Type: application/json
Link: <https://api.aromastore.example/v1/coffees?limit=20&offset=60>; rel="next",
      <https://api.aromastore.example/v1/coffees?limit=20&offset=20>; rel="prev",
      <https://api.aromastore.example/v1/coffees?limit=20&offset=0>; rel="first",
      <https://api.aromastore.example/v1/coffees?limit=20&offset=120>; rel="last"

{
  "data": [ ],
  "total": 137
}

Why this combination and not another:

  • It is exactly the level 2 selective hypermedia we decided on in 01-05: links where they add value (navigation), without turning the body into a hypermedia document.
  • Link is a standard with registered rel values, not an in-house invention, and it works the same for JSON, CSV or PDF.
  • The links come ready-built: the client does not recalculate offsets or carry the filters across by hand. Notice that each link preserves every parameter of the original request —filters, ordering and fields—, which is precisely the mistake most often made when implementing it.
  • total in the body because it is a fact about the result, not about the navigation, and the JavaScript client has it to hand without parsing headers.
  • X-Total-Count is not used: it would duplicate total under a discouraged name.

With a cursor, Link carries only next (and prev if the model allows it), with no first or last, and the body carries no total. And on the last page there is no rel="next": its absence is the end-of-collection signal, and it is documented as such.

  1. Text search

Filtering is exact equality; searching is something else: partial, fuzzy, with relevance. The reserved parameter is q:

curl "https://api.aromastore.example/v1/coffees?q=yirgacheffe"
curl "https://api.aromastore.example/v1/coffees?q=chocolate&roast=medium&sort=priceEuros"

Aroma Store's contract for q:

  • It searches in name, origin and tastingNotes, and it is documented as such (a search that does not say where it searches is a black box).
  • Case- and accent-insensitive: q=peru finds "Perú".
  • It combines with the filters using a logical AND.
  • A minimum of 2 characters; with fewer, 400 with invalid_parameter.
  • The default ordering becomes relevance when q is present, unless sort is given explicitly. And careful: sorting by relevance is not stable, so ties are broken by id just as in section 5.

When does search deserve its own resource?

When it stops being "filtering a collection" and becomes a feature in its own right:

Signal Example Its own resource
It searches across several types of resource at once Coffees, blog articles and help pages GET /v1/search?q=espresso
It returns search metadata Score, facets, suggestions Yes
It is resolved by another engine Elasticsearch, OpenSearch Yes
GET /v1/search?q=espresso

{
  "data": [
    { "type": "coffee", "id": "cof_002", "title": "Colombia Huila", "relevance": 0.91,
      "_links": { "self": { "href": "/v1/coffees/cof_002" } } },
    { "type": "article", "id": "art_12", "title": "How to make a good espresso", "relevance": 0.74 }
  ],
  "total": 2,
  "facets": { "roast": { "medium": 1, "dark": 1 } }
}

Aroma Store does not create /search in v1: ?q= on each collection is enough. It is noted down as a candidate for when the blog exists.

Complex queries via POST

There is one legitimate case where the query does not fit in a URL: internal panel reports with many criteria, long lists of identifiers or expressions that exceed the practical URL length limit (around 2,000 characters on most servers and intermediaries).

POST /v1/orders/queries HTTP/1.1
Content-Type: application/json

{
  "customerIds": ["cus_842", "cus_843", "…600 more…"],
  "statuses": ["paid", "shipped"],
  "dateFrom": "2026-01-01",
  "limit": 100
}

It is a conscious compromise: you lose caching and POST stops meaning "create" and starts meaning "process this query", which is why the resource is called queries (a noun, 02-02) and returns 200, not 201. Aroma Store does not include it in v1, but it writes the pattern down for when the panel needs it, rather than improvising it.

  1. Defaults, limits and documentation

This is where the collections contract closes, and this table is the one module 3 will implement literally:

Parameter Default Maximum Behaviour when exceeded
limit 20 100 400 with invalid_parameter
offset 0 10,000 400 with invalid_parameter
fields All 30 fields 400
expand None 3 relationships 400
q 100 characters 400
sort Per collection 3 criteria 400

Two decisions worth reasoning through:

  • A limit above the maximum returns 400, it is not silently trimmed. Trimming is tempting ("be kind to the client"), but it leaves the consumer believing it received 1,000 elements when it only has 100: it will paginate wrongly and lose data without noticing. Failing visibly is kinder in the medium term.
  • The server paginates even if the client does not ask. GET /v1/coffees with no parameters returns 20 elements, total and the Link header with next. There is no way of asking for "everything".

And this is how every parameter is documented in the reference (02-08): name, type, whether it is required, default value, accepted values, behaviour with invalid values and a runnable example. In OpenAPI, this is written once as reusable parameters and referenced from every collection:

# Contract fragment: common collection parameters
components:
  parameters:
    limit:
      name: limit
      in: query
      description: Maximum number of elements to return.
      required: false
      schema:
        type: integer
        minimum: 1
        maximum: 100
        default: 20
      example: 20
    offset:
      name: offset
      in: query
      description: Number of elements to skip from the start of the collection.
      required: false
      schema:
        type: integer
        minimum: 0
        maximum: 10000
        default: 0
      example: 40

Common Mistakes and Tips

  • Not paginating by default. The most expensive mistake in this lesson: it works in development with 10 rows and takes production down with 100,000.
  • Sorting without a unique tie-break. Duplicates and lost elements when paginating, intermittently and irreproducibly.
  • Losing the filters in the pagination links. The rel="next" must carry sort, the filters and fields across; otherwise page 2 shows something different from page 1.
  • Silently ignoring unknown parameters. A typo (?roastt=medium) returns the entire catalogue and the client believes it has filtered.
  • Allowing sorting or filtering by any field. Without an index, every query is a full table scan.
  • Trimming limit without warning. The client asks for 1,000, receives 100 and believes the collection has 100.
  • Giving total with cursor pagination. Counting the whole collection cancels out the cursor's advantage. Better not to offer it and to document that.
  • Mixing pagination models in the same collection with no rules: cursor and offset together must give 400.
  • Tip: always test with data that changes. Insert rows between page 1 and page 2 and see what happens. That experiment uncovers half of all pagination bugs.
  • Tip: choose the model by usage, not by fashion. The cursor is technically superior, but if the panel needs "page 7 of 12", offset is the right answer.

Exercises

Exercise 1: build the queries

Write the complete curl request for each need, using only the contract from this lesson:

  1. Coffees from Ethiopia or Colombia, with a light roast, between 12 and 18 euros, from most expensive to cheapest, 10 per page, second page.
  2. Orders from customer cus_842 paid or shipped in March 2026, most recent first.
  3. Reviews pending moderation with 3 stars or fewer, showing only id, rating and comment.
  4. A search for "chocolate" in the catalogue, only available coffees, sorted by ascending price.

Exercise 2: diagnose broken pagination

Aroma Store's internal panel lists orders like this:

GET /v1/orders?sort=status&limit=50&offset=0
GET /v1/orders?sort=status&limit=50&offset=50
GET /v1/orders?sort=status&limit=50&offset=100

Users complain about two things: (a) sometimes they see the same order on two pages, and (b) the last page takes eight seconds with 400,000 orders.

Diagnose each symptom and propose a concrete solution, stating what is lost by it.

Exercise 3: design the pagination of a new collection

Aroma Store is adding /v1/events, the log of events sent to SwiftShip (evt_9f2c, order.paid, order.shipped…). Characteristics: it grows at a rate of thousands of events per day, it is never modified or deleted, and SwiftShip uses it to reconcile what it has received, walking through it entirely from the last known point.

Design this collection's contract: pagination model, parameters, filters, default ordering, metadata and headers. Justify each decision.

Solutions

Solution 1

# 1
curl "https://api.aromastore.example/v1/coffees?origin=Ethiopia,Colombia&roast=light&priceMin=12&priceMax=18&sort=-priceEuros&limit=10&offset=10"

# 2
curl "https://api.aromastore.example/v1/orders?customerId=cus_842&status=paid,shipped&dateFrom=2026-03-01&dateTo=2026-03-31&sort=-createdAt"

# 3
curl "https://api.aromastore.example/v1/reviews?status=pending_moderation&ratingMax=3&fields=id,rating,comment"

# 4
curl "https://api.aromastore.example/v1/coffees?q=chocolate&available=true&sort=priceEuros"

Observations: in number 1, the second page with limit=10 is offset=10, and the multi-value origin uses a comma. Number 3 needs a ratingMax filter that is not in the table in section 2: the correct answer includes noticing this and proposing that it be added as a backwards-compatible change, instead of inventing ?rating=<=3.

Solution 2

(a) Duplicates: the tie-break is missing. status has only three values, so there are tens of thousands of ties and the engine returns them in a different order on each query. Solution: the server always appends id as the last criterion (ORDER BY status, id), documents it and does not depend on the client asking for it. Cost: none, other than making sure the right index exists. It is a server fault, not a client one.

(b) Slowness: deep paging. With offset=399950, the engine reads and discards 399,950 rows. Solution: cursor pagination for /orders, plus the offset=10000 cap. What is lost: the panel will no longer be able to jump straight to page 5,000 or show "page 3 of 8,000", because the cursor only offers next and previous. Practical mitigation: almost nobody needs page 5,000 —what they need is to filter better—, so the complete solution combines cursors with date and status filters so that deep traversal stops being necessary.

Solution 3

Model: cursor pagination, without a shadow of doubt. The collection's three traits demand it: it grows without limit (offset would degrade), it is write-only and read sequentially (nobody needs "page 300"), and the use case is exactly "carry on from where you left off", which is the definition of a cursor.

Proposed contract:

Aspect Decision Justification
Pagination cursor + limit Constant cost at any depth
limit Default 50, maximum 200 Events are small; a larger batch than the standard one helps to reconcile quickly
Default ordering createdAt ascending, tie-broken by id It is walked forwards in time: the natural order of a log
Filters type (order.paid, order.shipped), orderId, dateFrom, dateTo, deliveryStatus They allow reconciliation by type or retrying the failed ones
total Not offered Counting millions of rows would cancel out the cursor's advantage; the absence is documented
Metadata Link with rel="next"; no first/last Consistent with the rest of the API and with the cursor model
End of collection The absence of rel="next" A documented signal; SwiftShip stores the last cursor and comes back later
Body {"data": [...], "nextCursor": "..."} The cursor in the body too, for the client's convenience
Immutability Events are never modified An old cursor remains valid indefinitely: a decisive advantage over offset

Example response:

HTTP/1.1 200 OK
Content-Type: application/json
Link: <https://api.aromastore.example/v1/events?limit=50&cursor=eyJmIjoiMjAyNi0wMy0xNFQxMDozMjowMFoiLCJpIjoiZXZ0XzlmMmMifQ>; rel="next"

{
  "data": [
    { "id": "evt_9f2c", "type": "order.paid", "orderId": "ord_5001",
      "createdAt": "2026-03-14T10:32:00Z", "deliveryStatus": "delivered" }
  ],
  "nextCursor": "eyJmIjoiMjAyNi0wMy0xNFQxMDozMjowMFoiLCJpIjoiZXZ0XzlmMmMifQ"
}

Conclusion

Collections are where an API meets reality, and Aroma Store now has a complete contract for them: explicit per-field filters, ranges with Min/Max and From/To, multi-value with commas, and the conscious refusal to invent a query language in the URL; sorting with sort and -field, with the critical rule of tie-breaking by id that makes pagination reliable; three pagination models understood in depth, with offset for stable collections such as /coffees and cursors for the ones that grow without brakes such as /orders, plus the offset cap that prevents deep paging; metadata split between total in the body and the standard Link header with its rel values; search with ?q= and the criteria for knowing when it deserves its own resource; and a table of defaults and maximums that protects the API from itself.

With this, the v1 contract is practically closed: resources, methods, codes, representations and collections. And as soon as a contract is published, the next problem begins: changing it without breaking anyone. In the next lesson, 02-07 API Versioning, we will draw a precise line between backwards-compatible and breaking changes, compare the five versioning strategies —path, query, header, media type and date—, justify why Aroma Store versions in /v1, and design the complete deprecation cycle with the Deprecation and Sunset headers.

REST API Course: Principles of Designing and Developing RESTful APIs

Module 1: Introduction to RESTful APIs

Module 2: Designing RESTful APIs

Module 3: Building RESTful APIs

Module 4: Best Practices and Security

Module 5: Tools and Frameworks

Module 6: Case Studies and Projects

© Copyright 2026. All rights reserved