---
title: 'Request and Response Bodies'
source: 'https://academia.sh/en/courses/api-design/request-and-response-bodies'
course: 'Web API Design'
language: en
updated: '2026-08-19T05:19:30+00:00'
license: 'CC BY-SA 4.0'
---

# Request and Response Bodies

The contract value of body format: one spelling for field naming, the silent loss of large integers and decimal amounts, writing dates with zone information, separating a missing field from an empty value, and the envelope decision for collection responses.

The previous lesson settled the response's machine-read part — the status code. What is
left is the body. In the measurement servers, bodies were written haphazardly: in one place
just an identity field, elsewhere an error name and a limit value, in another the table's
column names as they stood.

Body format is as much a contract as the status code. If a field name changes, the client
breaks; if how a number is written is chosen wrong, the client does not break — something
worse happens, it silently reads the wrong value. This lesson lays out the body decisions,
and two of them are shown by measurement: parsing numbers and dates, and separating a
missing field from an empty value.

## The Body Is a Contract

The first decision is field naming, and what matters is consistency, not the choice itself.
If one response has `issuedAt`, another `issuedDate`, and a third `issued_date`, the client
writes a separate parser for every endpoint. One casing is chosen for the whole course and
it does not change.

The second decision is that the body is not a copy of the database schema. In the previous
lesson the loan record was returned directly as a `SELECT *` result; this leaks column names
into the contract. When a column is renamed or added, the client is affected. The right
behavior is to build the representation explicitly: which fields go out is written down, the
rest stays inside.

The third decision concerns enumerated values. A loan record's status is reported with fixed
codes like `open`, `returned`, `overdue` — not display text like "Open" or "Returned." Text
depends on language and wording, while the code is part of the contract and can be compared.
What appears on screen is the client's decision.

## Numbers: A Silent Loss

In the library catalog, every copy has an accession number that comes from another system.
An overdue fine is also a decimal amount. What happens when both are written as numbers in
JSON is worth examining.

```bash
# Two silent losses in a JSON body: a large integer and an unformatted date.
node -e '
const body = `{"accession": 9007199254740993, "fine": 0.1, "extra": 0.2}`;
const c = JSON.parse(body);
console.log("sent accession        :", body.match(/\d{16}/)[0]);
console.log("parsed accession      :", String(c.accession));
console.log("equal                 :", String(c.accession) === body.match(/\d{16}/)[0]);
console.log("fine + extra          :", c.fine + c.extra);
'
echo "---"
TZ=Europe/Istanbul node -e '
for (const d of ["12/03/2026", "2026-03-12", "2026-03-12T00:00:00", "2026-03-12T00:00:00+03:00"])
  console.log(d.padEnd(28), new Date(d).toISOString());
'
```

```
sent accession        : 9007199254740993
parsed accession      : 9007199254740992
equal                 : false
fine + extra          : 0.30000000000000004
---
12/03/2026                   2026-12-02T21:00:00.000Z
2026-03-12                   2026-03-12T00:00:00.000Z
2026-03-12T00:00:00          2026-03-11T21:00:00.000Z
2026-03-12T00:00:00+03:00    2026-03-11T21:00:00.000Z
```

The first three lines show an identity breaking during parsing. JSON's number type's
precision depends on the parser's floating-point representation; once the double-precision
representation defined in the How Computers Work course exceeds its integer limit, a value
rounds to the nearest representable one. `...993` is sent to the server, `...992` is read on
the client. No error is raised; the loss stays invisible until a query made with that
identity comes back empty.

The fourth line is a second problem from the same root: the result of `0.1 + 0.2` is not
exactly `0.3`. When monetary amounts travel as decimal numbers, addition produces
accumulating errors.

Both problems have the same fix: **numbers that will not be computed on travel as strings.**
Identities are strings — no arithmetic is done on them anyway. Monetary amounts travel
either as an integer in the smallest unit (cents) or as a string, and comparison is done in
that form. An identity looking like a number does not make it one.

## Dates: No Such Thing as a Format-Free String

The output's second section shows date fields, and all four lines hit a different trap.
This block was run with `TZ=Europe/Istanbul`; the third line's result changes when the local
time zone changes — and that is exactly the problem.

`12/03/2026` was parsed not as March 12 but as December 2. Because the order of day and
month cannot be read from the notation itself, the parser applied its own assumption. If a
field like this exists in a contract, half its clients read eight months into the future.

`2026-03-12` is a zone-free day and was taken as midday UTC. `2026-03-12T00:00:00`, carrying
no zone information, was counted as **local** time and shifted back a day once converted to
UTC. The same string corresponds to two different instants on two different machines. Only
the last line — the form that writes the zone offset explicitly — points to the same instant
everywhere.

The rule is this: **if an instant is being reported, the zone offset is written.** Fields
like the moment a loan is issued travel in the full form. If only a calendar day is being
reported — a due date, say — the field's name should say so and its notation should carry
no time of day at all. Mixing the two kinds in the same field produces one-day shifts across
daylight-saving transitions.

## Absent and Empty Are Not the Same Thing

In a partial update, the client can say three things: set this field to this value, clear
this field, do not touch this field. In a JSON body, the first two are conveyed with a
value, the third with **the field being absent altogether.** A form that ignores this
distinction can never carry out a "clear" request.

```js
// field-server.mjs — the "absent" vs. "empty" distinction in a partial update
import { createServer } from "node:http";
import { DatabaseSync } from "node:sqlite";

const db = new DatabaseSync("library.db");
const readBody = (request) => new Promise((resolve) => {
  let data = ""; request.on("data", (p) => (data += p));
  request.on("end", () => resolve(data ? JSON.parse(data) : {}));
});
const respond = (response, status, data) => {
  response.writeHead(status, { "content-type": "application/json; charset=utf-8" });
  response.end(JSON.stringify(data));
};

const server = createServer(async (request, response) => {
  const path = new URL(request.url, "http://127.0.0.1").pathname;
  const loose = /^\/loose\/loans\/(\d+)$/.exec(path);
  const strict = /^\/strict\/loans\/(\d+)$/.exec(path);
  if (request.method !== "PATCH" || !(loose || strict))
    return respond(response, 404, { error: "path_not_found" });

  const id = Number((loose ?? strict)[1]);
  const g = await readBody(request);
  const existing = db.prepare("SELECT * FROM loan WHERE id = ?").get(id);
  if (!existing) return respond(response, 404, { error: "loan_not_found" });

  if (loose) {
    // A form that does not separate an empty value from an absent field: null can never be written.
    const next = g.return ?? existing.return;
    db.prepare("UPDATE loan SET return = ? WHERE id = ?").run(next, id);
  } else {
    // Checks whether the field is present in the body; its value is evaluated separately.
    if ("return" in g) db.prepare("UPDATE loan SET return = ? WHERE id = ?").run(g.return, id);
  }
  respond(response, 200, db.prepare("SELECT id, return FROM loan WHERE id = ?").get(id));
});

server.listen(8479, "127.0.0.1", () => console.log("field server 127.0.0.1:8479"));
```

```bash
# The same three bodies are sent to both forms; the return field's final value is compared.
rm -f library.db && sqlite3 library.db < schema.sql
node field-server.mjs & server=$!
sleep 0.4

send() { curl -sS -X PATCH -H 'content-type: application/json' -d "$2" "http://127.0.0.1:8479$1"; echo; }
for mode in loose strict; do
  echo "--- $mode ---"
  send "/$mode/loans/2" '{"return":"2026-03-20"}'
  send "/$mode/loans/2" '{"member":"U-1001"}'
  send "/$mode/loans/2" '{"return":null}'
done

kill $server
```

```
field server 127.0.0.1:8479
--- loose ---
{"id":2,"return":"2026-03-20"}
{"id":2,"return":"2026-03-20"}
{"id":2,"return":"2026-03-20"}
--- strict ---
{"id":2,"return":"2026-03-20"}
{"id":2,"return":"2026-03-20"}
{"id":2,"return":null}
```

The first two lines of both modes match: a value is assigned, the field is kept if absent
from the body. The third line diverges. In the loose form, the `return` field could not be
cleared, because the `??` operator treats `null` as "not given" and restores the old value.
There is no way to fix a loan record closed by mistake; the client sends the request, gets a
200, and nothing changes.

In the strict form, the distinction is read from the body's structure: if the field is
present, its value — `null` included — is applied; if not, it is left untouched. This
behavior must be written into the contract, because the client needs to knowingly use the
difference between "not sending the field" and "sending it empty."

The same distinction holds on the response side. Sending `null` for a field with no value
says something different from omitting it: the first says "this field exists but is
empty," the second says "this field is not in this representation." One rule is chosen for
the whole course; sometimes `null`, sometimes an absent field in a response forces the
client to handle both possibilities.

## Collection Response: Bare Array or Envelope

A collection response's shortest form is a bare array. Being short is its only advantage. No
extra information can be attached to an array: how many records there are, where the next
page starts, what criteria the query ran with. This information can travel in headers, but
headers may not be cached alongside the body, and reading them takes separate work on the
client.

The **envelope** form wraps the body in an object: records sit in one field, pagination
information in another. Its cost is one extra level of nesting, its gain is that the
response can describe itself. The next two lessons — pagination and connection-based
responses — will use that field, so the envelope form is chosen for the whole course.

Singular resource responses, however, are not wrapped. The body returned from `/loans/2` is
directly the loan record's representation; wrapping it in a field adds no information and
just forces every client to descend one more level.

## Summary

- One casing is chosen for field naming and does not change through the course; the body is
  an explicitly built representation, not a copy of the database schema.
- Numbers that will not be computed on — identities — travel as strings; in the measurement,
  `9007199254740993` became `9007199254740992` when parsed, with no error raised.
- Monetary amounts do not travel as decimal numbers; an integer in the smallest unit or a
  string is used instead.
- Fields that report an instant write the zone offset explicitly; zone-free notations
  correspond to different instants on different machines, and notations with an ambiguous
  day/month order can drift by months.
- In a partial update, whether a field is present in the body and whether its value is empty
  are separate pieces of information; a form that ignores this distinction makes clearing a
  field impossible.
- Collection responses are wrapped in an envelope, because pagination and navigation
  information will travel inside the body; singular resource responses are not wrapped.

## Next Step

The envelope's empty field is the next lesson's subject. While the loan collection was
limited to three rows, sending every record in a single response was not a problem; at tens
of thousands of records, that response overwhelms both server and client. Records need to be
handed out in pieces, and there is more than one way to do it. The next lesson builds
offset-based pagination, actually produces a reader seeing a record twice or not at all once
a record is inserted in between, and then shows a method that does not drift under the same
scenario.
