11 min read

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:

javascript
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.user always exists
  • name is always a string
  • created_at is always an ISO 8601 string
  • active is 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

code
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):

json
{
  "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.

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

json
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:

ConventionExampleUsed by
snake_casecreated_at, first_nameStripe, Twilio, most REST APIs
camelCasecreatedAt, firstNameJavaScript-native APIs, GraphQL

Use snake_case for REST APIs. Reasons:

  • JSON has no typing standard — snake_case is 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.

json
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:

json
// 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.

json
// 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:

code
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:

MethodSuccess StatusReturn Body?What to Return
GET200YesThe resource
POST201YesThe created resource (with server-assigned fields: id, created_at)
PUT200YesThe updated resource
PATCH200YesThe updated resource
DELETE204NoEmpty 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)

json
{
  "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

json
{
  "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

json
{
  "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

json
✅ "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

  • T separates date from time (mandatory)
  • Z means 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:

json
{
  "id": 1234567890123456789,
  "id_str": "1234567890123456789"
}

The modern fix: Return large IDs as strings.

json
{"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.

json
❌ "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

json
✅ "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

code
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)

code
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:

code
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:

code
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:

json
{
  "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:

code
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
json
{
  "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:

json
{
  "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

TermDefinition
EnvelopeA top-level wrapper object in a response ({"data": ..., "meta": ...}).
snake_caseNaming convention: words separated by underscores (created_at). Recommended for REST.
camelCaseNaming convention: words joined with uppercase first letter (createdAt). Common in JS.
ISO 8601International standard for date/time: YYYY-MM-DDTHH:MM:SSZ. Use this always.
204 No ContentHTTP status for successful requests with no response body. Standard for DELETE.
201 CreatedHTTP status for successful resource creation. Should include Location header with new resource URL.
Partial responseReturning only requested fields via ?fields= query param. Reduces bandwidth.
Idempotency keyClient-generated unique ID to safely retry non-idempotent requests.
X-Request-IDCustom header carrying a unique ID per request for distributed tracing and debugging.
PATCHHTTP method for partial updates — only the fields you send are changed.
PUTHTTP 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 patternReturn a structured object with null fields rather than returning null for the whole structure.
Content-EncodingResponse header indicating body compression format (gzip, br).
Sunset headerResponse 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.