Building a JSON API Endpoint

Serve data instead of a page

Advanced 11 min

In this lesson

You have called other people’s APIs. Now you build one. The change is smaller than it sounds: a route that returned a rendered page returns JSON instead. This lesson covers jsonify, choosing a response shape and sticking to it, and how to test an endpoint without a browser.

Explain it like I’m 5

Building an API means your app answers with the ingredients instead of the finished meal, so another program can cook with them.

A route that returns data

In Unit 6 your routes returned HTML through a template. An API route returns data. Flask’s jsonify converts Python dicts and lists to JSON and sets the Content-Type header so the caller knows what it received.

Example · app.py
from flask import Flask, jsonify

app = Flask(__name__)

TASKS = [
    {"id": 1, "title": "Write tests", "done": False},
    {"id": 2, "title": "Ship it", "done": True},
]

@app.route("/api/tasks")
def list_tasks():
    return jsonify({"tasks": TASKS, "count": len(TASKS)})
The same @app.route you already know, answering with data.

Pick a response shape and keep it

The single kindest thing you can do for whoever calls your API, including future you, is to answer with the same shape every time. If a successful list request returns {"tasks": [...], "count": 2}, then an empty result should return {"tasks": [], "count": 0}, not null, not a bare [], and not a different key.

Wrapping the payload in an object rather than returning a bare list also leaves you room to add fields later, count, paging info, a warning, without breaking every existing caller.

Example
def list_response(tasks):
    return {"tasks": tasks, "count": len(tasks)}

print(list_response([{"id": 1, "title": "Write tests"}]))
print(list_response([]))
Output
{'tasks': [{'id': 1, 'title': 'Write tests'}], 'count': 1}
{'tasks': [], 'count': 0}
Same keys whether there is data or not.

Write the endpoint’s data function. api_tasks() should return a dict with a tasks list and a count, then print the count followed by each task’s id and title.

TASKS = [
    {"id": 1, "title": "Write tests", "done": False},
    {"id": 2, "title": "Ship it", "done": True},
]

def api_tasks():
    return {}  # TODO: return tasks and count

result = api_tasks()
print(result["count"])
for task in result["tasks"]:
    print(task["id"], task["title"])

Accepting data, and testing without a browser

A GET endpoint hands data out. To take data in, a route accepts POST and reads the request body, usually JSON, with request.get_json(). You will meet the full set of methods in the next lesson; the shape is what matters here.

You cannot click a POST endpoint in a browser, so test from the command line with curl, or better, with Flask’s test client, which calls your app in-process with no server running.

Example · app.py
@app.route("/api/tasks", methods=["POST"])
def create_task():
    payload = request.get_json()
    title = (payload or {}).get("title", "").strip()
    if not title:
        return jsonify({"error": "title is required"}), 400
    task = {"id": len(TASKS) + 1, "title": title, "done": False}
    TASKS.append(task)
    return jsonify({"task": task}), 201
Validate first, then act. Note the status codes.
Example · test_app.py
def test_list_tasks():
    client = app.test_client()
    response = client.get("/api/tasks")
    assert response.status_code == 200
    data = response.get_json()
    assert data["count"] == len(data["tasks"])
The test client calls your routes with no server running.

Common mistake: Returning a bare list from an endpoint

Why it happens:

The data is a list, so returning it directly feels honest.

How to fix it:

Wrap it in an object: {"tasks": [...]}. You can then add fields later without breaking callers, and you avoid a long-standing security wrinkle with top-level JSON arrays.

Common mistake: Changing the response shape between cases

Why it happens:

Returning null or an error string for the empty case seems more informative.

How to fix it:

Keep the keys identical and let the values be empty. Callers should never need to check which shape they got.

Common mistake: Storing what the caller sent without validating it

Why it happens:

The payload arrives as a convenient dict, so it looks ready to use.

How to fix it:

Check required fields exist and are non-blank before doing anything with them, and return 400 with a clear message when they aren’t. Never trust an incoming request.

What does jsonify do that returning a plain string does not?

An endpoint returns no matching tasks. What should it send?

Why use Flask's test client instead of curl?

Mini exercise (medium)

Write api_response(items, page_size) that returns a consistent API shape: the key items holding at most page_size entries, count for how many were returned, and has_more saying whether any were left over. It must return the same keys even when items is empty.

Practice here. Fill in the missing piece and click Run to try your answer in place.

def api_response(items, page_size):
    return {}  # TODO: items (at most page_size), count, has_more

print(api_response(["a", "b", "c"], 2))
print(api_response([], 2))

What to learn next

You built the server side: jsonify turning Python objects into a proper JSON response, a wrapper object so you can add fields later without breaking callers, the same shape whether there’s data or not, validation before storing anything, and Flask’s test client for checking routes with no server running.

Your endpoint reads data. Next it needs to change it. HTTP Methods and CRUD covers the verbs, the route-naming convention, and which status code to send back.