Skip to content
Published on

REST API Design Best Practices 2025: Essential Principles and Patterns Every Developer Must Know

Authors

Introduction

The API economy is projected to reach $13.7 trillion by 2027. API-first companies like Stripe, Twilio, and SendGrid have built multi-billion dollar businesses, and all modern software is connected through APIs. A well-designed API maximizes developer experience (DX), simplifies maintenance, and becomes a key driver of business growth.

This article systematically covers everything from REST API design fundamentals to advanced patterns, including industry standards and battle-tested best practices as of 2025. We cover every topic with code examples: Richardson Maturity Model, URL design, HTTP methods, status codes, pagination, error handling, versioning, security, and OpenAPI.


1. Why API Design Matters

The Growth of the API Economy

MetricValue
Global API Economy (2027 projection)$13.7 Trillion
Salesforce Revenue from APIsOver 50%
Average APIs Used per Enterprise15,000+
API-first Company Growth Rate2.6x vs Traditional

The Cost of Poor API Design

  • Increased developer onboarding time (average 2 weeks extra)
  • Higher customer churn (poor DX leads to 30% higher churn)
  • Exponentially growing maintenance costs
  • Client outages from breaking changes

API-First Design Principles

API-First means designing the API specification before implementing the application.

# Step 1: Write OpenAPI spec first
openapi: 3.1.0
info:
  title: User Service API
  version: 1.0.0
paths:
  /api/v1/users:
    get:
      summary: List users
      parameters:
        - name: page
          in: query
          schema:
            type: integer
      responses:
        '200':
          description: Success
Design Workflow:
1. Write API specification (OpenAPI)
2. Review and agree on the spec
3. Start frontend development with mock server
4. Implement backend
5. Contract Testing

2. REST Fundamentals: Richardson Maturity Model

The REST API maturity model proposed by Leonard Richardson consists of 4 levels.

Level 0: The Swamp of POX

All requests are sent to a single endpoint. Similar to SOAP-style services.

POST /api HTTP/1.1
Content-Type: application/json

{
  "action": "getUser",
  "userId": 123
}

Level 1: Resources

Individual resources are identified through URIs, but HTTP methods are not properly utilized.

POST /api/users/123 HTTP/1.1
Content-Type: application/json

{
  "action": "get"
}

Level 2: HTTP Verbs

HTTP methods are used correctly to express operations on resources. Most REST APIs are at this level.

GET /api/users/123 HTTP/1.1
Accept: application/json

---

PUT /api/users/123 HTTP/1.1
Content-Type: application/json

{
  "name": "Updated Name",
  "email": "user@example.com"
}

Level 3: Hypermedia Controls (HATEOAS)

Responses include links to related resources, enabling clients to dynamically navigate the API.

{
  "id": 123,
  "name": "John Doe",
  "email": "john@example.com",
  "_links": {
    "self": { "href": "/api/v1/users/123" },
    "orders": { "href": "/api/v1/users/123/orders" },
    "update": { "href": "/api/v1/users/123", "method": "PUT" },
    "delete": { "href": "/api/v1/users/123", "method": "DELETE" }
  }
}

The 6 REST Constraints

ConstraintDescription
Client-ServerSeparation of concerns between client and server
StatelessEach request is independent, no session stored on server
CacheableResponses must indicate cacheability
Uniform InterfaceConsistent interaction interface
Layered SystemAllows intermediate layers (proxies, load balancers)
Code on Demand (optional)Server can send executable code to client

3. URL Design: Naming Conventions and Patterns

Core Principles

  • Use nouns (not verbs) to represent resources
  • Use plural forms such as /users, /orders, /products
  • Lowercase with hyphens such as /user-profiles (not snake_case or camelCase)
  • Hierarchical structure such as /users/123/orders/456
  • Verbs only for actions such as /users/123/activate (state change actions)

20 URL Design Examples

# Collections
GET    /api/v1/users                    # List users
POST   /api/v1/users                    # Create user

# Individual Resources
GET    /api/v1/users/123                # Get specific user
PUT    /api/v1/users/123                # Replace entire user
PATCH  /api/v1/users/123                # Partially update user
DELETE /api/v1/users/123                # Delete user

# Nested Resources
GET    /api/v1/users/123/orders         # List user orders
GET    /api/v1/users/123/orders/456     # Get specific order
POST   /api/v1/users/123/orders         # Create order

# Filtering
GET    /api/v1/users?role=admin         # Filter by admin role
GET    /api/v1/products?category=electronics&min_price=100

# Search
GET    /api/v1/users/search?q=john      # Search users

# State Changes (actions)
POST   /api/v1/users/123/activate       # Activate account
POST   /api/v1/orders/456/cancel        # Cancel order
POST   /api/v1/payments/789/refund      # Refund payment

# Batch Operations
POST   /api/v1/users/batch              # Batch create
DELETE /api/v1/users/batch              # Batch delete

# Sub-resources (singleton)
GET    /api/v1/users/123/profile        # User profile
PUT    /api/v1/users/123/avatar         # Update avatar

Nested vs Flat: When to Use What?

PatternExampleProsCons
Nested/users/123/ordersOwnership relationship is clearComplex with deep nesting
Flat/orders?user_id=123Independent access possibleRelationship unclear
HybridNest up to 2 levels, then use query paramsBalanced approach-

Practical Guide: Limit nesting to a maximum of 2 levels. /users/123/orders is fine, but /users/123/orders/456/items/789/reviews is too deep.


4. HTTP Methods Deep Dive

Method Semantics

GET     Retrieve resource (read)          — Safe, Idempotent
POST    Create resource (write)           — Unsafe, Non-idempotent
PUT     Replace entire resource           — Unsafe, Idempotent
PATCH   Partially update resource         — Unsafe, Non-idempotent (generally)
DELETE  Delete resource                   — Unsafe, Idempotent
OPTIONS Check supported methods           — Safe, Idempotent
HEAD    Same as GET without body          — Safe, Idempotent

Understanding Idempotency

Idempotency means that whether you send the same request 1 time or 100 times, the result (server state) is identical.

MethodIdempotentSafeDescription
GETYesYesNo resource state change
HEADYesYesSame as GET, no body
OPTIONSYesYesReturns metadata only
PUTYesNoOverwriting with same data yields same result
DELETEYesNoDeleting already-deleted resource is 404 but state unchanged
POSTNoNoCreates new resource each time
PATCHNoNoRelative changes can produce different results

PUT vs PATCH Difference

// PUT: Replace entire resource (all fields required)
// PUT /api/v1/users/123
{
  "name": "John Doe",
  "email": "john@example.com",
  "age": 30,
  "role": "admin"
}

// PATCH: Partial update (changed fields only)
// PATCH /api/v1/users/123
{
  "age": 31
}

POST vs PUT for Creation

POST /api/v1/users          -> Server generates ID (201 Created + Location header)
PUT  /api/v1/users/123      -> Client specifies ID (replace if exists, create if not)

5. Status Code Strategy

2xx Success

CodeNameWhen to Use
200OKGET success, PUT/PATCH success
201CreatedResource created via POST (include Location header)
202AcceptedAsync operation accepted (processed later)
204No ContentDELETE success, PUT success (no body)

3xx Redirection

CodeNameWhen to Use
301Moved PermanentlyResource permanently moved (cached)
302FoundTemporary redirect
304Not ModifiedCache valid (ETag/Last-Modified)

4xx Client Errors

CodeNameWhen to Use
400Bad RequestMalformed request, validation failure
401UnauthorizedAuthentication required (no/expired token)
403ForbiddenAuthenticated but no permission
404Not FoundResource not found
405Method Not AllowedMethod not supported for resource
409ConflictResource conflict (duplicate creation, etc.)
422Unprocessable EntitySyntactically correct but semantic error
429Too Many RequestsRate limit exceeded

5xx Server Errors

CodeNameWhen to Use
500Internal Server ErrorUnexpected server error
502Bad GatewayUpstream server error
503Service UnavailableService temporarily unavailable (maintenance)
504Gateway TimeoutUpstream server timeout

Status Code Decision Flow

Request successful?
├── YES -> Return data?      -> 200 OK
│         Resource created?  -> 201 Created
│         Async operation?   -> 202 Accepted
│         No body needed?    -> 204 No Content
└── NO  -> Auth issue?       -> 401 / 403
          Request issue?     -> 400 / 422
          Resource missing?  -> 404
          Conflict?          -> 409
          Server issue?      -> 500 / 502 / 503

6. Pagination Patterns

Offset-based Pagination

The simplest approach, but has performance issues with large datasets.

GET /api/v1/users?page=3&size=20
{
  "data": [
    { "id": 41, "name": "User 41" },
    { "id": 42, "name": "User 42" }
  ],
  "pagination": {
    "page": 3,
    "size": 20,
    "total_items": 1000,
    "total_pages": 50,
    "has_next": true,
    "has_prev": true
  }
}
-- SQL implementation
SELECT * FROM users
ORDER BY id
LIMIT 20 OFFSET 40;
-- Problem: OFFSET 10000 means skipping 10000 rows -> performance degradation

Cursor-based Pagination

Ideal for large datasets. Uses a cursor (last items ID).

GET /api/v1/users?cursor=eyJpZCI6NDJ9&limit=20
{
  "data": [
    { "id": 43, "name": "User 43" },
    { "id": 44, "name": "User 44" }
  ],
  "pagination": {
    "next_cursor": "eyJpZCI6NjJ9",
    "has_next": true,
    "limit": 20
  }
}
// Node.js implementation
async function getCursorPaginatedUsers(cursor, limit = 20) {
  let query = 'SELECT * FROM users'
  const params = []

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

  query += ' ORDER BY id ASC LIMIT $' + (params.length + 1)
  params.push(limit + 1) // +1 to check if next page exists

  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({ id: data[data.length - 1].id })).toString('base64')
    : null

  return { data, pagination: { next_cursor: nextCursor, has_next: hasNext, limit } }
}

Keyset-based Pagination

Ideal for compound sorting.

GET /api/v1/users?after_date=2025-01-15&after_id=42&limit=20&sort=created_at
-- SQL implementation: created_at + id compound key
SELECT * FROM users
WHERE (created_at, id) > ('2025-01-15', 42)
ORDER BY created_at ASC, id ASC
LIMIT 20;

Pagination Comparison

FeatureOffsetCursorKeyset
Implementation DifficultyEasyMediumHard
Jump to Specific PageYesNoNo
Large-scale PerformancePoorGoodGood
Consistency on Data ChangePoorGoodGood
Compound SortingYesLimitedYes
Total CountYesExpensiveExpensive
Use CaseAdmin panelsSocial feedsTime-series data

7. Filtering, Sorting, and Field Selection

Filtering Patterns

# Simple filter
GET /api/v1/products?category=electronics&brand=apple

# Comparison operators
GET /api/v1/products?price_gte=100&price_lte=500

# Array filter
GET /api/v1/products?tags=wireless,bluetooth

# Date range
GET /api/v1/orders?created_after=2025-01-01&created_before=2025-03-31

Sorting

# Single sort
GET /api/v1/products?sort=price

# Descending
GET /api/v1/products?sort=-price

# Compound sort
GET /api/v1/products?sort=-created_at,name

Field Selection (Sparse Fieldsets)

# Request only needed fields (reduces response size)
GET /api/v1/users?fields=id,name,email

# Include related resources
GET /api/v1/users/123?include=orders,profile
{
  "id": 123,
  "name": "John Doe",
  "email": "john@example.com"
}

Advanced Filtering: RSQL/FIQL Style

# RQL (Resource Query Language) style
GET /api/v1/products?filter=category==electronics;price=gt=100;brand=in=(apple,samsung)

8. Versioning Strategies

Three Approaches

URL Path Versioning (Most Common)

GET /api/v1/users/123
GET /api/v2/users/123

Header Versioning

GET /api/users/123
Accept: application/vnd.myapp.v2+json

Query Parameter Versioning

GET /api/users/123?version=2

Comparison

FeatureURL PathHeaderQuery Param
VisibilityVery HighLowMedium
CachingEasyComplexMedium
Implementation DifficultyEasyMediumEasy
Browser TestingEasyHardEasy
API RoutingClearExtra logic neededExtra logic needed
Used ByGitHub, Stripe, GoogleMicrosoft, GitHub (GraphQL)AWS, Netflix

Versioning Best Practices

1. Manage only major versions (v1, v2) -- Minor/Patch are backward compatible
2. Support at least 2 versions simultaneously
3. Publish deprecation policy (minimum 6-month grace period)
4. Use Sunset header

  Sunset: Sat, 01 Mar 2026 00:00:00 GMT
  Deprecation: true
  Link: <https://api.example.com/v2/users>; rel="successor-version"

9. Error Handling: RFC 9457 Problem Details

RFC 9457 Standard Error Format

RFC 9457, which replaced RFC 7807 in 2023, defines the standard error response format for HTTP APIs.

{
  "type": "https://api.example.com/errors/validation",
  "title": "Validation Failed",
  "status": 422,
  "detail": "The request body contains invalid fields.",
  "instance": "/api/v1/users",
  "timestamp": "2025-03-15T10:30:00Z",
  "errors": [
    {
      "field": "email",
      "code": "INVALID_FORMAT",
      "message": "Please enter a valid email address."
    },
    {
      "field": "age",
      "code": "OUT_OF_RANGE",
      "message": "Age must be between 0 and 150."
    }
  ]
}

Error Response Implementation (Node.js/Express)

// Error class definition
class ApiError extends Error {
  constructor(status, type, title, detail, errors = []) {
    super(detail)
    this.status = status
    this.type = type
    this.title = title
    this.detail = detail
    this.errors = errors
  }
}

// Factory functions
const ApiErrors = {
  notFound: (resource, id) =>
    new ApiError(
      404,
      'https://api.example.com/errors/not-found',
      'Resource Not Found',
      `${resource} with id ${id} was not found.`
    ),

  validationFailed: (errors) =>
    new ApiError(
      422,
      'https://api.example.com/errors/validation',
      'Validation Failed',
      'The request body contains invalid fields.',
      errors
    ),

  rateLimited: (retryAfter) =>
    new ApiError(
      429,
      'https://api.example.com/errors/rate-limit',
      'Rate Limit Exceeded',
      `Too many requests. Retry after ${retryAfter} seconds.`
    ),
}

// Express error handler
app.use((err, req, res, next) => {
  if (err instanceof ApiError) {
    return res.status(err.status).json({
      type: err.type,
      title: err.title,
      status: err.status,
      detail: err.detail,
      instance: req.originalUrl,
      timestamp: new Date().toISOString(),
      errors: err.errors.length > 0 ? err.errors : undefined,
    })
  }

  // Unexpected error
  console.error('Unexpected error:', err)
  res.status(500).json({
    type: 'https://api.example.com/errors/internal',
    title: 'Internal Server Error',
    status: 500,
    detail: 'An unexpected error occurred.',
    instance: req.originalUrl,
    timestamp: new Date().toISOString(),
  })
})

Internationalized Error Messages

// i18n error messages
const errorMessages = {
  INVALID_EMAIL: {
    ko: '유효한 이메일 주소를 입력하세요.',
    en: 'Please enter a valid email address.',
    ja: '有効なメールアドレスを入力してください。',
  },
  FIELD_REQUIRED: {
    ko: '필수 입력 항목입니다.',
    en: 'This field is required.',
    ja: '必須入力項目です。',
  },
}

function getErrorMessage(code, lang = 'en') {
  return errorMessages[code]?.[lang] || errorMessages[code]?.['en'] || code
}

10. Rate Limiting and Throttling

Algorithm Comparison

Token Bucket

Bucket capacity: 100 tokens
Refill rate: 10 tokens/sec

[Request arrives] -> Tokens available? -> YES -> Consume token, process request
                                       -> NO  -> 429 response
class TokenBucket {
  constructor(capacity, refillRate) {
    this.capacity = capacity
    this.tokens = capacity
    this.refillRate = refillRate
    this.lastRefill = Date.now()
  }

  consume() {
    this.refill()
    if (this.tokens > 0) {
      this.tokens--
      return true
    }
    return false
  }

  refill() {
    const now = Date.now()
    const elapsed = (now - this.lastRefill) / 1000
    this.tokens = Math.min(this.capacity, this.tokens + elapsed * this.refillRate)
    this.lastRefill = now
  }
}

Sliding Window Log

class SlidingWindowLog {
  constructor(windowMs, maxRequests) {
    this.windowMs = windowMs
    this.maxRequests = maxRequests
    this.logs = new Map() // key -> timestamps[]
  }

  isAllowed(key) {
    const now = Date.now()
    const windowStart = now - this.windowMs

    if (!this.logs.has(key)) {
      this.logs.set(key, [])
    }

    const timestamps = this.logs.get(key).filter((ts) => ts > windowStart)
    this.logs.set(key, timestamps)

    if (timestamps.length < this.maxRequests) {
      timestamps.push(now)
      return true
    }

    return false
  }
}

Rate Limit Response Headers

HTTP/1.1 200 OK
X-RateLimit-Limit: 1000
X-RateLimit-Remaining: 998
X-RateLimit-Reset: 1678886400

---

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

{
  "type": "https://api.example.com/errors/rate-limit",
  "title": "Rate Limit Exceeded",
  "status": 429,
  "detail": "You have exceeded the rate limit. Please retry after 30 seconds."
}

Rate Limit Tier Design

TierRequests/minRequests/dayConcurrent Connections
Free601,0005
Basic30010,00020
Pro1,000100,00050
Enterprise10,0001,000,000200

11. Security

OAuth2 Flow Summary

Authorization Code Flow (Server Apps):
1. User -> Auth Server (Login)
2. Auth Server -> Client (Authorization Code)
3. Client -> Auth Server (Code + Client Secret -> Access Token)
4. Client -> Resource Server (Access Token)

Authorization Code + PKCE (SPA/Mobile):
1. Generate code_verifier
2. code_challenge = SHA256(code_verifier)
3. Include code_challenge in authorization request
4. Send code_verifier during token exchange for verification

JWT Best Practices

1. Access Token expiry: 15 minutes (max 1 hour)
2. Refresh Token expiry: 7 days (max 30 days)
3. Signing algorithm: RS256 (asymmetric) or ES256 recommended
4. Never put sensitive info in payload
5. Always validate aud, iss, exp claims
6. Store in httpOnly cookies (prevent XSS)
7. Apply Token Rotation

API Key Management

# Header approach (recommended)
GET /api/v1/users HTTP/1.1
Authorization: Bearer <access_token>
X-API-Key: <api_key>

# Never include in URL
# BAD: /api/v1/users?api_key=abc123

CORS Configuration

// Express CORS setup
const cors = require('cors')

app.use(
  cors({
    origin: ['https://app.example.com', 'https://admin.example.com'],
    methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'],
    allowedHeaders: ['Content-Type', 'Authorization', 'X-API-Key'],
    credentials: true,
    maxAge: 86400, // Preflight cache 24 hours
  })
)

Input Validation

// Request validation with Zod
const { z } = require('zod')

const CreateUserSchema = z.object({
  name: z.string().min(2).max(100),
  email: z.string().email(),
  age: z.number().int().min(0).max(150).optional(),
  role: z.enum(['user', 'admin', 'moderator']).default('user'),
})

// Middleware
function validate(schema) {
  return (req, res, next) => {
    const result = schema.safeParse(req.body)
    if (!result.success) {
      return res.status(422).json({
        type: 'https://api.example.com/errors/validation',
        title: 'Validation Failed',
        status: 422,
        errors: result.error.issues.map((issue) => ({
          field: issue.path.join('.'),
          code: issue.code,
          message: issue.message,
        })),
      })
    }
    req.body = result.data
    next()
  }
}

app.post('/api/v1/users', validate(CreateUserSchema), createUser)

12. OpenAPI 3.1

Spec Structure

openapi: 3.1.0
info:
  title: E-Commerce API
  description: E-Commerce REST API
  version: 1.0.0
  contact:
    name: API Team
    email: api@example.com

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

paths:
  /products:
    get:
      operationId: listProducts
      tags: [Products]
      summary: List all products
      parameters:
        - name: category
          in: query
          schema:
            type: string
        - name: sort
          in: query
          schema:
            type: string
            enum: [price, -price, name, -name, created_at]
        - name: cursor
          in: query
          schema:
            type: string
        - 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/CursorPagination'
        '429':
          $ref: '#/components/responses/RateLimited'

components:
  schemas:
    Product:
      type: object
      required: [id, name, price]
      properties:
        id:
          type: integer
          format: int64
        name:
          type: string
          minLength: 1
          maxLength: 200
        price:
          type: number
          format: decimal
          minimum: 0
        category:
          type: string
        created_at:
          type: string
          format: date-time

    CursorPagination:
      type: object
      properties:
        next_cursor:
          type: string
          nullable: true
        has_next:
          type: boolean
        limit:
          type: integer

  responses:
    RateLimited:
      description: Rate limit exceeded
      headers:
        X-RateLimit-Limit:
          schema:
            type: integer
        Retry-After:
          schema:
            type: integer
      content:
        application/problem+json:
          schema:
            $ref: '#/components/schemas/ProblemDetail'

  securitySchemes:
    BearerAuth:
      type: http
      scheme: bearer
      bearerFormat: JWT
    ApiKeyAuth:
      type: apiKey
      in: header
      name: X-API-Key

security:
  - BearerAuth: []
  - ApiKeyAuth: []

Contract-First vs Code-First

ApproachProsCons
Contract-FirstSpec-based agreement, instant mock server, code generationAdditional spec maintenance cost
Code-FirstFast development, code is source of truthSpec-code drift possible

Code Generation

# Generate client SDK with OpenAPI Generator
npx @openapitools/openapi-generator-cli generate \
  -i openapi.yaml \
  -g typescript-axios \
  -o ./generated/client

# Generate server stub
npx @openapitools/openapi-generator-cli generate \
  -i openapi.yaml \
  -g nodejs-express-server \
  -o ./generated/server

13. API Testing

Automated Testing with Postman/Newman

// Postman Test Script example
pm.test('Status code is 200', () => {
  pm.response.to.have.status(200)
})

pm.test('Response has correct structure', () => {
  const json = pm.response.json()
  pm.expect(json).to.have.property('data')
  pm.expect(json.data).to.be.an('array')
  pm.expect(json).to.have.property('pagination')
})

pm.test('Pagination cursor exists', () => {
  const json = pm.response.json()
  if (json.data.length > 0) {
    pm.expect(json.pagination).to.have.property('next_cursor')
  }
})
# Run in CI/CD pipeline with Newman
newman run collection.json \
  --environment production.json \
  --reporters cli,junit \
  --reporter-junit-export results.xml

Contract Testing with Pact

// Consumer Test (Frontend)
const { PactV3 } = require('@pact-foundation/pact')

const provider = new PactV3({
  consumer: 'WebApp',
  provider: 'UserService',
})

describe('User API', () => {
  it('fetches a user by ID', async () => {
    await provider
      .given('user 123 exists')
      .uponReceiving('a request for user 123')
      .withRequest({
        method: 'GET',
        path: '/api/v1/users/123',
        headers: { Accept: 'application/json' },
      })
      .willRespondWith({
        status: 200,
        headers: { 'Content-Type': 'application/json' },
        body: {
          id: 123,
          name: 'John Doe',
          email: 'john@example.com',
        },
      })
      .executeTest(async (mockServer) => {
        const response = await fetch(`${mockServer.url}/api/v1/users/123`, {
          headers: { Accept: 'application/json' },
        })
        expect(response.status).toBe(200)
      })
  })
})

Load Testing with k6

// k6 load test
import http from 'k6/http'
import { check, sleep } from 'k6'

export const options = {
  stages: [
    { duration: '30s', target: 50 }, // Ramp up
    { duration: '1m', target: 100 }, // Sustain
    { duration: '30s', target: 0 }, // Ramp down
  ],
  thresholds: {
    http_req_duration: ['p(95)<500'], // 95% of requests under 500ms
    http_req_failed: ['rate<0.01'], // Error rate under 1%
  },
}

export default function () {
  const res = http.get('https://api.example.com/v1/products?limit=20')

  check(res, {
    'status is 200': (r) => r.status === 200,
    'response time < 500ms': (r) => r.timings.duration < 500,
    'has data array': (r) => JSON.parse(r.body).data.length > 0,
  })

  sleep(1)
}

14. Real-World Examples: Leading API Design Patterns

Stripe API

Stripe provides industry-leading API DX.

Stripe Core Design Principles:
1. Consistent resource naming (/v1/customers, /v1/charges, /v1/subscriptions)
2. Expand parameter for including related resources
3. Idempotency keys (Idempotency-Key header)
4. Detailed error responses
5. Auto-pagination (has_more + starting_after)
# Stripe-style request
POST /v1/charges HTTP/1.1
Authorization: Bearer sk_test_xxx
Idempotency-Key: unique-request-id-123
Content-Type: application/x-www-form-urlencoded

amount=2000&currency=usd&source=tok_visa

GitHub API

GitHub API Features:
1. REST + GraphQL hybrid
2. Conditional requests (ETag, If-None-Match)
3. Link header pagination
4. Hypermedia (URL templates)
5. Clear Rate Limit display
# GitHub conditional request
GET /repos/octocat/hello-world HTTP/1.1
If-None-Match: "etag-value"

# 304 Not Modified (use cache)
# or 200 OK (with new ETag)

Twitter(X) API v2

Twitter API v2 Features:
1. Field selection (fields parameter)
2. Expansions for related objects
3. Streaming endpoints
4. Granular Rate Limits
5. OAuth2 + App-only Auth
# Twitter-style: field selection + expansion
GET /2/tweets?ids=123,456&tweet.fields=created_at,public_metrics&expansions=author_id&user.fields=username

15. Quiz

Q1. What is the main difference between PUT and PATCH?

PUT replaces the entire resource (all fields required), while PATCH modifies only specific fields of a resource. PUT guarantees idempotency, but PATCH may not be idempotent in cases of relative changes (e.g., incrementing a counter).

Q2. What does Richardson Maturity Model Level 3 mean?

Level 3 represents HATEOAS (Hypermedia as the Engine of Application State). API responses include links to related resources, allowing clients to dynamically navigate the API without hardcoded URLs. It is the final stage of true REST.

Q3. What are the problems with Offset pagination and what are the alternatives?

Offset pagination degrades in performance as the OFFSET grows larger because the database must skip more rows. Also, when data is added or deleted, duplicates or gaps can occur. The alternative is Cursor-based pagination, which uses the last items ID as a cursor and queries efficiently using WHERE id conditions.

Q4. What is the difference between HTTP 401 and 403?

401 Unauthorized means authentication is required (no token or expired token). 403 Forbidden means the request is authenticated but the user lacks permission for that resource. In other words, 401 is "Who are you?" and 403 is "I know who you are, but you cannot access this."

Q5. What are the pros and cons of URL Path versioning?

Pros: High visibility, easy caching, browser-testable, clear routing. Cons: URLs become longer, all clients must update URLs on version change, and multiple URLs exist for the same resource.

Q6. What are the key fields in RFC 9457 Problem Details?

The core fields of RFC 9457 are type (error type URI), title (human-readable error summary), status (HTTP status code), detail (specific description), and instance (URI where the problem occurred). type and title are effectively required, while others are optional but recommended.

Q7. What is the difference between Token Bucket and Sliding Window algorithms?

Token Bucket replenishes tokens at a constant rate and consumes them per request. It allows burst traffic. Sliding Window tracks the number of requests within a time window, removing old requests as the window moves. It is more precise but uses more memory.


References

  • RFC 9110: HTTP Semantics
  • RFC 9457: Problem Details for HTTP APIs
  • RFC 6749: OAuth 2.0 Authorization Framework
  • OpenAPI Specification 3.1.0
  • Roy Fielding's REST Dissertation (2000)
  • Stripe API Reference
  • GitHub REST API Documentation
  • Google API Design Guide
  • Microsoft REST API Guidelines
  • JSON:API Specification

Conclusion

REST API design carries significance beyond just technical decisions. A well-designed API enhances developer experience, facilitates system integration, and creates business value.

Key takeaways:

  1. Resource-centric URL design with nouns, plural forms, and maximum 2-level nesting
  2. Correct HTTP methods with understanding of semantics and idempotency
  3. Accurate status codes so clients can handle errors correctly
  4. Cursor pagination which is essential for large datasets
  5. RFC 9457 error format for consistent and useful error responses
  6. URL Path versioning as the most practical choice
  7. Rate Limiting to ensure API stability and fair usage
  8. OpenAPI 3.1 for specification automation and code generation

Apply these principles to build APIs that developers love.