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.

REST — nouns vs verbs in endpoint naming
# 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.

REST — endpoint naming conventions
# 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.

MethodWhat It DoesExampleBody
GETRead a resource or listGET /users/42None
POSTCreate a new resourcePOST /usersNew resource data
PUTReplace a resource completelyPUT /users/42Full resource data
PATCHUpdate part of a resourcePATCH /users/42Changed fields only
DELETERemove a resourceDELETE /users/42None
ℹ️ 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.

HTTP — the status codes every API developer must know
# 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.

JSON — a consistent response envelope
// 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" } }

Pagination — Never Return Everything at Once

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.

Python and Flask — pagination with query parameters
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.

Python and Flask — centralised error handling
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.

Python and Flask — versioned API with Blueprints
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.

Python — JWT authentication with a decorator
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

Performance Tips

Caching Responses

If lots of clients are asking for the same data and that data does not change very often — like a list of categories, a leaderboard, or a product catalogue — there is no reason to hit the database every single time. Cache the response and serve it from memory instead.

Python — response caching with Flask-Caching and Redis
from flask_caching import Cache app.config['CACHE_TYPE'] = 'RedisCache' app.config['CACHE_REDIS_URL'] = 'redis://localhost:6379/0' app.config['CACHE_DEFAULT_TIMEOUT'] = 300 # 5 minutes cache = Cache(app) # This endpoint is cached for 5 minutes # The first request hits the database, all others get cached response @app.route('/api/v1/categories') @cache.cached(timeout=300) def get_categories(): cats = Category.query.all() return jsonify({'data': [c.to_dict() for c in cats]}) # You can also set HTTP cache headers so the browser caches too from flask import make_response resp = make_response(jsonify(data)) resp.headers['Cache-Control'] = 'public, max-age=300' return resp

Rate Limiting — Protect Your API From Abuse

Without rate limiting, a single client can send thousands of requests per second and either crash your server or rack up a huge database bill. Rate limiting caps how many requests each client can make in a given time window.

Python — rate limiting with Flask-Limiter
from flask_limiter import Limiter from flask_limiter.util import get_remote_address limiter = Limiter( get_remote_address, app=app, default_limits=['100 per minute'] # 100 requests per minute by default ) # This endpoint has a stricter limit (login should be limited) @app.route('/api/v1/auth/login', methods=['POST']) @limiter.limit('5 per minute') def login(): pass # When a client hits the limit, Flask-Limiter returns: # 429 Too Many Requests # Retry-After: 60 (how many seconds to wait)
Always send Retry-After: when you return a 429 response, include a Retry-After header telling the client how many seconds to wait before trying again. Good clients will respect this and back off automatically.

A Complete Flask API Example

Here is a full, production-style Flask endpoint that puts all these best practices together in one place:

Python — a clean production-ready Flask endpoint
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.