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
- Why an unbounded collection is a problem
- Filtering: query param conventions
- Range filters and multi-value filters
- Why not to invent a query language in the URL
- Sorting
- Offset pagination
- Page pagination
- Cursor pagination
- Comparison and deep paging
- Where the pagination metadata travels
- Text search
- Defaults, limits and documentation
- 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".
- 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
400withinvalid_parameter. Ignoring it silently is worse: the client thinks it has filtered and receives the whole catalogue. - An invalid value returns
400:?roast=super-roastedis not a valid enumeration value. - No results is
200with{"data": [], "total": 0}, never404(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.
- 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:
- 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.
- 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 returns400withinvalid_parameter. The reason is performance: every sortable field needs its index. - A default order, also documented:
/coffeesbynameascending,/ordersby-createdAt,/reviewsby-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.
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.
- 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.
- Page pagination
It is the same model with different arithmetic, more convenient for anyone rendering page controls:
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.
- Cursor pagination
Instead of "skip 5,000", the server hands over an opaque pointer to the position where it left off.
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=-createdAtis not valid for?sort=priceEuros: mixing them gives400withinvalid_parameter. - There is no
totalin 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.
- 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.
- 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:
b) In custom headers, GitHub style:
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.
Linkis a standard with registeredrelvalues, 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. totalin 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-Countis not used: it would duplicatetotalunder 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.
- 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,originandtastingNotes, and it is documented as such (a search that does not say where it searches is a black box). - Case- and accent-insensitive:
q=perufinds "Perú". - It combines with the filters using a logical AND.
- A minimum of 2 characters; with fewer,
400withinvalid_parameter. - The default ordering becomes relevance when
qis present, unlesssortis given explicitly. And careful: sorting by relevance is not stable, so ties are broken byidjust 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.
- 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
limitabove the maximum returns400, 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/coffeeswith no parameters returns 20 elements,totaland theLinkheader withnext. 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: 40Common 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 carrysort, the filters andfieldsacross; 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
limitwithout warning. The client asks for 1,000, receives 100 and believes the collection has 100. - Giving
totalwith 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:
cursorandoffsettogether must give400. - 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:
- Coffees from Ethiopia or Colombia, with a light roast, between 12 and 18 euros, from most expensive to cheapest, 10 per page, second page.
- Orders from customer
cus_842paid or shipped in March 2026, most recent first. - Reviews pending moderation with 3 stars or fewer, showing only
id,ratingandcomment. - 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=100Users 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
- What Is an API?
- History and Evolution of APIs
- HTTP Fundamentals for APIs
- Basic Principles of REST
- The Richardson Maturity Model and HATEOAS
- REST vs. SOAP
- REST Compared with GraphQL, gRPC and Webhooks
Module 2: Designing RESTful APIs
- RESTful API Design Principles
- Resources and URIs
- HTTP Methods
- HTTP Status Codes
- Representations, Headers and Content Negotiation
- Filtering, Sorting, Pagination and Search
- API Versioning
- API Documentation
Module 3: Building RESTful APIs
- Setting Up the Development Environment
- Building a Basic Server
- Handling Requests and Responses
- Input Data Validation
- Persistence and the Data Access Layer
- Authentication and Authorisation
- Error Handling
- Testing and Validation
Module 4: Best Practices and Security
- API Design Best Practices
- Security in RESTful APIs
- OAuth 2.0 and OpenID Connect in Practice
- Rate Limiting and Throttling
- CORS and Security Policies
- HTTP Caching and Performance
- Observability: Logs, Metrics and Traces
Module 5: Tools and Frameworks
- Postman for API Testing
- Swagger and OpenAPI for Documentation
- Popular Frameworks for RESTful APIs
- Contracts, Mocks and Automated API Testing
- Continuous Integration and Deployment
- API Gateways and Developer Portals
