The envelope is already designed: we know which URI the request goes to, with which method and with which code the server responds. What is missing is what goes inside. This lesson designs the body of Aroma Store's responses —field names, their types, how dates and monetary amounts are represented, what gets embedded and what gets linked— and the headers that govern that body. It is a lesson of small, very long-lived decisions: the name of a published field is contract for years, and getting monetary amounts wrong is paid for in lost cents. By the end you will have the canonical representation of every resource and the content negotiation rules that module 3 will implement.
Contents
- What a representation is
- Naming and type conventions
- Nulls versus absent fields
- Dates, times and time zones
- Monetary amounts
- Enumerations and booleans
- Envelope or bare object
- Relationships: embed or link
- Selective hypermedia: designing
_links - Expansion and sparse fieldsets
- Content negotiation
- Responses that are not JSON
- What a representation is
Let us recall the distinction from 01-04: the resource is the conceptual entity (the coffee cof_001), and the representation is one of its concrete forms at a given moment. The same resource can be represented as JSON in English, JSON in Catalan, a PDF or a JPEG image, and they all share a URI.
graph LR
R["Resource<br/><b>/v1/orders/ord_5001/invoice</b>"] --> A["Accept: application/json<br/>→ JSON with the amounts"]
R --> B["Accept: application/pdf<br/>→ PDF on letterhead"]
R --> C["Accept-Language: ca<br/>→ the same content in Catalan"]
Hence the rule already stated in 02-02: the format does not go in the URL, it is negotiated with headers. And hence too the importance of this lesson's decisions: the representation is what the consumer actually sees and programs against.
- Naming and type conventions
2.1. camelCase
Aroma Store uses camelCase for every JSON field name: priceEuros, createdAt, tastingNotes, customerId.
| Style | Example | Who uses it | Comment |
|---|---|---|---|
camelCase |
priceEuros |
Google, Stripe (partly), most | Natural in JavaScript, which is the main consumer |
snake_case |
price_euros |
Stripe, Twitter/X, Slack | Natural in Python/Ruby, readable |
PascalCase |
PriceEuros |
Old .NET APIs | Rare today |
kebab-case |
price-euros |
Practically nobody | Awkward: it forces obj["price-euros"] |
None is better in the abstract. Aroma Store chooses camelCase because its main consumers are JavaScript (the SPA, Aroma Mobile with React Native, the Node.js server) and so the JSON object is used without translation. What is mandatory is not mixing them: {"priceEuros": 14.50, "created_at": "..."} is the kind of detail that poisons an API.
2.2. Field naming rules
- No technical prefixes or cryptic abbreviations:
name, notstrNameornm. Idsuffix for references:customerId,coffeeId. It makes clear that it is a reference and not the object.- A unit suffix where there is one:
priceEuros,weightGrams,durationSeconds. It wipes out the question "what unit is this in?" in one stroke. - Plurals for arrays:
tastingNotes,items,data. - No
is/hasprefixes: the boolean is calledactive,soldOut,gift. - Domain names, not table names:
origin, notoriginFk.
2.3. Types
| JSON type | Use in Aroma Store | Example |
|---|---|---|
string |
Text, identifiers, dates, enumerations | "cof_001" |
number |
Quantities and amounts | 14.50, 120 |
boolean |
Flags | true |
array |
Collections and lists of values | ["citrus", "floral"] |
object |
Nested structures | {"coffeeId": "...", "quantity": 2} |
null |
Absence with meaning | "shippedAt": null |
Two hard rules:
- A field's type never changes. If
stockis a number, it cannot become"120"in another response. It is a breaking change (02-07) and an inexhaustible source of bugs, because"0"is truthy in JavaScript and0is falsy. - Identifiers are always
string. Even though5001looks like a number,ord_5001is an opaque string, and that protects you on the day the format changes.
- Nulls versus absent fields
Three possible states for a field, and you have to choose what each one means:
| Form | The meaning we give it | Example |
|---|---|---|
| Field present with a value | There is data | "shippedAt": "2026-03-16T09:00:00Z" |
Field present with null |
The data exists conceptually but has no value yet | "shippedAt": null (an unshipped order) |
| Absent field | The field does not apply to this resource or was not requested | No shippedAt on a cancelled order |
Aroma Store's rule: a resource's representation always includes the same fields, using null for whatever does not have a value yet. The only legitimate absences are (a) fields the client has excluded with fields= and (b) objects that were not requested with expand=.
Why this rule is worth it:
// With the rule: the client writes this and it always works
const date = order.shippedAt ?? "Awaiting shipment";
// Without the rule, the client has to defend against three cases
const date = ("shippedAt" in order)
? (order.shippedAt === null ? "Pending" : order.shippedAt)
: "Unknown";Two nuances:
nullis not used in arrays: a list with no elements is[], nevernull. That way the client can iterate over it without checking.- In a
PATCHwith Merge Patch,nullmeans "delete" (02-03). It is a deliberate asymmetry between input and output, and it has to be documented.
- Dates, times and time zones
All dates and times go in ISO-8601 with an explicit time zone, in UTC (Z).
{
"createdAt": "2026-03-14T10:30:00Z",
"paidAt": "2026-03-14T10:32:15Z",
"shippedAt": null,
"expiryDate": "2027-01-31"
}| Format | Example | Verdict |
|---|---|---|
ISO-8601 with Z |
2026-03-14T10:30:00Z |
✅ Aroma Store's standard |
| ISO-8601 with an offset | 2026-03-14T11:30:00+01:00 |
✅ Accepted on input, normalised to UTC |
| ISO-8601 with no zone | 2026-03-14T10:30:00 |
❌ Ambiguous: whose time is it? |
| Date only | 2027-01-31 |
✅ Only when the time does not apply |
| Epoch in seconds | 1773484200 |
❌ Illegible; seconds/milliseconds ambiguity |
| Local format | 14/03/2026 11:30 |
❌ Ambiguous (March or the 3rd?) and language-dependent |
Important points:
- UTC in storage and in transport; local time only in presentation. The client formats according to the user's zone; the server never assumes Madrid.
- Watch out for daylight saving. Spain switches from
+01:00to+02:00; if you store local time, two orders placed in the small hours of the changeover can appear out of order or duplicated. - Dates with no time (
2027-01-31) for anything that is a calendar day, such as a batch's expiry. Giving itT00:00:00Zinvites off-by-one-day errors from zone shifts. - Consistent names:
<something>Atfor instants,<something>Daysfor durations. Aroma Store avoids a baretimestamp.
- Monetary amounts
The section where the most money is lost to a technical oversight. Never use binary floating point for money on the server.
// The classic that surprises everyone
0.1 + 0.2 // 0.30000000000000004
14.50 * 3 // 43.5 (fine)
0.07 * 100 // 7.000000000000001
(29.00 * 0.21).toFixed(2) // "6.09" ... sometimesJSON's number, when processed as an IEEE 754 double, cannot represent 0.1 exactly. Adding up a hundred order items accumulates error, and in accounting a one-cent discrepancy is a real problem.
Representation options:
| Option | Example | For | Against |
|---|---|---|---|
| Decimal number | "priceEuros": 14.50 |
Readable, convenient for the client | Floating-point risk if the client does the arithmetic |
| Integer in cents | "priceCents": 1450 |
Exact, no decimals | Everyone has to know the scale; ugly to read |
| Decimal string | "priceEuros": "14.50" |
Exact and unambiguous | Forces parsing; awkward to sort |
| Amount object | {"amount": "14.50", "currency": "EUR"} |
Explicit, multi-currency | Verbose |
Aroma Store's decision:
- In the representation: a number with exactly two decimals and the
Eurossuffix (14.50,29.00). It is readable and direct for the clients, which only display it. - On the server and in the database: integer cents or an exact decimal type. The conversion happens at the edge (03-05).
- Totals are always calculated by the server. The client never adds up amounts to present them as official: that is why the order includes an already calculated
totalEuros. - Currency: v1 is euros only and that is stated in the documentation. If more currencies ever appear, an optional
currencyfield with the default value"EUR"will be added —a backwards-compatible change (02-07)— rather than restructuring the amounts.
A detail that surprises people: 29.00 in JSON may be serialised as 29 depending on the library, because JSON does not distinguish integers from decimals. That is acceptable —numerically they are equal— and the client formats with two decimals when displaying. If it bothers you, the alternative is the decimal string, with its cost.
- Enumerations and booleans
6.1. Enumerations
Aroma Store's enumerated values go in lowercase snake_case, with stable and extensible values:
| Field | v1 values |
|---|---|
roast |
light, medium, dark |
status (order) |
pending_payment, paid, shipped |
status (review) |
pending_moderation, published, rejected |
status (shipment) |
pending_pickup, out_for_delivery, delivered |
Rules of the contract:
- The value is never translated.
status: "paid"is a machine identifier; the label the user sees is supplied by the client. If a human-readable text is needed tomorrow, a separate field is added (statusText), the value is not changed. - The list can grow. The documentation warns from day one: handle an unknown value gracefully (the robustness principle, 02-01). Adding
status: "returned"must not break anyone. - The list does not shrink and values are not renamed. That really is breaking.
- No numeric codes.
roast: 1forces you to maintain a lookup table out of band and is illegible in a log.
6.2. Booleans
A boolean should only be used when the concept is genuinely binary and permanently so. Many "booleans" end up turning into enumerations: approved: true/false does not cover "pending moderation", which is exactly why reviews have status and not approved. When in doubt, enumeration: extending an enumeration is backwards-compatible; turning a boolean into an enumeration is not.
- Envelope or bare object
For a collection, what gets returned?
Option A, a bare array:
[
{ "id": "cof_001", "name": "Ethiopia Yirgacheffe" },
{ "id": "cof_002", "name": "Colombia Huila" }
]Option B, an envelope (Aroma Store):
{
"data": [
{ "id": "cof_001", "name": "Ethiopia Yirgacheffe" },
{ "id": "cof_002", "name": "Colombia Huila" }
],
"total": 2
}| Criterion | Bare array | data/total envelope |
|---|---|---|
| Simplicity for the client | Maximum: you iterate directly | One level more (response.data) |
| Adding metadata later | Breaking change | Additive, breaks nothing |
| Total number of elements | No room for it (or it goes in a header) | total |
| Consistency with element responses | Two different shapes | Two different shapes equally |
| Historical JSON hijacking risk | Existed in old browsers | Mitigated |
Aroma Store's decision: an envelope for collections, a bare object for elements.
GET /v1/coffees/cof_001 → { "id": "cof_001", "name": "...", ... }
GET /v1/coffees → { "data": [...], "total": 137 }The main reason is tolerance to change: if tomorrow you have to add total, _links or deprecation notices to a collection, with the envelope it is additive and with the bare array you would have to break every client. And we do not wrap individual elements ({"data": {...}}) because it adds noise without contributing anything: an element is already an extensible object.
The practical consequence for the client, which has to be documented well:
// Collection
const response = await fetch("/v1/coffees").then(r => r.json());
response.data.forEach(coffee => console.log(coffee.name));
console.log(`There are ${response.total} coffees in total`);
// Element
const coffee = await fetch("/v1/coffees/cof_001").then(r => r.json());
console.log(coffee.name);The pagination metadata that goes alongside total is decided in 02-06.
- Relationships: embed or link
An order has a customer and items pointing at coffees. How much of that travels in the response?
Linking:
{
"id": "ord_5001",
"customerId": "cus_842",
"totalEuros": 29.00,
"_links": {
"self": { "href": "/v1/orders/ord_5001" },
"customer": { "href": "/v1/customers/cus_842" }
}
}Embedding:
{
"id": "ord_5001",
"customer": {
"id": "cus_842",
"name": "Marta García",
"email": "[email protected]"
},
"totalEuros": 29.00
}| Criterion | Linking | Embedding |
|---|---|---|
| Response size | Minimal | Larger |
| Number of client calls | More (chattiness) | Fewer |
| Freshness of the data | Always current when requested | A copy from the moment of the response |
| Caching | Each resource is cached separately | Invalidates the whole set |
| Risk of exposing too much | Low | High (personal data, permissions) |
| Coupling | Low | High |
Aroma Store's criteria:
- Embed what is almost always needed and is small and stable. The coffee's
nameandpriceEurosin each order item are embedded, and the price is embedded frozen: the order's price is the one that applied on the day of purchase, not the current one. Here embedding is not an optimisation, it is business correctness. - Link what is large, changeable or sensitive. The full customer, a coffee's reviews, the invoice.
- Never embed an unbounded collection. A coffee with 4,000 reviews cannot carry them inside: they are linked and paginated.
- Everything else, on demand with
expand(section 10).
This is what an Aroma Store order ends up looking like:
{
"id": "ord_5001",
"customerId": "cus_842",
"status": "pending_payment",
"totalEuros": 29.00,
"createdAt": "2026-03-14T10:30:00Z",
"paidAt": null,
"shippedAt": null,
"items": [
{ "coffeeId": "cof_001", "name": "Ethiopia Yirgacheffe", "quantity": 2, "priceEuros": 14.50 }
],
"_links": {
"self": { "href": "/v1/orders/ord_5001" },
"customer": { "href": "/v1/customers/cus_842" },
"pay": { "href": "/v1/orders/ord_5001/payment", "method": "POST" },
"cancel": { "href": "/v1/orders/ord_5001/cancellation", "method": "POST" }
}
}
- Selective hypermedia: designing
_links
_linksIn 01-05 we fixed the target level: solid Richardson 2 with selective hypermedia. Let us pin down exactly what that means in the contract, because "selective" without rules turns into chaos.
What DOES carry links:
| Element | Links | Reason |
|---|---|---|
| Every individual resource | self |
The canonical URI, essential with nested views (02-02) |
| Every resource with relationships | Links to the related resources | Stops the client building URLs |
| Orders only | State-dependent action links | The state machine is real and it changes |
| Collections | Pagination via the Link header |
Decided in 02-06 |
What does NOT carry links: elements inside a collection do not carry complete _links (only self), so as not to multiply the response's weight twentyfold; coffees carry no action links, because they have no state machine.
Link format. An object with href and, for actions, method:
"_links": {
"self": { "href": "/v1/orders/ord_5001" },
"pay": { "href": "/v1/orders/ord_5001/payment", "method": "POST" }
}It is the simplified HAL shape (01-05), but without adopting application/hal+json or _embedded: we stay with application/json, because we do not want to force clients to understand a complete hypermedia format.
The state-dependent action links of an order are the heart of selective hypermedia:
| Status | _links present |
|---|---|
pending_payment |
self, customer, pay, cancel |
paid |
self, customer, invoice, shipment, cancel |
shipped |
self, customer, invoice, shipment, return |
And the rule that makes this useful at all, written in the documentation: "if an action link is not present, that action is not possible right now; do not build it by hand". The SPA renders its buttons from _links instead of replicating the state machine, which is exactly what we were after.
The link URIs are relative to the host (/v1/orders/ord_5001). It is documented that way so that it works identically in production, in staging and locally.
- Expansion and sparse fieldsets
The two escape valves announced in 02-01 for governing granularity.
10.1. Expansion (expand)
It embeds a related resource on demand, saving calls:
{
"id": "ord_5001",
"customerId": "cus_842",
"customer": {
"id": "cus_842",
"name": "Marta García",
"email": "[email protected]"
},
"totalEuros": 29.00
}Aroma Store's rules for expand:
- A comma-separated list:
?expand=customer,items.coffee. - Only one level of depth is allowed (
items.coffeeyes;items.coffee.reviewsno) to avoid uncontrollable queries. - The original field stays:
customerIddoes not disappear whencustomeris added. That way the client does not have to write two different access paths. - Only documented relationships can be expanded; an unknown value returns
400withinvalid_parameter. - A maximum of three expansions per request.
10.2. Sparse fieldsets (fields)
The client asks only for what it is going to use.
{
"data": [
{ "id": "cof_001", "name": "Ethiopia Yirgacheffe", "priceEuros": 14.50 },
{ "id": "cof_002", "name": "Colombia Huila", "priceEuros": 12.90 }
],
"total": 137
}Rules:
idis always included, whether asked for or not: without it the response is unusable.- Fields that were not asked for are omitted (the only legitimate exception to the rule in section 3).
- An unknown field returns
400withinvalid_parameter, instead of being silently ignored: that way typos are caught. - It is not combined with
expandover the same relationship in v1, so as not to multiply the number of cases.
This connects directly with the over-fetching we discussed in 01-07 when comparing REST with GraphQL: fields and expand cover 90% of the real need without giving up HTTP caching or taking on the complexity of a query language. It is a well-designed REST API's answer to that argument.
And the cost, which has to be taken on with eyes open: every combination of parameters is a different URL and therefore a different cache entry. Multiplying variants reduces the cache hit rate (04-06).
- Content negotiation
This is the mechanism by which client and server agree on the representation. The client proposes with Accept-* headers, the server chooses and declares its choice with Content-* headers.
| Client header | What it negotiates | Response header | Error if no agreement |
|---|---|---|---|
Accept |
Format | Content-Type |
406 Not Acceptable |
Accept-Language |
Language | Content-Language |
406 (or the default language) |
Accept-Encoding |
Compression | Content-Encoding |
Served uncompressed |
Accept-Charset |
Character set | (in Content-Type) |
Obsolete: today everything is UTF-8 |
Content-Type (request) |
The format of what it sends | — | 415 Unsupported Media Type |
11.1. Quality factors (q=)
The client can express weighted preferences, from 0 to 1 (1 by default):
Accept: application/json;q=1.0, application/xml;q=0.8, */*;q=0.1
Accept-Language: ca;q=1.0, es;q=0.8, en;q=0.5It reads: "I prefer JSON; failing that, XML; as a last resort, anything" and "I prefer Catalan; failing that, Spanish; failing that, English". The server walks the options by descending q and serves the first one it can produce.
11.2. What Aroma Store negotiates
GET /v1/coffees/cof_001 HTTP/1.1
Accept: application/json
Accept-Language: ca, en;q=0.8
Accept-Encoding: gzip, brHTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
Content-Language: ca
Content-Encoding: br
Vary: Accept, Accept-Language, Accept-Encoding
{
"id": "cof_001",
"name": "Ethiopia Yirgacheffe",
"origin": "Ethiopia",
"roast": "light",
"priceEuros": 14.50,
"tastingNotes": ["cítric", "floral", "te negre"]
}Notice three things:
tastingNotescomes translated, because it is marketing copy aimed at the end user.roastis not translated: it is an enumeration, a machine identifier (section 6).Content-Language: cadeclares what was served; it is essential, because the client asked for two languages and needs to know which one it got.Varytells intermediate caches that the response depends on those headers and that they must not serve the Catalan version to somebody asking for English. ForgettingVaryis a serious and subtle bug: it is detailed in 04-06.
Aroma Store's language contract: en, es and ca are served; the default language is en; if an unavailable language is requested, 406 is not returned, en is served and Content-Language: en is declared. It is a pragmatic decision: for content, an alternative language is better than an error.
11.3. Compression
Accept-Encoding: gzip, br allows compression. A catalogue of 137 coffees in JSON can go from 180 KB down to around 15 KB with gzip: it is the best cost/benefit optimisation in the entire API. It is enabled on the server or on the gateway and it does not change the contract. Performance details are in 04-06.
11.4. Specific media types and versioning by media type
Besides application/json, you can define your own types that identify the exact shape of the representation:
The vnd. prefix marks vendor types. The second example is versioning by media type, one of the strategies we will compare in 02-07. Aroma Store does not use it —it versions in the path— but it is worth recognising: when requesting content from an API that versions this way, Accept: application/json will give you whichever version the server considers the default, which may not be the one you expect.
- Responses that are not JSON
Not everything is JSON, and content negotiation is precisely what lets them coexist without dirtying the URIs.
12.1. The PDF invoice
The same resource, two representations:
# JSON representation: structured data
curl -H "Accept: application/json" \
https://api.aromastore.example/v1/orders/ord_5001/invoice{
"id": "inv_88",
"orderId": "ord_5001",
"invoiceNumber": "2026/000188",
"issuedAt": "2026-03-14T10:33:00Z",
"netAmountEuros": 23.97,
"vatEuros": 5.03,
"totalEuros": 29.00,
"_links": { "self": { "href": "/v1/orders/ord_5001/invoice" } }
}# PDF representation: a document to print or file away
curl -H "Accept: application/pdf" -o invoice.pdf \
https://api.aromastore.example/v1/orders/ord_5001/invoiceHTTP/1.1 200 OK
Content-Type: application/pdf
Content-Disposition: attachment; filename="invoice-2026-000188.pdf"
Content-Length: 48213
Accept-Ranges: bytesContent-Disposition suggests the file name on download, and Accept-Ranges announces that partial downloads are supported, which is what enables the 206 from 02-04. And there is no URL with .pdf: it is the same resource.
12.2. Uploading a coffee's image
Here it is the client that sends something that is not JSON. Two approaches:
a) Direct binary with PUT on the singleton /coffees/{id}/image:
curl -i -X PUT "https://api.aromastore.example/v1/coffees/cof_001/image" \
-H "Authorization: Bearer <token>" \
-H "Content-Type: image/jpeg" \
--data-binary @yirgacheffe.jpgHTTP/1.1 200 OK
Content-Type: application/json
Location: https://api.aromastore.example/v1/coffees/cof_001/image
{
"url": "https://cdn.aromastore.example/coffees/cof_001.jpg",
"widthPx": 1200, "heightPx": 1200, "bytes": 184320, "format": "image/jpeg"
}It is clean, idempotent and needs no additional format.
b) multipart/form-data when you have to send a file and metadata at the same time:
curl -i -X POST "https://api.aromastore.example/v1/coffees/cof_001/images" \
-H "Authorization: Bearer <token>" \
-F "[email protected];type=image/jpeg" \
-F "description=Roasted beans, overhead shot"Aroma Store's decision: option (a), because in v1 there is only one main image per coffee and the description is a field of the coffee itself. The upload contract:
- Supported formats:
image/jpeg,image/png,image/webp. Anything else →415. - Maximum size: 5 MB. If exceeded →
413withbody_too_large. - The response is JSON, even though the request was binary: the response describes the created resource, it does not return it.
- The image is served from the CDN, not from the API. The API stores and returns its URL.
12.3. Other formats that will turn up
- CSV for internal panel exports:
Accept: text/csvon/v1/orders. text/event-streamfor the live panel's SSE that we decided on in 01-07: it is another kind of negotiated response, with a persistent connection.
In both cases the rule is the same: the format is negotiated, the URI does not change.
Common Mistakes and Tips
- Mixing naming conventions. A
camelCaseAPI with twosnake_casefields slips through review and afterwards cannot be removed without breaking clients. - Returning dates with no time zone.
"2026-03-14T10:30:00"is ambiguous; the bug shows up in March and in October, at the clock change. - Doing money arithmetic in floating point. Store cents or exact decimals and convert at the edge.
- Translating the enumerations.
status: "pagat"forces clients to maintain per-language tables. Machine values are not translated. - Returning a bare array for collections. You are left with nowhere to put metadata without breaking the contract.
- Embedding large objects "because it is convenient". The response balloons, caching degrades and you end up exposing personal data where you should not.
- Forgetting
Vary. With an intermediate cache, one user can receive the response in somebody else's language. It is the hardest bug in this lesson to reproduce. - Putting the format in the URL.
.json/.pdfduplicate identities; that is whatAcceptis for. - Tip: write the ideal JSON by hand first. Before looking at the data model, write the response you would like to receive. It is the best defence against dumping tables.
- Tip: review every field by asking "who consumes this?". If there is no answer, remove it: every published field has to be maintained forever.
Exercises
Exercise 1: fix a representation
This is the JSON a team proposes for a review. Rewrite it according to Aroma Store's conventions and justify each change.
{
"Id": 101,
"coffee_id": 1,
"user": { "id": 842, "name": "Marta García", "password_hash": "$2b$10$..." },
"rating": "5",
"comment": "A spectacular coffee",
"approved": true,
"date": "14/03/2026 11:30",
"price_paid": 14.5,
"replies": null
}Exercise 2: design the content negotiation
Aroma Mobile, with the app in Catalan and on a slow network, wants the detail of order ord_5001 with the customer's data included, but only the fields it renders on screen (id, status, totalEuros, createdAt).
a) Write the complete curl request with all the relevant headers.
b) Write the server's response with its headers.
c) Explain why Vary is needed and what exactly would happen if it were omitted.
Exercise 3: embed or link
For each relationship, decide whether it is embedded, linked or offered via expand, and justify it with the criteria from section 8:
- The coffee's name inside an order item.
- The 4,000 reviews of
cof_001on the coffee's detail page. - The full customer inside an order, in the internal panel.
- The shipping address inside an order.
- A coffee's average rating in the catalogue listing.
- A customer's payment history on their record.
Solutions
Solution 1
{
"id": "rev_101",
"coffeeId": "cof_001",
"customerId": "cus_842",
"rating": 5,
"comment": "A spectacular coffee",
"status": "published",
"createdAt": "2026-03-14T10:30:00Z",
"replies": [],
"_links": {
"self": { "href": "/v1/reviews/rev_101" },
"coffee": { "href": "/v1/coffees/cof_001" },
"replies": { "href": "/v1/reviews/rev_101/replies" }
}
}| Problem | Correction |
|---|---|
"Id": 101 |
"id": "rev_101": lowercase, a string and with a type prefix |
coffee_id |
coffeeId: camelCase consistent with the rest |
Embedded user |
Replaced by customerId + a link: the complete object is not needed here |
password_hash |
Removed. A very serious leak: a sensitive field must never be serialised because a whole object was embedded |
"rating": "5" |
A number, not a string: it is a quantity and it sorts numerically |
approved: true |
status: "published": the boolean does not cover pending_moderation or rejected |
"date": "14/03/2026 11:30" |
createdAt in ISO-8601 UTC; the local format is ambiguous and language-dependent |
price_paid |
Removed: it does not belong to a review; that data lives in the order item |
"replies": null |
[]: empty arrays are not null, so that the client can always iterate over them |
Solution 2
a)
curl -i "https://api.aromastore.example/v1/orders/ord_5001?expand=customer&fields=id,status,totalEuros,createdAt" \
-H "Authorization: Bearer <token>" \
-H "Accept: application/json" \
-H "Accept-Language: ca, en;q=0.8" \
-H "Accept-Encoding: gzip, br"b)
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
Content-Language: ca
Content-Encoding: br
Vary: Accept, Accept-Language, Accept-Encoding
{
"id": "ord_5001",
"status": "pending_payment",
"totalEuros": 29.00,
"createdAt": "2026-03-14T10:30:00Z",
"customer": { "id": "cus_842", "name": "Marta García", "email": "[email protected]" }
}Note: status stays in its machine form because it is an enumeration, not translatable text; the app turns it into "Pendent de pagament" when rendering. And customer appears even though it is not in fields because the expansion is explicit: it is documented that way so that it does not surprise anyone.
c) Vary tells intermediate caches (CDN, corporate proxy, gateway) which request headers influence the response. Without it, the cache would store this response under the plain "URL" key. The concrete consequence: the next user who requested the same order with Accept-Language: en would receive the stored Catalan version; and a client that did not support Brotli could receive a body compressed with br that it cannot decompress, so the response would be unreadable rubbish.
Solution 3
| # | Decision | Justification |
|---|---|---|
| 1 | Embed | Small, always needed and, above all, a historical copy: the order must show the name and the price from the day of purchase |
| 2 | Link | An unbounded collection: never embedded. _links.reviews points at /v1/coffees/cof_001/reviews, paginated (02-06) |
| 3 | expand=customer |
The panel needs it often, but embedding it always would expose personal data to every consumer and would fatten every response |
| 4 | Embed | It is part of the order and also frozen data: the address it was sent to, even if the customer changes it later |
| 5 | Embed a calculated field (averageRating, reviewCount) |
They are two numbers, they are shown on every catalogue card and they save one call per coffee: linking here would be pure chattiness |
| 6 | Link | A collection that grows without limit, with sensitive data and occasional use: /v1/customers/cus_842/payments |
Conclusion
The representation is what the consumer actually sees, and it is now designed from top to bottom: camelCase, identifiers as opaque strings, fields always present with null for whatever has no value yet and [] for empty lists, ISO-8601 dates in UTC, amounts in euros with two decimals and exact cents internally, extensible untranslated snake_case enumerations, and the {"data": [...], "total": n} envelope for collections against the bare object for elements. We have fixed what gets embedded —what is small, stable and frozen, such as the name and price of an item— and what gets linked, exactly what the _links of level 2 selective hypermedia look like, and how expand and fields give each consumer the granularity it needs without giving up REST. And we have settled content negotiation with Accept, Accept-Language and Accept-Encoding, with Vary as an indispensable piece, including the representations that are not JSON: the PDF invoice and image uploads.
One piece remains that we have been postponing and that shows up as soon as the catalogue grows: what happens when a collection has 4,000 elements. In the next lesson, 02-06 Filtering, Sorting, Pagination and Search, we will design Aroma Store's large collections: the conventions for filters and ranges, stable sorting, the three pagination models with their comparison table and the deep paging problem, where the pagination metadata travels —total and the Link header—, text search and the default limits that protect the API.
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
