An API is how two programs talk to each other. When you build a web app, a mobile app or a data service, you almost always need a REST API sitting in the middle — receiving requests, doing work, and sending back data.
Building an API that technically works is not very hard. Building one that is clean, predictable and fast is a different skill. A well-designed API feels obvious to use. Developers can guess what an endpoint does without reading the docs. Errors give clear messages. Responses are consistent every time.
This guide walks through the core best practices every developer should know — from naming endpoints to handling errors to making your API fast. Everything is in plain simple English with working Python and Flask examples.
What Is a REST API
REST stands for Representational State Transfer. That sounds complicated but the idea is simple. A REST API is a set of URLs that represent things (called resources), and you interact with those things using standard HTTP methods like GET, POST, PUT and DELETE.
Think of it like a library. The library has books, members and loans as resources. You can look up a book, add a new member, update a loan date or remove a record. The URL tells you what resource you are working with, and the HTTP method tells you what action you are taking.
REST does not enforce strict rules. It is a set of design conventions that most developers have agreed to follow so APIs work in a predictable way across the whole industry.
ℹ️
Stateless: each request in a REST API must contain all the information needed to process it. The server does not remember previous requests. If you need authentication, the token must be included in every request — not just the first one.
Naming Your Endpoints
Good endpoint naming is the first thing people notice about your API. A well-named endpoint tells you exactly what resource it works with and how to use it without reading any documentation.
Use Nouns Not Verbs
Your URLs should describe what the resource is, not what the action is. The HTTP method (GET, POST, DELETE) already tells you the action. Putting the action in the URL as well is redundant and makes your API harder to read.
# BAD — verbs in the URL, hard to predict
GET /getUsers
POST /createUser
DELETE /deleteUser/42
GET /fetchAllArticles
POST /submitComment
# GOOD — nouns only, action comes from the HTTP method
GET /users # get all users
POST /users # create a new user
DELETE /users/42 # delete user with id 42
GET /articles # get all articles
POST /articles/7/comments # add a comment to article 7
Use Plural Nouns and Lowercase
Always use the plural form of the resource name. A collection of users is /users, not /user. Keep everything lowercase and use hyphens to separate words in multi-word resource names.
# BAD naming
/User # uppercase — avoid
/blogPost # camelCase — avoid
/blog_posts # underscores — use hyphens instead
/user/profile # singular — avoid
# GOOD naming
/users # plural, lowercase
/blog-posts # hyphen for multi-word
/users/42 # specific resource by id
/users/42/posts # nested resource — posts belonging to user 42
/users/42/posts/7 # specific post belonging to specific user
# Keep nesting to 2 levels maximum
# /users/42/posts/7/comments is fine
# /users/42/posts/7/comments/3/likes/author is too deep — flatten it
HTTP Methods — Match the Action to the Right Method
HTTP gives us a set of methods (also called verbs) that each have a clear meaning. Using them correctly makes your API predictable. Any developer who knows REST will immediately understand what each endpoint does.
| Method | What It Does | Example | Body |
| GET | Read a resource or list | GET /users/42 | None |
| POST | Create a new resource | POST /users | New resource data |
| PUT | Replace a resource completely | PUT /users/42 | Full resource data |
| PATCH | Update part of a resource | PATCH /users/42 | Changed fields only |
| DELETE | Remove a resource | DELETE /users/42 | None |
ℹ️
PUT vs PATCH: PUT replaces the whole resource. If you PUT a user and forget to include the email field, the email gets deleted. PATCH only updates the fields you send. For most partial updates use PATCH — it is safer and more efficient.
HTTP Status Codes — Tell the Client What Actually Happened
A status code is a three-digit number the server sends back with every response. It tells the client whether the request succeeded, failed or something else happened. Using the right status code is one of the most important parts of good API design.
Never send a 200 OK response with an error message inside the body. That forces clients to parse every response body just to find out if it succeeded. The status code should tell the story on its own.
# 2xx — Success
200 OK # request worked, here is the result
201 Created # new resource was created successfully (POST)
204 No Content # worked, but nothing to send back (DELETE)
# 3xx — Redirects
301 Moved Permanently # the resource has moved to a new URL forever
304 Not Modified # cached version is still valid, nothing changed
# 4xx — Client made a mistake
400 Bad Request # the request body or params are invalid
401 Unauthorized # not logged in, please authenticate first
403 Forbidden # logged in but you do not have permission
404 Not Found # this resource does not exist
409 Conflict # the request conflicts with current state (e.g. duplicate email)
422 Unprocessable # valid JSON but the values fail validation
429 Too Many Requests # the client is sending too many requests (rate limit)
# 5xx — Server made a mistake
500 Internal Server Error # something unexpected broke on the server
502 Bad Gateway # upstream server returned an invalid response
503 Service Unavailable # server is overloaded or under maintenance
Request and Response Shape
Consistent JSON Structure
Every response from your API should have the same predictable shape. Clients should not have to guess whether the data is in a data key, a result key or directly at the root. Pick one structure and use it everywhere.
// Success response — single resource
{
"success": true,
"data": {
"id": 42,
"name": "Shashank Shekhar",
"email": "shashank@example.com",
"created_at": "2025-01-15T10:30:00Z"
}
}
// Success response — list of resources
{
"success": true,
"data": [...],
"meta": {
"page": 1,
"per_page": 20,
"total": 157,
"total_pages": 8
}
}
// Error response
{
"success": false,
"error": {
"code": "VALIDATION_ERROR",
"message": "Email address is not valid",
"field": "email"
}
}
If your database has 50,000 users, never return all 50,000 in one response. It will time out, crash the client and destroy your server performance. Always paginate lists.
from flask import Flask, request, jsonify
app = Flask(__name__)
@app.route('/api/v1/users', methods=['GET'])
def get_users():
# Read pagination params from query string
# e.g. GET /api/v1/users?page=2&per_page=20
page = request.args.get('page', 1, type=int)
per_page = request.args.get('per_page', 20, type=int)
# Cap per_page so nobody requests 100,000 at once
per_page = min(per_page, 100)
# Calculate offset
offset = (page - 1) * per_page
total = User.query.count()
users = User.query.offset(offset).limit(per_page).all()
total_pages = (total + per_page - 1) // per_page
return jsonify({
'success': True,
'data': [u.to_dict() for u in users],
'meta': {
'page': page,
'per_page': per_page,
'total': total,
'total_pages': total_pages,
}
}), 200
Error Handling — Give Developers Useful Error Messages
When something goes wrong, a good API tells the developer exactly what happened and how to fix it. A bad API returns {"error": "something went wrong"} and leaves them guessing.
Your error response should always include a machine-readable error code so clients can handle it in code, and a human-readable message so developers understand it instantly.
from flask import Flask, jsonify
app = Flask(__name__)
# A helper that returns a consistent error shape every time
def error_response(code, message, status, field=None):
body = {
'success': False,
'error': {'code': code, 'message': message}
}
if field:
body['error']['field'] = field
return jsonify(body), status
# Register global error handlers for common cases
@app.errorhandler(404)
def not_found(e):
return error_response('NOT_FOUND', 'The resource you requested does not exist', 404)
@app.errorhandler(405)
def method_not_allowed(e):
return error_response('METHOD_NOT_ALLOWED', 'This HTTP method is not allowed here', 405)
@app.errorhandler(500)
def server_error(e):
return error_response('SERVER_ERROR', 'An unexpected error occurred', 500)
# In your route, return specific errors like this
@app.route('/api/v1/users/<int:user_id>')
def get_user(user_id):
user = User.query.get(user_id)
if not user:
return error_response(
'NOT_FOUND',
f'User with id {user_id} does not exist',
404
)
return jsonify({'success': True, 'data': user.to_dict()}), 200
⚠️
Never expose internal errors to clients. If a database query fails, send a clean 500 Server Error response. Log the real error on the server for debugging. Never send stack traces, database errors or file paths to the client — that is a security risk.
API Versioning — Plan for Change From Day One
At some point your API will need to change in a way that breaks existing clients. Maybe you need to rename a field, remove an endpoint or change a response shape. If you have not versioned your API, every change like this will break every client that uses it.
The simplest approach is to include the version number in the URL. When you need to make breaking changes, create a new version. Old clients keep working on v1 while new clients use v2.
from flask import Flask, Blueprint, jsonify
app = Flask(__name__)
# Version 1 blueprint
v1 = Blueprint('v1', __name__, url_prefix='/api/v1')
@v1.route('/users')
def get_users_v1():
return jsonify({'data': [], 'version': 'v1'})
# Version 2 blueprint — can have a completely different response shape
v2 = Blueprint('v2', __name__, url_prefix='/api/v2')
@v2.route('/users')
def get_users_v2():
# v2 can return a different shape without breaking v1 clients
return jsonify({'users': [], 'version': 'v2'})
app.register_blueprint(v1)
app.register_blueprint(v2)
# Clients hit: GET /api/v1/users or GET /api/v2/users
# Both work independently and can evolve separately
Authentication — Protect Your Endpoints
Most API endpoints need to know who is making the request. The standard approach for REST APIs is JWT (JSON Web Tokens). When a user logs in, the server gives them a token. The client sends this token in the Authorization header with every subsequent request.
import jwt
from functools import wraps
from flask import request, jsonify
SECRET_KEY = 'your-secret-key-keep-this-safe'
def require_auth(f):
@wraps(f)
def wrapper(*args, **kwargs):
auth_header = request.headers.get('Authorization')
if not auth_header or not auth_header.startswith('Bearer '):
return jsonify({'error': 'Missing or invalid token'}), 401
try:
token = auth_header.split(' ')[1]
payload = jwt.decode(token, SECRET_KEY, algorithms=['HS256'])
request.current_user_id = payload['user_id']
except jwt.ExpiredSignatureError:
return jsonify({'error': 'Token has expired'}), 401
except jwt.InvalidTokenError:
return jsonify({'error': 'Token is invalid'}), 401
return f(*args, **kwargs)
return wrapper
# Apply to any route that needs authentication
@app.route('/api/v1/profile')
@require_auth
def get_profile():
user_id = request.current_user_id
return jsonify({'user_id': user_id}), 200
A Complete Flask API Example
Here is a full, production-style Flask endpoint that puts all these best practices together in one place:
from flask import Flask, Blueprint, request, jsonify
posts_bp = Blueprint('posts', __name__, url_prefix='/api/v1')
# GET /api/v1/posts — list all posts (paginated)
# GET /api/v1/posts/7 — get one post
# POST /api/v1/posts — create a post
# PATCH /api/v1/posts/7 — update part of a post
# DELETE /api/v1/posts/7 — delete a post
@posts_bp.route('/posts', methods=['POST'])
@require_auth
@limiter.limit('30 per minute')
def create_post():
data = request.get_json()
# Validate required fields
if not data or not data.get('title'):
return error_response(
'VALIDATION_ERROR',
'Title is required',
422,
field='title'
)
if len(data['title']) > 200:
return error_response(
'VALIDATION_ERROR',
'Title must be under 200 characters',
422,
field='title'
)
# Create the post
post = Post(
title= data['title'],
body= data.get('body', ''),
author_id= request.current_user_id
)
db.session.add(post)
db.session.commit()
# Return 201 Created, not 200 OK
return jsonify({
'success': True,
'data': post.to_dict()
}), 201 # Created, not 200
⚡ Key Takeaways
- Use nouns not verbs in your URL paths. The HTTP method already tells you the action. GET /users is correct. GET /getUsers is not.
- Use plural, lowercase resource names with hyphens for multi-word names. Keep nesting to two levels maximum to avoid deeply nested URLs.
- Match actions to the right HTTP method: GET for reading, POST for creating, PUT for full replacement, PATCH for partial updates, DELETE for removing.
- Use correct status codes. 201 for created resources. 204 for successful deletes with no body. 401 for unauthenticated. 403 for unauthorised. 422 for validation errors. Never send a 200 with an error inside the body.
- Keep your response shape consistent across every endpoint. Use the same envelope with success, data and meta keys everywhere.
- Always paginate lists. Never return a full database table in one response. Cap per_page on the server so clients cannot request unlimited rows.
- Give useful error messages with a machine-readable error code and a human-readable message. Never expose internal error details, stack traces or database errors to clients.
- Version your API from day one using /api/v1/ in the URL. This lets you make breaking changes in a new version without affecting existing clients.
- Add caching for data that does not change often. Add rate limiting to protect against abuse and always return a Retry-After header with 429 responses.
Tags:
REST API
Backend
Flask
HTTP
Python
Beginner
Shashank Shekhar
Founder & Creator — Hoopsiper.com
Full stack developer and educator. Building Hoopsiper to help developers learn faster through practical, no-fluff coding guides on JavaScript, AI/ML, Python and modern web development.