JSON APIs, Validation, and Error Contracts

LESSON

Backend Development Foundations

009 25 min beginner

JSON APIs, Validation, and Error Contracts

By the end of this lesson, you will be able to...

  • Separate JSON parsing, schema validation, and business validation in one endpoint.

  • Design an error response that tells a client whether to fix input, refresh state, sign in, retry, or stop.

  • Review a JSON API contract for unsafe ambiguity, weak error codes, and compatibility risk.

Idea in one sentence: A JSON API is a boundary contract: it must say what shape it accepts, what rules it enforces, and what each failure means for the caller's next action.

Core Insight

The previous lesson traced how a web framework carries a request through routing, middleware, validation, handler code, and response serialization.

Now zoom into one route:

POST /orders

A mobile checkout client sends a JSON body. Most users place orders successfully. Some users see a spinner, then the app retries. The backend logs show several different causes:

body was not valid JSON
items[0].quantity was "two"
shipping_address_id was missing
product prod_9 was inactive
payment provider timed out

To the user, these may all feel like:

Checkout failed.

To the client application, they are different situations.

A malformed body is a client bug. A missing field can be fixed before retry. An inactive product means the cart should refresh. A payment timeout may be retryable, but only if the backend made retry safe.

The naive API design is:

{"error": "bad request"}

That hides the decision the client needs to make.

A better API contract answers:

What shape do you accept?
Which rules did this request break?
Is the same request worth retrying?
What can the client safely show?
What evidence can support use to find the server-side event?

JSON is only the encoding. The contract is the agreement around shape, rules, status codes, error bodies, compatibility, and retry meaning.

The Small Situation

Use one checkout request:

POST /orders
Content-Type: application/json
Authorization: Bearer ...
{
  "customer_id": "cus_42",
  "shipping_address_id": "addr_12",
  "items": [
    {"product_id": "prod_7", "quantity": 2},
    {"product_id": "prod_9", "quantity": 1}
  ],
  "idempotency_key": "checkout-abc123"
}

This endpoint contract includes more than the JSON object:

Plain meaning:

A schema is a declared shape for data.

In this scenario:

The schema says items must be a non-empty list, and each item must include product_id and integer quantity.

Technical name:

This declared body shape is a schema. It may live in OpenAPI, JSON Schema, framework declarations, typed code, validation libraries, or a mix of those.

The Naive Design

The easiest design is to treat every failure as a generic bad request:

{
  "error": "bad request"
}

That looks simple for the backend. It is hard for the client.

The client cannot tell whether to:

The backend also loses operational clarity. If every failure looks the same in metrics, the team cannot easily see whether a mobile release is sending malformed JSON, whether a catalog update made products inactive, or whether a payment dependency is timing out.

The goal is not to create a fancy error format. The goal is to make the failure category visible enough for software on both sides of the boundary.

A Worked Validation Trace

Trace the checkout request through layers.

Input:
  raw HTTP body bytes

Step 1: JSON parsing
  bytes -> decoded JSON value
  failure here means the server cannot inspect fields safely

Intermediate state:
  either decoded JSON exists
  or the request stops with a parse error

Step 2: schema validation
  decoded JSON -> order command shape
  customer_id is required string
  shipping_address_id is required string
  items is non-empty array
  quantity is integer >= 1
  idempotency_key is required string

Intermediate state:
  either a schema-valid command exists
  or the request stops with field errors

Step 3: business validation
  command -> allowed order attempt?
  customer matches authenticated caller
  address belongs to customer
  products exist and are active
  quantities are available

Intermediate state:
  either the operation is allowed
  or the request stops with a business error

Step 4: handler work
  create order inside the needed transaction boundary
  use idempotency key to make retry safer

Output:
  success response or stable error response

Now compare failure examples:

body = {"customer_id":
  -> parse failure
  -> client must fix request generation

quantity = "two"
  -> schema failure
  -> client can highlight items[0].quantity

product prod_9 is inactive
  -> business failure
  -> client should refresh cart/catalog state

payment provider timeout
  -> dependency or processing failure
  -> retry depends on idempotency and committed state

So far, validation is a funnel. Bytes become JSON. JSON becomes a command. A command becomes an allowed operation only after business state is checked.

Check: The body is valid JSON, but items[0].quantity is "two" instead of 2. Which layer failed?

Think first, then reveal.

Answer: Schema validation. The server parsed JSON successfully, but the decoded object did not match the endpoint's expected shape.

Designing An Error Envelope

An error contract should be stable enough for clients to code against.

A common design is an error envelope: one predictable top-level shape for failures.

For a schema validation failure:

{
  "error": {
    "code": "validation_failed",
    "message": "The request body has invalid fields.",
    "request_id": "req_481",
    "fields": [
      {
        "path": "items[0].quantity",
        "code": "must_be_integer",
        "message": "Quantity must be an integer greater than or equal to 1."
      }
    ]
  }
}

For a business rule failure:

{
  "error": {
    "code": "product_unavailable",
    "message": "One or more products are no longer available.",
    "request_id": "req_482",
    "details": {
      "product_id": "prod_9"
    }
  }
}

The exact field names are project choices. The design goals are stable:

Do not expose stack traces, SQL fragments, internal class names, raw tokens, secret values, or payment provider internals. Operators need rich evidence in logs and traces. Clients need a safe contract.

Check: If the client receives code: validation_failed for items[0].quantity, should it automatically retry the same request?

Think first, then reveal.

Answer: No. The same invalid body will fail again. The client should correct the field, show feedback, or stop until the user changes input.

Status Codes Should Match Client Decisions

HTTP status codes are part of the contract.

Useful beginner categories:

The client decision matters more than memorizing every code:

400/422 -> fix request before trying again
401     -> refresh login or ask user to sign in
403     -> stop or show permission problem
404     -> stop, hide, or refresh visible resource state
409     -> reload state or handle conflict path
429     -> wait according to rate-limit guidance
500/503 -> retry only if operation and policy make retry safe

One useful review habit is to write a client decision table:

Failure code             Client action                 Server evidence
validation_failed        highlight fields              request_id, field paths
product_unavailable      refresh cart/catalog          product_id, catalog version
authentication_required  sign in or refresh token      request_id, auth failure type
conflict                 reload state or resolve       idempotency key, current state
temporary_unavailable    retry only if safe policy     request_id, dependency signal

The table keeps the error contract honest. If two different failures require different client actions, they should not share one vague error string. If a failure needs operator investigation, the response should carry a safe handle such as request_id, while logs carry the private detail.

Retry is the sharp edge.

Retried GET /orders/1001 is usually safe.

Retried POST /orders can create duplicate orders unless the backend designed for it. That is why the request includes:

"idempotency_key": "checkout-abc123"

The key lets the server recognize repeated attempts for the same intended checkout. The server can return the original result, continue a known attempt, or reject a conflicting reuse of the key.

An error contract should therefore say more than "failed." It should help the client know whether the same request is invalid, whether refreshed state is needed, whether identity must change, or whether a careful retry is reasonable.

Compatibility Pressure

APIs outlive one deployment.

Mobile clients may remain installed for months. Browser code may be cached. Partner integrations may upgrade slowly. A backend change that feels small can break clients if the contract has no evolution plan.

Good compatibility habits:

Unknown fields are a real trade-off.

Rejecting unknown fields catches typos:

{"quanity": 2}

Ignoring unknown fields may help forward compatibility for low-risk endpoints.

For checkout, strict rejection is often safer because a misspelled field can change money movement. For a low-risk search endpoint, ignoring an unknown optional filter might be acceptable if documented.

The trade-off is strictness versus compatibility. Strict contracts catch mistakes early and protect state. Flexible contracts can help old and new clients coexist. The important part is to choose deliberately by endpoint risk, not by accident.

Common Confusions

Confusion: JSON Is The API Contract

Why it is tempting:

The request and response bodies are visible, so it feels like the contract is only the JSON shape.

Better model:

JSON is the encoding. The contract also includes method, path, headers, authentication expectations, validation rules, status codes, error bodies, retry meaning, and compatibility policy.

Confusion: Validation Is One Step

Why it is tempting:

Frameworks often expose one validator function or one schema declaration.

Better model:

Parsing, schema validation, and business validation fail for different reasons. They should give the client different guidance.

Confusion: Human Messages Are Enough

Why it is tempting:

A readable message helps during manual testing.

Better model:

Clients need stable machine-readable codes. Human messages can improve over time, but code paths should not depend on changing prose.

Practice

Classify these failures for POST /orders:

A. Body is not valid JSON.
B. items[0].quantity is "two".
C. product_id "prod_9" is inactive.
D. Payment provider times out after the order request starts.

A good answer should mention:

Model answer:

A is a parse failure. Return a safe bad-request error and do not retry the same bytes. Operators need request ID and route, but not raw secrets.

B is schema validation. Return a field-level error for items[0].quantity. The client should fix the value before retrying.

C is business validation. Return a stable code such as product_unavailable and tell the client enough to refresh the cart or catalog state.

D is a dependency or processing failure. Retry depends on idempotency and what state was committed. Operators need request ID, idempotency key, payment attempt evidence, and order state.

Connections

The previous lesson showed where validation and error handling sit in a web framework pipeline. This lesson designs the contract those stages enforce.

The next lesson separates authentication from authorization and session state, which explains the 401 and 403 parts of the error contract more deeply.

Resources

Key Takeaways

PREVIOUS Web Frameworks and the Request Handler Pipeline NEXT Authentication, Authorization, and Session State