Return JSON from an API
Why JSON Design Matters
The web chatbot's browser page talks to the server through a JSON API (Lesson 66). Other clients may use the same API: a mobile app, another backend service, or automated tests. A consistent, predictable JSON design makes every client simpler:
| Without a design | With a design |
|---|---|
| Each endpoint returns a different shape | All responses follow the same conventions |
| Errors sometimes come as text, sometimes as JSON | Every error has the same structure |
| Timestamps as raw numbers in some places, strings in others | One timestamp format everywhere |
| Clients guess which fields exist | Response models document and guarantee the fields |
| SDK objects or internal data leak out | Only deliberate, public fields are returned |
This lesson defines JSON conventions for the chatbot API, enforces them with response models and exception handlers in FastAPI (with Flask equivalents), adds pagination, and shows how to return Claude-generated structured data safely.
JSON Conventions
| Convention | Choice | Reason |
|---|---|---|
| Top-level shape | Always an object, such as {"sessions": [...]}, never a bare list | New fields can be added later without breaking clients |
| Key naming | snake_case | Consistent with Python and with the Claude API |
| Timestamps | ISO 8601 strings in UTC, e.g. "2026-11-06T10:15:02Z" | Unambiguous and readable in every language |
| Errors | {"error": {"code": "...", "message": "..."}} | One shape for every failure |
Error code | Stable, machine-readable, snake_case | Clients branch on it |
Error message | Friendly, safe to show users | No internal details |
| Missing values | Omit optional fields or use null consistently | Clients know what to expect |
HTTP Status Codes
| Status | Meaning in this API | Example |
|---|---|---|
200 OK | Successful read or action | Listing sessions |
201 Created | A resource was created | Creating a session |
204 No Content | Success with no body | Deleting a session |
400 Bad Request | The request is invalid for a reason the server checked | An empty message |
404 Not Found | The resource does not exist, or is not the user's | Another user's session |
422 Unprocessable Entity | The body failed schema validation | A message longer than allowed |
429 Too Many Requests | Rate limit reached | Too many messages per hour (Lesson 70) |
503 Service Unavailable | A dependency, such as the Claude API, is temporarily unavailable | Overloaded or unreachable API (Lesson 70) |
Response Models in FastAPI
A response model declares the exact shape of a successful response. FastAPI uses it to validate and filter the data you return, and to document it at /docs:
from datetime import datetime
from pydantic import BaseModel
class SessionOut(BaseModel):
id: str
title: str
updated_at: datetime
class SessionList(BaseModel):
sessions: list[SessionOut]
@app.get("/api/sessions", response_model=SessionList)
def get_sessions(request: Request):
...
What the response model gives you:
- Filtering: fields not in the model are not returned, so internal data cannot leak by accident.
- Validation: if your code returns data of the wrong shape, FastAPI raises an error on the server instead of sending a broken response.
- Serialization:
datetimevalues become ISO 8601 strings automatically. - Documentation: clients see the exact response shape in
/docs.
One Error Format with Exception Handlers
Instead of catching the same exceptions in every endpoint, register exception handlers once. FastAPI calls them whenever a matching exception escapes an endpoint:
from fastapi.exceptions import RequestValidationError
@app.exception_handler(SessionNotFound)
async def handle_session_not_found(request: Request, exc: SessionNotFound):
return error("session_not_found", "That conversation was not found.", 404)
@app.exception_handler(RequestValidationError)
async def handle_validation_error(request: Request, exc: RequestValidationError):
problems = exc.errors()
first = problems[0] if problems else {}
field = ".".join(str(part) for part in first.get("loc", [])[1:]) or "request"
return error("invalid_request", f"Invalid {field}: {first.get('msg', 'invalid value')}.", 422)
SessionNotFoundhandling is now central, so endpoints no longer need their owntry/except.RequestValidationErroris raised by FastAPI when a request body or parameter fails validation. By default, FastAPI responds with{"detail": [...]}. The handler converts this to the API's error format, naming the first invalid field.locis a path such as("body", "message"), and[1:]drops the leading"body"or"query"part.
In Flask, the equivalent is @app.errorhandler:
@app.errorhandler(SessionNotFound)
def handle_session_not_found(exc):
return error("session_not_found", "That conversation was not found.", 404)
Pagination
A long conversation can contain hundreds of messages. Returning all of them at once is slow, and a page usually shows only the most recent ones. Pagination returns results in pages:
GET /api/sessions/{id}/messages?limit=50&offset=0
{
"messages": [{"role": "user", "text": "..."}, {"role": "assistant", "text": "..."}],
"total": 132,
"next_offset": 50
}
| Field | Meaning |
|---|---|
limit | Maximum number of items per page (validated, with an upper bound) |
offset | How many items to skip |
total | Total number of items |
next_offset | The offset for the next page, or null when there are no more pages |
Returning Claude-Generated Data as JSON
Some endpoints return data that Claude generates, such as a summary or extracted fields. Use structured outputs (Lesson 36) to make Claude's output match a Pydantic model, then return that model through the endpoint's response model. The JSON your API returns is then validated twice: by the structured-output schema when Claude generates it, and by FastAPI's response model when you return it.
Never return SDK objects such as Message directly from your API. Convert them into your own response shapes (Lesson 50).
Practical Example: JSON Improvements for the FastAPI Chatbot
These changes update fastapi_app.py from Lesson 68. They replace the existing get_sessions and get_messages endpoints and add new handlers and a summary endpoint.
"""Additions and replacements for fastapi_app.py (Lesson 68)."""
from datetime import datetime, timezone
import anthropic
from fastapi import Query, Request
from fastapi.exceptions import RequestValidationError
from pydantic import BaseModel
from chat_service import client, list_sessions, session_messages
from sessions import SessionNotFound
# ---------- Response models ----------
class SessionOut(BaseModel):
id: str
title: str
updated_at: datetime
class SessionList(BaseModel):
sessions: list[SessionOut]
class MessageOut(BaseModel):
role: str
text: str
class MessagePage(BaseModel):
messages: list[MessageOut]
total: int
next_offset: int | None
class SessionSummary(BaseModel):
summary: str
topics: list[str]
# ---------- Exception handlers ----------
@app.exception_handler(SessionNotFound)
async def handle_session_not_found(request: Request, exc: SessionNotFound):
return error("session_not_found", "That conversation was not found.", 404)
@app.exception_handler(RequestValidationError)
async def handle_validation_error(request: Request, exc: RequestValidationError):
problems = exc.errors()
first = problems[0] if problems else {}
field = ".".join(str(part) for part in first.get("loc", [])[1:]) or "request"
return error("invalid_request", f"Invalid {field}: {first.get('msg', 'invalid value')}.", 422)
# ---------- Endpoints ----------
@app.get("/api/sessions", response_model=SessionList)
def get_sessions(request: Request):
rows = list_sessions(current_user(request))
return {"sessions": [
{**row, "updated_at": datetime.fromtimestamp(row["updated_at"], tz=timezone.utc)}
for row in rows
]}
@app.get("/api/sessions/{session_id}/messages", response_model=MessagePage)
def get_messages(session_id: str, request: Request,
limit: int = Query(50, ge=1, le=200), offset: int = Query(0, ge=0)):
messages = session_messages(current_user(request), session_id) # SessionNotFound → handler
page = messages[offset:offset + limit]
next_offset = offset + limit if offset + limit < len(messages) else None
return {"messages": page, "total": len(messages), "next_offset": next_offset}
@app.get("/api/sessions/{session_id}/summary", response_model=SessionSummary)
def get_summary(session_id: str, request: Request):
messages = session_messages(current_user(request), session_id)
if not messages:
return error("empty_session", "This conversation has no messages yet.", 400)
transcript = "\n".join(f"{m['role']}: {m['text']}" for m in messages[-40:])
try:
result = client.messages.parse(
model="claude-haiku-5-5",
max_tokens=1024,
output_format=SessionSummary,
messages=[{"role": "user", "content":
f"<transcript>\n{transcript}\n</transcript>\n\n"
"Summarize this conversation in at most 2 sentences, and list up to 5 topics."}],
)
except anthropic.APIError:
return error("summary_unavailable", "A summary is not available right now. Please try again.", 503)
if result.stop_reason != "end_turn" or result.parsed_output is None:
return error("summary_unavailable", "A summary is not available right now. Please try again.", 503)
return result.parsed_output
Example responses:
GET /api/sessions
{"sessions": [{"id": "3f9c2a7e41d84b6f9c1e0a5b7d2f8c61", "title": "Django Empty Form Error", "updated_at": "2026-11-06T10:15:02Z"}]}
GET /api/sessions/3f9c…/messages?limit=2
{"messages": [{"role": "user", "text": "My Django view crashes..."}, {"role": "assistant", "text": "That usually means..."}], "total": 6, "next_offset": 2}
GET /api/sessions/3f9c…/summary
{"summary": "The student debugged a Django view that crashed on empty form input, and learned to use form validation.", "topics": ["Django views", "form validation", "error handling"]}
POST /api/sessions/3f9c…/chat with {"message": ""}
{"error": {"code": "invalid_request", "message": "Invalid message: String should have at least 1 character."}}
How It Works
- Response models define every successful response.
SessionOut.updated_atis adatetime, so FastAPI serializes it as an ISO 8601 string. The stored value is a Unix timestamp, whichdatetime.fromtimestamp(..., tz=timezone.utc)converts. {**row, "updated_at": ...}copies each session dictionary and replaces one field, without modifying the original.- Exception handlers produce every error in the
{"error": {...}}format. Endpoints raise exceptions or returnerror(...), and never build ad-hoc error shapes. - Returning a
JSONResponsefrom an endpoint that has aresponse_modelbypasses the model. That is howget_summary()returns errors with their own shape and status code. - Pagination parameters use
Query(..., ge=, le=), so invalid values such aslimit=0orlimit=5000are rejected with the API's error format, through the validation handler. next_offsetisNone(JSONnull) on the last page, which tells clients to stop.- The summary endpoint sends a transcript of the latest 40 messages to a fast model with
messages.parse()and theSessionSummarymodel, then returnsparsed_output. That is a Pydantic model instance, which FastAPI serializes through the sameresponse_model. Failures and incomplete responses become a503with a friendly message.
Common Mistakes
Returning Bare Lists
What happens: adding metadata later, such as total or next_offset, breaks existing clients.
Fix: always return an object at the top level.
Inconsistent Error Formats
What happens: clients need special handling for each endpoint, and some errors show "Request failed." instead of a helpful message.
Fix: one error shape, produced centrally by exception handlers.
Exposing Internal Details
What happens: stack traces, SDK exception text, or database errors reach users and reveal implementation details.
Fix: return friendly messages and stable codes; keep details in server logs (Lesson 70).
Returning SDK Objects or Unfiltered Data
What happens: responses contain internal fields, and their shape changes when the SDK changes.
Fix: map data to response models.
Unbounded Lists
What happens: very long conversations produce huge, slow responses.
Fix: paginate, with a validated upper limit.
Best Practices
- Write down your JSON conventions, and follow them in every endpoint.
- Use response models to validate, filter, and document responses.
- Centralize error formatting with exception handlers.
- Use correct HTTP status codes, with stable error codes in the body.
- Use ISO 8601 UTC timestamps.
- Paginate collections, with validated limits.
- Generate JSON with structured outputs when Claude produces the data, and validate it again with response models.
Exercise
- Change the page from Lesson 66 to load only the most recent 50 messages, using
totalto calculate the rightoffset. - Add a
created_atfield toSessionOut. Which parts of the code must change? - Write a Flask version of
get_messageswith the same pagination and error format. - Use
TestClientto verify thatlimit=0returns status422with the error codeinvalid_request.
Step 3:
@app.get("/api/sessions/<session_id>/messages")
def get_messages(session_id: str):
try:
limit = int(request.args.get("limit", 50))
offset = int(request.args.get("offset", 0))
except ValueError:
return error("invalid_request", "limit and offset must be integers.", 422)
if not 1 <= limit <= 200 or offset < 0:
return error("invalid_request", "limit must be 1-200 and offset must be 0 or more.", 422)
messages = session_messages(current_user(), session_id) # SessionNotFound → errorhandler
page = messages[offset:offset + limit]
next_offset = offset + limit if offset + limit < len(messages) else None
return jsonify({"messages": page, "total": len(messages), "next_offset": next_offset})
Flask has no automatic query validation, so the checks are written by hand, producing the same error format.
Step 4:
from fastapi.testclient import TestClient
from fastapi_app import app
with TestClient(app) as client:
session_id = client.post("/api/sessions").json()["id"]
response = client.get(f"/api/sessions/{session_id}/messages", params={"limit": 0})
assert response.status_code == 422
assert response.json()["error"]["code"] == "invalid_request"