Chapter 4: Request & Response Design
"The URL is the address. The body is the contract.
A bad contract breaks every client. A good one lasts a decade."
1. Physical Reality First
When your API sends a response, it sends bytes. Those bytes travel over a network, arrive at a client written by someone else, and get parsed by code you will never see.
The client engineer reads your JSON and writes code like:
const name = response.data.user.name;
const createdAt = new Date(response.data.user.created_at);
const isActive = response.data.user.active;
They make assumptions:
data.useralways existsnameis always a stringcreated_atis always an ISO 8601 stringactiveis always a boolean
If you return null for name one day, their app crashes.
If you return active: "true" (string) instead of active: true (boolean), their conditional fails silently.
If you rename created_at to createdAt, every integration breaks overnight.
Response design is a contract. Breaking it has consequences.
This chapter teaches you how to design request and response bodies that are consistent, predictable, and long-lived.
2. Request Body Design
2.1 Always Use JSON
Content-Type: application/json
Unless you have a specific reason not to (file uploads, legacy systems), use JSON everywhere. It's human-readable, universally parseable, and tooling support is excellent.
2.2 Required vs Optional Fields
Be explicit in your API documentation (and ideally your schema) about what is required vs optional.
On creation (POST):
{
"name": "Trilochan", ← required
"email": "t@example.com", ← required
"phone": "+91-9876543210", ← optional
"role": "admin" ← optional, defaults to "user"
}
If a required field is missing, return 400 Bad Request with a clear error (Chapter 6 covers error design).
On partial update (PATCH):
Only include the fields you want to change. The server applies only what's provided.
PATCH /users/42
{"email": "new@example.com"}
Only the email changes. Everything else stays the same. This is different from PUT.
On full replacement (PUT):
Include the entire resource representation. Missing fields get set to null/default.
PUT /users/42
{
"name": "Trilochan",
"email": "new@example.com",
"phone": null,
"role": "user"
}
2.3 Field Naming Convention: snake_case
Pick one and use it everywhere. The two options:
| Convention | Example | Used by |
|---|---|---|
snake_case | created_at, first_name | Stripe, Twilio, most REST APIs |
camelCase | createdAt, firstName | JavaScript-native APIs, GraphQL |
Use snake_case for REST APIs. Reasons:
- JSON has no typing standard —
snake_caseis the dominant REST convention - Matches most database column naming
- Avoids confusion when the same field appears in different languages (Python uses snake_case, JS uses camelCase — snake_case is the neutral ground)
Never mix conventions in the same API.
2.4 Accepting Unknown Fields
Be strict on what you send, liberal on what you accept. (Postel's Law)
If a client sends extra fields you don't recognize, ignore them. Don't reject the request. This allows clients to send fields for newer API versions without breaking older servers.
POST /users
{
"name": "Trilochan",
"email": "t@example.com",
"future_field": "some value" ← ignore this, don't reject
}
3. Response Body Design
3.1 The Envelope Debate
An envelope wraps your data in a top-level structure:
// With envelope
{
"data": {
"id": 42,
"name": "Trilochan"
},
"meta": {
"request_id": "req_8f2a3b"
}
}
// Without envelope
{
"id": 42,
"name": "Trilochan"
}
Arguments for envelope:
- Consistent place to add metadata (request ID, rate limit info, pagination)
- Clean separation between data and metadata
- Easier to add top-level fields later without breaking the data structure
- Consistent structure whether response is a single object or a list
Arguments against envelope:
- Extra nesting clients must unwrap every time
- HTTP headers are already designed for metadata — use them
- Makes responses larger for no data gain
The pragmatic answer: Use envelopes for collection responses (you need pagination metadata). For single-resource responses, put metadata in headers.
// Collection: envelope makes sense
GET /orders →
{
"data": [
{"id": 99, "status": "pending"},
{"id": 100, "status": "shipped"}
],
"pagination": {
"total": 450,
"page": 1,
"per_page": 20,
"next": "/orders?page=2"
}
}
// Single resource: no envelope needed
GET /orders/99 →
{
"id": 99,
"status": "pending",
"total": 2500,
"created_at": "2024-01-15T10:30:00Z"
}
Metadata for single resources goes in headers:
X-Request-ID: req_8f2a3b
X-RateLimit-Remaining: 97
3.2 What to Return After Each Method
This is where most APIs are inconsistent. Be explicit:
| Method | Success Status | Return Body? | What to Return |
|---|---|---|---|
| GET | 200 | Yes | The resource |
| POST | 201 | Yes | The created resource (with server-assigned fields: id, created_at) |
| PUT | 200 | Yes | The updated resource |
| PATCH | 200 | Yes | The updated resource |
| DELETE | 204 | No | Empty body |
Why return the full resource after POST/PUT/PATCH?
The server might set fields the client didn't send: id, created_at, updated_at, slug, computed fields. If you only return {"success": true}, the client must make a second GET request to see what was created. Return the full resource and save that round trip.
Why 204 (No Content) for DELETE?
The resource is gone. There's nothing to return. 204 says "success, no body" — more specific than 200 with empty body.
3.3 Handling Null and Missing Fields
Three strategies, pick one and be consistent:
Strategy 1: Always include all fields, null if empty (recommended)
{
"id": 42,
"name": "Trilochan",
"phone": null,
"bio": null
}
Clients always get the same fields. No "key not found" errors. The client knows phone exists as a concept but has no value.
Strategy 2: Omit null fields
{
"id": 42,
"name": "Trilochan"
}
Smaller responses. But clients must use ?. (optional chaining) everywhere. Can't distinguish "field doesn't exist" from "field is null".
Strategy 3: Null object pattern
{
"id": 42,
"name": "Trilochan",
"address": {
"street": null,
"city": null,
"country": null
}
}
Nested structure always present, fields inside may be null. Good for structured optional data.
Recommendation: Strategy 1 for simplicity. Strategy 3 for complex nested optionals. Never mix within the same API.
3.4 Date and Time Format: Always ISO 8601 UTC
✅ "created_at": "2024-01-15T10:30:00Z"
✅ "created_at": "2024-01-15T10:30:00.000Z" ← milliseconds ok
✅ "scheduled_at": "2024-01-15T10:30:00+05:30" ← timezone offset ok for input
❌ "created_at": "January 15, 2024"
❌ "created_at": "15/01/2024"
❌ "created_at": 1705312200 ← Unix timestamp (not human-readable, timezone-ambiguous)
❌ "created_at": "2024-01-15 10:30:00" ← no T, no Z
Always store and return UTC. If you need to display in local timezone, the client handles that. Your API has no concept of a client's timezone.
ISO 8601 format: YYYY-MM-DDTHH:MM:SS.mmmZ
Tseparates date from time (mandatory)Zmeans UTC (mandatory for stored timestamps)
3.5 Numbers: Integers vs Strings for IDs
This is a trap that has burned many teams.
The problem: JavaScript's Number type is a 64-bit float. Its max safe integer is 2^53 - 1 = 9,007,199,254,740,991. Twitter's user IDs and tweet IDs exceeded this. JavaScript clients silently corrupted IDs by rounding them.
Twitter's fix: Return IDs as both integer and string:
{
"id": 1234567890123456789,
"id_str": "1234567890123456789"
}
The modern fix: Return large IDs as strings.
{"id": "1234567890123456789"}
Rule: Use string for IDs if they might exceed 2^53. Use UUID strings always (no integer overflow). Use integers for small counts, amounts, indices.
Money: never use float.
❌ "amount": 19.99 ← floating point: might become 19.990000000000001
✅ "amount": 1999 ← store in smallest unit (paise, cents)
✅ "amount": "19.99" ← string if you need decimal display
Stripe stores all monetary amounts in the smallest currency unit (cents for USD, paise for INR).
3.6 Booleans Are Booleans
✅ "is_active": true
✅ "is_active": false
❌ "is_active": "true"
❌ "is_active": 1
❌ "is_active": "yes"
JSON has a native boolean type. Use it. String "true" and boolean true are different types. Many clients will parse them correctly but some won't — and you'll never know until production.
4. Designing for Partial Responses
Some resources are large. A user profile might have 50 fields. A client displaying a user list only needs id, name, and avatar_url.
Option 1: Field selection via query param
GET /users?fields=id,name,avatar_url
Response only contains those fields. Saves bandwidth. Reduces parsing time. Used by Google APIs.
Option 2: Sparse fieldsets (JSON:API standard)
GET /users?fields[users]=id,name&fields[orders]=id,total
More complex but handles nested resource selection.
Option 3: Just return everything
Valid for most APIs. Only add field selection when bandwidth is measurably a problem (mobile APIs, high-volume endpoints).
5. Response Headers as Metadata
Metadata that is not part of the resource belongs in headers, not the body:
X-Request-ID: req_8f2a3b4c ← trace this request through your logs
X-RateLimit-Limit: 1000
X-RateLimit-Remaining: 847
X-RateLimit-Reset: 1705312800 ← Unix timestamp when limit resets
X-Response-Time: 23ms ← server processing time
Deprecation: true ← this endpoint is being phased out
Sunset: Sat, 31 Dec 2024 23:59:59 GMT ← when this endpoint dies
X-Request-ID is non-negotiable for production APIs. Generate a unique ID per request on the server. Return it in the response. Log it. When a client reports a bug, they give you the request ID and you find the exact log entry in seconds.
6. Compression
Large response bodies can be compressed:
Client sends: Accept-Encoding: gzip, br
Server sends: Content-Encoding: gzip
[gzip-compressed body]
Gzip typically reduces JSON by 60–80%. A 100KB response becomes 20KB. For APIs with large responses (search results, reports, export endpoints), always enable compression.
Most frameworks enable gzip by default. Brotli (br) compresses better than gzip but is slightly slower to compress (fine for pre-compressible static responses, not for dynamic JSON).
7. How Real Systems Design Responses
Stripe — consistent envelope with typed objects:
{
"id": "ch_3OaB1234",
"object": "charge", ← always tells you what type this is
"amount": 2500,
"currency": "inr",
"status": "succeeded",
"created": 1705312200, ← Stripe uses Unix timestamps (debatable choice)
"livemode": true,
"metadata": {} ← always present, empty object if unused
}
Note metadata is always an empty object {}, never null. Clients never need to null-check it.
GitHub — headers for metadata, no envelope for single resources:
HTTP/2 200
Content-Type: application/json; charset=utf-8
ETag: "abc123def456"
X-RateLimit-Limit: 60
X-RateLimit-Remaining: 59
X-GitHub-Request-Id: 1234:5678
{
"id": 42,
"login": "trilochan",
"name": "Trilochan B",
"email": null,
"created_at": "2024-01-15T10:30:00Z"
}
All nulls included, ISO 8601 dates, no wrapping envelope.
Twitter v2 API — envelope for everything:
{
"data": {
"id": "1234567890123456789",
"text": "Hello world"
},
"includes": {
"users": [{"id": "987", "name": "Trilochan"}]
}
}
Even single resources use data. The includes key holds related resources (avoids N+1 fetches). Novel pattern — good for complex nested data.
8. Vocabulary Map
| Term | Definition |
|---|---|
| Envelope | A top-level wrapper object in a response ({"data": ..., "meta": ...}). |
| snake_case | Naming convention: words separated by underscores (created_at). Recommended for REST. |
| camelCase | Naming convention: words joined with uppercase first letter (createdAt). Common in JS. |
| ISO 8601 | International standard for date/time: YYYY-MM-DDTHH:MM:SSZ. Use this always. |
| 204 No Content | HTTP status for successful requests with no response body. Standard for DELETE. |
| 201 Created | HTTP status for successful resource creation. Should include Location header with new resource URL. |
| Partial response | Returning only requested fields via ?fields= query param. Reduces bandwidth. |
| Idempotency key | Client-generated unique ID to safely retry non-idempotent requests. |
| X-Request-ID | Custom header carrying a unique ID per request for distributed tracing and debugging. |
| PATCH | HTTP method for partial updates — only the fields you send are changed. |
| PUT | HTTP method for full replacement — all fields must be included; missing fields are cleared. |
| Postel's Law | "Be conservative in what you send, liberal in what you accept." |
| Null object pattern | Return a structured object with null fields rather than returning null for the whole structure. |
| Content-Encoding | Response header indicating body compression format (gzip, br). |
| Sunset header | Response header declaring when an endpoint will be removed. Standard for deprecation. |
9. Practice Problems
Problem 1 — Failure Analysis:
Your API returns monetary amounts as floats: "price": 19.99. A JavaScript client stores this and later sends it back in a payment request. The payment processor charges 19.990000000000001. The customer is overcharged by a fraction of a paisa/cent. Explain the root cause. How do you fix both the API response and the client's storage?
Problem 2 — Design From Scratch:
Design the complete request and response JSON for: (a) POST /orders — creating an order with line items, (b) GET /orders/99 — fetching a single order, (c) GET /orders — listing orders with pagination. Include all fields, use correct types, decide on envelope vs no-envelope, and include relevant response headers.
Problem 3 — Synthesis:
Your API has 3 million mobile clients on v1. You want to rename firstName and lastName to first_name and last_name, and change date format from Unix timestamp to ISO 8601. If you make these changes in-place, what breaks? Design a migration plan that doesn't break existing clients. What HTTP mechanism helps you communicate the deprecation timeline?
Problem 4 — Go Deeper:
A client calls PATCH /users/42 with {"phone": null}. Should this set the phone to null (clear it) or be ignored (treat as "not provided")? This is a real API design dilemma. Describe both behaviors, explain the trade-off, and explain how JSON Merge Patch (RFC 7396) solves it. What HTTP Content-Type value signals JSON Merge Patch?
Problem 5 — Connect to Previous:
From Chapter 1: GET is cacheable by default. From Chapter 3: GET /orders?fields=id,name returns partial data. From this chapter: GET /orders returns the full resource. If a client caches GET /orders and then another client requests GET /orders?fields=id,name, a naive cache might serve the full response for both. Describe what an ETag is and how the Vary response header solves this cache collision problem.
What's Coming Next
Your resources are shaped. Your bodies are clean. Now you need to answer: who is allowed to call this API?
Chapter 5 covers authentication and authorization — the difference between "who are you?" and "what can you do?". We'll go from API keys (simple) to JWT (stateless tokens) to OAuth 2.0 (delegated access) to the exact flows that Stripe, GitHub, and Google use. Auth is where most security bugs live. Build it right from the start.