Return JSON from an API

Su Suriya Ravichandran Updated 10 Nov 2026
9 min read ·Lesson 69 of 200

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 designWith a design
Each endpoint returns a different shapeAll responses follow the same conventions
Errors sometimes come as text, sometimes as JSONEvery error has the same structure
Timestamps as raw numbers in some places, strings in othersOne timestamp format everywhere
Clients guess which fields existResponse models document and guarantee the fields
SDK objects or internal data leak outOnly 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

ConventionChoiceReason
Top-level shapeAlways an object, such as {"sessions": [...]}, never a bare listNew fields can be added later without breaking clients
Key namingsnake_caseConsistent with Python and with the Claude API
TimestampsISO 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 codeStable, machine-readable, snake_caseClients branch on it
Error messageFriendly, safe to show usersNo internal details
Missing valuesOmit optional fields or use null consistentlyClients know what to expect

HTTP Status Codes

StatusMeaning in this APIExample
200 OKSuccessful read or actionListing sessions
201 CreatedA resource was createdCreating a session
204 No ContentSuccess with no bodyDeleting a session
400 Bad RequestThe request is invalid for a reason the server checkedAn empty message
404 Not FoundThe resource does not exist, or is not the user'sAnother user's session
422 Unprocessable EntityThe body failed schema validationA message longer than allowed
429 Too Many RequestsRate limit reachedToo many messages per hour (Lesson 70)
503 Service UnavailableA dependency, such as the Claude API, is temporarily unavailableOverloaded 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: datetime values 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)
  • SessionNotFound handling is now central, so endpoints no longer need their own try/except.
  • RequestValidationError is 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. loc is 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
}
FieldMeaning
limitMaximum number of items per page (validated, with an upper bound)
offsetHow many items to skip
totalTotal number of items
next_offsetThe 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

  1. Response models define every successful response. SessionOut.updated_at is a datetime, so FastAPI serializes it as an ISO 8601 string. The stored value is a Unix timestamp, which datetime.fromtimestamp(..., tz=timezone.utc) converts.
  2. {**row, "updated_at": ...} copies each session dictionary and replaces one field, without modifying the original.
  3. Exception handlers produce every error in the {"error": {...}} format. Endpoints raise exceptions or return error(...), and never build ad-hoc error shapes.
  4. Returning a JSONResponse from an endpoint that has a response_model bypasses the model. That is how get_summary() returns errors with their own shape and status code.
  5. Pagination parameters use Query(..., ge=, le=), so invalid values such as limit=0 or limit=5000 are rejected with the API's error format, through the validation handler.
  6. next_offset is None (JSON null) on the last page, which tells clients to stop.
  7. The summary endpoint sends a transcript of the latest 40 messages to a fast model with messages.parse() and the SessionSummary model, then returns parsed_output. That is a Pydantic model instance, which FastAPI serializes through the same response_model. Failures and incomplete responses become a 503 with 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

  1. Change the page from Lesson 66 to load only the most recent 50 messages, using total to calculate the right offset.
  2. Add a created_at field to SessionOut. Which parts of the code must change?
  3. Write a Flask version of get_messages with the same pagination and error format.
  4. Use TestClient to verify that limit=0 returns status 422 with the error code invalid_request.
Show answers for steps 3 and 4

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"

0 Comments

Reviewed before they appear

No comments yet.

Anthropic SDK Using Python
Anthropic SDK Intro Claude API Intro SDK Requirements Install Anthropic SDK Upgrade the SDK Create an API Key Set API Keys Use Environment Variables Import Anthropic Create a Client Send Your First Message Choose a Claude Model Set Max Tokens Read a Response Print Response Text Understand Message Blocks Use System Prompts Send User Messages Set Temperature Handle Stop Reasons Messages API Intro Create Messages Use Message Roles Send Conversation History Continue a Conversation Build a Chat Loop Reset Chat History Use Multiple Turns Inspect Message IDs Read Usage Data Write Clear Prompts Set a System Prompt Add Context to Prompts Use Prompt Templates Add Examples to Prompts Request JSON Output Use XML Tags Control Response Length Improve Prompt Quality Avoid Prompt Injection Read Text Blocks Read Content Blocks Check Stop Reasons Count Input Tokens Count Output Tokens Count Tokens Before Sending Limit Output Tokens Use Stop Sequences Read Request IDs Parse API Responses Streaming Intro Stream Text Output Use Stream Events Use Stream Helpers Get Final Messages Stream Async Responses Handle Stream Errors Build a Live Chat Stream Long Responses Close Streams Safely Build a Chatbot Keep Chat History Manage Chat Sessions Add Chat Memory Build a CLI Chatbot Build a Web Chatbot Use Flask with Claude Use FastAPI with Claude Return JSON from an API Add Chat Error Messages Async SDK Intro Create an Async Client Send Async Messages Use asyncio Run Parallel Requests Stream Async Text Handle Async Errors Set Async Timeouts Close Async Clients Choose Sync or Async Tool Use Intro Define a Tool Create Tool Schemas Send Tools to Claude Read Tool Calls Run Python Tools Return Tool Results Continue After Tool Use Control Tool Choice Handle Tool Errors Use the beta_tool Decorator Create a Tool Runner Add Tool Docstrings Validate Tool Inputs Use Multiple Tools Limit Tool Calls Log Tool Activity Build a Weather Tool Build a Search Tool Build a Calculator Tool Structured Output Intro Request JSON Data Validate JSON Output Use Pydantic Models Handle Invalid JSON Extract Fields from Text Generate Typed Data Use Image URLs Send Base64 Images Read PDFs with Claude Use Output Schemas Build Data Extraction Test Structured Responses Send Image Inputs Upload Files Use File IDs Process Text Files Summarize Documents Extract Table Data Handle File Errors Read Cache Usage Reduce Repeated Input Costs Debug Cache Misses Prompt Caching Intro Enable Prompt Caching Cache System Prompts Cache Long Context Test Cache Performance Use Cache Breakpoints Cache Best Practices Message Batches Intro Create a Message Batch Set Batch IDs Check Batch Status List Message Batches Get Batch Results Process Batch Outputs Handle Failed Batch Items Use Batches for Bulk Tasks Compare Batches and Requests Handle API Errors Handle Rate Limits Retry Failed Requests Set Request Timeouts Handle Network Errors Use Exponential Backoff Build Reliable API Calls Use Extended Thinking Log API Errors Track Request IDs Paginate API Results Set Thinking Budgets Read Thinking Blocks Use Tool Search Use Web Search Tools Use Computer Tools Use Citations Use Token Counting Read Raw Responses Set Custom Headers Use Claude on AWS Bedrock Use Claude on Vertex AI Use Claude on Microsoft Foundry Configure Cloud Credentials Choose a Cloud Provider Compare API Integrations Configure Provider Clients Handle Cloud Errors Use Cloud SDK Extras Deploy Cloud Apps Protect API Keys Use a .env File Ignore Secrets in Git Validate User Input Reduce Prompt Injection Limit Tool Permissions Avoid Unsafe Tool Calls Sanitize Tool Results Set Usage Limits Review Security Logs Test SDK Functions Mock API Responses Test Error Handling Test Streaming Code Measure Token Usage Track API Costs Configure Logging Deploy with Docker Deploy a FastAPI App Monitor Production Apps Build a Claude Chatbot Build a Text Summarizer Build a Code Explainer Build a JSON Extractor Build a PDF Assistant Build a Streaming Chat App Build a Tool-Calling Agent Build a Batch Processor Build a Research Assistant Build a Claude API Dashboard
Ask about this post
AI Ask about this post

Ask questions about Return JSON from an API and get answers drawn from it.

Signed-in readers only.