Skip to content

10 API Design Principles for Better RESTful Services (2026)

DodaTech Updated 2026-06-23 12 min read

In this guide, you will learn 10 Api Design principles that help you build RESTful services developers love to use. A well-designed API reduces integration effort, prevents misuse, and adapts to changing requirements without breaking existing clients.

Api Design determines how easily other developers can integrate with your service. The difference between a great API and a frustrating one is not the underlying functionality — it is consistency, predictability, and clear communication through the API itself. These 10 principles cover resource modeling, naming conventions, error handling, versioning, pagination, Rate Limiting, authentication, documentation, performance, and testing.

Each principle includes specific rules, code examples showing correct and incorrect implementations, and the reasoning behind the recommendation. The principles are informed by the REST architectural style, industry best practices from major API providers, and lessons learned from maintaining production APIs at scale.

Use Resources, Not Actions

Model your API around nouns (resources) rather than verbs (actions).

REST APIs represent resources — the nouns of your domain — and use HTTP methods to describe actions on those resources. GET retrieves, POST creates, PUT replaces, PATCH updates, DELETE removes. URLs should describe the resource, not the action.

# Poor API design (actions in URLs)
GET /api/getUsers
POST /api/createUser
POST /api/deleteUser?id=5
POST /api/activateUserAccount

# RESTful API design (resources with HTTP methods)
GET    /api/users          # List users
POST   /api/users          # Create user
GET    /api/users/5        # Get user by ID
PATCH  /api/users/5        # Update user partially
DELETE /api/users/5        # Delete user
POST   /api/users/5/activate  # Special action as sub-resource

Why it matters: Action-based APIs are inconsistent — each endpoint invents its own action naming convention. Resource-based APIs are predictable: once a developer knows the resource model, they can infer 80 percent of the endpoints. This reduces documentation reading and integration errors.

Use Consistent Naming Conventions

Apply the same naming conventions to every endpoint, parameter, and response field.

Consistency is the most important API quality. Use plural nouns for collection endpoints (/users, /orders, /products). Use lowercase with hyphens or underscores consistently. Use the same field naming style (camelCase or snake_case) in requests and responses. Use consistent parameter names for pagination, filtering, and sorting.

// Inconsistent API responses
// Endpoint 1 response:
{ "userName": "Alice", "createdAt": "2026-06-23" }

// Endpoint 2 response:
{ "user_name": "Bob", "created_at": "2026-06-24" }

// Consistent API responses
{ "user_name": "Alice", "created_at": "2026-06-23" }
{ "user_name": "Bob",   "created_at": "2026-06-24" }

Why it matters: Inconsistency forces API consumers to handle each endpoint differently, multiplying integration work. Consistent naming means a client library or integration pattern works across all endpoints with minimal adaptation.

Use Standard HTTP Status Codes

Return the appropriate HTTP status code for every response to communicate the outcome clearly.

HTTP status codes provide a universal language for API responses. Use them correctly and consistently so clients can handle responses programmatically without parsing response bodies. 200 for success, 201 for creation, 204 for deletion, 400 for client errors, 401 for unauthenticated, 403 for unauthorized, 404 for not found, 409 for conflicts, 422 for validation errors, 429 for rate limits, 500 for server errors.

from flask import jsonify, abort

@app.route("/api/users", methods=["POST"])
def create_user():
    data = request.get_json()
    
    # Validate input
    if not data or "email" not in data:
        return jsonify({"error": "email is required"}), 422
    
    # Check for duplicate
    if User.query.filter_by(email=data["email"]).first():
        return jsonify({"error": "email already exists"}), 409
    
    # Create user
    user = User(email=data["email"], name=data.get("name"))
    db.session.add(user)
    db.session.commit()
    
    return jsonify(user.to_dict()), 201

Why it matters: Correct status codes enable automated error handling in client code. A client can reliably retry on 5xx, show a login prompt on 401, and surface validation errors on 422 — all without parsing the response body.

Design for Backward Compatibility

Make additive changes by default and version only when breaking changes are unavoidable.

Backward compatibility means existing clients continue working without modifications when you deploy new API versions. Follow the Robustness Principle: be conservative in what you send, be liberal in what you accept. Add new fields to responses (clients ignore unknown fields). Make request fields optional with sensible defaults. Add new endpoints without modifying existing ones.

# Adding a new field to response (backward compatible)
# Old response:
# { "id": 5, "name": "Alice" }

# New response (old client ignores 'profile_url'):
# { "id": 5, "name": "Alice", "profile_url": "/users/5/profile" }

# Making a new field optional
# Old request (no 'phone' field): { "name": "Alice", "email": "alice@example.com" }
# New request (still works): { "name": "Alice", "email": "alice@example.com" }

Why it matters: Breaking changes force every client to update simultaneously — an impossible coordination challenge at scale. Backward compatible changes let clients migrate at their own pace. When breaking changes are unavoidable, version the API explicitly (v1, v2) and support old versions for a documented deprecation period.

Implement Consistent Error Responses

Return structured, descriptive error responses that help clients understand and fix the problem.

Error responses should include a machine-readable error code, a human-readable message, details about which field caused the error, and a request identifier for debugging. Consistent error format enables client-side error handling libraries and reduces support requests.

// Good error response
{
    "error": {
        "code": "VALIDATION_ERROR",
        "message": "The request data is invalid",
        "details": [
            {
                "field": "email",
                "code": "INVALID_FORMAT",
                "message": "email must be a valid email address]
            },
            {
                "field": "age",
                "code": "OUT_OF_RANGE",
                "message": "age must be between 0 and 150"
            }
        ],
        "request_id": "req_abc123"
    }
}

Why it matters: A vague "400 Bad Request" response forces the client developer to guess what went wrong. Structured error responses reduce integration time from hours to minutes by telling the client exactly what to fix.

Implement Pagination with Cursors

Use cursor-based pagination for stable, performant result set navigation.

Cursor-based pagination uses a pointer to the last item in the previous page, avoiding the problems of offset-based pagination (OFFSET in SQL): inconsistent results when items are inserted or deleted between page requests, and performance degradation on deep pages (OFFSET 100000 requires scanning through 100000 rows).

@app.route("/api/users")
def list_users():
    # Cursor-based pagination
    cursor = request.args.get("cursor")
    limit = min(int(request.args.get("limit", 20)), 100)
    
    query = User.query.order_by(User.id)
    if cursor:
        query = query.filter(User.id > cursor)
    
    users = query.limit(limit + 1).all()
    has_more = len(users) > limit
    users = users[:limit]
    
    next_cursor = users[-1].id if has_more else None
    
    return jsonify({
        "data": [u.to_dict() for u in users],
        "pagination": {
            "next_cursor": next_cursor,
            "has_more": has_more
        }
    })

Why it matters: Offset pagination breaks when data changes between requests — items appear on multiple pages or are skipped. Cursor pagination provides stable, consistent iteration over result sets regardless of concurrent modifications and performs efficiently at any depth.

Authenticate and Authorize Every Request

Require authentication on every endpoint by default and enforce fine-grained authorization.

Every API endpoint should require authentication unless explicitly intended to be public. Default-deny prevents accidentally exposing sensitive endpoints. Authorization should be checked for each request — authenticated does not mean authorized. Use standard authentication schemes: OAuth 2.0 with Bearer tokens for external APIs, API keys for service-to-service communication.

from functools import wraps

def require_auth(f):
    @wraps(f)
    def decorated(*args, **kwargs):
        auth_header = request.headers.get("Authorization")
        if not auth_header or not auth_header.startswith("Bearer "):
            return jsonify({"error": "Authentication required"}), 401
        
        token = auth_header.split(" ", 1)[1]
        user = verify_token(token)
        if not user:
            return jsonify({"error": "Invalid or expired token"}), 401
        
        request.current_user = user
        return f(*args, **kwargs)
    return decorated

@app.route("/api/users/<int:user_id>", methods=["DELETE"])
@require_auth
def delete_user(user_id):
    # Additional authorization check
    if not request.current_user.is_admin:
        return jsonify({"error": "Admin access required"}), 403
    
    User.query.filter_by(id=user_id).delete()
    db.session.commit()
    return "", 204

Why it matters: Unauthenticated APIs are a security incident waiting to happen. Default-deny authentication ensures that a developer forgetting to add authentication to a new endpoint does not accidentally expose sensitive data.

Provide Comprehensive Documentation

Document the API with a machine-readable specification that generates interactive documentation.

OpenAPI (formerly Swagger) is the standard for REST API documentation. An OpenAPI specification describes every endpoint, its parameters, request body schema, response schemas, authentication requirements, and error codes in a machine-readable format. From this specification, you can generate interactive documentation, client SDKs, and test suites.

# OpenAPI 3.0 snippet
paths:
  /users:
    get:
      summary: List users
      parameters:
        - name: cursor
          in: query
          schema:
            type: integer
      responses:
        '200':
          description: Paginated list of users
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/User'
                  pagination:
                    $ref: '#/components/schemas/Pagination'

Why it matters: Documentation that is separate from the code inevitably drifts out of sync. An OpenAPI specification generated from code (using tools like FastAPI, drf-spectacular, or swagger-core) is always accurate because it is derived from the actual implementation.

Design for Performance

Implement caching, compression, partial responses, and efficient query patterns.

API performance directly affects client experience and your infrastructure costs. Cache responses with ETags and Cache-Control headers for data that changes infrequently. Support partial responses (fields parameter) so clients request only the data they need. Use HTTP compression (gzip, brotli). Minimize database queries with eager loading and query optimization.

@app.route("/api/products/<int:product_id>")
def get_product(product_id):
    product = Product.query.get(product_id)
    if not product:
        return jsonify({"error": "Not found"}), 404
    
    # Support partial responses
    fields = request.args.get("fields")
    if fields:
        field_list = fields.split(",")
        result = {f: getattr(product, f) for f in field_list if hasattr(product, f)}
    else:
        result = product.to_dict()
    
    # ETag for caching
    etag = hashlib.md5(str(result).encode()).hexdigest()
    if request.headers.get("If-None-Match") == etag:
        return "", 304
    
    return jsonify(result), 200, {"ETag": etag}

Why it matters: Reducing API response size and using caching reduces server load, decreases latency, and improves client experience. A fields parameter can reduce a 50KB response to 2KB, which matters significantly on mobile networks and at scale.

Version Your API Explicitly

Use URL-based or header-based versioning to manage breaking changes over time.

Versioning communicates to clients that the API contract may change. Include the version in the URL path (/v1/users) or in a custom header (Accept: application/vnd.myapp.v1+json). URL-based versioning is simpler for clients and more visible. Deprecate old versions with clear timelines communicated through sunset headers.

# URL-based versioning
@app.route("/v1/users")
def list_users_v1():
    return jsonify({"users": [u.to_dict_v1() for u in User.query.all()]})

@app.route("/v2/users")
def list_users_v2():
    return jsonify({
        "data": [u.to_dict_v2() for u in User.query.all()],
        "pagination": {"total": User.query.count()}
    })

# Deprecation header
@app.after_request
def add_deprecation_headers(response):
    if request.path.startswith("/v1/"):
        response.headers["Sunset"] = "Sat, 31 Dec 2026 23:59:59 GMT"
        response.headers["Deprecation"] = "true"
    return response

Why it matters: Without versioning, every change risks breaking unknown clients. With versioning, you can evolve the API while maintaining stability for existing clients. A sunset header gives clients time to migrate, reducing support incidents during transitions.

API Rate Limiting Strategies

Rate Limiting protects your API from abuse, accidental excessive usage, and denial of service attacks. Choose a Rate Limiting strategy that fits your API usage patterns.

Token bucket algorithm: Each client has a token bucket that refills at a fixed rate. Burst requests consume stored tokens, then requests are limited to the refill rate. This allows natural bursts while preventing sustained abuse. Redis is the most common implementation backend.

from flask_limiter import Limiter
from flask_limiter.util import get_remote_address

limiter = Limiter(
    key_func=get_remote_address,
    storage_uri="redis://localhost:6379",
    default_limits=["200 per day", "50 per hour"]
)

@app.route("/api/users")
@limiter.limit("100 per minute")
def list_users():
    return jsonify({"users": [u.to_dict() for u in User.query.all()]})

@app.route("/api/login")
@limiter.limit("10 per minute")
def login():
    # Stricter limit for authentication endpoints
    pass

Return rate limit headers: Tell clients their current rate limit status so they can adjust their request rate without hitting the limit. Use standard headers: X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset, and Retry-After.

Different limits for different endpoints: Authentication endpoints need stricter limits (10-20 requests per minute) than read-only data endpoints (100-1000 requests per minute). Write endpoints (POST, PUT, DELETE) should have lower limits than read endpoints.

Why it matters: Without Rate Limiting, a single abusive client can degrade API performance for all users. With Rate Limiting and informative headers, clients can self-regulate their request rate, reducing support tickets and infrastructure costs.

API Security Best Practices

API security extends beyond authentication. Every endpoint must be designed with security in mind from the start.

Validate content types: Reject requests with unexpected Content-Type headers. A JSON API should reject requests with Content-Type: text/html and return 415 Unsupported Media Type. This prevents content type confusion attacks.

Implement request size limits: Set maximum request body sizes at the reverse proxy and application level. A 1MB limit prevents memory exhaustion attacks through large payloads. Return 413 Payload Too Large when the limit is exceeded.

Sanitize response data: Never return raw database errors, stack traces, or internal identifiers in API responses. Map internal errors to generic client-facing messages. Log the full error internally with a correlation ID.

@app.errorhandler(Exception)
def handle_error(error):
    # Log full details internally
    correlation_id = str(uuid.uuid4())
    logger.error("Internal error", extra={
        "correlation_id": correlation_id,
        "error": str(error),
        "traceback": traceback.format_exc()
    })
    
    # Return safe response to client
    return jsonify({
        "error": "INTERNAL_ERROR",
        "message": "An unexpected error occurred",
        "correlation_id": correlation_id
    }), 500

Why it matters: API security vulnerabilities directly expose your data and infrastructure. The same input validation, Rate Limiting, and error handling principles that improve API quality also prevent the most common API security exploits.

Practice Questions

  1. A developer proposes an endpoint POST /api/deleteUser with the user ID in the request body. Explain what REST principle this violates and how it should be redesigned.

  2. Your API currently returns error responses with a plain text body: "User not found." Design a structured error response format that supports multiple validation errors and includes a request identifier.

  3. A client reports that paginated results skip items when new records are inserted between page requests. Explain why this happens with offset pagination and how cursor-based pagination solves it.

  4. Your team is adding a new optional field to the user resource response. What specific changes should you make to ensure backward compatibility? What should you avoid?

  5. Design the Rate Limiting response headers for an API that allows 1000 requests per hour per user. Include the Retry-After header format and the response status code for rate-limited requests.

Should I use REST or GraphQL for my API?

REST is the right choice for most APIs because it is simpler, cacheable, and has universal client support. GraphQL excels for complex data graphs where clients need flexible query capabilities. Start with REST unless you have a specific need for GraphQL's query flexibility.

How do I handle API versioning?

Use URL-based versioning (/v1/, /v2/) for simplicity and visibility. Support each version for a minimum of 12 months after a replacement is available. Include deprecation warnings (Sunset header) in responses at least 6 months before removal. Monitor version usage to plan deprecation timelines.

{{< faq "What is the most common Api Design mistake?">}} Inconsistent naming and response formats. When every endpoint uses different naming conventions, error formats, and pagination patterns, client code becomes a collection of special cases. Consistency reduces integration effort more than any other design decision.{{< /faq >}}

Brand Credit

Built by the developers of Doda Browser, DodaZIP, and Durga Antivirus Pro. Our APIs serve millions of requests daily, powering the sync infrastructure of Doda Browser and the threat intelligence pipeline of Durga Antivirus Pro. These design principles evolved from five years of operating production APIs and are codified in our internal Api Design review process. Every new endpoint is reviewed against these 10 principles before it is deployed.

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro