Back to Technology

RESTful API Design Patterns

April 1, 2026 Wasil Zafar 50 min read

Design production-grade REST APIs — master resource modeling, HTTP methods and status codes, authentication schemes, versioning strategies, pagination, rate limiting, error handling, HATEOAS, and OpenAPI documentation.

Table of Contents

  1. History of REST
  2. REST Architectural Principles
  3. Resource Design & URL Structure
  4. HTTP Methods & Status Codes
  5. Authentication & Authorization
  6. API Versioning Strategies
  7. Pagination, Filtering & Sorting
  8. Error Handling & Response Format
  9. Rate Limiting & Throttling
  10. OpenAPI & Documentation
  11. Case Studies
  12. Exercises
  13. API Design Document Generator
  14. Conclusion & Resources

History of REST

In the year 2000, Roy Fielding published his doctoral dissertation at the University of California, Irvine, titled "Architectural Styles and the Design of Network-based Software Architectures." Chapter 5 of that dissertation introduced Representational State Transfer (REST) as an architectural style for distributed hypermedia systems. It was not a protocol, not a specification, and not a framework — it was a set of constraints that, when applied to the design of web services, produce systems that are scalable, simple, and evolvable.

Fielding was not an ivory-tower academic. He was one of the principal authors of the HTTP/1.1 specification (RFC 2616) and a co-founder of the Apache HTTP Server project. REST was his formalization of the architectural principles that made the World Wide Web work.

SOAP vs REST: The Protocol Wars

Before REST gained mainstream adoption, the dominant paradigm for web services was SOAP (Simple Object Access Protocol). SOAP used XML for message formatting, WSDL (Web Services Description Language) for service contracts, and operated as an RPC (Remote Procedure Call) mechanism tunneled over HTTP POST.

Think of SOAP as sending a letter through a bureaucratic postal system: you fill out a standardized envelope (XML), include a cover sheet describing the contents (WSDL), and the post office (HTTP) does not care what is inside — it just carries the envelope. REST, by contrast, is like using the web itself: you navigate to an address (URL), the type of action is the HTTP method (GET to read, POST to create), and the content comes in whatever format the server provides (JSON, XML, HTML).

Aspect SOAP REST
Message format XML only JSON, XML, HTML, plain text
Transport HTTP, SMTP, TCP HTTP (by convention)
Contract WSDL (strict, machine-readable) OpenAPI/Swagger (optional)
State Can be stateful (WS-* extensions) Stateless (each request is self-contained)
Caching Not built-in Leverages HTTP caching (ETags, Cache-Control)
Adoption (2024) Legacy enterprise systems Dominant for web and mobile APIs

The Richardson Maturity Model

Leonard Richardson proposed a maturity model for REST APIs with four levels. Most APIs that call themselves "RESTful" are actually at Level 2. True REST (Level 3) is rare in practice.

Level Name Description Example
0 The Swamp of POX One URL, one HTTP method (POST), XML payloads SOAP-style RPC over HTTP
1 Resources Individual URLs for resources, but only POST POST /users/create, POST /users/delete
2 HTTP Verbs Proper use of GET, POST, PUT, DELETE + status codes GET /users/42, DELETE /users/42
3 Hypermedia (HATEOAS) Responses include links to related actions/resources Response body contains "_links": {...}
Key Insight: Fielding himself has stated that "REST APIs must be hypertext-driven" (Level 3). Most production APIs operate at Level 2 and call themselves RESTful, which is technically incorrect but pragmatically useful. Do not let architectural purity prevent you from shipping. Aim for Level 2 as a minimum, and add HATEOAS where it provides concrete value (e.g., self-documenting API navigation).

REST Architectural Principles

Fielding defined six constraints for REST. An API that violates any of these is, by definition, not RESTful (though it may still be perfectly useful).

1. Client-Server Separation

The client and server are independent. The client does not know how data is stored; the server does not know how data is displayed. This separation allows each to evolve independently.

2. Statelessness

Every request from the client must contain all the information needed to process it. The server does not store session state between requests. Authentication tokens, pagination cursors, and filter parameters must all be sent with each request.

Think of a stateless API like a new cashier at a fast-food restaurant who has no memory between orders. Every customer must state their complete order from scratch, including any special instructions, every single time. This seems inefficient for the customer, but it means the restaurant can replace any cashier at any time, add more cashiers during rush hour, and never worry about one cashier's notepad getting lost.

3. Cacheability

Responses must declare whether they are cacheable or not. This is done via HTTP headers like Cache-Control, ETag, and Last-Modified.

4. Uniform Interface

The interface between client and server is standardized. This is the most fundamental constraint and consists of four sub-constraints: identification of resources (URLs), manipulation through representations (JSON/XML), self-descriptive messages (Content-Type, status codes), and HATEOAS.

5. Layered System

The client cannot tell whether it is connected directly to the end server or to an intermediary (load balancer, CDN, API gateway). Each layer only knows about the adjacent layer.

6. Code on Demand (Optional)

Servers can optionally extend client functionality by sending executable code (JavaScript). This is the only optional constraint and is rarely used in API design.

// Example: a fully stateless request
// Every piece of information the server needs is in the request itself
const response = await fetch('https://api.example.com/v1/users?page=2&limit=20', {
    method: 'GET',
    headers: {
        'Authorization': 'Bearer eyJhbGciOiJIUzI1NiIs...',
        'Accept': 'application/json',
        'Accept-Language': 'en-US',
        'X-Request-ID': 'req-abc-123-def-456'
    }
});

Resource Design & URL Structure

Resources are the fundamental abstraction in REST. A resource is any concept that can be named: a user, an order, a product, a search result. The URL (Uniform Resource Locator) is the address of a resource.

URL Design Rules

  • Use nouns, not verbs: /users not /getUsers
  • Use plural nouns: /users not /user
  • Use hierarchical structure for relationships: /users/42/orders
  • Use kebab-case: /order-items not /orderItems
  • Avoid deep nesting (max 2-3 levels): /users/42/orders is fine; /users/42/orders/99/items/7/reviews is too deep
  • Use query parameters for filtering, sorting, and pagination: /users?role=admin&sort=-created_at&page=2
# Good URL design
GET    /api/v1/users                    # List all users
GET    /api/v1/users/42                 # Get user 42
POST   /api/v1/users                    # Create a user
PUT    /api/v1/users/42                 # Replace user 42
PATCH  /api/v1/users/42                 # Partially update user 42
DELETE /api/v1/users/42                 # Delete user 42

GET    /api/v1/users/42/orders          # List user 42's orders
GET    /api/v1/users/42/orders/99       # Get order 99 of user 42

# Filtering, sorting, pagination via query params
GET    /api/v1/products?category=electronics&min_price=100&sort=-rating&page=3&limit=20

# Bad URL design (common mistakes)
GET    /api/v1/getUser/42               # Verb in URL
POST   /api/v1/users/42/delete          # Action in URL (use DELETE method)
GET    /api/v1/user                     # Singular noun

Handling Non-CRUD Operations

Not everything maps neatly to CRUD. For actions that do not fit the resource model, you have several options:

# Option 1: Treat the action as a sub-resource
POST   /api/v1/users/42/activate        # Activate a user account
POST   /api/v1/orders/99/cancel          # Cancel an order
POST   /api/v1/payments/77/refund        # Refund a payment

# Option 2: Use a controller resource
POST   /api/v1/search                    # Complex search with body params
POST   /api/v1/batch                     # Batch operations

# Option 3: Use PATCH with a state change
PATCH  /api/v1/users/42
# Body: { "status": "active" }
Key Insight: When in doubt, ask yourself: "Is this a thing (resource) or an action?" If it is a thing, make it a resource with standard CRUD methods. If it is an action on a resource, make it a sub-resource endpoint. The Stripe API handles this elegantly: POST /v1/charges/ch_xxx/refund treats "refund" as an action on a charge resource.

HTTP Methods & Status Codes

HTTP Methods

Method CRUD Idempotent Safe Request Body Response Body
GET Read Yes Yes No Yes
POST Create No No Yes Yes
PUT Replace Yes No Yes Yes
PATCH Partial update No* No Yes Yes
DELETE Delete Yes No Optional Optional
HEAD Read metadata Yes Yes No No (headers only)
OPTIONS Describe Yes Yes No Yes

Idempotent means calling the method multiple times has the same effect as calling it once. PUT /users/42 with the same body always produces the same result. POST /users with the same body may create a duplicate. PATCH is technically not idempotent because the effect depends on the current state, though many implementations are idempotent in practice.

Status Codes

// Express.js examples showing proper status code usage

// 200 OK — successful GET or PATCH
app.get('/api/v1/users/:id', async (req, res) => {
    const user = await User.findById(req.params.id);
    if (!user) return res.status(404).json({ error: 'User not found' });
    res.status(200).json(user);
});

// 201 Created — successful POST (include Location header)
app.post('/api/v1/users', async (req, res) => {
    const user = await User.create(req.body);
    res.status(201)
       .location(`/api/v1/users/${user.id}`)
       .json(user);
});

// 204 No Content — successful DELETE
app.delete('/api/v1/users/:id', async (req, res) => {
    await User.destroy({ where: { id: req.params.id } });
    res.status(204).send();
});

// 400 Bad Request — invalid input
// 401 Unauthorized — no or invalid authentication
// 403 Forbidden — authenticated but not authorized
// 404 Not Found — resource does not exist
// 409 Conflict — e.g., duplicate email
// 422 Unprocessable Entity — valid JSON but fails validation
// 429 Too Many Requests — rate limit exceeded
// 500 Internal Server Error — unhandled server error
Warning: Never return 200 OK with an error message in the body. This breaks HTTP semantics and confuses clients, caches, and monitoring tools. If something went wrong, use a 4xx or 5xx status code. A surprising number of production APIs make this mistake, including some from major companies.

Authentication & Authorization

Authentication verifies who you are. Authorization determines what you are allowed to do. REST APIs commonly use one of four authentication mechanisms.

API Keys

# API key in header (preferred)
curl -H "X-API-Key: sk_live_abc123def456" \
     https://api.example.com/v1/users

# API key in query parameter (avoid — logged in URLs and server logs)
curl https://api.example.com/v1/users?api_key=sk_live_abc123def456

OAuth 2.0 Authorization Code Flow

# Step 1: Redirect user to authorization server
# Browser navigates to:
https://auth.example.com/authorize?
  response_type=code&
  client_id=your_client_id&
  redirect_uri=https://yourapp.com/callback&
  scope=read:users write:users&
  state=random_csrf_token

# Step 2: User grants permission, auth server redirects to callback
# https://yourapp.com/callback?code=AUTH_CODE_HERE&state=random_csrf_token

# Step 3: Exchange authorization code for access token
curl -X POST https://auth.example.com/token \
  -d "grant_type=authorization_code" \
  -d "code=AUTH_CODE_HERE" \
  -d "client_id=your_client_id" \
  -d "client_secret=your_client_secret" \
  -d "redirect_uri=https://yourapp.com/callback"

# Step 4: Use the access token
curl -H "Authorization: Bearer eyJhbGciOiJSUzI1NiIs..." \
     https://api.example.com/v1/users

JWT (JSON Web Tokens)

// JWT structure: header.payload.signature
// Example payload:
{
    "sub": "user_42",
    "name": "Wasil Zafar",
    "role": "admin",
    "iat": 1711929600,   // issued at
    "exp": 1711933200    // expires in 1 hour
}

// Server-side: verify and decode JWT
const jwt = require('jsonwebtoken');

function authenticateToken(req, res, next) {
    const authHeader = req.headers['authorization'];
    const token = authHeader && authHeader.split(' ')[1]; // "Bearer TOKEN"

    if (!token) return res.status(401).json({ error: 'Token required' });

    jwt.verify(token, process.env.JWT_SECRET, (err, user) => {
        if (err) return res.status(403).json({ error: 'Invalid token' });
        req.user = user;
        next();
    });
}

Comparison Table

Method Complexity Best For Security Notes
API Key Low Server-to-server, internal APIs No user context; easy to leak; rotate regularly
Basic Auth Low Simple internal tools Base64 encoded (not encrypted); requires HTTPS
OAuth 2.0 High Third-party access, social login Industry standard; supports scoped permissions
JWT Bearer Medium Microservices, SPAs, mobile apps Stateless; cannot be revoked without blocklist; set short expiry
Key Insight: JWTs are not encrypted by default — they are only signed. Anyone who intercepts a JWT can read its contents (Base64-decode the payload). Never put sensitive information (passwords, credit card numbers) in a JWT payload. Always transmit JWTs over HTTPS. For truly confidential payloads, use JWE (JSON Web Encryption).

API Versioning Strategies

APIs evolve. New fields are added, old fields are deprecated, response structures change. Versioning ensures existing clients continue to work while new clients use the latest version.

Strategy Example Pros Cons
URI Path /api/v1/users Simple, visible, cacheable URL changes for every version; breaks bookmarks
Query Parameter /api/users?version=1 Optional; default to latest Easy to forget; less visible
Custom Header X-API-Version: 1 Clean URLs; no URL changes Hidden; harder to test in browser
Accept Header (Media Type) Accept: application/vnd.example.v1+json Most RESTful; content negotiation Complex; hard to test; poor tooling support
// Express.js: URI path versioning (most common)
const v1Router = require('./routes/v1');
const v2Router = require('./routes/v2');

app.use('/api/v1', v1Router);
app.use('/api/v2', v2Router);

// Express.js: Header-based versioning
app.use('/api/users', (req, res, next) => {
    const version = req.headers['x-api-version'] || '2'; // default to latest
    if (version === '1') {
        return v1UserHandler(req, res, next);
    }
    return v2UserHandler(req, res, next);
});
Key Insight: Stripe uses URI path versioning (/v1/) but with a twist: they version at the API level, not the endpoint level. Individual endpoints evolve through dated API versions (e.g., Stripe-Version: 2024-01-01) sent as a header. This lets them make incremental changes without bumping the entire API version. Most teams should start with URI path versioning and only add header-based versioning if they need finer-grained control.

Pagination, Filtering & Sorting

Offset-Based Pagination

# Request page 3, 20 items per page
GET /api/v1/products?page=3&limit=20

# Alternative: offset + limit
GET /api/v1/products?offset=40&limit=20
{
    "data": [
        { "id": 41, "name": "Widget A", "price": 29.99 },
        { "id": 42, "name": "Widget B", "price": 39.99 }
    ],
    "pagination": {
        "page": 3,
        "limit": 20,
        "total": 157,
        "total_pages": 8,
        "has_next": true,
        "has_prev": true
    },
    "_links": {
        "self":  "/api/v1/products?page=3&limit=20",
        "first": "/api/v1/products?page=1&limit=20",
        "prev":  "/api/v1/products?page=2&limit=20",
        "next":  "/api/v1/products?page=4&limit=20",
        "last":  "/api/v1/products?page=8&limit=20"
    }
}

Cursor-Based Pagination

Offset pagination breaks down with large datasets: OFFSET 1000000 in SQL forces the database to scan and discard 1 million rows. Cursor-based pagination uses an opaque token (usually a Base64-encoded value from the last item) to efficiently fetch the next page.

# First request
GET /api/v1/events?limit=50

# Response includes a cursor for the next page
# "next_cursor": "eyJpZCI6MTUwLCJ0cyI6IjIwMjQtMDMtMTVUMTQ6MDA6MDBaIn0="

# Subsequent request
GET /api/v1/events?limit=50&cursor=eyJpZCI6MTUwLCJ0cyI6IjIwMjQtMDMtMTVUMTQ6MDA6MDBaIn0=
// Server-side cursor implementation (Node.js + PostgreSQL)
async function getEvents(cursor, limit = 50) {
    let query = 'SELECT * FROM events';
    const params = [];

    if (cursor) {
        const decoded = JSON.parse(Buffer.from(cursor, 'base64').toString());
        query += ' WHERE (created_at, id) < ($1, $2)';
        params.push(decoded.ts, decoded.id);
    }

    query += ' ORDER BY created_at DESC, id DESC LIMIT $' + (params.length + 1);
    params.push(limit + 1); // Fetch one extra to determine if there's a next page

    const rows = await db.query(query, params);
    const hasNext = rows.length > limit;
    const data = hasNext ? rows.slice(0, limit) : rows;

    const nextCursor = hasNext
        ? Buffer.from(JSON.stringify({
            ts: data[data.length - 1].created_at,
            id: data[data.length - 1].id
        })).toString('base64')
        : null;

    return { data, next_cursor: nextCursor, has_next: hasNext };
}

Filtering and Sorting

# Filtering by multiple fields
GET /api/v1/products?category=electronics&brand=sony&min_price=100&max_price=500

# Sorting (prefix with - for descending)
GET /api/v1/products?sort=-price,name

# Field selection (sparse fieldsets)
GET /api/v1/users?fields=id,name,email

# Full-text search
GET /api/v1/products?q=wireless+headphones

Error Handling & Response Format

RFC 7807: Problem Details for HTTP APIs

RFC 7807 (updated by RFC 9457) defines a standard format for error responses. Using a standard format means clients can parse errors consistently across different APIs.

{
    "type": "https://api.example.com/errors/validation-error",
    "title": "Validation Error",
    "status": 422,
    "detail": "The request body contains invalid fields.",
    "instance": "/api/v1/users",
    "errors": [
        {
            "field": "email",
            "message": "Must be a valid email address",
            "value": "not-an-email"
        },
        {
            "field": "age",
            "message": "Must be a positive integer",
            "value": -5
        }
    ]
}

Custom Error Envelope

Many APIs use a custom error envelope that wraps both success and error responses in a consistent structure:

// Express.js error handling middleware
class AppError extends Error {
    constructor(statusCode, code, message, details = null) {
        super(message);
        this.statusCode = statusCode;
        this.code = code;
        this.details = details;
    }
}

// Centralized error handler
app.use((err, req, res, next) => {
    if (err instanceof AppError) {
        return res.status(err.statusCode).json({
            error: {
                code: err.code,
                message: err.message,
                details: err.details,
                request_id: req.id,
                timestamp: new Date().toISOString()
            }
        });
    }

    // Unhandled errors — never expose stack traces in production
    console.error('Unhandled error:', err);
    res.status(500).json({
        error: {
            code: 'INTERNAL_ERROR',
            message: 'An unexpected error occurred.',
            request_id: req.id,
            timestamp: new Date().toISOString()
        }
    });
});

// Usage in a route
app.post('/api/v1/users', async (req, res, next) => {
    try {
        const existing = await User.findByEmail(req.body.email);
        if (existing) {
            throw new AppError(409, 'EMAIL_EXISTS',
                'A user with this email address already exists.',
                { email: req.body.email });
        }
        const user = await User.create(req.body);
        res.status(201).json({ data: user });
    } catch (err) {
        next(err);
    }
});
Warning: Never expose internal error details (stack traces, SQL queries, file paths) in production API responses. These are goldmines for attackers. Log them server-side with a request ID, and return only the request ID to the client so they can reference it when contacting support.

Rate Limiting & Throttling

Rate limiting protects your API from abuse, prevents resource exhaustion, and ensures fair usage across all clients. Without rate limiting, a single misbehaving client can degrade performance for everyone.

Common Algorithms

Algorithm How It Works Pros Cons
Fixed Window Count requests in fixed time windows (e.g., per minute) Simple to implement Burst at window boundaries (2x burst possible)
Sliding Window Weighted count across current + previous window Smoother than fixed window Slightly more complex
Token Bucket Tokens added at fixed rate; each request consumes a token Allows controlled bursts; smooth rate Requires tracking bucket state
Leaky Bucket Requests enter a queue; processed at fixed rate Perfectly smooth output rate Adds latency; queue can fill up

HTTP Headers for Rate Limiting

# Standard rate limit headers (draft-ietf-httpapi-ratelimit-headers)
HTTP/1.1 200 OK
X-RateLimit-Limit: 1000           # Maximum requests per window
X-RateLimit-Remaining: 742        # Requests remaining in current window
X-RateLimit-Reset: 1711933200     # Unix timestamp when window resets
Retry-After: 30                   # Seconds to wait (only on 429 responses)

# When rate limit is exceeded:
HTTP/1.1 429 Too Many Requests
X-RateLimit-Limit: 1000
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1711933200
Retry-After: 30
Content-Type: application/json

{
    "error": {
        "code": "RATE_LIMIT_EXCEEDED",
        "message": "You have exceeded the rate limit of 1000 requests per hour.",
        "retry_after": 30
    }
}
// Express.js rate limiting with express-rate-limit
const rateLimit = require('express-rate-limit');

const apiLimiter = rateLimit({
    windowMs: 60 * 60 * 1000,  // 1 hour
    max: 1000,                  // 1000 requests per window per IP
    standardHeaders: true,      // Return X-RateLimit-* headers
    legacyHeaders: false,
    message: {
        error: {
            code: 'RATE_LIMIT_EXCEEDED',
            message: 'Too many requests. Please try again later.'
        }
    },
    keyGenerator: (req) => {
        // Rate limit by API key if present, otherwise by IP
        return req.headers['x-api-key'] || req.ip;
    }
});

app.use('/api/', apiLimiter);

OpenAPI & Documentation

The OpenAPI Specification (formerly Swagger Specification) is the industry standard for describing REST APIs. An OpenAPI document is a machine-readable YAML or JSON file that defines every endpoint, request/response schema, authentication method, and error format.

openapi: 3.0.3
info:
  title: E-Commerce API
  version: 1.0.0
  description: API for managing products, orders, and users.
  contact:
    name: API Support
    email: api-support@example.com

servers:
  - url: https://api.example.com/v1
    description: Production

security:
  - bearerAuth: []

paths:
  /products:
    get:
      summary: List products
      tags: [Products]
      parameters:
        - name: category
          in: query
          schema:
            type: string
          description: Filter by category
        - name: page
          in: query
          schema:
            type: integer
            default: 1
        - name: limit
          in: query
          schema:
            type: integer
            default: 20
            maximum: 100
      responses:
        '200':
          description: Successful response
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/Product'
                  pagination:
                    $ref: '#/components/schemas/Pagination'
        '401':
          $ref: '#/components/responses/Unauthorized'

    post:
      summary: Create a product
      tags: [Products]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreateProduct'
      responses:
        '201':
          description: Product created
          headers:
            Location:
              schema:
                type: string
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Product'

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: JWT

  schemas:
    Product:
      type: object
      properties:
        id:
          type: integer
        name:
          type: string
        price:
          type: number
          format: float
        category:
          type: string

    CreateProduct:
      type: object
      required: [name, price]
      properties:
        name:
          type: string
          minLength: 1
          maxLength: 200
        price:
          type: number
          minimum: 0.01
        category:
          type: string

    Pagination:
      type: object
      properties:
        page:
          type: integer
        limit:
          type: integer
        total:
          type: integer
        total_pages:
          type: integer

  responses:
    Unauthorized:
      description: Authentication required
      content:
        application/json:
          schema:
            type: object
            properties:
              error:
                type: object
                properties:
                  code:
                    type: string
                    example: UNAUTHORIZED
                  message:
                    type: string
                    example: Invalid or missing authentication token
Key Insight: Write your OpenAPI spec before writing code (Design-First approach). This forces you to think about the API contract upfront, generates documentation automatically, enables client SDK generation, and allows frontend and backend teams to work in parallel. Tools like Swagger UI, Redoc, and Stoplight render interactive documentation directly from the spec file.

Case Studies

Case Study 1: Stripe API

Stripe's API is widely considered the gold standard of REST API design. Key design decisions that make it exceptional:

  • Predictable resource URLs: /v1/charges, /v1/customers, /v1/subscriptions. Every resource follows the same pattern.
  • Consistent object structure: Every object has an id, object (type name), created (timestamp), and livemode (boolean) field.
  • Expandable objects: GET /v1/charges/ch_xxx?expand[]=customer&expand[]=invoice lets clients fetch related objects in a single request, reducing N+1 query problems.
  • Idempotency keys: POST requests accept an Idempotency-Key header. If you retry a request with the same key, Stripe returns the original response instead of creating a duplicate charge.
  • Versioning via date: Stripe-Version: 2024-04-10 pins your integration to a specific API version. Breaking changes are released as new dated versions.
  • Detailed error messages: Every error includes type, code, message, param (the field that caused the error), and doc_url (link to documentation).

Case Study 2: GitHub API

GitHub's REST API (v3) demonstrates practical HATEOAS and advanced pagination:

  • Hypermedia navigation: Every response includes a url field and related resource URLs (e.g., a repository object includes issues_url, pulls_url, branches_url).
  • Link header pagination: Link: <https://api.github.com/repos/...?page=2>; rel="next", <...?page=5>; rel="last"
  • Conditional requests: Heavy use of ETag and If-None-Match headers. Cached responses do not count against rate limits.
  • Granular OAuth scopes: repo, repo:status, public_repo, read:org — clients request only the permissions they need.
  • Rate limiting: 5,000 requests/hour for authenticated users, 60/hour for unauthenticated. Rate limit headers on every response.

Case Study 3: Twitter (X) API v2

Twitter's API v2 redesign (launched 2020) addressed shortcomings of v1.1:

  • Field selection: GET /2/tweets?ids=123&tweet.fields=created_at,public_metrics&expansions=author_id&user.fields=name,username. Clients specify exactly which fields they want, reducing payload size by up to 80%.
  • Expansion system: Related objects (author, media, referenced tweets) are included in an includes object rather than nested inline, preventing duplication.
  • Tiered access levels: Free, Basic ($100/month), Pro ($5,000/month), Enterprise. Each tier has different rate limits and endpoint access.

Exercises

Exercise 1 Beginner

Design a Bookstore API

Design the URL structure and HTTP methods for a bookstore API. Support the following operations: list all books, get a single book, create a book, update a book, delete a book, list all reviews for a book, add a review, search books by title or author, and filter books by genre. Write out each endpoint with its HTTP method, URL, request body (if any), and expected response status code.

URL design HTTP methods status codes
Exercise 2 Intermediate

Build a Paginated API with Express

Implement a REST API with Express.js that serves a list of 1,000 products from an in-memory array. Implement both offset-based and cursor-based pagination. Add filtering by category and price range, and sorting by price or name. Include proper Link headers for pagination navigation. Write tests that verify: (1) correct page sizes, (2) last page handling, (3) empty results for out-of-range pages, and (4) cursor stability when new items are inserted.

pagination filtering Express.js testing
Exercise 3 Advanced

Design an API with OAuth 2.0 and Rate Limiting

Design and implement a complete API for a task management application with: (1) JWT-based authentication with access and refresh tokens, (2) role-based authorization (admin, member, viewer), (3) token bucket rate limiting with different limits per role, (4) RFC 7807 error responses, (5) an OpenAPI 3.0 specification file, and (6) idempotency keys for POST requests. Deploy it and test it with curl, Postman, and an auto-generated client from the OpenAPI spec.

OAuth 2.0 JWT rate limiting OpenAPI idempotency

API Design Document Generator

Use this tool to document your REST API design decisions — versioning strategy, authentication method, error format, endpoints, and rate limiting configuration. Download as Word, Excel, PDF, or PowerPoint for team review or stakeholder communication.

API Design Document Generator

Document your API design configuration and export for review. All data stays in your browser — nothing is sent to any server.

Draft auto-saved

All data stays in your browser. Nothing is sent to or stored on any server.

Conclusion & Resources

REST is not a technology — it is an architectural style. The best REST APIs are not the ones that follow every constraint perfectly, but the ones that are consistent, predictable, well-documented, and easy to use. We have covered the full spectrum: from Fielding's original principles to the practical patterns used by Stripe, GitHub, and Twitter in production.

The most important takeaways:

  • Resources are nouns, methods are verbs. Let HTTP do the heavy lifting.
  • Be consistent. If /users returns a list and /users/42 returns a single user, every resource should follow this pattern.
  • Use proper status codes. 200 for success, 201 for creation, 4xx for client errors, 5xx for server errors. Never return 200 with an error body.
  • Design for the consumer. Your API's users are other developers. Good documentation, clear error messages, and predictable behavior save them hours of debugging.
  • Version from day one. It is far easier to add /v1/ at the start than to retrofit versioning after clients depend on your endpoints.

Recommended Resources

Technology