All articles
API Backend Architecture REST

Designing RESTful API Endpoints: Best Practices for HTTP Methods and URL Structure

Palakorn Voramongkol
April 28, 2025 18 min read

“A comprehensive guide to RESTful API endpoint design — covering HTTP methods (GET, POST, PUT, PATCH, DELETE), URL structure, status codes, pagination patterns, error handling with RFC 7807, and real-world implementations in TypeScript, Java, Python, and C#.”

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:

  1. Client-Server — the UI and data storage concerns are separated; they evolve independently
  2. Statelessness — every request contains all information the server needs; no session state is stored server-side
  3. Cacheability — responses must define themselves as cacheable or non-cacheable to prevent clients from reusing stale data
  4. 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
  5. Layered System — clients cannot tell whether they’re connected to the end server or an intermediary (load balancer, cache, gateway)
  6. 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).

ConceptExample
Collection resourceGET /users
Singleton resourceGET /users/42
Sub-resource collectionGET /users/42/orders
Sub-resource singletonGET /users/42/orders/7
Action on resourcePOST /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)
MethodSafeIdempotentRequest BodyTypical Response
GETYesYesNo200 OK
POSTNoNoYes201 Created
PUTNoYesYes200 OK / 204 No Content
PATCHNoNo*Yes200 OK / 204 No Content
DELETENoYesOptional204 No Content
HEADYesYesNo200 OK (no body)
OPTIONSYesYesNo200 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; null values 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 deletedAt timestamp, hides from queries, but retains the record. The API still returns 204; 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 /getUserGET /users/42
POST /createOrderPOST /orders
POST /deleteAccountDELETE /accounts/42
POST /updateUserProfilePATCH /users/42/profile
POST /searchProductsGET /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:

StrategyExampleProsCons
URI path/v1/usersVisible, easy to route, cacheableBreaks REST purity (version isn’t a resource)
HeaderAccept: application/vnd.api+json;version=1Cleaner URLs, REST compliantHarder to test in browser, complex routing
Query param/users?version=1Easy to testPollutes 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 occurrence
  • instance — 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-BasedCursor-Based
Random page accessYesNo
Stable under inserts/deletesNoYes
Total count availableYesNo
Performance at scaleDegradesConsistent
Implementation complexityLowMedium

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

CodeNameUse Case
200OKSuccessful GET, PUT, PATCH; or POST when not creating
201CreatedSuccessful POST/PUT that creates a new resource
202AcceptedRequest accepted for async processing
204No ContentSuccessful DELETE; PUT/PATCH returning no body
207Multi-StatusBulk operations with mixed results

4xx Client Errors

CodeNameUse Case
400Bad RequestMalformed request syntax, invalid JSON
401UnauthorizedMissing or invalid authentication credentials
403ForbiddenAuthenticated but not authorized for this resource
404Not FoundResource does not exist
405Method Not AllowedHTTP method not supported for this endpoint
409ConflictState conflict (duplicate, version mismatch)
410GoneResource permanently deleted (was here, now gone)
415Unsupported Media TypeWrong Content-Type header
422Unprocessable EntityWell-formed request but semantic validation failed
429Too Many RequestsRate 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

CodeNameUse Case
500Internal Server ErrorUnhandled exception; don’t leak stack traces
502Bad GatewayUpstream service returned invalid response
503Service UnavailableServer temporarily overloaded or in maintenance
504Gateway TimeoutUpstream 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 token
  • Basic <base64(user:pass)> — only over HTTPS, only for machine-to-machine
  • ApiKey <key> — simple API key (often in X-API-Key header 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:

AreaKey Decision
HTTP MethodsUse GET/POST/PUT/PATCH/DELETE with correct semantics
URL StructurePlural nouns, 2-level nesting, path versioning
Status Codes2xx for success, 4xx for client error, 5xx for server error
Error FormatRFC 7807 Problem Details everywhere
PaginationCursor-based for large datasets, offset for small ones
PUT vs PATCHPUT = full replacement, PATCH = partial update (RFC 7396)
SecurityValidate input, rate limit, use Authorization header, restrict CORS
DocumentationOpenAPI 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.

Comments powered by Giscus are not yet configured. Set PUBLIC_GISCUS_REPO_ID and PUBLIC_GISCUS_CATEGORY_ID in apps/web/.env to enable.

PV

Written by Palakorn Voramongkol

Software Engineer Specialist with 20+ years of experience. Writing about architecture, performance, and building production systems.

More about me

Continue Reading