backend intermediate

Building a JSON REST API

min read Frederick Tubiermont

Building a JSON REST API

Flask is excellent for building JSON APIs — lightweight, explicit, and easy for AI to generate reliably. This tutorial covers response envelopes, API key authentication, and clean route patterns.

Consistent Response Envelope

Every endpoint should return the same shape so clients never have to guess the structure:

from flask import jsonify

def api_response(data=None, error=None, status=200):
    """Wrap any response in a consistent envelope."""
    return jsonify({
        "data": data,
        "error": error,
        "status": status
    }), status

Usage in routes:

# Success
return api_response(data={"id": 1, "title": "Hello"})

# Not found
return api_response(error="Tutorial not found", status=404)

# Validation error
return api_response(error="Title is required", status=400)

Clients always receive {"data": ..., "error": ..., "status": ...}. The error field is null on success, and data is null on errors.

API Key Authentication

Create the api_keys Table

CREATE TABLE IF NOT EXISTS api_keys (
    id SERIAL PRIMARY KEY,
    key VARCHAR(64) UNIQUE NOT NULL,
    name VARCHAR(100) NOT NULL,  -- label for this key, e.g. "Mobile App"
    active BOOLEAN DEFAULT TRUE,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    last_used_at TIMESTAMP
);

CREATE INDEX idx_api_keys_key ON api_keys(key);

Generate a Key

import secrets

def generate_api_key():
    return secrets.token_hex(32)  # 64-character hex string

# Run once to create a key:
# python -c "from app import generate_api_key; print(generate_api_key())"
# Then INSERT it into the api_keys table manually

The Auth Decorator

import functools
from flask import request

def require_api_key(f):
    @functools.wraps(f)
    def decorated(*args, **kwargs):
        auth_header = request.headers.get("Authorization", "")

        if not auth_header.startswith("Bearer "):
            return api_response(error="Missing Authorization header", status=401)

        key = auth_header[len("Bearer "):]

        with get_db() as conn:
            with conn.cursor() as cur:
                cur.execute(
                    "SELECT id FROM api_keys WHERE key = %s AND active = TRUE",
                    (key,)
                )
                api_key = cur.fetchone()

        if not api_key:
            return api_response(error="Invalid or inactive API key", status=401)

        # Update last_used_at (optional, useful for auditing)
        with get_db() as conn:
            with conn.cursor() as cur:
                cur.execute(
                    "UPDATE api_keys SET last_used_at = NOW() WHERE id = %s",
                    (api_key["id"],)
                )
                conn.commit()

        return f(*args, **kwargs)
    return decorated

Versioned API Routes

Prefix all routes with /api/v1/ so you can introduce /api/v2/ later without breaking existing clients:

API_PREFIX = "/api/v1"

@app.route(f"{API_PREFIX}/tutorials")
@require_api_key
def api_tutorials():
    page = request.args.get("page", 1, type=int)
    per_page = min(request.args.get("per_page", 20, type=int), 100)
    offset = (page - 1) * per_page

    with get_db() as conn:
        with conn.cursor() as cur:
            cur.execute("""
                SELECT id, title, slug, excerpt, category, difficulty, estimated_time
                FROM tutorials
                ORDER BY published_at DESC
                LIMIT %s OFFSET %s
            """, (per_page, offset))
            tutorials = cur.fetchall()

            cur.execute("SELECT COUNT(*) FROM tutorials")
            total = cur.fetchone()["count"]

    return api_response(data={
        "tutorials": tutorials,
        "page": page,
        "per_page": per_page,
        "total": total,
        "total_pages": (total + per_page - 1) // per_page
    })

@app.route(f"{API_PREFIX}/tutorials/<slug>")
@require_api_key
def api_tutorial_detail(slug):
    with get_db() as conn:
        with conn.cursor() as cur:
            cur.execute(
                "SELECT * FROM tutorials WHERE slug = %s",
                (slug,)
            )
            tutorial = cur.fetchone()

    if not tutorial:
        return api_response(error="Tutorial not found", status=404)

    return api_response(data=tutorial)

Proper HTTP Status Codes

Code Meaning When to Use
200 OK Successful GET, PUT, PATCH
201 Created Successful POST that creates a resource
204 No Content Successful DELETE
400 Bad Request Missing or invalid input
401 Unauthorized Missing or invalid API key
403 Forbidden Valid key but not allowed this action
404 Not Found Resource does not exist
409 Conflict Duplicate (e.g. unique constraint)
429 Too Many Requests Rate limit exceeded
500 Internal Server Error Unexpected server failure

Creating a Resource (POST)

@app.route(f"{API_PREFIX}/tutorials", methods=["POST"])
@require_api_key
def api_create_tutorial():
    body = request.get_json(silent=True)
    if not body:
        return api_response(error="Request body must be JSON", status=400)

    title = body.get("title", "").strip()
    slug = body.get("slug", "").strip()

    if not title:
        return api_response(error="title is required", status=400)
    if not slug:
        return api_response(error="slug is required", status=400)

    try:
        with get_db() as conn:
            with conn.cursor() as cur:
                cur.execute("""
                    INSERT INTO tutorials (title, slug, excerpt, content, category, difficulty)
                    VALUES (%s, %s, %s, %s, %s, %s)
                    RETURNING id
                """, (
                    title, slug,
                    body.get("excerpt", ""),
                    body.get("content", ""),
                    body.get("category", "backend"),
                    body.get("difficulty", "beginner")
                ))
                new_id = cur.fetchone()["id"]
                conn.commit()
    except Exception:
        return api_response(error="Slug already exists", status=409)

    return api_response(data={"id": new_id}, status=201)

Testing With curl

# List tutorials
curl -H "Authorization: Bearer your-api-key-here" \
     https://yourapp.com/api/v1/tutorials

# Get a specific tutorial
curl -H "Authorization: Bearer your-api-key-here" \
     https://yourapp.com/api/v1/tutorials/flask-authentication

# Create a tutorial
curl -X POST \
     -H "Authorization: Bearer your-api-key-here" \
     -H "Content-Type: application/json" \
     -d "{\"title\": \"New Tutorial\", \"slug\": \"new-tutorial\", \"category\": \"backend\", \"difficulty\": \"beginner\"}" \
     https://yourapp.com/api/v1/tutorials

AI Prompt That Generated This

"Build a versioned JSON REST API in Flask under /api/v1/. Include: a consistent api_response() envelope helper, a require_api_key decorator that reads from Authorization: Bearer header and validates against an api_keys table in PostgreSQL using psycopg2, and GET/POST routes for a tutorials resource. Use proper HTTP status codes."

Next Steps

Was this helpful?

Get More Flask Vibe Tutorials

Join 1,000+ developers getting weekly Flask tips and AI-friendly code patterns.

No spam. Unsubscribe anytime.