RESTful API Design: The Complete Senior+ Guide
System Design Deep Dive — Architecture, Patterns, Security, and Production-Grade Implementation
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.
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
| Company | API Style | Versioning | Key Strength |
|---|---|---|---|
| Stripe | RESTful, resource-oriented | Date-based (/v1) | Exceptional consistency, idempotency keys, comprehensive error handling |
| GitHub | RESTful with hypermedia | Header-based (Accept) | Rich link relations, conditional requests, GraphQL alternative |
| Shopify | RESTful + GraphQL | URI-versioned (/admin/api/2024-01) | Webhook system, bulk operations, admin-level granularity |
| AWS | RPC-style over HTTP | Query string (?Version=) | Signature-based auth, regional endpoints, comprehensive SDKs |
| Twilio | RESTful with TwiML | Subdomain-based | Self-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.
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" }
}
}
Maturity Level Comparison
| Level | Resources | HTTP Methods | Status Codes | Hypermedia | Caching |
|---|---|---|---|---|---|
| Level 0 | No | Single (POST) | Generic (200/500) | No | No |
| Level 1 | Yes | Single (POST) | Generic (200/500) | No | No |
| Level 2 | Yes | Multiple (GET/POST/PUT/DELETE) | Specific (200/201/404/500) | No | Yes (GET) |
| Level 3 | Yes | Multiple | Specific | Yes | Yes |
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/orderItemsor/order_items. Kebab-case is the URL convention used by the vast majority of major APIs. - Limit nesting to two levels:
/users/{id}/ordersis fine./users/{id}/orders/{id}/items/{id}/reviewsis 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.jsonis an anti-pattern. Use theAcceptheader 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
| Pattern | Example | When to Use |
|---|---|---|
| Collection + ID | /users/{id} | Standard resource access |
| Nested collection | /users/{id}/orders | Resource belongs to parent | /orders?status=pending | Filtering within a collection |
| Action sub-resource | /orders/{id}/cancellation | Non-CRUD operations |
| Search endpoint | /products/search?q=laptop | Full-text or complex search |
| Singleton resource | /settings | One-per-account resources |
/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
| Method | Purpose | Idempotent | Safe | Cacheable | Request Body | Response Body |
|---|---|---|---|---|---|---|
| GET | Retrieve a resource or collection | Yes | Yes | Yes | No | Yes (resource representation) |
| POST | Create a resource or trigger an operation | No | No | Conditional | Yes | Yes (created resource or result) |
| PUT | Replace a resource entirely | Yes | No | No | Yes (full resource) | Yes (replaced resource) |
| PATCH | Partial update of a resource | No* | No | No | Yes (partial changes) | Yes (updated resource) |
| DELETE | Remove a resource | Yes | No | No | Optional | Optional (confirmation) |
| HEAD | GET without response body | Yes | Yes | Yes | No | No |
| OPTIONS | Discover supported methods | Yes | Yes | No | No | Yes (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.
| Code | Meaning | When to Use |
|---|---|---|
| 200 OK | Request succeeded | GET returns resource, PUT returns updated resource |
| 201 Created | Resource created | POST succeeds — include Location header with new resource URL |
| 204 No Content | Success with no response body | DELETE succeeds, PUT where client has current version |
| 400 Bad Request | Client error — malformed request | Invalid JSON, missing required fields, malformed URL |
| 401 Unauthorized | Authentication required | No token, expired token, invalid token |
| 403 Forbidden | Authenticated but not authorized | Valid token but insufficient permissions |
| 404 Not Found | Resource does not exist | Invalid ID, deleted resource |
| 409 Conflict | Request conflicts with current state | Duplicate creation, concurrent modification |
| 422 Unprocessable Entity | Valid JSON but semantic errors | Email format invalid, date in the past |
| 429 Too Many Requests | Rate limit exceeded | Client exceeded per-second or per-minute limit |
| 500 Internal Server Error | Server-side failure | Unexpected error, database failure, unhandled exception |
| 502 Bad Gateway | Upstream service failure | Dependency returned invalid response |
| 503 Service Unavailable | Service temporarily down | Maintenance, 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();
}
}
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
| Convention | Example | Used By |
|---|---|---|
| snake_case | created_at, order_items | Ruby on Rails APIs, Stripe, Shopify |
| camelCase | createdAt, orderItems | JavaScript ecosystem, many REST APIs |
| PascalCase | CreatedAt, OrderItems | C# / .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"
}
});
}
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 Category | Status Code | Consumer Action | Retryable? |
|---|---|---|---|
| Validation Error | 400, 422 | Fix the request and retry | No |
| Authentication Error | 401 | Refresh token and retry | No |
| Authorization Error | 403 | Request elevated permissions | No |
| Not Found | 404 | Verify resource ID | No |
| Conflict | 409 | Resolve conflict and retry | Maybe |
| Rate Limited | 429 | Back off and retry after delay | Yes |
| Server Error | 500 | Retry with exponential backoff | Yes |
| Service Unavailable | 503 | Retry after Retry-After header value | Yes |
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;
}
}
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.
| Pattern | Example | Pros | Cons |
|---|---|---|---|
| Offset-based | ?page=3&per_page=20 | Simple, allows "jump to page" | Page drift, slow for deep pages |
| Cursor-based | ?cursor=abc123&per_page=20 | No drift, consistent, fast | No page jumping, more complex |
| Seek-based | ?after_id=42&per_page=20 | Simple, database-friendly | Requires sortable, unique column |
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
| Mechanism | How It Works | Best For | Tradeoffs |
|---|---|---|---|
| API Key | Static key in header or query param | Server-to-server, simple integrations | Static, cannot be revoked per-session, no expiry |
| JWT (Bearer Token) | Signed token with claims, validated locally | Stateless auth, microservices | Cannot be revoked early, token bloat, key rotation complexity |
| OAuth 2.0 | Authorization code flow with refresh tokens | Third-party access, user delegation | Complex implementation, requires token storage |
| mTLS | Client certificate validation | High-security service-to-service | Certificate management overhead, not browser-friendly |
| Session Cookie | Server-side session with cookie | Browser-based apps | Stateful, 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();
}
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
| Algorithm | How It Works | Pros | Cons |
|---|---|---|---|
| Token Bucket | Tokens refill at a fixed rate; each request consumes a token | Allows controlled bursts, smooth rate | More complex to implement |
| Sliding Window Log | Store timestamp of each request; count within window | Precise, no edge burst | Memory-intensive, requires sorted set |
| Sliding Window Counter | Weighted count of current and previous window | Memory-efficient, smooth | Approximate, not exact |
| Fixed Window | Count requests in fixed time period | Simple, low memory | Edge burst at window boundaries |
| Leaky Bucket | Requests enter a queue; processed at fixed rate | Smooth output, prevents bursts | Adds 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
}
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
| Approach | Example | Pros | Cons |
|---|---|---|---|
| URL path | /api/v1/users | Explicit, easy to test, visible in logs | URL proliferation, router complexity |
| Header (Accept) | Accept: application/vnd.api.v1+json | Clean URLs, content negotiation | Hard to test in browser, invisible in logs |
| Query parameter | /api/users?version=1 | Optional, easy to implement | Easy to forget, cacheability issues |
| Date-based | /api/2026-01-15/users | Automatic deprecation, clear timeline | Frequent 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 Type | Example | Breaking? | Version Bump? |
|---|---|---|---|
| Add new field to response | Add phone_number to user | No | No |
| Add new optional request parameter | Add ?include=profile | No | No |
| Add new endpoint | POST /api/v1/users/verify | No | No |
| Remove a response field | Remove legacy_id | Yes | Yes |
| Change field type | amount from int to string | Yes | Yes |
| Change error response format | From string to object | Yes | Yes |
| Add required request parameter | Require currency | Yes | Yes |
| Change URL structure | /users/{id}/orders → /orders?user_id={id} | Yes | Yes |
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
});
});
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
| Header | Purpose | Example |
|---|---|---|
Cache-Control | Directs caching behavior | public, max-age=300 |
ETag | Version identifier for conditional requests | "abc123" |
Last-Modified | When the resource was last changed | Wed, 15 Jan 2026 10:30:00 GMT |
Expires | Legacy expiry date (use Cache-Control instead) | Wed, 15 Jan 2026 10:35:00 GMT |
Vary | Cache key components | Accept, 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.
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
| Relation | Purpose | Example |
|---|---|---|
self | Canonical URL of this resource | /api/v1/orders/ord_abc123 |
next | Next page in a collection | /api/v1/orders?cursor=abc123 |
prev | Previous page in a collection | /api/v1/orders?cursor=xyz789 |
first | First page in a collection | /api/v1/orders?page=1 |
last | Last page in a collection | /api/v1/orders?page=10 |
related | Related resource | /api/v1/users/{id}/orders |
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-Pattern | Problem | Solution |
|---|---|---|
| N+1 queries | Fetching related data one record at a time | Eager loading, batching, DataLoader pattern |
| Over-fetching | Returning all fields when consumer needs few | Field selection (?fields=id,name), sparse fieldsets |
| Under-fetching | Consumer needs multiple round-trips for related data | Include linked resources, compound endpoints |
| No pagination | Unbounded collections exhaust memory | Cursor-based pagination with default limits |
| Synchronous blocking | Long-running operations block the response | Async processing, 202 Accepted with polling |
| No compression | Large JSON responses waste bandwidth | Gzip/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
});
}
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
| Practice | Implementation | Priority |
|---|---|---|
| HTTPS everywhere | TLS 1.2+ on all endpoints, HSTS header | Critical |
| Input validation | Validate and sanitize all inputs server-side | Critical |
| Authentication | JWT or OAuth 2.0 with short-lived tokens | Critical |
| Authorization | Check permissions on every request | Critical |
| Rate limiting | Per-user and per-IP rate limits | High |
| CORS | Restrict origins to trusted domains | High |
| Security headers | CSP, X-Content-Type-Options, X-Frame-Options | High |
| Audit logging | Log all mutations with user, time, and IP | High |
| Secret management | Vault for secrets, never in code or config | Critical |
| Dependency scanning | Automated CVE scanning in CI/CD | Medium |
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.
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>();
});
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 Type | Scope | Speed | Tool |
|---|---|---|---|
| Unit Tests | Business logic, validators, formatters | Milliseconds | xUnit, NUnit |
| Integration Tests | Endpoints with real database | Seconds | WebApplicationFactory |
| Contract Tests | API matches OpenAPI spec | Seconds | Prism, Schemathesis |
| Load Tests | Performance under concurrent load | Minutes | k6, NBomber |
| Security Tests | OWASP Top 10 vulnerabilities | Minutes | OWASP 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 Scenario | Target Metric | Threshold |
|---|---|---|
| 100 concurrent GET requests | P95 response time | < 200ms |
| 50 concurrent POST requests | P95 response time | < 500ms |
| Mixed read/write (80/20 split) | Throughput | > 500 req/sec |
| Spike to 10x normal load | Error rate | < 1% |
| Sustained load for 30 minutes | Memory leak | < 5% increase |
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.
| Aspect | Stripe | GitHub | Shopify |
|---|---|---|---|
| Versioning | Date-based, per-account | Accept header | URI-based, yearly |
| Pagination | Cursor-based | Cursor + Link header | Cursor-based |
| Error Format | Typed error codes | Standard HTTP errors | GraphQL-style errors |
| Auth | Secret keys + restricted keys | OAuth + PATs | OAuth + Admin API tokens |
| Idempotency | Built-in idempotency keys | Not primary focus | Bulk operation deduplication |
| Rate Limits | Per-key, tiered | Per-user, secondary limits | RESTLEAK bucket algorithm |
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
| Metric | Value |
|---|---|
| GET response time target | < 200ms at P95 |
| POST/PUT/DELETE response time target | < 500ms at P95 |
| Max nesting depth in URLs | 2 levels |
| Max collection page size | 100 items |
| JWT access token lifetime | 5-15 minutes |
| Cache-Control max-age for static data | 300-3600 seconds |
| Rate limit per API key (typical) | 1000 requests/minute |
| Minimum API version support period | 6-12 months |
| OpenAPI spec update frequency | Every API change (CI/CD) |
| HTTPS enforcement | 100% (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