system-design46 min read

RESTful API Design: The Complete Senior+ Guide | Ayodhyya

RESTful API Design: The Complete Senior+ Guide

System Design Deep Dive — Architecture, Patterns, Security, and Production-Grade Implementation

Senior+ System Design Guide 10,000+ Words 18 Deep-Dive Sections C# · Mermaid · Real-World Case Studies

1. Introduction & Why API Design Matters

A well-designed API is a product. It is the interface between your system and every consumer — web applications, mobile applications, third-party integrations, and internal microservices. A poorly designed API creates friction, bugs, and a support burden that compounds for years. A well-designed API feels intuitive, is hard to misuse, and can evolve without breaking existing consumers. In the modern software landscape, APIs are not just technical artifacts — they are business contracts that define how organizations collaborate, how partners integrate, and how users interact with digital products.

REST (Representational State Transfer) has become the dominant architectural style for designing networked applications. Its principles — statelessness, uniform interface, layered systems, and resource-based architecture — provide a scalable foundation for building APIs that can serve millions of clients simultaneously. However, merely using HTTP endpoints with JSON payloads does not make an API RESTful. True adherence to REST principles requires careful consideration of resource modeling, state management, hypermedia controls, and semantic use of HTTP methods and status codes.

The consequences of poor API design are severe and far-reaching. Teams waste months working around design flaws. Consumers build brittle workarounds for inconsistencies. Breaking changes cascade through dependent systems, causing outages and losing trust. Support teams drown in tickets caused by confusing error messages and undocumented behavior. Conversely, well-designed APIs like Stripe's, GitHub's, and Twilio's have become competitive advantages — developers choose platforms partly based on API quality. Stripe's API is so well-designed that it has become the gold standard against which all other payment APIs are measured, demonstrating that API design is a first-class engineering discipline.

Key Insight: API design is a long-term commitment. Once consumers integrate with your API, changing its behavior becomes exponentially expensive. Every endpoint, every field name, every error code becomes part of a public contract. Design for the next decade, not the next sprint.

This guide covers the principles, patterns, and conventions that distinguish senior-level API design from basic endpoint creation. We will explore resource naming, HTTP semantics, request and response design, error handling, pagination, authentication, rate limiting, versioning, caching, performance optimization, security hardening, documentation, testing, and real-world case studies from companies like Stripe, GitHub, and Shopify. Every section includes production-ready C# code examples, architectural diagrams, and detailed tables comparing design alternatives. By the end of this guide, you will have a comprehensive understanding of what it takes to design, implement, and operate RESTful APIs at scale.

Who This Guide Is For

This guide is written for senior software engineers, architects, and tech leads who design and build APIs professionally. It assumes familiarity with HTTP, JSON, and basic web development. It does not assume prior knowledge of advanced API design patterns. Whether you are designing your first public API or rethinking the API layer of an existing platform, the patterns in this guide will help you make informed decisions that stand the test of time.

Throughout this guide, we use C# and ASP.NET Core for code examples because of its strong typing, expressive syntax, and growing popularity in enterprise API development. However, the principles and patterns are language-agnostic — they apply equally to Java, Python, Go, TypeScript, or any other language used for building REST APIs. The focus is on design principles and architectural decisions, not framework-specific implementation details.

How to Read This Guide

You do not need to read this guide linearly. Each section is self-contained and addresses a specific aspect of API design. If you are designing a new API, start with Sections 3-6 (resource naming, HTTP methods, request/response design, and error handling) as these form the foundation. If you are scaling an existing API, focus on Sections 9-13 (rate limiting, versioning, caching, hypermedia, and performance optimization). If you are preparing for an architecture interview, the Interview Q&A section at the end covers the most commonly asked questions with detailed answers.

Real-World API Quality Comparison

CompanyAPI StyleVersioningKey Strength
StripeRESTful, resource-orientedDate-based (/v1)Exceptional consistency, idempotency keys, comprehensive error handling
GitHubRESTful with hypermediaHeader-based (Accept)Rich link relations, conditional requests, GraphQL alternative
ShopifyRESTful + GraphQLURI-versioned (/admin/api/2024-01)Webhook system, bulk operations, admin-level granularity
AWSRPC-style over HTTPQuery string (?Version=)Signature-based auth, regional endpoints, comprehensive SDKs
TwilioRESTful with TwiMLSubdomain-basedSelf-describing URLs, callback-driven architecture

Each of these APIs reflects different design priorities. Stripe prioritizes developer experience and consistency. GitHub prioritizes hypermedia discoverability. Shopify prioritizes merchant flexibility. AWS prioritizes service uniformity across hundreds of services. Understanding these tradeoffs is essential for making informed decisions about your own API design. The patterns we explore in this guide draw from the best practices observed across these industry-leading APIs, adapted for general-purpose application across domains.

2. REST Maturity Model & Richardson Levels

Leonard Richardson proposed a maturity model that classifies APIs into four levels based on how well they use HTTP and REST principles. Understanding this model helps teams assess their current API maturity and plan improvements. The levels are not all-or-nothing — many production APIs sit at Level 2, which provides a good balance of practicality and REST compliance. Level 3 (full HATEOAS) is rarely achieved in practice but is valuable to understand for its theoretical completeness.

graph LR L0["Level 0: Swamp of POX"] --> L1["Level 1: Resources"] L1 --> L2["Level 2: HTTP Verbs"] L2 --> L3["Level 3: Hypermedia Controls"] style L0 fill:#f85149,color:#fff style L1 fill:#d29922,color:#fff style L2 fill:#58a6ff,color:#fff style L3 fill:#3fb950,color:#fff

Level 0 — Swamp of POX (Plain Old XML)

At Level 0, the API uses a single URL endpoint and a single HTTP method (usually POST) for all operations. The request body contains everything: the operation to perform, the parameters, and the target resource. This is essentially Remote Procedure Call (RPC) over HTTP. SOAP-based web services often fall into this category. While functional, this approach defeats the purpose of using HTTP — you lose content negotiation, caching, status codes, and the uniform interface that makes the web scale.

HTTP — Level 0
POST /api/service
Content-Type: application/json

{
    "operation": "getUser",
    "userId": 42
}

POST /api/service
Content-Type: application/json

{
    "operation": "createOrder",
    "items": [...]
}

Level 1 — Resources

Level 1 introduces the concept of resources. Instead of a single endpoint, each resource gets its own URL. However, the API may still use a single HTTP method (POST) for all operations, or use query parameters to indicate the desired action. This is better than Level 0 because it gives resources identifiable addresses, but it still underutilizes HTTP semantics.

HTTP — Level 1
POST /api/users/42
{ "action": "get" }

POST /api/orders
{ "action": "create", "items": [...] }

Level 2 — HTTP Verbs

Level 2 is where most well-designed REST APIs operate. Each resource has its own URL, and the HTTP method indicates the action: GET for retrieval, POST for creation, PUT for full replacement, PATCH for partial update, DELETE for removal. Status codes communicate outcomes: 200 for success, 201 for created, 404 for not found, 500 for server errors. This level provides the practical benefits of REST: caching for GET requests, idempotency for PUT/DELETE, and meaningful status codes for error handling.

HTTP — Level 2
GET    /api/users/42
POST   /api/orders
PUT    /api/users/42
PATCH  /api/orders/123
DELETE /api/users/42

Level 3 — Hypermedia Controls (HATEOAS)

Level 3 adds hypermedia controls — responses include links that describe available actions. A GET /users/42 response might include links for editing, deleting, or viewing the user's orders. This enables clients to navigate the API without hardcoding URLs, making the API self-documenting and more resilient to server-side URL changes. We cover HATEOAS in depth in Section 12.

JSON — Level 3
{
    "id": 42,
    "name": "Jane Doe",
    "email": "jane@example.com",
    "_links": {
        "self": { "href": "/api/users/42", "method": "GET" },
        "update": { "href": "/api/users/42", "method": "PUT" },
        "delete": { "href": "/api/users/42", "method": "DELETE" },
        "orders": { "href": "/api/users/42/orders", "method": "GET" }
    }
}
Practical Guidance: Most production APIs should target Level 2. Level 3 provides theoretical elegance but adds implementation complexity that many teams find impractical. Include relevant links (self, next/prev for pagination) without building a fully hypermedia-driven API. The pragmatic approach is to use hypermedia selectively where it provides clear value — pagination links, action links on resources, and discovery endpoints.

Maturity Level Comparison

LevelResourcesHTTP MethodsStatus CodesHypermediaCaching
Level 0NoSingle (POST)Generic (200/500)NoNo
Level 1YesSingle (POST)Generic (200/500)NoNo
Level 2YesMultiple (GET/POST/PUT/DELETE)Specific (200/201/404/500)NoYes (GET)
Level 3YesMultipleSpecificYesYes

3. Resource Naming & URL Structure

URLs represent resources (nouns), not actions (verbs). This is the most fundamental rule of REST API design and the most frequently violated. Every endpoint should map to a resource or a collection of resources. The HTTP method indicates the action. When you put verbs in URLs like /api/getUsers or /api/createOrder, you are designing an RPC-style API that happens to use HTTP as a transport layer, losing the benefits of REST's uniform interface.

Naming Rules

  • Use plural nouns for collections: /users, /orders, /products. Even for a single resource, use the plural form with an identifier: /users/{id}, not /user/{id}.
  • Use kebab-case for multi-word resources: /order-items, /payment-methods, not /orderItems or /order_items. Kebab-case is the URL convention used by the vast majority of major APIs.
  • Limit nesting to two levels: /users/{id}/orders is fine. /users/{id}/orders/{id}/items/{id}/reviews is not. Deep nesting indicates missing abstractions — the reviews endpoint should be /reviews?order_item_id={id}.
  • Use query parameters for filtering, sorting, and pagination: /users?status=active&sort=-created_at&page=2. Never put filtering criteria in the URL path.
  • Do not use file extensions: /users/42.json is an anti-pattern. Use the Accept header for content negotiation instead.
  • Do not expose implementation details: Avoid database table names, internal service names, or technology-specific paths in URLs.
HTTP — Good vs Bad URL Design
# GOOD: Resource-oriented, plural nouns, kebab-case
GET    /api/v1/users
POST   /api/v1/users
GET    /api/v1/users/42
PUT    /api/v1/users/42
GET    /api/v1/users/42/orders
GET    /api/v1/order-items?status=shipped

# BAD: Verbs in URLs, singular nouns, deep nesting, file extensions
GET    /api/v1/getUser
POST   /api/v1/createUser
GET    /api/v1/user/42
GET    /api/v1/users/42/orders/7/items/3/reviews
GET    /api/v1/users.json

Action Endpoints (When Verbs Are Acceptable)

Sometimes a operation does not map cleanly to CRUD on a resource. For example, canceling an order, resetting a password, or sending an invoice. The pragmatic solution is to use a sub-resource that represents the action: POST /orders/{id}/cancellation creates a cancellation resource, POST /users/{id}/password-reset triggers a password reset. This keeps URLs noun-based while clearly expressing intent. Avoid verbs in the URL path itself — use POST to a noun sub-resource instead.

HTTP — Action Endpoints
# Cancellation as a resource
POST /api/v1/orders/42/cancellation
{ "reason": "customer_request" }

# Password reset as a sub-resource
POST /api/v1/users/42/password-reset
{ "email": "user@example.com" }

# Invoice as a generated resource
POST /api/v1/invoices
{ "order_id": 42, "format": "pdf" }

Resource Naming Patterns

Filter via query
PatternExampleWhen to Use
Collection + ID/users/{id}Standard resource access
Nested collection/users/{id}/ordersResource belongs to parent
/orders?status=pendingFiltering within a collection
Action sub-resource/orders/{id}/cancellationNon-CRUD operations
Search endpoint/products/search?q=laptopFull-text or complex search
Singleton resource/settingsOne-per-account resources
Consistency Over Individual Choices: The most important rule in resource naming is consistency. If you use /users/{id}/orders, do not switch to /customers/{id}/purchases in another part of the API. Choose a convention, document it, and enforce it through code review. Inconsistency is the number one source of API consumer confusion.

4. HTTP Methods, Status Codes & Semantics

Proper use of HTTP methods and status codes is what separates a RESTful API from a URL-based RPC system. Each HTTP method carries specific semantic guarantees — idempotency, safety, cacheability — that clients and intermediaries (proxies, CDNs, browsers) rely on. Violating these semantics breaks caching, confuses clients, and makes your API harder to integrate with.

HTTP Methods

MethodPurposeIdempotentSafeCacheableRequest BodyResponse Body
GETRetrieve a resource or collectionYesYesYesNoYes (resource representation)
POSTCreate a resource or trigger an operationNoNoConditionalYesYes (created resource or result)
PUTReplace a resource entirelyYesNoNoYes (full resource)Yes (replaced resource)
PATCHPartial update of a resourceNo*NoNoYes (partial changes)Yes (updated resource)
DELETERemove a resourceYesNoNoOptionalOptional (confirmation)
HEADGET without response bodyYesYesYesNoNo
OPTIONSDiscover supported methodsYesYesNoNoYes (allowed methods)

Idempotency means calling the method multiple times produces the same result as calling it once. GET, PUT, and DELETE are idempotent. POST is not — sending the same POST request twice may create two resources. PATCH is generally not idempotent, though JSON Merge Patch (RFC 7396) can be made idempotent if the patch document represents the desired final state rather than incremental changes.

Safe methods do not modify server state. GET, HEAD, and OPTIONS are safe — they can be called without side effects. This is why search engine crawlers can safely issue GET requests to your API without worrying about accidentally deleting data.

HTTP Status Codes

Status codes communicate the outcome of a request. Using the correct status code is essential for clients to understand what happened and how to respond. The most common mistake is returning 200 OK for everything, including errors. This forces clients to parse the response body to determine success, which is fragile and inconsistent.

CodeMeaningWhen to Use
200 OKRequest succeededGET returns resource, PUT returns updated resource
201 CreatedResource createdPOST succeeds — include Location header with new resource URL
204 No ContentSuccess with no response bodyDELETE succeeds, PUT where client has current version
400 Bad RequestClient error — malformed requestInvalid JSON, missing required fields, malformed URL
401 UnauthorizedAuthentication requiredNo token, expired token, invalid token
403 ForbiddenAuthenticated but not authorizedValid token but insufficient permissions
404 Not FoundResource does not existInvalid ID, deleted resource
409 ConflictRequest conflicts with current stateDuplicate creation, concurrent modification
422 Unprocessable EntityValid JSON but semantic errorsEmail format invalid, date in the past
429 Too Many RequestsRate limit exceededClient exceeded per-second or per-minute limit
500 Internal Server ErrorServer-side failureUnexpected error, database failure, unhandled exception
502 Bad GatewayUpstream service failureDependency returned invalid response
503 Service UnavailableService temporarily downMaintenance, overloaded, dependency unavailable
C#
[ApiController]
[Route("api/v1/[controller]")]
public class OrdersController : ControllerBase
{
    [HttpPost]
    [ProducesResponseType(typeof(OrderDto), StatusCodes.Status201Created)]
    [ProducesResponseType(typeof(ErrorResponse), StatusCodes.Status400BadRequest)]
    [ProducesResponseType(typeof(ErrorResponse), StatusCodes.Status409Conflict)]
    public async Task CreateOrder(
        [FromBody] CreateOrderRequest request)
    {
        var validation = _validator.Validate(request);
        if (!validation.IsValid)
            return BadRequest(ErrorResponse.FromValidation(validation));

        try
        {
            var order = await _orderService.CreateAsync(request);
            return CreatedAtAction(
                nameof(GetOrder),
                new { id = order.Id },
                order);
        }
        catch (DuplicateOrderException ex)
        {
            return Conflict(ErrorResponse.FromException(ex));
        }
    }

    [HttpGet("{id:guid}")]
    [ProducesResponseType(typeof(OrderDto), StatusCodes.Status200OK)]
    [ProducesResponseType(StatusCodes.Status404NotFound)]
    public async Task GetOrder(Guid id)
    {
        var order = await _orderService.GetByIdAsync(id);
        if (order == null) return NotFound();
        return Ok(order);
    }

    [HttpDelete("{id:guid}")]
    [ProducesResponseType(StatusCodes.Status204NoContent)]
    [ProducesResponseType(StatusCodes.Status404NotFound)]
    public async Task DeleteOrder(Guid id)
    {
        var deleted = await _orderService.DeleteAsync(id);
        if (!deleted) return NotFound();
        return NoContent();
    }
}
Never Return 200 for Errors: Returning HTTP 200 with an error body is an anti-pattern that breaks HTTP semantics. Intermediaries (CDNs, proxies, browsers) cache 200 responses and treat them as successful. Clients that check status codes will see 200 and assume success, missing the error in the body entirely. Use 4xx for client errors and 5xx for server errors. Always.

5. Request & Response Design

Consistent request and response formats are the backbone of a usable API. When every endpoint follows the same conventions for field naming, date formats, pagination wrapping, and metadata, consumers can build generic clients and handlers that work across the entire API. Inconsistency forces consumers to write special-case code for every endpoint, increasing integration cost and error rates.

Response Envelope

Use a consistent response envelope for all endpoints. For single resources, return the resource directly. For collections, wrap in an object with a data array and pagination metadata. This approach avoids the ambiguity of returning a bare array (which prevents adding metadata later) while keeping single-resource responses simple.

JSON — Single Resource Response
{
    "id": "ord_abc123",
    "customer_id": "cus_xyz789",
    "status": "pending",
    "total": 99.99,
    "currency": "USD",
    "created_at": "2026-01-15T10:30:00Z",
    "updated_at": "2026-01-15T10:30:00Z"
}
JSON — Collection Response with Pagination
{
    "data": [
        {
            "id": "ord_abc123",
            "customer_id": "cus_xyz789",
            "status": "pending",
            "total": 99.99
        },
        {
            "id": "ord_def456",
            "customer_id": "cus_xyz789",
            "status": "shipped",
            "total": 149.99
        }
    ],
    "pagination": {
        "total": 1247,
        "page": 1,
        "per_page": 20,
        "has_more": true,
        "next_cursor": "eyJpZCI6Im9yZF9kZWY0NTYifQ=="
    }
}

Field Naming Conventions

ConventionExampleUsed By
snake_casecreated_at, order_itemsRuby on Rails APIs, Stripe, Shopify
camelCasecreatedAt, orderItemsJavaScript ecosystem, many REST APIs
PascalCaseCreatedAt, OrderItemsC# / .NET APIs

Choose one convention and use it everywhere. If your API serves multiple language ecosystems, snake_case is the most widely compatible — JavaScript consumers can easily convert to camelCase, and most HTTP clients handle the conversion automatically. Pick a convention based on your primary consumer ecosystem, not your internal implementation language.

Date and Time Format

Always use ISO 8601 format in UTC: 2026-01-15T10:30:00Z. The Z suffix explicitly indicates UTC. Never use Unix timestamps in API responses — they are unreadable by humans and ambiguous regarding leap seconds. Always include timezone information. Store all timestamps in UTC internally and convert to the user's local timezone in the presentation layer, not the API layer.

ID Format

Use string IDs, not integers. String IDs (like Stripe's cus_abc123) provide several advantages: they encode the resource type (preventing ID confusion), they are opaque (hiding internal sequence numbers), they support multiple ID generation strategies (UUIDs, ULIDs, prefixed IDs), and they are URL-safe. If you use UUIDs, consider ULIDs for sortability — ULIDs encode a timestamp prefix, making them naturally ordered in database indexes.

C#
public class ApiResponse<T>
{
    public T Data { get; set; }
    public PaginationMeta? Pagination { get; set; }
    public ResponseMetadata Meta { get; set; }
}

public class PaginationMeta
{
    public int Total { get; set; }
    public int Page { get; set; }
    public int PerPage { get; set; }
    public bool HasMore { get; set; }
    public string? NextCursor { get; set; }
}

public class ResponseMetadata
{
    public string RequestId { get; set; }
    public DateTime ServerTime { get; set; }
    public string ApiVersion { get; set; }
}

// Usage in controller
[HttpGet]
public async Task<ActionResult<ApiResponse<List<OrderDto>>>>
    GetOrders([FromQuery] OrderQuery query)
{
    var (orders, total) = await _orderService
        .QueryAsync(query);

    return Ok(new ApiResponse<List<OrderDto>>
    {
        Data = orders,
        Pagination = new PaginationMeta
        {
            Total = total,
            Page = query.Page,
            PerPage = query.PerPage,
            HasMore = query.Page * query.PerPage < total,
            NextCursor = orders.LastOrDefault()?.Id
        },
        Meta = new ResponseMetadata
        {
            RequestId = HttpContext.TraceIdentifier,
            ServerTime = DateTime.UtcNow,
            ApiVersion = "2026-01-15"
        }
    });
}
The X-Request-Id Pattern: Every API response should include a unique request identifier (in a header or response body). This ID enables consumers to correlate their request with server-side logs when debugging. Generate a unique ID per request (UUID or ULID), propagate it through all internal service calls, and return it in the X-Request-Id response header. This single practice reduces debugging time by 50% or more.

6. Error Handling & Problem Details (RFC 7807)

Every API will encounter errors. The difference between a good API and a bad one is how those errors are communicated. A good error response tells the consumer exactly what went wrong, why it went wrong, and how to fix it. A bad error response returns a generic "Internal Server Error" with no context, forcing the consumer to guess and retry blindly.

RFC 7807 defines a standard format for HTTP API problem details: a JSON object with type (a URI identifying the error type), title (a short human-readable summary), status (the HTTP status code), detail (a human-readable explanation), and instance (a URI identifying the specific occurrence). This standard provides a machine-readable error code, a human-readable message, and enough context to debug without exposing internal implementation details.

JSON — RFC 7807 Problem Details
{
    "type": "https://api.example.com/errors/validation-failed",
    "title": "Validation Failed",
    "status": 422,
    "detail": "The request body contains 2 validation errors.",
    "instance": "/api/v1/orders/req_abc123",
    "errors": [
        {
            "field": "email",
            "code": "invalid_format",
            "message": "Must be a valid email address",
            "received": "not-an-email"
        },
        {
            "field": "amount",
            "code": "out_of_range",
            "message": "Must be between 0.01 and 1000000.00",
            "received": -50.00
        }
    ]
}

Error Classification Strategy

Classify errors into categories that guide consumer behavior. Client errors (4xx) indicate something the consumer can fix: invalid input, missing authentication, insufficient permissions. Server errors (5xx) indicate something the provider must fix: database failure, dependency timeout, internal bug. The error response should help consumers distinguish between retryable errors (5xx, 429) and permanent errors (400, 422, 404).

Error CategoryStatus CodeConsumer ActionRetryable?
Validation Error400, 422Fix the request and retryNo
Authentication Error401Refresh token and retryNo
Authorization Error403Request elevated permissionsNo
Not Found404Verify resource IDNo
Conflict409Resolve conflict and retryMaybe
Rate Limited429Back off and retry after delayYes
Server Error500Retry with exponential backoffYes
Service Unavailable503Retry after Retry-After header valueYes
C#
public class ProblemDetailsExceptionFilter : IExceptionFilter
{
    private readonly ILogger<ProblemDetailsExceptionFilter> _logger;

    public void OnException(ExceptionContext context)
    {
        var exception = context.Exception;

        var problemDetails = exception switch
        {
            ValidationException ex => new ProblemDetails
            {
                Type = "https://api.example.com/errors/validation-failed",
                Title = "Validation Failed",
                Status = StatusCodes.Status422UnprocessableEntity,
                Detail = ex.Message,
                Extensions = { ["errors"] = ex.Errors }
            },
            NotFoundException ex => new ProblemDetails
            {
                Type = "https://api.example.com/errors/not-found",
                Title = "Resource Not Found",
                Status = StatusCodes.Status404NotFound,
                Detail = ex.Message
            },
            ConflictException ex => new ProblemDetails
            {
                Type = "https://api.example.com/errors/conflict",
                Title = "Resource Conflict",
                Status = StatusCodes.Status409Conflict,
                Detail = ex.Message
            },
            UnauthorizedAccessException => new ProblemDetails
            {
                Type = "https://api.example.com/errors/forbidden",
                Title = "Forbidden",
                Status = StatusCodes.Status403Forbidden,
                Detail = "You do not have permission to access this resource."
            },
            _ => new ProblemDetails
            {
                Type = "https://api.example.com/errors/internal",
                Title = "Internal Server Error",
                Status = StatusCodes.Status500InternalServerError,
                Detail = "An unexpected error occurred. Please try again later."
            }
        };

        _logger.LogError(exception,
            "Unhandled exception: {ExceptionType}: {Message}",
            exception.GetType().Name, exception.Message);

        context.Result = new ObjectResult(problemDetails)
        {
            StatusCode = problemDetails.Status
        };
        context.ExceptionHandled = true;
    }
}
Never Expose Stack Traces: Production error responses must never include stack traces, SQL queries, file paths, or internal service names. These details leak implementation information that attackers can exploit. Log the full details server-side for debugging, but return only safe, actionable information to the consumer.

7. Pagination, Filtering & Sorting

Every list endpoint must support pagination. Returning unbounded collections is a denial-of-service attack waiting to happen — a single request can exhaust database memory, network bandwidth, and client resources. Pagination limits response size, improves latency, and enables progressive loading in user interfaces.

Offset-Based Pagination

Offset-based pagination uses page number and page size: /users?page=3&per_page=20. It is simple to implement and familiar to most developers. However, it has a critical flaw: page drift. If an item is inserted or deleted while a consumer is paginating, subsequent pages may skip or duplicate items. This makes offset-based pagination unsuitable for real-time feeds or any dataset that changes during iteration.

C#
public class OffsetPaginationHandler<T>
{
    public async Task<PagedResult<T>>
        GetPageAsync(IQueryable<T> query, int page, int perPage)
    {
        var total = await query.CountAsync();
        var items = await query
            .Skip((page - 1) * perPage)
            .Take(perPage)
            .ToListAsync();

        return new PagedResult<T>
        {
            Data = items,
            Pagination = new PaginationMeta
            {
                Total = total,
                Page = page,
                PerPage = perPage,
                HasMore = page * perPage < total
            }
        };
    }
}

Cursor-Based Pagination

Cursor-based pagination uses an opaque cursor that encodes the position in the dataset: /users?cursor=eyJpZCI6NDJ9&per_page=20. The cursor is typically the ID or a composite key of the last item in the previous page. This approach eliminates page drift — the cursor always points to the correct position regardless of concurrent modifications. Cursor-based pagination is preferred for real-time data, feeds, and any dataset that changes frequently.

C#
public class CursorPaginationHandler<T> where T : IHasId
{
    public async Task<PagedResult<T>>
        GetPageAsync(IQueryable<T> query, string? cursor, int limit)
    {
        if (!string.IsNullOrEmpty(cursor))
        {
            var lastId = DecodeCursor(cursor);
            query = query.Where(x => x.Id > lastId);
        }

        var items = await query
            .OrderBy(x => x.Id)
            .Take(limit + 1)
            .ToListAsync();

        var hasMore = items.Count > limit;
        if (hasMore) items.RemoveAt(items.Count - 1);

        return new PagedResult<T>
        {
            Data = items,
            Pagination = new PaginationMeta
            {
                HasMore = hasMore,
                NextCursor = hasMore
                    ? EncodeCursor(items.Last().Id)
                    : null
            }
        };
    }

    private static string EncodeCursor(Guid id) =
        Convert.ToBase64String(id.ToByteArray());

    private static Guid DecodeCursor(string cursor) =
        new Guid(Convert.FromBase64String(cursor));
}

Filtering Conventions

Use query parameters for filtering with consistent conventions. Exact match uses the field name: ?status=active. Range queries use bracket notation: ?created_at[gte]=2026-01-01&created_at[lte]=2026-01-31. Multiple values use comma separation: ?status=pending,processing. Text search uses a dedicated parameter: ?q=search+term. Always document which fields are filterable and which operators are supported.

HTTP — Filtering Examples
# Exact match
GET /api/v1/orders?status=pending

# Range query
GET /api/v1/orders?created_at[gte]=2026-01-01&created_at[lte]=2026-01-31

# Multiple values
GET /api/v1/products?category=electronics,books&status=active

# Text search
GET /api/v1/products?q=laptop&sort=-rating

# Combined filtering, sorting, and pagination
GET /api/v1/orders?status=shipped&total[gte]=100&sort=-shipped_at&page=1&per_page=25

Sorting Conventions

Use a sort query parameter with field names. Prefix with a minus sign for descending order: sort=-created_at for newest first. Multiple sort fields: sort=-priority,created_at. Allow sorting only on indexed fields to prevent expensive database scans. Document the default sort order for every endpoint.

PatternExampleProsCons
Offset-based?page=3&per_page=20Simple, allows "jump to page"Page drift, slow for deep pages
Cursor-based?cursor=abc123&per_page=20No drift, consistent, fastNo page jumping, more complex
Seek-based?after_id=42&per_page=20Simple, database-friendlyRequires sortable, unique column
Key Decision: Use cursor-based pagination for all APIs that serve real-time data or have high write throughput. Use offset-based pagination for admin dashboards and internal tools where page jumping is important and data changes slowly. If in doubt, default to cursor-based — it scales better and avoids subtle bugs.

8. Authentication & Authorization

Authentication (who are you?) and authorization (what can you do?) are fundamental to every API. REST APIs use several authentication mechanisms, each with different tradeoffs. The choice depends on your security requirements, client types, and operational complexity.

Authentication Mechanisms

MechanismHow It WorksBest ForTradeoffs
API KeyStatic key in header or query paramServer-to-server, simple integrationsStatic, cannot be revoked per-session, no expiry
JWT (Bearer Token)Signed token with claims, validated locallyStateless auth, microservicesCannot be revoked early, token bloat, key rotation complexity
OAuth 2.0Authorization code flow with refresh tokensThird-party access, user delegationComplex implementation, requires token storage
mTLSClient certificate validationHigh-security service-to-serviceCertificate management overhead, not browser-friendly
Session CookieServer-side session with cookieBrowser-based appsStateful, CSRF risk, not API-friendly

JWT Authentication Implementation

JWT (JSON Web Token) is the most common authentication mechanism for REST APIs. The token contains claims (user ID, tenant ID, permissions, expiry) signed with a secret or private key. The server validates the signature and extracts claims without database lookups, making it stateless and fast. However, JWTs cannot be revoked before expiry — if a token is compromised, you must wait for it to expire. Use short-lived access tokens (5-15 minutes) with long-lived refresh tokens to mitigate this.

C#
public class JwtAuthenticationHandler
    : AuthenticationHandler<JwtBearerOptions>
{
    protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
    {
        var token = Request.Headers.Authorization
            .FirstOrDefault()?.Split(" ").Last();

        if (string.IsNullOrEmpty(token))
            return AuthenticateResult.Fail("No token provided");

        try
        {
            var handler = new JwtSecurityTokenHandler();
            var key = Encoding.UTF8.GetBytes(
                _config["Jwt:SecretKey"]);

            var parameters = new TokenValidationParameters
            {
                ValidateIssuer = true,
                ValidIssuer = _config["Jwt:Issuer"],
                ValidateAudience = true,
                ValidAudience = _config["Jwt:Audience"],
                ValidateLifetime = true,
                ValidateIssuerSigningKey = true,
                IssuerSigningKey = new SymmetricSecurityKey(key),
                ClockSkew = TimeSpan.FromSeconds(30)
            };

            var principal = handler.ValidateToken(
                token, parameters, out var validatedToken);

            var claims = new ClaimsPrincipal(new ClaimsIdentity(
                principal.Claims, JwtBearerDefaults
                    .AuthenticationScheme));

            return AuthenticateResult.Success(
                new AuthenticationTicket(claims,
                    JwtBearerDefaults.AuthenticationScheme));
        }
        catch (SecurityTokenExpiredException)
        {
            return AuthenticateResult.Fail("Token expired");
        }
        catch (SecurityTokenException ex)
        {
            return AuthenticateResult.Fail(ex.Message);
        }
    }
}

Authorization Patterns

Authorization determines what an authenticated user can do. Three common patterns: role-based access control (RBAC) assigns permissions to roles and users to roles; attribute-based access control (ABAC) evaluates policies based on user attributes, resource attributes, and environment conditions; and resource-based authorization checks if the user owns or has access to the specific resource being accessed.

C#
// Role-based authorization
[Authorize(Roles = "Admin,Manager")]
[HttpDelete("{id:guid}")]
public async Task<IActionResult> DeleteOrder(Guid id) { ... }

// Policy-based authorization (more flexible)
[Authorize(Policy = "CanManageOrders")]
[HttpPut("{id:guid}")]
public async Task<IActionResult> UpdateOrder(
    Guid id, UpdateOrderRequest request) { ... }

// Resource-based authorization
[HttpPut("{id:guid}")]
public async Task<IActionResult> UpdateOrder(
    Guid id, UpdateOrderRequest request)
{
    var order = await _orderService.GetByIdAsync(id);
    if (order == null) return NotFound();

    // Check if user owns this order or is an admin
    if (!await _authService.CanAccessAsync(
        User, order, Permission.Update))
    {
        return Forbid();
    }

    await _orderService.UpdateAsync(id, request);
    return Ok();
}
Authentication vs Authorization Errors: Return 401 Unauthorized when the request lacks valid authentication (no token, expired token). Return 403 Forbidden when the request has valid authentication but insufficient permissions. This distinction is critical — 401 tells the client to re-authenticate, while 403 tells the client they are authenticated but not authorized.

9. Rate Limiting & Throttling

Rate limiting protects your API from abuse, ensures fair resource allocation among consumers, and prevents cascading failures when demand exceeds capacity. Without rate limiting, a single misbehaving client can consume all available resources, degrading the experience for every other consumer. Rate limiting is not optional for production APIs — it is a fundamental reliability mechanism.

Rate Limiting Algorithms

AlgorithmHow It WorksProsCons
Token BucketTokens refill at a fixed rate; each request consumes a tokenAllows controlled bursts, smooth rateMore complex to implement
Sliding Window LogStore timestamp of each request; count within windowPrecise, no edge burstMemory-intensive, requires sorted set
Sliding Window CounterWeighted count of current and previous windowMemory-efficient, smoothApproximate, not exact
Fixed WindowCount requests in fixed time periodSimple, low memoryEdge burst at window boundaries
Leaky BucketRequests enter a queue; processed at fixed rateSmooth output, prevents burstsAdds latency, queue management

Token Bucket Implementation

C#
public class TokenBucketRateLimiter
{
    private readonly ConcurrentDictionary<string, TokenBucket>
        _buckets = new();
    private readonly int _capacity;
    private readonly double _refillRate;

    public TokenBucketRateLimiter(int capacity, double refillRate)
    {
        _capacity = capacity;
        _refillRate = refillRate; // tokens per second
    }

    public RateLimitResult TryConsume(
        string key, int tokens = 1)
    {
        var bucket = _buckets.GetOrAdd(key,
            _ => new TokenBucket(_capacity, _refillRate));

        lock (bucket)
        {
            bucket.Refill();

            if (bucket.Tokens >= tokens)
            {
                bucket.Tokens -= tokens;
                return new RateLimitResult
                {
                    Allowed = true,
                    Remaining = (int)bucket.Tokens,
                    RetryAfter = null
                };
            }

            var deficit = tokens - bucket.Tokens;
            var retryAfter = TimeSpan.FromSeconds(
                deficit / _refillRate);

            return new RateLimitResult
            {
                Allowed = false,
                Remaining = 0,
                RetryAfter = retryAfter
            };
        }
    }

    private class TokenBucket
    {
        public double Tokens { get; set; }
        public double LastRefill { get; set; }
        private readonly int _capacity;
        private readonly double _refillRate;

        public TokenBucket(int capacity, double refillRate)
        {
            _capacity = capacity;
            _refillRate = refillRate;
            Tokens = capacity;
            LastRefill = Stopwatch.GetTimestamp();
        }

        public void Refill()
        {
            var now = Stopwatch.GetTimestamp();
            var elapsed = (now - LastRefill)
                / Stopwatch.Frequency;
            Tokens = Math.Min(_capacity,
                Tokens + elapsed * _refillRate);
            LastRefill = now;
        }
    }
}

Response Headers

Always include rate limit information in response headers so consumers can adapt their behavior. The standard headers are X-RateLimit-Limit (maximum requests allowed), X-RateLimit-Remaining (requests remaining in current window), X-RateLimit-Reset (Unix timestamp when the window resets), and Retry-After (seconds to wait, included only on 429 responses).

HTTP — Rate Limit Headers
# Successful response with rate limit info
HTTP/1.1 200 OK
X-RateLimit-Limit: 1000
X-RateLimit-Remaining: 847
X-RateLimit-Reset: 1737000000

# Rate limited response
HTTP/1.1 429 Too Many Requests
X-RateLimit-Limit: 1000
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1737000060
Retry-After: 30
Content-Type: application/problem+json

{
    "type": "https://api.example.com/errors/rate-limited",
    "title": "Rate Limit Exceeded",
    "status": 429,
    "detail": "You have exceeded 1000 requests per minute.",
    "retry_after": 30
}
Tiered Rate Limiting: Apply different rate limits based on authentication level and subscription tier. Unauthenticated requests: 100/hour per IP. Free tier: 1,000/hour per API key. Pro tier: 10,000/hour. Enterprise: 100,000/hour. This prevents abuse while giving paying customers the throughput they need. Rate limits should be configurable per customer for enterprise accounts.

10. API Versioning Strategies

APIs evolve. Requirements change, new features are added, and old patterns are replaced. Versioning allows you to make changes without breaking existing consumers. The key insight is that versioning is a contract management problem — it tells consumers which version of the contract they are using and when changes take effect.

Versioning Approaches

ApproachExampleProsCons
URL path/api/v1/usersExplicit, easy to test, visible in logsURL proliferation, router complexity
Header (Accept)Accept: application/vnd.api.v1+jsonClean URLs, content negotiationHard to test in browser, invisible in logs
Query parameter/api/users?version=1Optional, easy to implementEasy to forget, cacheability issues
Date-based/api/2026-01-15/usersAutomatic deprecation, clear timelineFrequent versions, URL churn

URL path versioning is the most widely adopted approach because of its simplicity and explicitness. It is used by Stripe (date-based), GitHub (Accept header with URL fallback), Shopify (URI-versioned), and most major public APIs. For internal APIs, header versioning is viable because you control all consumers. For public APIs, URL versioning is almost always the right choice.

Non-Breaking vs Breaking Changes

Not all changes require a new version. Understanding the difference between breaking and non-breaking changes is critical for managing API evolution efficiently.

Change TypeExampleBreaking?Version Bump?
Add new field to responseAdd phone_number to userNoNo
Add new optional request parameterAdd ?include=profileNoNo
Add new endpointPOST /api/v1/users/verifyNoNo
Remove a response fieldRemove legacy_idYesYes
Change field typeamount from int to stringYesYes
Change error response formatFrom string to objectYesYes
Add required request parameterRequire currencyYesYes
Change URL structure/users/{id}/orders/orders?user_id={id}YesYes
C#
// Version routing in ASP.NET Core
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();

// Register versioned API groups
app.MapGroup("/api/v1")
    .MapV1Endpoints()
    .WithTags("v1");

app.MapGroup("/api/v2")
    .MapV2Endpoints()
    .WithTags("v2");

// V2 adds new fields but maintains backward compatibility
app.MapGet("/api/v2/users/{id}", async (Guid id) =>
{
    var user = await GetUserAsync(id);
    return Results.Ok(new UserV2Response
    {
        Id = user.Id,
        Name = user.Name,
        Email = user.Email,
        Phone = user.Phone,       // New in v2
        Preferences = user.Prefs  // New in v2
    });
});
Stripe's Versioning Model: Stripe uses date-based API versions (e.g., 2026-01-15). Each account is pinned to the API version at creation time. New API versions are announced months in advance, and accounts can be upgraded manually. This model provides maximum stability — your API never changes unexpectedly — while allowing gradual adoption of new features. Consider this model if you are building a developer platform.

11. Caching Strategies

Caching is one of the most powerful performance optimization tools for REST APIs. HTTP provides built-in caching semantics through headers that browsers, CDNs, and proxy servers understand natively. Properly implemented caching can reduce server load by 80% or more for read-heavy APIs while improving latency for consumers.

HTTP Caching Headers

HeaderPurposeExample
Cache-ControlDirects caching behaviorpublic, max-age=300
ETagVersion identifier for conditional requests"abc123"
Last-ModifiedWhen the resource was last changedWed, 15 Jan 2026 10:30:00 GMT
ExpiresLegacy expiry date (use Cache-Control instead)Wed, 15 Jan 2026 10:35:00 GMT
VaryCache key componentsAccept, Authorization

Cache-Control Directives

HTTP — Cache-Control Examples
# Public resource, cacheable by CDN for 5 minutes
GET /api/v1/products/public-listing
Cache-Control: public, max-age=300

# Private resource, cacheable by browser for 1 minute
GET /api/v1/users/me
Cache-Control: private, max-age=60

# Never cache (dynamic, user-specific data)
GET /api/v1/users/me/orders
Cache-Control: no-store, no-cache, must-revalidate

# Conditional request support
GET /api/v1/products/prod_123
Cache-Control: private, max-age=60
ETag: "v42"

# If client already has this version, server returns 304
GET /api/v1/products/prod_123
If-None-Match: "v42"
→ 304 Not Modified (no body, no re-transfer)
C#
public class CachingMiddleware
{
    private readonly RequestDelegate _next;

    public async Task InvokeAsync(HttpContext context)
    {
        await _next(context);

        var endpoint = context.GetEndpoint();
        var cacheAttr = endpoint?.Metadata
            .GetMetadata<CacheControlAttribute>();

        if (cacheAttr != null)
        {
            context.Response.Headers["Cache-Control"] =
                cacheAttr_directive;
        }

        // ETag support for conditional requests
        if (context.Response.StatusCode == 200 &&
            context.Request.Headers
                .TryGetValue("If-None-Match", out var etag))
        {
            var currentEtag = context.Response.Headers["ETag"];
            if (etag == currentEtag)
            {
                context.Response.StatusCode = 304;
                context.Response.Body = Stream.Null;
            }
        }
    }
}

[AttributeUsage(AttributeTargets.Method)]
public class CacheControlAttribute : Attribute
{
    public string Directive { get; }
    public CacheControlAttribute(string directive)
    {
        Directive = directive;
    }
}

// Usage
[HttpGet("{id}")]
[CacheControl("private, max-age=60")]
public async Task<IActionResult> GetProduct(string id)
{
    var product = await _productService.GetByIdAsync(id);
    Response.Headers["ETag"] = $"\"{product.Version}\"";
    return Ok(product);
}

Cache Invalidation Strategies

Cache invalidation is one of the hardest problems in computer science. For REST APIs, the most practical approach is time-based expiry (max-age) combined with version-based conditional requests (ETags). When a resource changes, the ETag changes, causing clients with stale caches to receive fresh data on their next request. For real-time data, use no-cache or very short max-age values. For rarely-changing data (product catalogs, configuration), use long max-age values with stale-while-revalidate for background refresh.

Cache Key Design: The Vary header tells caches which request components affect the cached response. If your API returns different content based on the Authorization header, include Vary: Authorization to prevent one user's data from being served to another. Always include Vary: Accept if your API supports multiple content types.

12. HATEOAS & Hypermedia Controls

HATEOAS (Hypermedia as the Engine of Application State) is the highest level of REST maturity. It means that the API responses include links that tell the client what actions are available next. Instead of the client hardcoding URLs and knowing which endpoints to call, the server provides discoverable links. This decouples the client from the server's URL structure, enabling the server to change URLs without breaking clients.

In practice, full HATEOAS is rarely implemented because of its complexity. However, selective use of hypermedia controls provides real value. Pagination links (next, previous, first, last) are universally useful. Action links on resources (edit, delete, cancel) improve discoverability. Self links enable clients to reference resources by their canonical URL. The pragmatic approach is to include links where they provide clear value without building a fully hypermedia-driven API.

JSON — Practical Hypermedia Response
{
    "id": "ord_abc123",
    "status": "pending",
    "total": 99.99,
    "items": [
        {
            "product_id": "prod_xyz",
            "name": "Wireless Headphones",
            "quantity": 1,
            "unit_price": 99.99
        }
    ],
    "_links": {
        "self": {
            "href": "/api/v1/orders/ord_abc123",
            "method": "GET"
        },
        "cancel": {
            "href": "/api/v1/orders/ord_abc123/cancellation",
            "method": "POST",
            "confirm": "Are you sure you want to cancel this order?"
        },
        "payment": {
            "href": "/api/v1/orders/ord_abc123/payments",
            "method": "POST"
        },
        "customer": {
            "href": "/api/v1/customers/cus_xyz789",
            "method": "GET"
        }
    }
}

Link Relation Types

RelationPurposeExample
selfCanonical URL of this resource/api/v1/orders/ord_abc123
nextNext page in a collection/api/v1/orders?cursor=abc123
prevPrevious page in a collection/api/v1/orders?cursor=xyz789
firstFirst page in a collection/api/v1/orders?page=1
lastLast page in a collection/api/v1/orders?page=10
relatedRelated resource/api/v1/users/{id}/orders
Pragmatic HATEOAS: Include self links on every resource. Include pagination links (next, prev) on every collection response. Include action links where they guide the consumer through a workflow (e.g., an order might link to payment or cancellation depending on its state). Do not build a fully hypermedia-driven client unless you have a specific need for URL decoupling.

13. Performance Optimization

API performance directly impacts user experience, operational costs, and scalability. A slow API frustrates consumers, increases infrastructure costs (more servers needed to handle the same load), and limits your ability to scale. Performance optimization requires understanding where time is spent and addressing the biggest bottlenecks first.

Common Performance Anti-Patterns

Anti-PatternProblemSolution
N+1 queriesFetching related data one record at a timeEager loading, batching, DataLoader pattern
Over-fetchingReturning all fields when consumer needs fewField selection (?fields=id,name), sparse fieldsets
Under-fetchingConsumer needs multiple round-trips for related dataInclude linked resources, compound endpoints
No paginationUnbounded collections exhaust memoryCursor-based pagination with default limits
Synchronous blockingLong-running operations block the responseAsync processing, 202 Accepted with polling
No compressionLarge JSON responses waste bandwidthGzip/Brotli compression middleware

Field Selection Implementation

C#
public class FieldSelectionFilter : IActionFilter
{
    public void OnActionExecuted(ActionExecutedContext context)
    {
        if (context.Result is not ObjectResult { Value: not null } result)
            return;

        var fields = context.HttpContext.Request
            .Query["fields"].FirstOrDefault()?
            .Split(',', StringSplitOptions.RemoveEmptyEntries);

        if (fields == null || fields.Length == 0) return;

        var settings = new JsonSerializerOptions
        {
            PropertyNamingPolicy = JsonNamingPolicy.CamelCase
        };

        // Dynamically filter the response to requested fields
        var json = JsonSerializer.Serialize(result.Value, settings);
        var doc = JsonDocument.Parse(json);
        var root = doc.RootElement;

        var filtered = new Dictionary<string, object?>();
        foreach (var field in fields)
        {
            if (root.TryGetProperty(field, out var value))
            {
                filtered[field] = value;
            }
        }

        result.Value = filtered;
        result.DeclaredType = typeof(Dictionary<string, object?>);
    }
}

// Usage: GET /api/v1/users/42?fields=id,name,email
// Returns only id, name, and email fields

Compression Configuration

C#
// Enable response compression in ASP.NET Core
builder.Services.AddResponseCompression(options =>
{
    options.EnableForHttps = true;
    options.Providers.Add<BrotliCompressionProvider>();
    options.Providers.Add<GzipCompressionProvider>();
    options.MimeTypes = ResponseCompressionDefaults
        .MimeTypes.Concat(new[] { "application/json" });
});

builder.Services.Configure<BrotliCompressionProviderOptions>(options =>
{
    options.Level = CompressionLevel.Fastest;
});

// In pipeline
app.UseResponseCompression();

Async Processing Pattern

Some operations take too long for a synchronous HTTP response — generating reports, processing large datasets, training ML models. The standard pattern is to accept the request immediately (202 Accepted), start processing in the background, and return a status URL the consumer can poll. This prevents timeouts and allows the consumer to track progress.

C#
[HttpPost("reports")]
public async Task<IActionResult> GenerateReport(
    [FromBody] ReportRequest request)
{
    var jobId = Guid.NewGuid();

    // Queue background processing
    await _queue.EnqueueAsync(new ReportJob
    {
        JobId = jobId,
        Request = request,
        CreatedAt = DateTime.UtcNow
    });

    return Accepted(new
    {
        job_id = jobId,
        status_url = $"/api/v1/jobs/{jobId}",
        status = "queued"
    });
}

[HttpGet("jobs/{jobId:guid}")]
public async Task<IActionResult> GetJobStatus(Guid jobId)
{
    var job = await _jobService.GetByIdAsync(jobId);
    return Ok(new
    {
        job_id = job.Id,
        status = job.Status, // queued, processing, completed, failed
        progress = job.Progress, // 0.0 to 1.0
        result_url = job.Status == "completed"
            ? $"/api/v1/reports/{job.Id}/download"
            : null,
        error = job.Status == "failed"
            ? job.ErrorMessage : null
    });
}
Performance Budget: Set explicit performance budgets for your API: GET endpoints should respond within 200ms at P95. POST/PUT/DELETE should respond within 500ms at P95. List endpoints with 20 items should respond within 300ms. If an endpoint exceeds its budget, profile it before adding more infrastructure — the problem is usually a query or a missing index, not insufficient server capacity.

14. Security Best Practices

API security is not a feature — it is a requirement. Every API endpoint is a potential attack surface. A compromised API can leak user data, modify critical records, or serve as a pivot point for broader infrastructure attacks. Security must be designed into the API from the start, not bolted on after a breach.

Security Checklist

PracticeImplementationPriority
HTTPS everywhereTLS 1.2+ on all endpoints, HSTS headerCritical
Input validationValidate and sanitize all inputs server-sideCritical
AuthenticationJWT or OAuth 2.0 with short-lived tokensCritical
AuthorizationCheck permissions on every requestCritical
Rate limitingPer-user and per-IP rate limitsHigh
CORSRestrict origins to trusted domainsHigh
Security headersCSP, X-Content-Type-Options, X-Frame-OptionsHigh
Audit loggingLog all mutations with user, time, and IPHigh
Secret managementVault for secrets, never in code or configCritical
Dependency scanningAutomated CVE scanning in CI/CDMedium

Input Validation

C#
public class CreateOrderRequest
{
    [Required]
    [StringLength(100, MinimumLength = 1)]
    public string CustomerId { get; set; }

    [Required]
    [MinLength(1), MaxLength(50)]
    public List<OrderItemRequest> Items { get; set; }

    [Required]
    [RegularExpression(@"^[A-Z]{3}$")]
    public string Currency { get; set; }

    [Range(0.01, 1000000.00)]
    public decimal Total { get; set; }

    // Sanitize free-text fields
    [StringLength(2000)]
    [RegularExpression(@"^[a-zA-Z0-9\s\.\,\-\_]+$")]
    public string? Notes { get; set; }
}

public class OrderItemRequest
{
    [Required]
    [StringLength(50)]
    public string ProductId { get; set; }

    [Range(1, 1000)]
    public int Quantity { get; set; }

    // Prevent XSS in product names
    [StringLength(200)]
    public string ProductName { get; set; }
}

// Global validation filter
public class ValidationFilter<T> : IEndpointFilter where T : class
{
    public async ValueTask<object?> InvokeAsync(
        EndpointFilterInvocationContext context,
        EndpointFilterDelegate next)
    {
        var validator = context.HttpContext
            .RequestServices
            .GetService<IValidator<T>>();

        if (validator != null)
        {
            var request = context.Arguments
                .OfType<T>().FirstOrDefault();

            if (request != null)
            {
                var result = await validator.ValidateAsync(
                    new ValidationContext<T>(request));

                if (!result.IsValid)
                {
                    return Results.ValidationProblem(
                        result.ToDictionary());
                }
            }
        }

        return await next(context);
    }
}

CORS Configuration

C#
builder.Services.AddCors(options =>
{
    options.AddPolicy("ApiCors", policy =>
    {
        policy.WithOrigins(
                "https://app.example.com",
                "https://admin.example.com")
              .WithMethods("GET", "POST", "PUT", "PATCH", "DELETE")
              .WithHeaders("Authorization", "Content-Type", "X-Request-Id")
              .WithExposedHeaders("X-RateLimit-Limit",
                  "X-RateLimit-Remaining", "X-RateLimit-Reset")
              .SetPreflightMaxAge(TimeSpan.FromMinutes(5));
    });
});

app.UseCors("ApiCors");

Security Headers

Every API response should include security headers that protect consumers from common attacks. The Strict-Transport-Security header (HSTS) forces browsers to use HTTPS for all future requests to your domain. The X-Content-Type-Options: nosniff header prevents browsers from MIME-sniffing responses. The X-Frame-Options: DENY header prevents clickjacking. The Content-Security-Policy header restricts resource loading to trusted origins. The Referrer-Policy: strict-origin-when-cross-origin header controls how much referrer information is shared. These headers are simple to implement and provide significant security benefits with zero consumer-facing impact.

C#
// Security headers middleware
app.Use(async (context, next) =>
{
    context.Response.Headers["Strict-Transport-Security"] =
        "max-age=31536000; includeSubDomains; preload";
    context.Response.Headers["X-Content-Type-Options"] =
        "nosniff";
    context.Response.Headers["X-Frame-Options"] = "DENY";
    context.Response.Headers["Referrer-Policy"] =
        "strict-origin-when-cross-origin";
    context.Response.Headers["Permissions-Policy"] =
        "camera=(), microphone=(), geolocation=()";
    context.Response.Headers["Content-Security-Policy"] =
        "default-src 'none'; frame-ancestors 'none'";
    await next();
});

API Key Rotation and Revocation

API keys must be rotatable without downtime. The system should support multiple active keys per account — when a new key is generated, the old key remains valid for a configurable grace period (typically 24-72 hours). This allows consumers to update their integrations without interruption. Key revocation should be immediate — when a key is compromised, it must be disabled instantly. Store keys as salted hashes (like passwords), never in plaintext. This prevents key exposure even if the database is compromised. Support scoped keys that grant access to specific endpoints or resources — a read-only key for monitoring, a write-only key for integrations, and a full-access key for administrative tools. Scoped keys minimize the blast radius if a key is compromised.

SQL Injection & XSS: Never concatenate user input into SQL queries or HTML output. Use parameterized queries (ORMs handle this automatically). Sanitize all free-text input before storing or displaying. Use Content Security Policy headers to prevent XSS attacks. These are not optional best practices — they are baseline security requirements that every API must implement.

15. API Documentation & OpenAPI

API documentation is the contract between your team and every consumer. Bad documentation forces consumers to read source code, file support tickets, or guess at behavior. Good documentation enables self-service integration, reduces support burden, and accelerates adoption. The OpenAPI Specification (formerly Swagger) is the industry standard for REST API documentation.

OpenAPI Specification Components

An OpenAPI specification describes your entire API: endpoints, request/response schemas, authentication requirements, error formats, and example values. It is a machine-readable JSON or YAML file that can generate interactive documentation portals (Swagger UI, Redoc), client SDKs (openapi-generator), and server stubs. The specification should be the single source of truth for your API contract.

YAML — OpenAPI Snippet
openapi: 3.1.0
info:
  title: Order Management API
  version: "2026-01-15"
  description: |
    API for managing orders, payments, and shipments.
    All endpoints require JWT authentication.
paths:
  /api/v1/orders:
    post:
      summary: Create a new order
      operationId: createOrder
      tags: [Orders]
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreateOrderRequest'
            example:
              customer_id: "cus_abc123"
              items:
                - product_id: "prod_xyz"
                  quantity: 1
              currency: "USD"
      responses:
        '201':
          description: Order created
          headers:
            Location:
              description: URL of the created order
              schema:
                type: string
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Order'
        '400':
          $ref: '#/components/responses/ValidationError'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '429':
          $ref: '#/components/responses/RateLimited'

Documentation as Code

C#
// Generate OpenAPI from code annotations in ASP.NET Core
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen(c =>
{
    c.SwaggerDoc("v1", new OpenApiInfo
    {
        Title = "Order Management API",
        Version = "2026-01-15",
        Description = "RESTful API for managing orders",
        Contact = new OpenApiContact
        {
            Name = "API Support",
            Email = "api-support@example.com",
            Url = new Uri("https://docs.example.com")
        }
    });

    c.AddSecurityDefinition("bearer", new OpenApiSecurityScheme
    {
        Type = SecuritySchemeType.Http,
        Scheme = "bearer",
        BearerFormat = "JWT",
        Description = "JWT Authorization header"
    });

    c.OperationFilter<AddRequiredHeaderParameter>();
});
Documentation Best Practices: Write the OpenAPI specification alongside your code, not after. Publish an interactive documentation portal (Swagger UI or Redoc) that allows consumers to try requests directly. Include example requests and responses for every endpoint. Keep documentation up to date with every API change — outdated documentation is worse than no documentation.

16. Testing REST APIs

API testing validates that endpoints behave correctly under normal conditions, handle edge cases gracefully, and maintain security under attack. A comprehensive testing strategy covers unit tests for business logic, integration tests for endpoint behavior, contract tests for consumer compatibility, and load tests for performance validation.

Test Categories

Test TypeScopeSpeedTool
Unit TestsBusiness logic, validators, formattersMillisecondsxUnit, NUnit
Integration TestsEndpoints with real databaseSecondsWebApplicationFactory
Contract TestsAPI matches OpenAPI specSecondsPrism, Schemathesis
Load TestsPerformance under concurrent loadMinutesk6, NBomber
Security TestsOWASP Top 10 vulnerabilitiesMinutesOWASP ZAP
C#
public class OrdersApiTests : IClassLifetimeFixture<ApiFactory>
{
    private readonly HttpClient _client;

    public OrdersApiTests(ApiFactory factory)
    {
        _client = factory.CreateClient();
        _client.DefaultRequestHeaders.Authorization =
            new AuthenticationHeaderValue("Bearer",
                TestAuthToken.Generate());
    }

    [Fact]
    public async Task CreateOrder_ValidRequest_Returns201WithLocation()
    {
        var request = new CreateOrderRequest
        {
            CustomerId = "cus_test123",
            Items = new List<OrderItemRequest>
            {
                new() { ProductId = "prod_abc", Quantity = 2 }
            },
            Currency = "USD"
        };

        var response = await _client.PostAsJsonAsync(
            "/api/v1/orders", request);

        Assert.Equal(HttpStatusCode.Created, response.StatusCode);
        Assert.NotNull(response.Headers.Location);

        var order = await response.Content
            .ReadFromJsonAsync<OrderDto>();
        Assert.NotNull(order);
        Assert.Equal("pending", order.Status);
    }

    [Fact]
    public async Task CreateOrder_MissingCurrency_Returns422()
    {
        var request = new
        {
            customer_id = "cus_test123",
            items = new[] { new { product_id = "prod_abc", quantity = 1 } }
            // No currency field
        };

        var response = await _client.PostAsJsonAsync(
            "/api/v1/orders", request);

        Assert.Equal(HttpStatusCode.UnprocessableEntity,
            response.StatusCode);

        var error = await response.Content
            .ReadFromJsonAsync<ProblemDetails>();
        Assert.Contains("currency",
            error.Extensions["errors"]?.ToString());
    }

    [Fact]
    public async Task GetOrder_NonexistentId_Returns404()
    {
        var response = await _client.GetAsync(
            "/api/v1/orders/nonexistent-id");

        Assert.Equal(HttpStatusCode.NotFound,
            response.StatusCode);
    }

    [Fact]
    public async Task DeleteOrder_Returns204NoContent()
    {
        var created = await CreateTestOrder();
        var response = await _client.DeleteAsync(
            $"/api/v1/orders/{created.Id}");

        Assert.Equal(HttpStatusCode.NoContent, response.StatusCode);

        // Verify deletion
        var getResponse = await _client.GetAsync(
            $"/api/v1/orders/{created.Id}");
        Assert.Equal(HttpStatusCode.NotFound,
            getResponse.StatusCode);
    }
}

Contract Testing

C#
[Fact]
public async Task Api_Contract_MatchesOpenApiSpec()
{
    // Verify that all responses match the OpenAPI schema
    var spec = await File.ReadAllTextAsync("openapi.yaml");
    var validator = new OpenApiSchemaValidator();

    var response = await _client.GetAsync("/api/v1/orders");
    var json = await response.Content.ReadAsStringAsync();

    var result = validator.Validate(json, spec,
        "/api/v1/orders/get");

    Assert.True(result.IsValid,
        $"Contract violations: {string.Join(", ", result.Errors)}");
}

Load Testing Strategy

Load testing validates that your API meets performance targets under realistic concurrent load. Use tools like k6 or NBomber to simulate concurrent users executing typical API workflows. Test scenarios should include: read-heavy workloads (100 concurrent users issuing GET requests), write-heavy workloads (50 concurrent users creating resources), mixed workloads (80% reads, 20% writes), and burst traffic (sudden spike to 10x normal load). Measure response time percentiles (P50, P95, P99), error rates under load, throughput (requests per second), and resource utilization (CPU, memory, database connections). Establish baseline metrics and compare after every significant change. Performance regressions should be treated as bugs — if an API endpoint slows down by more than 20% after a code change, investigate and fix before deploying to production.

Load Test ScenarioTarget MetricThreshold
100 concurrent GET requestsP95 response time< 200ms
50 concurrent POST requestsP95 response time< 500ms
Mixed read/write (80/20 split)Throughput> 500 req/sec
Spike to 10x normal loadError rate< 1%
Sustained load for 30 minutesMemory leak< 5% increase
Testing Strategy Summary: Write unit tests for all business logic (validators, services, formatters). Write integration tests for every endpoint covering happy path, validation errors, auth errors, and edge cases. Run contract tests in CI/CD to ensure API changes do not break consumers. Run load tests monthly to validate performance assumptions. Run security scans quarterly. Automate all tests in your CI/CD pipeline — every pull request should run the full test suite before merging.

17. Real-World Case Studies

Understanding how major companies design their APIs provides practical insights for building your own. Each case study highlights different design priorities and the tradeoffs they made.

Stripe — The Gold Standard

Stripe's API is widely considered the best-designed payment API. Key design decisions: date-based versioning (/v1 pinned per account), idempotency keys on all POST requests (clients supply a unique key to prevent duplicate charges), expandable related objects (?expand[]=customer to inline related resources), and consistent error responses with typed error codes. Stripe's API demonstrates that investing in design pays dividends in developer adoption and reduced support costs.

GitHub — Hypermedia Done Right

GitHub's API uses Accept header versioning and includes pagination links in every collection response. The Link header provides next/prev/first/last URLs, enabling cursor-based pagination across billions of resources. GitHub also supports conditional requests via If-None-Match (ETags) and If-Modified-Since headers, reducing unnecessary data transfer. Their API demonstrates that hypermedia controls (pagination links) provide real value without full HATEOAS complexity.

Shopify — Merchant-Focused Design

Shopify's Admin API uses URI-based versioning (/admin/api/2024-01) and provides bulk operations for processing large datasets asynchronously. Their webhook system allows merchants to subscribe to specific events and receive real-time notifications. Shopify also demonstrates the "API as a product" approach — their developer portal, documentation, and SDKs are first-class products with dedicated teams.

AspectStripeGitHubShopify
VersioningDate-based, per-accountAccept headerURI-based, yearly
PaginationCursor-basedCursor + Link headerCursor-based
Error FormatTyped error codesStandard HTTP errorsGraphQL-style errors
AuthSecret keys + restricted keysOAuth + PATsOAuth + Admin API tokens
IdempotencyBuilt-in idempotency keysNot primary focusBulk operation deduplication
Rate LimitsPer-key, tieredPer-user, secondary limitsRESTLEAK bucket algorithm
Key Takeaway: All three companies share common practices: consistent naming conventions, comprehensive error handling, pagination on all list endpoints, and thorough documentation. The differences are in versioning strategy, auth mechanism, and rate limiting approach — all of which are driven by their specific consumer base and business requirements. Study these APIs to inform your own design decisions. Notice how all three invest heavily in developer experience — detailed error messages with unique error codes, clear rate limit headers in every response, and interactive API explorers or sandbox environments that let developers test requests without leaving the documentation. Their shared emphasis on backward compatibility, incremental versioning, and transparent communication about breaking changes underscores a fundamental truth: a well-designed API is never truly finished — it evolves continuously based on real-world consumer feedback and changing business requirements.

18. Interview Q&A Deep Dive

Q1: How do you design a RESTful API for an e-commerce platform?

Answer: Start with resource identification: Users, Products, Orders, Payments, Shipments, Cart. Map CRUD operations to HTTP methods: GET/POST for collections, GET/PUT/DELETE for individual resources. Nest related resources: /users/{id}/orders, /orders/{id}/items. Use query parameters for filtering: /products?category=electronics&price[gte]=50&sort=-rating. Implement pagination on every list endpoint using cursor-based pagination. Define a consistent error format using RFC 7807 Problem Details. Use idempotency keys on POST requests to prevent duplicate orders from network retries. Version the API from day one using URL path versioning (/api/v1/). Document everything with OpenAPI and publish an interactive documentation portal.

Q2: When would you choose REST over GraphQL, and vice versa?

Answer: Choose REST when: you have simple resource-oriented data, HTTP caching matters (public APIs), you have many consumers with different needs, or you are building a public API where discoverability matters. Choose GraphQL when: clients need flexible data fetching (different views need different fields), you have deeply nested or interconnected data, mobile clients need to minimize over-fetching, or you have a single consumer (your own frontend) that needs fine-grained control. The hybrid approach (REST for public APIs, GraphQL for internal frontends) is increasingly common. Stripe uses REST exclusively. GitHub offers both REST and GraphQL. Shopify offers both. The choice depends on your specific requirements, not dogma.

Q3: How do you handle API versioning without breaking existing consumers?

Answer: The key is to minimize breaking changes. Add new fields to responses (consumers that do not need them ignore them). Make new parameters optional with sensible defaults. Use additive-only changes for responses. When breaking changes are unavoidable, introduce a new version and maintain the old version for at least 6-12 months. Communicate deprecation timelines clearly through changelogs and email notifications. Use the Deprecation header to signal upcoming removal. Support at least two versions simultaneously. Test new versions against existing consumer SDKs before deploying. Stripe's model — date-based versioning with per-account pinning — is the gold standard for maximum stability.

Q4: How do you implement idempotency for POST requests?

Answer: The client generates a unique idempotency key (UUID) and includes it in the request header: Idempotency-Key: abc-123-def-456. The server stores the key with the response in a cache (Redis) with a TTL of 24 hours. On the first request, the server processes the operation, stores the response, and returns 201. On duplicate requests (same key), the server returns the stored response without re-processing. The key prevents duplicate charges, duplicate resource creation, and duplicate notifications. The TTL ensures keys eventually expire. The cache is checked before processing — if the key exists, return the cached response immediately. This pattern is used by Stripe, PayPal, and Square for financial transaction safety.

Q5: How do you handle pagination for real-time data where items are frequently inserted or deleted?

Answer: Use cursor-based pagination with a stable sort key (preferably a monotonically increasing ID or ULID). The cursor encodes the position in the dataset — typically the ID of the last item in the previous page. On the next page request, the query filters to items with IDs greater than the cursor value. This eliminates page drift — items inserted or deleted between pages do not cause skipped or duplicated items. For very high-throughput datasets, consider keyset pagination with composite keys (timestamp + ID) for guaranteed ordering. Avoid offset-based pagination for real-time data because the offset is invalidated by concurrent modifications. Cursor-based pagination is the standard approach used by Twitter, Instagram, and Facebook for timeline feeds.

Q6: How do you design error responses that are both machine-readable and human-friendly?

Answer: Use RFC 7807 Problem Details as the standard format. Include a type URI that identifies the error category (machine-readable), a title that summarizes the error (human-readable), a status code that matches the HTTP status, and a detail that explains what went wrong specifically. For validation errors, include an errors array with field-level details (field name, error code, rejected value, message). Always include a trace_id or instance for debugging correlation. Never expose stack traces or internal details. Log the full error details server-side for debugging. The consumer should be able to understand the error from the title and fix it from the errors details without contacting support.

Q7: How do you handle file uploads in a RESTful API?

Answer: For small files (<5MB), use multipart/form-data encoding with a POST endpoint: POST /api/v1/documents. Include the file as a form field along with any metadata fields. For large files (>5MB), use presigned URLs: the client requests a presigned upload URL from the API (POST /api/v1/uploads), receives a URL to upload directly to object storage (S3), uploads the file, and then notifies the API that the upload is complete (PATCH /api/v1/uploads/{id}). This offloads file transfer from your API server to the storage service, preventing bandwidth bottlenecks. Support chunked uploads for very large files (>100MB) with resumable upload protocols. Always validate file type, size, and content before accepting uploads.

Q8: What are the most common API design mistakes you see in production?

Answer: The top mistakes: (1) Returning 200 for all responses, including errors — this breaks HTTP semantics and confuses intermediaries. (2) Inconsistent naming conventions — mixing camelCase and snake_case across endpoints. (3) No pagination on list endpoints — leading to memory exhaustion and slow responses. (4) Exposing internal IDs (database auto-increment integers) — allowing scraping and enumeration. (5) Missing rate limiting — allowing abuse and cascading failures. (6) Verbs in URLs — /api/getUsers instead of /api/users. (7) Inconsistent error formats — some endpoints return {"error": "message"} while others return {"message": "error"}. (8) No API versioning — making breaking changes that break consumers. (9) Over-fetching — returning entire database records when consumers need only a few fields. (10) Not documenting the API — forcing consumers to read source code.

Key Numbers to Remember

MetricValue
GET response time target< 200ms at P95
POST/PUT/DELETE response time target< 500ms at P95
Max nesting depth in URLs2 levels
Max collection page size100 items
JWT access token lifetime5-15 minutes
Cache-Control max-age for static data300-3600 seconds
Rate limit per API key (typical)1000 requests/minute
Minimum API version support period6-12 months
OpenAPI spec update frequencyEvery API change (CI/CD)
HTTPS enforcement100% (no exceptions)

Pre-Interview Checklist

  • Know the Richardson Maturity Model (Levels 0-3) and what each level provides
  • Be able to design a resource model for any domain (e-commerce, social, healthcare)
  • Understand HTTP method semantics (idempotency, safety, cacheability)
  • Know when to use each status code and why returning 200 for errors is wrong
  • Design pagination (cursor vs offset) and explain tradeoffs
  • Explain rate limiting algorithms (token bucket, sliding window) with tradeoffs
  • Discuss API versioning strategies and when each is appropriate
  • Know JWT authentication flow and security considerations
  • Design error responses using RFC 7807 Problem Details
  • Explain caching strategies (Cache-Control, ETags, conditional requests)
  • Discuss HATEOAS and when it provides real value
  • Know the difference between REST and GraphQL and when to use each

RESTful API Design — Senior+ Guide | Ayodhyya