Designing RESTful API Endpoints: Best Practices for HTTP Methods and URL Structure
A well-designed API is a contract. It defines how clients and servers communicate, and bad decisions made early compound into years of technical debt. Whether you’re building a public API, an internal microservice, or a mobile backend, the same principles apply: use HTTP correctly, model resources cleanly, and communicate clearly through status codes and response bodies.
This guide walks through every layer of REST API endpoint design — from foundational constraints through HTTP method semantics, URL conventions, pagination, error handling, security, and documentation — with complete working implementations in TypeScript, Java, Python, and C#.
Part 1: REST Fundamentals
What REST Actually Is
REST (Representational State Transfer) is an architectural style, not a protocol. Roy Fielding defined six constraints in his 2000 dissertation that together produce APIs that are scalable, stateless, and predictable:
- Client-Server — the UI and data storage concerns are separated; they evolve independently
- Statelessness — every request contains all information the server needs; no session state is stored server-side
- Cacheability — responses must define themselves as cacheable or non-cacheable to prevent clients from reusing stale data
- Uniform Interface — a consistent way to interact with all resources, built on four sub-constraints: identification of resources, manipulation through representations, self-descriptive messages, and HATEOAS
- Layered System — clients cannot tell whether they’re connected to the end server or an intermediary (load balancer, cache, gateway)
- Code on Demand (optional) — servers can extend client functionality by transferring executable code
Statelessness is the most misunderstood constraint. It means the server does not store request context between calls — not that you cannot have a database. Authentication tokens, pagination cursors, and filter parameters must all be sent with every request.
Resources and URIs
In REST, everything is a resource — a conceptual mapping to an entity or collection. Resources are identified by URIs and manipulated through their representations (typically JSON or XML).
| Concept | Example |
|---|---|
| Collection resource | GET /users |
| Singleton resource | GET /users/42 |
| Sub-resource collection | GET /users/42/orders |
| Sub-resource singleton | GET /users/42/orders/7 |
| Action on resource | POST /users/42/deactivate |
The key insight: URIs identify what, HTTP methods describe what to do with it.
Richardson Maturity Model
Most teams evolve through three levels before reaching true REST:
- Level 0 — single URI, all operations via POST (SOAP, XML-RPC style)
- Level 1 — separate URIs per resource, but all via POST
- Level 2 — correct HTTP verbs per operation (where most production APIs live)
- Level 3 — HATEOAS: responses include links to possible next actions
Most greenfield APIs should target Level 2 and add HATEOAS selectively where it genuinely helps clients discover state transitions.
Part 2: HTTP Methods Deep Dive
Each HTTP method has precise semantics. Two properties matter most:
- Safe — no observable side effects (GET, HEAD, OPTIONS)
- Idempotent — repeating N times produces the same result as once (GET, PUT, DELETE, HEAD, OPTIONS)
| Method | Safe | Idempotent | Request Body | Typical Response |
|---|---|---|---|---|
| GET | Yes | Yes | No | 200 OK |
| POST | No | No | Yes | 201 Created |
| PUT | No | Yes | Yes | 200 OK / 204 No Content |
| PATCH | No | No* | Yes | 200 OK / 204 No Content |
| DELETE | No | Yes | Optional | 204 No Content |
| HEAD | Yes | Yes | No | 200 OK (no body) |
| OPTIONS | Yes | Yes | No | 200 OK |
*PATCH is not guaranteed idempotent, but JSON Merge Patch (RFC 7396) operations typically are.
GET — Safe, Idempotent, Cacheable
GET retrieves a resource representation. It must never modify state. Use query parameters for filtering, sorting, and pagination. Responses should include Cache-Control headers.
// Express / Fastify
import { Router, Request, Response } from 'express';
const router = Router();
router.get('/users', async (req: Request, res: Response) => {
const { page = '1', limit = '20', sort = 'createdAt', order = 'desc', role } = req.query;
const filters = { ...(role && { role: String(role) }) };
const pagination = { page: Number(page), limit: Math.min(Number(limit), 100) };
const sorting = { field: String(sort), direction: String(order) as 'asc' | 'desc' };
const result = await userService.listUsers(filters, pagination, sorting);
res
.set('Cache-Control', 'private, max-age=60')
.set('X-Total-Count', String(result.total))
.status(200)
.json({
data: result.users,
pagination: {
page: pagination.page,
limit: pagination.limit,
total: result.total,
pages: Math.ceil(result.total / pagination.limit),
},
});
});
router.get('/users/:id', async (req: Request, res: Response) => {
const user = await userService.getUserById(req.params.id);
if (!user) {
return res.status(404).json({
type: 'https://api.example.com/errors/not-found',
title: 'User Not Found',
status: 404,
detail: `User ${req.params.id} does not exist`,
instance: req.path,
});
}
res.set('Cache-Control', 'private, max-age=300').status(200).json(user);
});// Spring Boot
@RestController
@RequestMapping("/users")
public class UserController {
@GetMapping
public ResponseEntity<PagedResponse<User>> listUsers(
@RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "20") int limit,
@RequestParam(defaultValue = "createdAt") String sort,
@RequestParam(defaultValue = "desc") String order,
@RequestParam(required = false) String role) {
int safeLimit = Math.min(limit, 100);
UserFilters filters = UserFilters.builder().role(role).build();
PageRequest pageable = PageRequest.of(page - 1, safeLimit,
Sort.by(Sort.Direction.fromString(order), sort));
Page<User> result = userService.listUsers(filters, pageable);
PagedResponse<User> body = PagedResponse.<User>builder()
.data(result.getContent())
.pagination(PaginationMeta.of(page, safeLimit, result.getTotalElements()))
.build();
return ResponseEntity.ok()
.header("Cache-Control", "private, max-age=60")
.header("X-Total-Count", String.valueOf(result.getTotalElements()))
.body(body);
}
@GetMapping("/{id}")
public ResponseEntity<?> getUser(@PathVariable String id) {
return userService.getUserById(id)
.map(user -> ResponseEntity.ok()
.header("Cache-Control", "private, max-age=300")
.body((Object) user))
.orElse(ResponseEntity.status(404).body(Map.of(
"type", "https://api.example.com/errors/not-found",
"title", "User Not Found",
"status", 404,
"detail", "User " + id + " does not exist")));
}
}# FastAPI
from fastapi import APIRouter, Query, HTTPException
from typing import Optional
from models import User, PagedResponse, PaginationMeta
router = APIRouter(prefix="/users")
@router.get("", response_model=PagedResponse[User])
async def list_users(
page: int = Query(1, ge=1),
limit: int = Query(20, ge=1, le=100),
sort: str = Query("created_at"),
order: str = Query("desc", pattern="^(asc|desc)$"),
role: Optional[str] = None,
):
filters = {"role": role} if role else {}
result = await user_service.list_users(filters, page, limit, sort, order)
return PagedResponse(
data=result.users,
pagination=PaginationMeta(
page=page,
limit=limit,
total=result.total,
pages=-(-result.total // limit), # ceiling division
),
)
@router.get("/{user_id}", response_model=User)
async def get_user(user_id: str):
user = await user_service.get_user_by_id(user_id)
if not user:
raise HTTPException(
status_code=404,
detail={
"type": "https://api.example.com/errors/not-found",
"title": "User Not Found",
"status": 404,
"detail": f"User {user_id} does not exist",
},
)
return user// ASP.NET Core
[ApiController]
[Route("users")]
public class UsersController : ControllerBase
{
[HttpGet]
[ResponseCache(Duration = 60, Location = ResponseCacheLocation.Client)]
public async Task<IActionResult> ListUsers(
[FromQuery] int page = 1,
[FromQuery] int limit = 20,
[FromQuery] string sort = "createdAt",
[FromQuery] string order = "desc",
[FromQuery] string? role = null)
{
var safeLimit = Math.Min(limit, 100);
var filters = new UserFilters { Role = role };
var result = await _userService.ListUsersAsync(filters, page, safeLimit, sort, order);
Response.Headers["X-Total-Count"] = result.Total.ToString();
return Ok(new
{
data = result.Users,
pagination = new { page, limit = safeLimit, total = result.Total, pages = (int)Math.Ceiling((double)result.Total / safeLimit) }
});
}
[HttpGet("{id}")]
public async Task<IActionResult> GetUser(string id)
{
var user = await _userService.GetUserByIdAsync(id);
if (user == null)
return NotFound(new { type = "https://api.example.com/errors/not-found", title = "User Not Found", status = 404, detail = $"User {id} does not exist" });
Response.Headers["Cache-Control"] = "private, max-age=300";
return Ok(user);
}
}POST — Create Resources
POST creates a new resource under the collection URI. It is not idempotent — sending the same POST twice creates two resources. Always return 201 Created with a Location header pointing to the new resource.
router.post('/users', async (req: Request, res: Response) => {
const parsed = createUserSchema.safeParse(req.body);
if (!parsed.success) {
return res.status(422).json({
type: 'https://api.example.com/errors/unprocessable-entity',
title: 'Validation Failed',
status: 422,
detail: 'One or more fields failed validation',
errors: parsed.error.flatten().fieldErrors,
instance: req.path,
});
}
try {
const user = await userService.createUser(parsed.data);
res
.status(201)
.set('Location', `/users/${user.id}`)
.json(user);
} catch (err) {
if (err instanceof DuplicateEmailError) {
return res.status(409).json({
type: 'https://api.example.com/errors/conflict',
title: 'Email Already Registered',
status: 409,
detail: `The email ${parsed.data.email} is already in use`,
instance: req.path,
});
}
throw err;
}
});@PostMapping
public ResponseEntity<?> createUser(@Valid @RequestBody CreateUserRequest body,
BindingResult binding, HttpServletRequest request) {
if (binding.hasErrors()) {
Map<String, List<String>> fieldErrors = binding.getFieldErrors().stream()
.collect(Collectors.groupingBy(FieldError::getField,
Collectors.mapping(FieldError::getDefaultMessage, Collectors.toList())));
return ResponseEntity.status(422).body(Map.of(
"type", "https://api.example.com/errors/unprocessable-entity",
"title", "Validation Failed",
"status", 422,
"errors", fieldErrors));
}
try {
User user = userService.createUser(body);
URI location = URI.create("/users/" + user.getId());
return ResponseEntity.created(location).body(user);
} catch (DuplicateEmailException e) {
return ResponseEntity.status(409).body(Map.of(
"type", "https://api.example.com/errors/conflict",
"title", "Email Already Registered",
"status", 409,
"detail", e.getMessage()));
}
}from fastapi import APIRouter, Request
from fastapi.responses import JSONResponse
from pydantic import BaseModel, EmailStr
class CreateUserRequest(BaseModel):
name: str
email: EmailStr
role: str = "user"
@router.post("", status_code=201)
async def create_user(body: CreateUserRequest, request: Request):
try:
user = await user_service.create_user(body.dict())
return JSONResponse(
status_code=201,
content=user.dict(),
headers={"Location": f"/users/{user.id}"},
)
except DuplicateEmailError:
return JSONResponse(status_code=409, content={
"type": "https://api.example.com/errors/conflict",
"title": "Email Already Registered",
"status": 409,
"detail": f"The email {body.email} is already in use",
})[HttpPost]
public async Task<IActionResult> CreateUser([FromBody] CreateUserRequest body)
{
if (!ModelState.IsValid)
{
var errors = ModelState.ToDictionary(
k => k.Key,
v => v.Value!.Errors.Select(e => e.ErrorMessage).ToList());
return UnprocessableEntity(new { type = "https://api.example.com/errors/unprocessable-entity", title = "Validation Failed", status = 422, errors });
}
try
{
var user = await _userService.CreateUserAsync(body);
return CreatedAtAction(nameof(GetUser), new { id = user.Id }, user);
}
catch (DuplicateEmailException ex)
{
return Conflict(new { type = "https://api.example.com/errors/conflict", title = "Email Already Registered", status = 409, detail = ex.Message });
}
}PUT vs PATCH — The Critical Distinction
This is where most teams go wrong. The difference is precise:
- PUT replaces the entire resource representation. Every field must be supplied; omitted fields are removed or reset to defaults.
- PATCH applies a partial update. Only the provided fields are modified.
sequenceDiagram
participant C as Client
participant S as Server
participant DB as Database
Note over C,DB: PUT — Full Replacement
C->>S: PUT /users/42<br/>{ name, email, role, address, phone }
S->>DB: SELECT * FROM users WHERE id = 42
DB-->>S: existing user record
S->>S: Validate ALL fields present<br/>Replace entire document
S->>DB: UPDATE users SET name=?, email=?,<br/>role=?, address=?, phone=? WHERE id=42
DB-->>S: updated record
S-->>C: 200 OK — full user object
Note over C,DB: PATCH — Partial Update
C->>S: PATCH /users/42<br/>{ address: "123 New St" }
S->>DB: SELECT * FROM users WHERE id = 42
DB-->>S: existing user record
S->>S: Merge patch: only address changes<br/>name, email, role, phone unchanged
S->>DB: UPDATE users SET address=? WHERE id=42
DB-->>S: updated record
S-->>C: 200 OK — full user object
PATCH formats: The two standard approaches are:
- JSON Merge Patch (RFC 7396) — send a partial JSON object;
nullvalues delete fields. Simple, widely used. - JSON Patch (RFC 6902) — send an array of operation objects (
add,remove,replace,move,copy,test). Powerful, atomic, but verbose.
// JSON Merge Patch (RFC 7396) — Content-Type: application/merge-patch+json
{ "address": "123 New St", "phone": null }
// JSON Patch (RFC 6902) — Content-Type: application/json-patch+json
[
{ "op": "replace", "path": "/address", "value": "123 New St" },
{ "op": "remove", "path": "/phone" }
]
// PUT — full replacement
router.put('/users/:id', async (req: Request, res: Response) => {
const parsed = replaceUserSchema.safeParse(req.body);
if (!parsed.success) {
return res.status(422).json({ type: 'https://api.example.com/errors/unprocessable-entity', title: 'Validation Failed', status: 422, errors: parsed.error.flatten().fieldErrors });
}
const updated = await userService.replaceUser(req.params.id, parsed.data);
if (!updated) return res.status(404).json({ type: 'https://api.example.com/errors/not-found', title: 'User Not Found', status: 404 });
res.status(200).json(updated);
});
// PATCH — partial update (JSON Merge Patch)
router.patch('/users/:id', async (req: Request, res: Response) => {
const parsed = patchUserSchema.safeParse(req.body); // all fields optional
if (!parsed.success) {
return res.status(422).json({ type: 'https://api.example.com/errors/unprocessable-entity', title: 'Validation Failed', status: 422, errors: parsed.error.flatten().fieldErrors });
}
const updated = await userService.patchUser(req.params.id, parsed.data);
if (!updated) return res.status(404).json({ type: 'https://api.example.com/errors/not-found', title: 'User Not Found', status: 404 });
res.status(200).json(updated);
});// PUT
@PutMapping("/{id}")
public ResponseEntity<?> replaceUser(@PathVariable String id,
@Valid @RequestBody ReplaceUserRequest body) {
return userService.replaceUser(id, body)
.map(u -> ResponseEntity.ok((Object) u))
.orElse(ResponseEntity.status(404).body(Map.of(
"type", "https://api.example.com/errors/not-found",
"title", "User Not Found", "status", 404)));
}
// PATCH
@PatchMapping(value = "/{id}", consumes = "application/merge-patch+json")
public ResponseEntity<?> patchUser(@PathVariable String id,
@RequestBody Map<String, Object> patch) {
return userService.patchUser(id, patch)
.map(u -> ResponseEntity.ok((Object) u))
.orElse(ResponseEntity.status(404).body(Map.of(
"type", "https://api.example.com/errors/not-found",
"title", "User Not Found", "status", 404)));
}from typing import Optional
from pydantic import BaseModel
class ReplaceUserRequest(BaseModel):
name: str
email: EmailStr
role: str
address: str
phone: str
class PatchUserRequest(BaseModel):
name: Optional[str] = None
email: Optional[EmailStr] = None
role: Optional[str] = None
address: Optional[str] = None
phone: Optional[str] = None
@router.put("/{user_id}")
async def replace_user(user_id: str, body: ReplaceUserRequest):
updated = await user_service.replace_user(user_id, body.dict())
if not updated:
raise HTTPException(status_code=404, detail={"title": "User Not Found", "status": 404})
return updated
@router.patch("/{user_id}")
async def patch_user(user_id: str, body: PatchUserRequest):
# exclude_unset preserves JSON Merge Patch semantics
patch = body.dict(exclude_unset=True)
updated = await user_service.patch_user(user_id, patch)
if not updated:
raise HTTPException(status_code=404, detail={"title": "User Not Found", "status": 404})
return updated// PUT
[HttpPut("{id}")]
public async Task<IActionResult> ReplaceUser(string id, [FromBody] ReplaceUserRequest body)
{
if (!ModelState.IsValid) return UnprocessableEntity(ModelState);
var updated = await _userService.ReplaceUserAsync(id, body);
return updated != null ? Ok(updated) : NotFound(new { title = "User Not Found", status = 404 });
}
// PATCH
[HttpPatch("{id}")]
public async Task<IActionResult> PatchUser(string id, [FromBody] JsonElement patch)
{
// Use JsonMergePatch or Microsoft.AspNetCore.JsonPatch
var updated = await _userService.PatchUserAsync(id, patch);
return updated != null ? Ok(updated) : NotFound(new { title = "User Not Found", status = 404 });
}DELETE — Idempotent Removal
DELETE removes a resource. It is idempotent: deleting an already-deleted resource should return 204 No Content, not 404 (though some teams return 404 on second delete — pick one and document it).
Soft delete vs hard delete:
- Hard delete — removes the record from the database permanently. Returns
204 No Content. - Soft delete — sets a
deletedAttimestamp, hides from queries, but retains the record. The API still returns204; the resource is simply no longer accessible via GET.
router.delete('/users/:id', async (req: Request, res: Response) => {
const exists = await userService.userExists(req.params.id);
if (!exists) {
return res.status(404).json({
type: 'https://api.example.com/errors/not-found',
title: 'User Not Found',
status: 404,
detail: `User ${req.params.id} does not exist`,
});
}
await userService.softDeleteUser(req.params.id); // sets deletedAt
res.status(204).send();
});@DeleteMapping("/{id}")
public ResponseEntity<?> deleteUser(@PathVariable String id) {
if (!userService.userExists(id)) {
return ResponseEntity.status(404).body(Map.of(
"type", "https://api.example.com/errors/not-found",
"title", "User Not Found", "status", 404,
"detail", "User " + id + " does not exist"));
}
userService.softDeleteUser(id);
return ResponseEntity.noContent().build();
}@router.delete("/{user_id}", status_code=204)
async def delete_user(user_id: str):
exists = await user_service.user_exists(user_id)
if not exists:
raise HTTPException(status_code=404, detail={
"type": "https://api.example.com/errors/not-found",
"title": "User Not Found",
"status": 404,
})
await user_service.soft_delete_user(user_id)[HttpDelete("{id}")]
public async Task<IActionResult> DeleteUser(string id)
{
if (!await _userService.UserExistsAsync(id))
return NotFound(new { type = "https://api.example.com/errors/not-found", title = "User Not Found", status = 404, detail = $"User {id} does not exist" });
await _userService.SoftDeleteUserAsync(id);
return NoContent();
}HEAD and OPTIONS
HEAD returns the same headers as GET but with no response body. Use it to check resource existence, get Content-Length, or validate caching without downloading data.
OPTIONS describes the communication options for a resource. The browser sends it automatically before cross-origin requests (CORS preflight). Always implement it correctly.
OPTIONS /users/42 HTTP/1.1
Host: api.example.com
Origin: https://app.example.com
Access-Control-Request-Method: PATCH
Access-Control-Request-Headers: Content-Type, Authorization
HTTP/1.1 204 No Content
Allow: GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: GET, POST, PUT, PATCH, DELETE
Access-Control-Allow-Headers: Content-Type, Authorization
Access-Control-Max-Age: 86400
Part 3: URL Structure Best Practices
Nouns, Not Verbs
The HTTP method already expresses the action. The URI should describe the resource, not the operation.
| Wrong (verb in URL) | Correct (noun + HTTP method) |
|---|---|
POST /getUser | GET /users/42 |
POST /createOrder | POST /orders |
POST /deleteAccount | DELETE /accounts/42 |
POST /updateUserProfile | PATCH /users/42/profile |
POST /searchProducts | GET /products?q=shirt&color=blue |
Exception: Resource actions that don’t map cleanly to CRUD operations. Use a sub-resource noun:
POST /users/42/deactivate # trigger deactivation (not "deactivateUser")
POST /orders/7/cancel # trigger cancellation
POST /payments/9/refund # trigger refund
Plural Resource Names
Always use plural nouns for collections. Singular for singletons (account settings, global config).
/users ✓
/user ✗
/orders ✓
/order ✗
/users/42/profile ✓ (singleton sub-resource — one profile per user)
Nested Resources
Use nesting to express ownership/containment relationships, but limit to two levels deep. Deeper nesting becomes unwieldy.
/users/:userId/orders # orders belonging to a user
/users/:userId/orders/:orderId # specific order belonging to a user
/orders/:orderId/items # line items within an order
When a resource has multiple natural parents, prefer a flat resource with query parameters:
# Problematic: /users/:userId/organizations/:orgId/projects/:projectId
# Better:
GET /projects?userId=42&orgId=7
Versioning Strategies
Versioning your API from day one saves enormous pain later. Three common approaches:
| Strategy | Example | Pros | Cons |
|---|---|---|---|
| URI path | /v1/users | Visible, easy to route, cacheable | Breaks REST purity (version isn’t a resource) |
| Header | Accept: application/vnd.api+json;version=1 | Cleaner URLs, REST compliant | Harder to test in browser, complex routing |
| Query param | /users?version=1 | Easy to test | Pollutes query params, often accidentally omitted |
URI path versioning is the most widely adopted in practice:
https://api.example.com/v1/users
https://api.example.com/v2/users
Maintain old versions for a documented deprecation period. Add a Deprecation and Sunset header when a version is being retired:
Deprecation: Mon, 01 Jan 2026 00:00:00 GMT
Sunset: Mon, 01 Jul 2026 00:00:00 GMT
Link: <https://api.example.com/v2/users>; rel="successor-version"
Query Parameters
Use query parameters for filtering, sorting, and pagination — not for addressing resources.
GET /products?category=electronics&minPrice=100&maxPrice=500
GET /orders?status=pending&createdAfter=2025-01-01
GET /users?sort=lastName&order=asc&page=2&limit=25
GET /users?q=alice # full-text search
GET /users?fields=id,name,email # sparse fieldsets
Part 4: Request and Response Patterns
Response Envelope vs Flat Response
Two common conventions for structuring JSON responses:
// Flat — simpler, direct mapping to resource
{
"id": "42",
"name": "Alice",
"email": "alice@example.com"
}
// Envelope — adds metadata, easier to evolve
{
"data": { "id": "42", "name": "Alice", "email": "alice@example.com" },
"meta": { "requestId": "req-abc123", "version": "1.0" }
}
For list endpoints, always use an envelope to carry pagination metadata alongside the array.
RFC 7807 Problem Details
Never return bare error strings. Use the RFC 7807 Problem Details format for all error responses. It provides a machine-readable standard that clients can consistently parse.
{
"type": "https://api.example.com/errors/insufficient-funds",
"title": "Insufficient Funds",
"status": 402,
"detail": "Account balance $12.00 is below the required $50.00",
"instance": "/payments/abc123",
"balance": 12.00,
"required": 50.00
}
Fields:
type— URI identifying the problem type (should resolve to documentation)title— short human-readable summary (should not change between occurrences)status— HTTP status code (mirrors the response status)detail— human-readable explanation specific to this occurrenceinstance— URI identifying this specific occurrence of the problem- Additional members are allowed and encouraged
sequenceDiagram
participant C as Client
participant S as API Server
participant V as Validator
participant DB as Database
C->>S: POST /payments<br/>{ amount: -50, currency: "XYZ" }
S->>V: Validate request body
V-->>S: amount must be positive<br/>currency XYZ not supported
S-->>C: 422 Unprocessable Entity<br/>{ type: .../validation-error,<br/> title: Validation Failed,<br/> errors: { amount: [...], currency: [...] } }
Note over C: Client fixes request
C->>S: POST /payments<br/>{ amount: 50, currency: "USD" }
S->>V: Validate request body
V-->>S: Valid
S->>DB: Check account balance
DB-->>S: balance = $12.00
S-->>C: 402 Payment Required<br/>{ type: .../insufficient-funds,<br/> title: Insufficient Funds,<br/> detail: balance $12 < required $50 }
Note over C: User tops up account
C->>S: POST /payments<br/>{ amount: 50, currency: "USD" }
S->>DB: Check balance → $100
DB-->>S: Sufficient funds
S->>DB: Process payment
DB-->>S: payment created
S-->>C: 201 Created<br/>Location: /payments/pay_xyz789
Pagination: Cursor-Based vs Offset-Based
Offset-based pagination is simple but has problems with large datasets and concurrent modifications:
GET /users?page=3&limit=25
{
"data": [...],
"pagination": {
"page": 3,
"limit": 25,
"total": 1240,
"pages": 50,
"prev": "/users?page=2&limit=25",
"next": "/users?page=4&limit=25"
}
}
Cursor-based pagination uses an opaque pointer to the last seen item. It handles insertions/deletions correctly and performs better on large datasets:
GET /users?limit=25&after=cursor_dXNlcl8xMDA
{
"data": [...],
"pagination": {
"limit": 25,
"hasMore": true,
"cursors": {
"before": "cursor_dXNlcl85MA",
"after": "cursor_dXNlcl8xMDA"
},
"next": "/users?limit=25&after=cursor_dXNlcl8xMDA"
}
}
sequenceDiagram
participant C as Client
participant S as API Server
participant DB as Database
C->>S: GET /users?limit=25
S->>DB: SELECT * FROM users ORDER BY id ASC LIMIT 26
DB-->>S: 26 rows (one extra to detect hasMore)
S->>S: hasMore = true (got 26, return first 25)<br/>cursor = base64(id of 25th row)
S-->>C: 200 OK<br/>{ data: [25 users], pagination: { hasMore: true, cursors: { after: "cursor_abc" } } }
C->>S: GET /users?limit=25&after=cursor_abc
S->>S: Decode cursor → id = 100
S->>DB: SELECT * FROM users WHERE id > 100 ORDER BY id ASC LIMIT 26
DB-->>S: 18 rows (less than 26, no more pages)
S->>S: hasMore = false
S-->>C: 200 OK<br/>{ data: [18 users], pagination: { hasMore: false } }
When to use which:
| Offset-Based | Cursor-Based | |
|---|---|---|
| Random page access | Yes | No |
| Stable under inserts/deletes | No | Yes |
| Total count available | Yes | No |
| Performance at scale | Degrades | Consistent |
| Implementation complexity | Low | Medium |
HATEOAS Links
HATEOAS (Hypermedia As The Engine Of Application State) embeds links to related actions in responses, making the API self-describing. Implement selectively — full HATEOAS is rarely worth the complexity:
{
"id": "42",
"name": "Alice",
"status": "active",
"_links": {
"self": { "href": "/users/42" },
"orders": { "href": "/users/42/orders" },
"deactivate": { "href": "/users/42/deactivate", "method": "POST" }
}
}
Bulk Operations
For operating on multiple resources at once, use a dedicated bulk endpoint:
POST /users/bulk
Content-Type: application/json
{
"operations": [
{ "op": "create", "data": { "name": "Bob", "email": "bob@example.com" } },
{ "op": "update", "id": "42", "data": { "role": "admin" } },
{ "op": "delete", "id": "99" }
]
}
Return 207 Multi-Status with per-operation results when operations may partially fail:
{
"results": [
{ "op": "create", "status": 201, "data": { "id": "101", "name": "Bob" } },
{ "op": "update", "id": "42", "status": 200, "data": { "id": "42", "role": "admin" } },
{ "op": "delete", "id": "99", "status": 404, "error": { "title": "User Not Found" } }
]
}
Part 5: HTTP Status Codes
2xx Success
| Code | Name | Use Case |
|---|---|---|
| 200 | OK | Successful GET, PUT, PATCH; or POST when not creating |
| 201 | Created | Successful POST/PUT that creates a new resource |
| 202 | Accepted | Request accepted for async processing |
| 204 | No Content | Successful DELETE; PUT/PATCH returning no body |
| 207 | Multi-Status | Bulk operations with mixed results |
4xx Client Errors
| Code | Name | Use Case |
|---|---|---|
| 400 | Bad Request | Malformed request syntax, invalid JSON |
| 401 | Unauthorized | Missing or invalid authentication credentials |
| 403 | Forbidden | Authenticated but not authorized for this resource |
| 404 | Not Found | Resource does not exist |
| 405 | Method Not Allowed | HTTP method not supported for this endpoint |
| 409 | Conflict | State conflict (duplicate, version mismatch) |
| 410 | Gone | Resource permanently deleted (was here, now gone) |
| 415 | Unsupported Media Type | Wrong Content-Type header |
| 422 | Unprocessable Entity | Well-formed request but semantic validation failed |
| 429 | Too Many Requests | Rate limit exceeded |
401 vs 403: 401 means “who are you?” (prove identity). 403 means “I know who you are, but you can’t do this.” Never return 404 to hide the existence of a resource the client lacks permission to access — that is a 403.
5xx Server Errors
| Code | Name | Use Case |
|---|---|---|
| 500 | Internal Server Error | Unhandled exception; don’t leak stack traces |
| 502 | Bad Gateway | Upstream service returned invalid response |
| 503 | Service Unavailable | Server temporarily overloaded or in maintenance |
| 504 | Gateway Timeout | Upstream service did not respond in time |
Part 6: Complete CRUD Implementation
Here is a fully featured Users API combining all patterns above — including validation, pagination, error handling, and soft delete.
// TypeScript — Express with Zod validation
import express, { Router, Request, Response, NextFunction } from 'express';
import { z } from 'zod';
const router = Router();
const createUserSchema = z.object({
name: z.string().min(1).max(100),
email: z.string().email(),
role: z.enum(['user', 'admin']).default('user'),
});
const patchUserSchema = createUserSchema.partial();
// GET /users — list with filtering, sorting, cursor pagination
router.get('/', async (req: Request, res: Response) => {
const { limit = '25', after, role, q } = req.query;
const safeLimit = Math.min(Number(limit), 100);
const cursor = after ? Buffer.from(String(after), 'base64').toString() : undefined;
const filters = {
...(role && { role: String(role) }),
...(q && { search: String(q) }),
...(cursor && { afterId: cursor }),
};
const users = await userService.listUsers(filters, safeLimit + 1);
const hasMore = users.length > safeLimit;
const page = hasMore ? users.slice(0, safeLimit) : users;
const nextCursor = hasMore ? Buffer.from(page[page.length - 1].id).toString('base64') : null;
res.status(200).json({
data: page,
pagination: {
limit: safeLimit,
hasMore,
cursors: { after: nextCursor },
...(nextCursor && { next: `/users?limit=${safeLimit}&after=${nextCursor}` }),
},
});
});
// POST /users
router.post('/', async (req: Request, res: Response) => {
const parsed = createUserSchema.safeParse(req.body);
if (!parsed.success) {
return res.status(422).json({ type: 'https://api.example.com/errors/unprocessable-entity', title: 'Validation Failed', status: 422, errors: parsed.error.flatten().fieldErrors, instance: req.path });
}
const user = await userService.createUser(parsed.data);
res.status(201).set('Location', `/users/${user.id}`).json(user);
});
// PATCH /users/:id
router.patch('/:id', async (req: Request, res: Response) => {
const parsed = patchUserSchema.safeParse(req.body);
if (!parsed.success) {
return res.status(422).json({ type: 'https://api.example.com/errors/unprocessable-entity', title: 'Validation Failed', status: 422, errors: parsed.error.flatten().fieldErrors });
}
const updated = await userService.patchUser(req.params.id, parsed.data);
if (!updated) return res.status(404).json({ type: 'https://api.example.com/errors/not-found', title: 'User Not Found', status: 404 });
res.status(200).json(updated);
});
// DELETE /users/:id
router.delete('/:id', async (req: Request, res: Response) => {
const deleted = await userService.softDeleteUser(req.params.id);
if (!deleted) return res.status(404).json({ type: 'https://api.example.com/errors/not-found', title: 'User Not Found', status: 404 });
res.status(204).send();
});
// Global error handler
app.use((err: Error, req: Request, res: Response, _next: NextFunction) => {
console.error(err);
res.status(500).json({ type: 'https://api.example.com/errors/internal', title: 'Internal Server Error', status: 500 });
});// Java — Spring Boot with Bean Validation
@RestController
@RequestMapping("/v1/users")
@RequiredArgsConstructor
public class UserController {
private final UserService userService;
@GetMapping
public ResponseEntity<Map<String, Object>> listUsers(
@RequestParam(defaultValue = "25") int limit,
@RequestParam(required = false) String after,
@RequestParam(required = false) String role,
@RequestParam(required = false) String q) {
int safeLimit = Math.min(limit, 100);
String cursor = after != null
? new String(Base64.getDecoder().decode(after))
: null;
UserFilters filters = UserFilters.builder()
.role(role).search(q).afterId(cursor).build();
List<User> users = userService.listUsers(filters, safeLimit + 1);
boolean hasMore = users.size() > safeLimit;
List<User> page = hasMore ? users.subList(0, safeLimit) : users;
String nextCursor = hasMore
? Base64.getEncoder().encodeToString(page.get(page.size() - 1).getId().getBytes())
: null;
Map<String, Object> pagination = new LinkedHashMap<>();
pagination.put("limit", safeLimit);
pagination.put("hasMore", hasMore);
if (nextCursor != null) {
pagination.put("cursors", Map.of("after", nextCursor));
pagination.put("next", "/v1/users?limit=" + safeLimit + "&after=" + nextCursor);
}
return ResponseEntity.ok(Map.of("data", page, "pagination", pagination));
}
@PostMapping
public ResponseEntity<?> createUser(@Valid @RequestBody CreateUserRequest body) {
User user = userService.createUser(body);
return ResponseEntity.status(201)
.header("Location", "/v1/users/" + user.getId())
.body(user);
}
@PatchMapping(value = "/{id}", consumes = "application/merge-patch+json")
public ResponseEntity<?> patchUser(@PathVariable String id,
@RequestBody Map<String, Object> patch) {
return userService.patchUser(id, patch)
.map(u -> ResponseEntity.ok((Object) u))
.orElse(ResponseEntity.status(404).body(Map.of(
"type", "https://api.example.com/errors/not-found",
"title", "User Not Found", "status", 404)));
}
@DeleteMapping("/{id}")
public ResponseEntity<?> deleteUser(@PathVariable String id) {
if (!userService.softDeleteUser(id))
return ResponseEntity.status(404).body(Map.of(
"type", "https://api.example.com/errors/not-found",
"title", "User Not Found", "status", 404));
return ResponseEntity.noContent().build();
}
}# Python — FastAPI with Pydantic v2
from fastapi import APIRouter, Query, HTTPException, Response
from fastapi.responses import JSONResponse
from pydantic import BaseModel, EmailStr
from typing import Optional
import base64
router = APIRouter(prefix="/v1/users", tags=["users"])
class CreateUserRequest(BaseModel):
name: str
email: EmailStr
role: str = "user"
class PatchUserRequest(BaseModel):
name: Optional[str] = None
email: Optional[EmailStr] = None
role: Optional[str] = None
def encode_cursor(value: str) -> str:
return base64.b64encode(value.encode()).decode()
def decode_cursor(cursor: str) -> str:
return base64.b64decode(cursor.encode()).decode()
@router.get("")
async def list_users(
limit: int = Query(25, ge=1, le=100),
after: Optional[str] = None,
role: Optional[str] = None,
q: Optional[str] = None,
):
cursor_id = decode_cursor(after) if after else None
filters = {"role": role, "search": q, "after_id": cursor_id}
users = await user_service.list_users(filters, limit + 1)
has_more = len(users) > limit
page = users[:limit]
next_cursor = encode_cursor(page[-1].id) if has_more else None
return {
"data": page,
"pagination": {
"limit": limit,
"hasMore": has_more,
"cursors": {"after": next_cursor} if next_cursor else {},
**({"next": f"/v1/users?limit={limit}&after={next_cursor}"} if next_cursor else {}),
},
}
@router.post("", status_code=201)
async def create_user(body: CreateUserRequest, response: Response):
user = await user_service.create_user(body.model_dump())
response.headers["Location"] = f"/v1/users/{user.id}"
return user
@router.patch("/{user_id}")
async def patch_user(user_id: str, body: PatchUserRequest):
patch = body.model_dump(exclude_unset=True)
updated = await user_service.patch_user(user_id, patch)
if not updated:
raise HTTPException(status_code=404, detail={"type": "https://api.example.com/errors/not-found", "title": "User Not Found", "status": 404})
return updated
@router.delete("/{user_id}", status_code=204)
async def delete_user(user_id: str):
deleted = await user_service.soft_delete_user(user_id)
if not deleted:
raise HTTPException(status_code=404, detail={"type": "https://api.example.com/errors/not-found", "title": "User Not Found", "status": 404})// C# — ASP.NET Core with FluentValidation
[ApiController]
[Route("v1/users")]
public class UsersController : ControllerBase
{
private readonly IUserService _userService;
public UsersController(IUserService userService) => _userService = userService;
[HttpGet]
public async Task<IActionResult> ListUsers(
[FromQuery] int limit = 25,
[FromQuery] string? after = null,
[FromQuery] string? role = null,
[FromQuery] string? q = null)
{
var safeLimit = Math.Min(limit, 100);
var cursor = after != null
? System.Text.Encoding.UTF8.GetString(Convert.FromBase64String(after))
: null;
var filters = new UserFilters { Role = role, Search = q, AfterId = cursor };
var users = await _userService.ListUsersAsync(filters, safeLimit + 1);
var hasMore = users.Count > safeLimit;
var page = hasMore ? users.Take(safeLimit).ToList() : users;
var nextCursor = hasMore
? Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes(page.Last().Id))
: null;
return Ok(new
{
data = page,
pagination = new
{
limit = safeLimit,
hasMore,
cursors = nextCursor != null ? new { after = nextCursor } : null,
next = nextCursor != null ? $"/v1/users?limit={safeLimit}&after={nextCursor}" : null
}
});
}
[HttpPost]
public async Task<IActionResult> CreateUser([FromBody] CreateUserRequest body)
{
if (!ModelState.IsValid) return UnprocessableEntity(ModelState);
var user = await _userService.CreateUserAsync(body);
return CreatedAtAction(nameof(GetUser), new { id = user.Id }, user);
}
[HttpPatch("{id}")]
public async Task<IActionResult> PatchUser(string id, [FromBody] JsonElement patch)
{
var updated = await _userService.PatchUserAsync(id, patch);
return updated != null ? Ok(updated) : NotFound(new { type = "https://api.example.com/errors/not-found", title = "User Not Found", status = 404 });
}
[HttpDelete("{id}")]
public async Task<IActionResult> DeleteUser(string id)
{
var deleted = await _userService.SoftDeleteUserAsync(id);
return deleted ? NoContent() : NotFound(new { type = "https://api.example.com/errors/not-found", title = "User Not Found", status = 404 });
}
}Part 7: Security Best Practices
Input Validation and Sanitization
Never trust client-supplied data. Validate at the boundary, before it touches any business logic or database.
- Schema validation — use Zod (TS), Bean Validation (Java), Pydantic (Python), or FluentValidation (C#) to enforce types, ranges, and formats
- Parameterized queries — never interpolate user input into SQL; always use prepared statements or an ORM
- Size limits — cap request body size (e.g., 1MB) and array lengths to prevent DoS
- Sanitize output — strip or encode HTML in text fields to prevent XSS in clients that render raw API output
# Nginx: enforce request body size
client_max_body_size 1m;
Rate Limiting
Protect every endpoint from abuse. Return 429 Too Many Requests with a Retry-After header:
HTTP/1.1 429 Too Many Requests
Retry-After: 60
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1714300800
{
"type": "https://api.example.com/errors/rate-limit-exceeded",
"title": "Rate Limit Exceeded",
"status": 429,
"detail": "You have exceeded 100 requests per minute. Try again after 60 seconds."
}
Use a token bucket or sliding window algorithm. Apply different limits per tier (anonymous, authenticated, premium). Implement rate limiting at the API gateway layer (Kong, AWS API Gateway, nginx) rather than application code where possible.
Authentication Headers
Never pass credentials in the URL path or query string — they appear in logs and browser history.
# Correct — credentials in Authorization header
GET /users HTTP/1.1
Authorization: Bearer eyJhbGciOiJSUzI1NiJ9...
Common schemes:
Bearer <token>— JWT or opaque OAuth 2.0 tokenBasic <base64(user:pass)>— only over HTTPS, only for machine-to-machineApiKey <key>— simple API key (often inX-API-Keyheader instead)
Validate the token on every request. Never log tokens. Set short expiry times on access tokens (15 minutes) and use refresh tokens for long-lived sessions.
CORS Configuration
Be explicit. Never set Access-Control-Allow-Origin: * for credentialed requests.
# Restrictive CORS — only allow your known origins
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: GET, POST, PUT, PATCH, DELETE, OPTIONS
Access-Control-Allow-Headers: Content-Type, Authorization
Access-Control-Allow-Credentials: true
Access-Control-Max-Age: 86400
Maintain an allowlist of trusted origins and dynamically set the header from that list, logging unauthorized origins.
Additional Hardening
# Security headers every API should return
Strict-Transport-Security: max-age=31536000; includeSubDomains
X-Content-Type-Options: nosniff
X-Frame-Options: DENY
Content-Security-Policy: default-src 'none'
Cache-Control: no-store # for sensitive endpoints
Part 8: API Documentation with OpenAPI
OpenAPI (formerly Swagger) is the industry standard for describing REST APIs. Define your API in YAML or JSON, then generate interactive documentation, client SDKs, and server stubs automatically.
openapi: 3.1.0
info:
title: Users API
version: 1.0.0
description: Manage users with full CRUD support
paths:
/v1/users:
get:
summary: List users
operationId: listUsers
parameters:
- name: limit
in: query
schema: { type: integer, minimum: 1, maximum: 100, default: 25 }
- name: after
in: query
schema: { type: string, description: Cursor for pagination }
- name: role
in: query
schema: { type: string, enum: [user, admin] }
responses:
'200':
description: Paginated list of users
content:
application/json:
schema: { $ref: '#/components/schemas/UserListResponse' }
'401': { $ref: '#/components/responses/Unauthorized' }
'429': { $ref: '#/components/responses/RateLimitExceeded' }
post:
summary: Create a user
operationId: createUser
requestBody:
required: true
content:
application/json:
schema: { $ref: '#/components/schemas/CreateUserRequest' }
responses:
'201':
description: User created
headers:
Location:
schema: { type: string }
content:
application/json:
schema: { $ref: '#/components/schemas/User' }
'409': { $ref: '#/components/responses/Conflict' }
'422': { $ref: '#/components/responses/ValidationError' }
components:
schemas:
User:
type: object
properties:
id: { type: string }
name: { type: string }
email: { type: string, format: email }
role: { type: string, enum: [user, admin] }
createdAt: { type: string, format: date-time }
CreateUserRequest:
type: object
required: [name, email]
properties:
name: { type: string, minLength: 1, maxLength: 100 }
email: { type: string, format: email }
role: { type: string, enum: [user, admin], default: user }
securitySchemes:
bearerAuth:
type: http
scheme: bearer
bearerFormat: JWT
security:
- bearerAuth: []
Tooling:
- Swagger UI / Redoc — interactive browser-based documentation from your OpenAPI spec
- Spectral — lint your OpenAPI spec against best-practice rules
- OpenAPI Generator — generate client SDKs in 50+ languages
- Prism — mock server from your spec for frontend development before backend is ready
Summary
A well-designed REST API is consistent, predictable, and hard to misuse. The principles covered in this guide compound:
| Area | Key Decision |
|---|---|
| HTTP Methods | Use GET/POST/PUT/PATCH/DELETE with correct semantics |
| URL Structure | Plural nouns, 2-level nesting, path versioning |
| Status Codes | 2xx for success, 4xx for client error, 5xx for server error |
| Error Format | RFC 7807 Problem Details everywhere |
| Pagination | Cursor-based for large datasets, offset for small ones |
| PUT vs PATCH | PUT = full replacement, PATCH = partial update (RFC 7396) |
| Security | Validate input, rate limit, use Authorization header, restrict CORS |
| Documentation | OpenAPI spec — it is your contract |
The biggest practical gains come from consistency: pick a convention for each decision above, document it in a team API style guide, and enforce it via linting (Spectral), code review, and contract tests. APIs that behave consistently across all endpoints are dramatically easier to consume, debug, and evolve.