Bad APIs are forever. Once clients depend on your mistakes, you’re stuck with them.

Here’s how to get it right the first time.

URL Structure

Resources are nouns, not verbs:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
# Good
GET    /users
GET    /users/123
POST   /users
PUT    /users/123
DELETE /users/123

# Bad
GET    /getUsers
POST   /createUser
POST   /deleteUser/123

Nest for relationships:

1
2
3
4
5
6
7
# User's orders
GET /users/123/orders
GET /users/123/orders/456

# But don't go too deep
# Bad: /users/123/orders/456/items/789/reviews
# Better: /order-items/789/reviews

Use query params for filtering, sorting, searching:

1
2
3
4
GET /users?status=active
GET /users?sort=created_at&order=desc
GET /users?search=john
GET /users?status=active&role=admin&sort=-created_at

HTTP Methods

Use them correctly:

MethodPurposeIdempotentSafe
GETReadYesYes
POSTCreateNoNo
PUTReplaceYesNo
PATCHUpdateYesNo
DELETEDeleteYesNo

PUT vs PATCH:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
# PUT replaces the entire resource
PUT /users/123
{
  "name": "John",
  "email": "john@example.com",
  "role": "admin"
}

# PATCH updates specific fields
PATCH /users/123
{
  "role": "admin"
}

Status Codes

Use the right ones:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
# Success
200 OK           # GET, PUT, PATCH succeeded
201 Created      # POST created a resource
204 No Content   # DELETE succeeded

# Client errors
400 Bad Request  # Malformed request
401 Unauthorized # No/invalid authentication
403 Forbidden    # Authenticated but not allowed
404 Not Found    # Resource doesn't exist
409 Conflict     # State conflict (duplicate email, etc.)
422 Unprocessable # Validation failed

# Server errors
500 Internal     # Something broke
503 Unavailable  # Service temporarily down

Flask example:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
from flask import Flask, jsonify, request

app = Flask(__name__)

@app.route('/users', methods=['POST'])
def create_user():
    data = request.json
    
    # Validation
    if not data.get('email'):
        return jsonify({
            'error': 'validation_error',
            'message': 'Email is required'
        }), 422
    
    # Check for duplicate
    if User.query.filter_by(email=data['email']).first():
        return jsonify({
            'error': 'conflict',
            'message': 'Email already exists'
        }), 409
    
    # Create
    user = User(**data)
    db.session.add(user)
    db.session.commit()
    
    return jsonify(user.to_dict()), 201

Error Responses

Be consistent and helpful:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
{
  "error": {
    "code": "validation_error",
    "message": "Request validation failed",
    "details": [
      {
        "field": "email",
        "message": "Invalid email format"
      },
      {
        "field": "age",
        "message": "Must be a positive integer"
      }
    ]
  }
}

Error handling middleware:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
from werkzeug.exceptions import HTTPException

class APIError(Exception):
    def __init__(self, code, message, details=None, status_code=400):
        self.code = code
        self.message = message
        self.details = details
        self.status_code = status_code

@app.errorhandler(APIError)
def handle_api_error(error):
    return jsonify({
        'error': {
            'code': error.code,
            'message': error.message,
            'details': error.details
        }
    }), error.status_code

@app.errorhandler(HTTPException)
def handle_http_error(error):
    return jsonify({
        'error': {
            'code': error.name.lower().replace(' ', '_'),
            'message': error.description
        }
    }), error.code

Pagination

Three approaches:

1. Offset pagination (simple, but slow at scale):

1
GET /users?limit=20&offset=40
1
2
3
4
5
6
7
8
9
{
  "data": [...],
  "pagination": {
    "total": 1000,
    "limit": 20,
    "offset": 40,
    "has_more": true
  }
}

2. Cursor pagination (better for large datasets):

1
GET /users?limit=20&cursor=eyJpZCI6MTIzfQ==
1
2
3
4
5
6
7
{
  "data": [...],
  "pagination": {
    "next_cursor": "eyJpZCI6MTQzfQ==",
    "has_more": true
  }
}

3. Keyset pagination (best performance):

1
GET /users?limit=20&after_id=123

Implementation:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
@app.route('/users')
def list_users():
    limit = min(int(request.args.get('limit', 20)), 100)
    cursor = request.args.get('cursor')
    
    query = User.query.order_by(User.id)
    
    if cursor:
        last_id = decode_cursor(cursor)
        query = query.filter(User.id > last_id)
    
    users = query.limit(limit + 1).all()
    has_more = len(users) > limit
    users = users[:limit]
    
    return jsonify({
        'data': [u.to_dict() for u in users],
        'pagination': {
            'next_cursor': encode_cursor(users[-1].id) if has_more else None,
            'has_more': has_more
        }
    })

Versioning

Three strategies:

1. URL versioning (most common):

1
2
GET /v1/users
GET /v2/users

2. Header versioning:

1
2
GET /users
Accept: application/vnd.api+json;version=2

3. Query parameter:

1
GET /users?version=2

My preference: URL versioning. It’s explicit, cacheable, and easy to debug.

Flask blueprint versioning:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
# v1/users.py
v1_bp = Blueprint('v1', __name__, url_prefix='/v1')

@v1_bp.route('/users')
def list_users():
    return jsonify([user.to_dict_v1() for user in users])

# v2/users.py
v2_bp = Blueprint('v2', __name__, url_prefix='/v2')

@v2_bp.route('/users')
def list_users():
    return jsonify([user.to_dict_v2() for user in users])

# app.py
app.register_blueprint(v1_bp)
app.register_blueprint(v2_bp)

Authentication

Bearer tokens are standard:

1
2
GET /users
Authorization: Bearer eyJhbGciOiJIUzI1NiIs...

JWT validation:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
from functools import wraps
import jwt

def require_auth(f):
    @wraps(f)
    def decorated(*args, **kwargs):
        token = request.headers.get('Authorization', '').replace('Bearer ', '')
        
        if not token:
            raise APIError('unauthorized', 'Missing token', status_code=401)
        
        try:
            payload = jwt.decode(token, SECRET_KEY, algorithms=['HS256'])
            request.user_id = payload['user_id']
        except jwt.ExpiredSignatureError:
            raise APIError('token_expired', 'Token has expired', status_code=401)
        except jwt.InvalidTokenError:
            raise APIError('invalid_token', 'Invalid token', status_code=401)
        
        return f(*args, **kwargs)
    return decorated

@app.route('/users/me')
@require_auth
def get_current_user():
    user = User.query.get(request.user_id)
    return jsonify(user.to_dict())

Rate Limiting

Protect your API:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
from flask_limiter import Limiter
from flask_limiter.util import get_remote_address

limiter = Limiter(
    app=app,
    key_func=get_remote_address,
    default_limits=["100 per minute"]
)

@app.route('/users')
@limiter.limit("10 per second")
def list_users():
    ...

Return rate limit headers:

XXX---RRRaaattteeeLLLiiimmmiiittt---LRRieemmsiaetit:n:i1n10g60:1293545678

Response Envelopes

Consistent structure:

1
2
3
4
5
6
7
{
  "data": { ... },
  "meta": {
    "request_id": "abc-123",
    "timestamp": "2026-02-10T21:00:00Z"
  }
}

For collections:

1
2
3
4
5
{
  "data": [ ... ],
  "pagination": { ... },
  "meta": { ... }
}

Documentation

Use OpenAPI/Swagger:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
openapi: 3.0.0
info:
  title: My API
  version: 1.0.0

paths:
  /users:
    get:
      summary: List users
      parameters:
        - name: limit
          in: query
          schema:
            type: integer
            default: 20
      responses:
        200:
          description: List of users
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/UserList'

Generate docs automatically with Flask-RESTX or FastAPI.

The Checklist

Before shipping:

  • Consistent URL naming (plural nouns)
  • Correct HTTP methods and status codes
  • Structured error responses
  • Pagination for list endpoints
  • Versioning strategy
  • Authentication
  • Rate limiting
  • Documentation
  • Request/response logging

The Bottom Line

Good APIs are boring. They’re predictable, consistent, and unsurprising.

Save your creativity for the product. Make the API obvious.


Building an API? Share your patterns on Twitter.