JSON APIs, Validation, and Error Contracts
LESSON
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:
- Method and path:
POST /orderscreates an order attempt. - Headers:
Content-Typemust describe JSON, and authentication evidence must be present. - Body shape: required fields, optional fields, field types, and nested item shape.
- Field rules:
quantitymust be an integer greater than zero. - Business rules: products must exist and be active, and the address must belong to the customer.
- Success response: what the client receives when an order is created.
- Error response: what the client receives when the request cannot complete.
- Retry meaning: whether repeating the same request is safe.
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:
- highlight a field,
- ask the user to sign in,
- refresh the cart,
- retry after a delay,
- stop because the operation is not allowed,
- contact support with useful evidence.
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:
- A machine-readable
codelets clients make decisions. - A safe
messagecan be shown or logged without leaking internals. - A
request_idconnects client reports to server logs. fieldsordetailspoint to safe, useful specifics.
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:
200or201: the request succeeded.400: the request could not be parsed or accepted as a valid client request.401: authentication is missing or invalid.403: the caller is known but not allowed.404: the resource is not visible or does not exist under this API's rules.409: the request conflicts with current state.422: the body is well-formed but semantically invalid, if that is your project convention.429: the client is rate limited.500: unexpected server failure.503: service temporarily unavailable.
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:
- Add optional response fields without requiring old clients to understand them.
- Avoid renaming or removing fields without a migration plan.
- Keep error
codevalues stable even when human messages improve. - Treat unknown request fields deliberately.
- Add new required fields through a versioned endpoint, negotiated feature, or compatibility window.
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:
- The validation layer or failure category.
- The kind of error response the client needs.
- Whether retrying the same request is reasonable.
- One piece of evidence operators need.
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
- [DOC] JSON Schema: Getting Started Step-By-Step
- Link: https://json-schema.org/learn/getting-started-step-by-step
- Focus: Connect JSON request shape to explicit validation rules.
- [DOC] OpenAPI Specification
- Link: https://spec.openapis.org/oas/latest.html
- Focus: See how paths, operations, request bodies, responses, and schemas form an API contract.
- [DOC] MDN: HTTP Response Status Codes
- Link: https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Status
- Focus: Use this as a status-code reference when mapping errors to client decisions.
- [DOC] Stripe API: Idempotent Requests
- Link: https://docs.stripe.com/api/idempotent_requests
- Focus: Study a production API example of making retries safer for create operations.
Key Takeaways
- JSON is an encoding; the API contract also includes method, path, headers, schema, validation rules, status codes, error bodies, and retry meaning.
- Parsing, schema validation, and business validation fail for different reasons and should give clients different guidance.
- Error envelopes should include stable codes, safe messages, useful field details, and a request ID that connects client reports to server evidence.
- Strictness protects correctness, but compatibility choices must be deliberate when clients may lag behind the server.
← Back to Backend Development Foundations