Designing requests and responses
Ids as strings, money in minor units, timestamps in UTC, an explicit allowlist of public fields, and a Location header that resolves where you think it does.
After this lesson you can
- Explain why 64-bit ids and money should not travel as JSON numbers
- Build a response from an explicit allowlist instead of serialising the database row
- Resolve a relative Location header correctly
The shape of a JSON body is the part of an API clients touch most, and the part that is hardest to change later. A few decisions made early save a breaking change.
Be consistent before being clever
Pick one casing (camelCase or snake_case) and use it everywhere,
including in error bodies and query parameters. Use the same name for
the same concept on every resource — not created, createdAt and
creation_date on three endpoints. Represent enumerations as strings
("status": "shipped"), never as magic integers.
Numbers that are not safe as numbers
The JSON grammar puts no limit on a number's size or precision, but JavaScript parses every JSON number into a 64-bit float, which holds integers exactly only up to 2^53 − 1. A 64-bit database id above that silently changes on its way into a browser. Money has the mirror-image problem: a binary float cannot represent most decimal fractions.
export function run() { const wire = '{"id": 9007199254740993, "idAsString": "9007199254740993"}'; const parsed = JSON.parse(wire); const prices = [0.1, 0.2]; const floatTotal = prices[0] + prices[1]; const centsTotal = 10 + 20; return { maxSafeInteger: Number.MAX_SAFE_INTEGER, idAsNumber: String(parsed.id), idAsString: parsed.idAsString, floatTotal, centsTotal, };}So send ids as strings (they are identifiers, not quantities), and money either as an integer number of minor units (cents) together with an ISO 4217 currency code, or as a decimal string. Currencies do not all have two decimals: the yen has none and the Kuwaiti dinar has three.
Send timestamps as RFC 3339 strings with an explicit offset, normally
UTC: "2026-03-01T09:30:00Z". A bare "2026-03-01 09:30" forces every
client to guess the time zone.
The response is not the row
Serialising the database entity straight into the response leaks fields
— a password hash, an internal flag, a cost price — the moment someone
adds a column. Build the response from an explicit allowlist of
public fields, mapped deliberately. A denylist ("everything except
passwordHash") fails open when the next sensitive column arrives.
The same applies to input. Binding a request body straight onto an
entity lets a client set fields it should never control, such as
"isAdmin": true (mass assignment). Accept only the fields each endpoint
documents.
Envelopes and partial responses
HTTP already carries status and metadata in the status line and headers,
so a single resource is usually returned bare. Collections often do get a
small envelope — {"data": [...], "nextCursor": "..."} — because the
page metadata has to live somewhere. Some APIs also let clients request
fewer fields (?fields=id,email); if you offer it, the allowlist still
applies, so asking for a private field returns nothing extra.
Location after a create
A 201 Created carries a Location header. RFC 9110 lets it be a
relative reference, which the client resolves against the request's URI
— and the result depends on a trailing slash in a way that surprises
people:
export function run() { const resolve = (location, requestUri) => new URL(location, requestUri).href; return { relativeNoSlash: resolve("42", "https://api.example.com/v1/orders"), relativeWithSlash: resolve("42", "https://api.example.com/v1/orders/"), absolutePath: resolve("/v1/orders/42", "https://api.example.com/v1/orders"), };}Returning a path that starts with / (or an absolute URI) avoids the
ambiguity entirely.
Common mistakes
- Sending 64-bit ids as JSON numbers. They round in JavaScript clients and the bug only appears once ids grow large enough.
- Money as a float (
19.99) — arithmetic on it drifts, as the example shows. Use minor units or a decimal string, plus a currency code. - Returning the ORM entity, so a new column ships to clients by default.
- A relative
Locationlike42, which resolves differently depending on whether the request URI ended in a slash.
What interviewers ask
- How would you represent money in an API? — integer minor units plus an ISO 4217 currency, or a decimal string; never a float; currencies differ in decimal places.
- Why might an API return ids as strings? — 64-bit ids exceed what a JavaScript number holds exactly; ids are opaque, not arithmetic.
- What is mass assignment and how do you prevent it? — binding request fields straight onto a model so clients set protected fields; prevent it with explicit per-endpoint input DTOs or allowlists.
Exercise · Pro
A public representation from an allowlist
Exercises are free in the first 3 lessons of every course. From here on they are part of Pro and Sprint; the lesson and its runnable examples stay open.
Upgrade