System Design 9 min read

API Design Part 1: HTTP & REST Fundamentals

Master HTTP methods, status codes, and REST maturity model. The foundation every API developer needs - from GET/POST basics to idempotency and proper status code selection.

MR

Moshiour Rahman

Advertisement

API Design Mastery Series

This is Part 1 of our comprehensive API Design series. By the end, you’ll master everything from HTTP fundamentals to production-grade system design patterns.

PartTopicLevel
1HTTP & REST FundamentalsBeginner
2Security & AuthenticationBeginner
3Rate Limiting & PaginationIntermediate
4Versioning & IdempotencyIntermediate
5Caching StrategiesIntermediate
6GraphQL & gRPCIntermediate
7Resilience & ObservabilityAdvanced
8Production MasteryAdvanced

This is not another surface-level API tutorial. This is the comprehensive reference that senior engineers wish they had when they started. Let’s start with the foundations that every API is built upon.

The Complete HTTP Method Reference

Every developer knows GET and POST. But understanding the full HTTP vocabulary separates senior engineers from juniors.

MethodIdempotentSafeCacheableRequest BodyUse Case
GETYesYesYesNoRetrieve resources
POSTNoNoNoYesCreate resources, complex queries
PUTYesNoNoYesFull resource replacement
PATCHNoNoNoYesPartial updates
DELETEYesNoNoOptionalRemove resources
HEADYesYesYesNoGet headers only (check existence)
OPTIONSYesYesNoNoCORS preflight, discover methods
TRACEYesYesNoNoDebug request path (disable in prod)
CONNECTNoNoNoNoEstablish tunnel (HTTPS proxy)

The PUT vs PATCH Debate (Interview Deep Dive)

// The Order resource
interface Order {
  id: string;
  customerId: string;
  items: OrderItem[];
  status: 'pending' | 'processing' | 'shipped' | 'delivered';
  shippingAddress: Address;
  totalAmount: number;
  createdAt: Date;
  updatedAt: Date;
}

// PUT: Full replacement - you MUST send everything
// PUT /orders/123
{
  "customerId": "cust_456",
  "items": [{ "productId": "prod_789", "quantity": 2 }],
  "status": "processing",
  "shippingAddress": {
    "street": "123 Main St",
    "city": "NYC",
    "zip": "10001"
  },
  "totalAmount": 99.99
}

// If you omit shippingAddress, it becomes null/undefined!
// This is why PUT is dangerous for partial updates

// PATCH: Partial update - only what changes
// PATCH /orders/123
{
  "status": "shipped"
}

// Only status changes, everything else preserved

Interview Question: “When would you use PUT over PATCH?”

Strong Answer: “PUT for complete resource replacement where client owns the full state, like updating a configuration file. PATCH for field-level updates in CRUD applications. But here’s the nuance: PUT is idempotent by definition - sending the same PUT request twice has the same effect. PATCH’s idempotency depends on implementation. If PATCH increments a counter ({"views": "+1"}), it’s not idempotent. This matters for retry logic in distributed systems.”

Follow-up: “How would you make PATCH idempotent?”

Answer: “Use absolute values instead of deltas, add version/ETag checks, or use JSON Patch (RFC 6902) with test operations.”

HTTP Status Codes: The Complete Mental Model

Status codes aren’t just numbers - they’re a contract between your API and clients.

HTTP Status Code Decision Tree

The Status Codes You Actually Need

// response-helpers.ts - Production-ready response utilities

interface ApiResponse<T> {
  success: boolean;
  data?: T;
  error?: {
    code: string;
    message: string;
    details?: Record<string, string[]>;
    traceId?: string;
  };
  meta?: {
    pagination?: PaginationMeta;
    rateLimit?: RateLimitMeta;
  };
}

// Success responses
export const ok = <T>(data: T, meta?: ApiResponse<T>['meta']): Response =>
  Response.json({ success: true, data, meta }, { status: 200 });

export const created = <T>(data: T, location?: string): Response => {
  const headers = location ? { Location: location } : {};
  return Response.json({ success: true, data }, { status: 201, headers });
};

export const accepted = (trackingUrl: string): Response =>
  Response.json(
    { success: true, data: { trackingUrl } },
    { status: 202, headers: { Location: trackingUrl } }
  );

export const noContent = (): Response =>
  new Response(null, { status: 204 });

// Client error responses
export const badRequest = (message: string, details?: Record<string, string[]>): Response =>
  Response.json({
    success: false,
    error: { code: 'BAD_REQUEST', message, details }
  }, { status: 400 });

export const unauthorized = (message = 'Authentication required'): Response =>
  Response.json({
    success: false,
    error: { code: 'UNAUTHORIZED', message }
  }, { status: 401, headers: { 'WWW-Authenticate': 'Bearer' } });

export const forbidden = (message = 'Insufficient permissions'): Response =>
  Response.json({
    success: false,
    error: { code: 'FORBIDDEN', message }
  }, { status: 403 });

export const notFound = (resource = 'Resource'): Response =>
  Response.json({
    success: false,
    error: { code: 'NOT_FOUND', message: `${resource} not found` }
  }, { status: 404 });

export const conflict = (message: string): Response =>
  Response.json({
    success: false,
    error: { code: 'CONFLICT', message }
  }, { status: 409 });

export const unprocessable = (message: string, details?: Record<string, string[]>): Response =>
  Response.json({
    success: false,
    error: { code: 'UNPROCESSABLE_ENTITY', message, details }
  }, { status: 422 });

export const tooManyRequests = (retryAfter: number): Response =>
  Response.json({
    success: false,
    error: {
      code: 'RATE_LIMITED',
      message: `Rate limit exceeded. Retry after ${retryAfter} seconds`
    }
  }, {
    status: 429,
    headers: { 'Retry-After': String(retryAfter) }
  });

// Server error responses
export const serverError = (traceId: string): Response =>
  Response.json({
    success: false,
    error: {
      code: 'INTERNAL_ERROR',
      message: 'An unexpected error occurred',
      traceId
    }
  }, { status: 500 });

export const serviceUnavailable = (retryAfter: number): Response =>
  Response.json({
    success: false,
    error: { code: 'SERVICE_UNAVAILABLE', message: 'Service temporarily unavailable' }
  }, {
    status: 503,
    headers: { 'Retry-After': String(retryAfter) }
  });

Interview Question: “What’s the difference between 401 and 403?”

Strong Answer: “401 means ‘I don’t know who you are’ - authentication failed or missing. 403 means ‘I know who you are, but you can’t do this’ - authenticated but not authorized. A common mistake is returning 403 for invalid tokens - that should be 401. And here’s a subtle point: some APIs return 404 instead of 403 for resources the user shouldn’t even know exist - this prevents information disclosure.”

Follow-up: “When would you use 422 vs 400?”

Answer: “400 for syntactically invalid requests - malformed JSON, wrong content-type, missing required fields. 422 for semantically invalid requests - the JSON is valid but the values don’t make business sense. For example, creating an order with quantity=-5 is 422, but {quantity: 'five'} is 400.”

REST Maturity Model (Richardson)

Understanding where your API sits on the maturity model helps communicate design decisions.

LevelExampleCharacteristics
3 - HATEOASGET /orders/123 returns _links: {cancel, pay}Clients discover actions from responses
2 - HTTP VerbsGET /orders, POST /orders, DELETE /orders/123Proper HTTP methods and status codes
1 - ResourcesPOST /getOrder, POST /createOrderMultiple endpoints, RPC-style
0 - POXPOST /api with {action: "getOrder"}Single endpoint, SOAP-like

Most production APIs target Level 2. HATEOAS (Level 3) is rarely worth the complexity unless you’re building a truly hypermedia-driven application.


URL Design Patterns That Scale

Resource Naming Conventions

PatternGoodBadWhy
Plural nouns/users, /orders/user, /orderConsistency, collection semantics
Lowercase/api/users/api/UsersURLs are case-sensitive
Hyphens/user-profiles/user_profiles, /userProfilesURL-friendly, readable
No verbsPOST /ordersPOST /createOrderHTTP method is the verb
Hierarchical/users/123/orders/orders?userId=123Shows ownership clearly

Nested vs Flat Resources

// Hierarchical (shows relationship, limited scope)
GET /users/123/orders          // Orders for user 123
GET /users/123/orders/456      // Order 456 of user 123

// Flat (flexible, good for cross-cutting queries)
GET /orders?userId=123         // Same result, but can filter by more
GET /orders?status=pending&createdAfter=2025-01-01

// Rule of thumb:
// - Nest when child ONLY exists under parent
// - Flatten when child can be queried independently

Action Endpoints (When REST Doesn’t Fit)

// Some operations don't map to CRUD - use sub-resources
POST /orders/123/cancel        // Cancel order (not DELETE - order still exists)
POST /orders/123/refund        // Refund order
POST /users/123/verify-email   // Trigger verification
POST /reports/generate         // Start async report generation

// Return 200 for immediate actions, 202 for async

Content Negotiation

// Request specific format
fetch('/api/users', {
  headers: {
    'Accept': 'application/json',           // Preferred format
    'Accept-Language': 'en-US,en;q=0.9',    // Preferred language
    'Accept-Encoding': 'gzip, deflate'      // Compression
  }
});

// Server responds with what it provides
// Content-Type: application/json; charset=utf-8
// Content-Language: en-US
// Content-Encoding: gzip

// Version negotiation via Accept header
'Accept': 'application/vnd.api+json;version=2'

Common Mistakes to Avoid

MistakeProblemFix
GET /users/delete/123Using GET for mutationsDELETE /users/123
POST /searchPOST for reads (not cacheable)GET /search?q=term
200 for errorsBreaks client error handlingUse proper 4xx/5xx
404 for empty listEmpty list isn’t “not found”200 with []
Ignoring Accept headerForces single formatSupport content negotiation
PUT for partial updatesCan accidentally nullify fieldsUse PATCH

Quick Reference Card

HTTP Methods & Status Codes Cheat Sheet


What’s Next?

Now that you understand HTTP fundamentals and REST principles, Part 2: Security & Authentication covers JWT implementation, OAuth 2.0 flows, and the security layers that protect production APIs.

Advertisement

MR

Moshiour Rahman

Software Architect & AI Engineer

Share:
MR

Moshiour Rahman

Software Architect & AI Engineer

Enterprise software architect with deep expertise in financial systems, distributed architecture, and AI-powered applications. Building large-scale systems at Fortune 500 companies. Specializing in LLM orchestration, multi-agent systems, and cloud-native solutions. I share battle-tested patterns from real enterprise projects.

Related Articles

Comments

Comments are powered by GitHub Discussions.

Configure Giscus at giscus.app to enable comments.