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

  1. What a representation is
  2. Naming and type conventions
  3. Nulls versus absent fields
  4. Dates, times and time zones
  5. Monetary amounts
  6. Enumerations and booleans
  7. Envelope or bare object
  8. Relationships: embed or link
  9. Selective hypermedia: designing _links
  10. Expansion and sparse fieldsets
  11. Content negotiation
  12. Responses that are not JSON

  1. 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.

  1. 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, not strName or nm.
  • Id suffix 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/has prefixes: the boolean is called active, soldOut, gift.
  • Domain names, not table names: origin, not originFk.

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:

  1. A field's type never changes. If stock is 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 and 0 is falsy.
  2. Identifiers are always string. Even though 5001 looks like a number, ord_5001 is an opaque string, and that protects you on the day the format changes.

  1. 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:

  • null is not used in arrays: a list with no elements is [], never null. That way the client can iterate over it without checking.
  • In a PATCH with Merge Patch, null means "delete" (02-03). It is a deliberate asymmetry between input and output, and it has to be documented.

  1. 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:00 to +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 it T00:00:00Z invites off-by-one-day errors from zone shifts.
  • Consistent names: <something>At for instants, <something>Days for durations. Aroma Store avoids a bare timestamp.

  1. 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" ... sometimes

JSON'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 Euros suffix (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 currency field 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.

  1. 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: 1 forces 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.

  1. 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.

  1. 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:

  1. Embed what is almost always needed and is small and stable. The coffee's name and priceEuros in 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.
  2. Link what is large, changeable or sensitive. The full customer, a coffee's reviews, the invoice.
  3. Never embed an unbounded collection. A coffee with 4,000 reviews cannot carry them inside: they are linked and paginated.
  4. 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" }
  }
}

  1. Selective hypermedia: designing _links

In 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.

  1. 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:

curl "https://api.aromastore.example/v1/orders/ord_5001?expand=customer"
{
  "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.coffee yes; items.coffee.reviews no) to avoid uncontrollable queries.
  • The original field stays: customerId does not disappear when customer is added. That way the client does not have to write two different access paths.
  • Only documented relationships can be expanded; an unknown value returns 400 with invalid_parameter.
  • A maximum of three expansions per request.

10.2. Sparse fieldsets (fields)

The client asks only for what it is going to use.

curl "https://api.aromastore.example/v1/coffees?fields=id,name,priceEuros&limit=3"
{
  "data": [
    { "id": "cof_001", "name": "Ethiopia Yirgacheffe", "priceEuros": 14.50 },
    { "id": "cof_002", "name": "Colombia Huila", "priceEuros": 12.90 }
  ],
  "total": 137
}

Rules:

  • id is 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 400 with invalid_parameter, instead of being silently ignored: that way typos are caught.
  • It is not combined with expand over 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).

  1. 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.5

It 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, br
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": "cof_001",
  "name": "Ethiopia Yirgacheffe",
  "origin": "Ethiopia",
  "roast": "light",
  "priceEuros": 14.50,
  "tastingNotes": ["cítric", "floral", "te negre"]
}

Notice three things:

  • tastingNotes comes translated, because it is marketing copy aimed at the end user. roast is not translated: it is an enumeration, a machine identifier (section 6).
  • Content-Language: ca declares what was served; it is essential, because the client asked for two languages and needs to know which one it got.
  • Vary tells intermediate caches that the response depends on those headers and that they must not serve the Catalan version to somebody asking for English. Forgetting Vary is 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:

Accept: application/vnd.aromastore.coffee+json
Accept: application/vnd.aromastore.v2+json

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.

  1. 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/invoice
HTTP/1.1 200 OK
Content-Type: application/pdf
Content-Disposition: attachment; filename="invoice-2026-000188.pdf"
Content-Length: 48213
Accept-Ranges: bytes

Content-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.jpg
HTTP/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 → 413 with body_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/csv on /v1/orders.
  • text/event-stream for 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 camelCase API with two snake_case fields 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/.pdf duplicate identities; that is what Accept is 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:

  1. The coffee's name inside an order item.
  2. The 4,000 reviews of cof_001 on the coffee's detail page.
  3. The full customer inside an order, in the internal panel.
  4. The shipping address inside an order.
  5. A coffee's average rating in the catalogue listing.
  6. 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

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