19 min read

Chapter 10: Testing & Documentation

"An API without tests is a promise you can't keep.
An API without documentation is a promise no one can find."


1. Physical Reality First

You have designed and built a REST API. Before you ship it, two questions must be answered:

  1. Does it work? — Testing
  2. Does anyone know how to use it? — Documentation

These are not afterthoughts. They are the difference between code that runs in production and a product that others can build on.

The cost of bad testing: a regression breaks a client's integration at 2am. You find out from an angry Slack message, not your own monitoring.

The cost of bad documentation: developers spend hours guessing how your API works. They build incorrect integrations. They file support tickets. They give up and use a competitor.

Good testing means: you know immediately when something breaks. Good documentation means: developers can integrate without asking you anything.

This chapter closes the loop on everything we've built. Testing verifies the correctness of every design decision from Chapters 1–9. Documentation communicates those decisions to the world.


2. The Testing Pyramid for APIs

code
            ┌──────────┐
            │  E2E     │  ← Few, slow, expensive
            │  Tests   │     Test full user journeys
           ┌┴──────────┴┐
           │ Integration │  ← Medium, test real DB + HTTP
           │   Tests    │
          ┌┴────────────┴┐
          │  Contract    │  ← OpenAPI schema validation
          │   Tests      │
         ┌┴──────────────┴┐
         │   Unit Tests   │  ← Many, fast, test logic in isolation
         └────────────────┘

Each layer tests different things. Don't rely on only one. An API covered only by unit tests can have every unit pass while the integrated system is completely broken.


3. Unit Testing — Logic in Isolation

Unit tests test individual functions without the network, database, or external services. They run in milliseconds and should be your largest test layer.

What to unit test in an API:

  • Validation logic: does validate_email("not-an-email") raise the right error?
  • Business logic: does calculate_order_total([...]) apply discounts correctly?
  • Authorization logic: does can_user_access_order(user, order) return the right boolean?
  • Serialization: does OrderSchema.from_db(order) produce the right JSON shape?
  • Error mapping: does a DB constraint violation map to a 409 response?
python
# Example: testing validation logic in isolation
def test_create_order_validates_quantity():
    with pytest.raises(ValidationError) as exc_info:
        CreateOrderRequest(
            items=[{"product_id": 1, "quantity": 0}]  # quantity must be >= 1
        )
    errors = exc_info.value.errors()
    assert any(e["loc"] == ("items", 0, "quantity") for e in errors)
    assert any(e["type"] == "greater_than_equal" for e in errors)

def test_order_total_applies_bulk_discount():
    items = [{"price": 1000, "quantity": 10}]
    total = calculate_order_total(items, user_tier="gold")
    assert total == 9000   # 10% discount for gold tier

def test_cannot_access_other_users_order():
    user = User(id=42, role="user")
    order = Order(id=99, user_id=77)   # belongs to different user
    assert can_user_access_order(user, order) is False

def test_admin_can_access_any_order():
    user = User(id=42, role="admin")
    order = Order(id=99, user_id=77)
    assert can_user_access_order(user, order) is True

Mocking in unit tests:

When your function calls a database or external service, mock it:

python
from unittest.mock import patch, MagicMock

def test_send_order_confirmation_calls_email_service():
    with patch("app.services.email.send") as mock_send:
        confirm_order(order_id=99, user_email="t@example.com")
        mock_send.assert_called_once_with(
            to="t@example.com",
            template="order_confirmation",
            data={"order_id": 99}
        )

The test verifies behavior (email service was called correctly) without actually sending email.


4. Integration Testing — The Real HTTP Layer

Integration tests spin up your actual application with a real (test) database and send real HTTP requests through the full stack. They test that all the layers work together: HTTP parsing → middleware → auth → route handler → DB → response serialization.

python
# FastAPI / pytest integration test
import pytest
from fastapi.testclient import TestClient
from app.main import app
from app.database import get_db, Base, engine

@pytest.fixture(scope="function")
def client(db):
    """Test client with a clean DB per test"""
    yield TestClient(app)

@pytest.fixture(scope="function")
def db():
    """Create tables, yield, drop tables"""
    Base.metadata.create_all(bind=engine)
    yield
    Base.metadata.drop_all(bind=engine)

@pytest.fixture
def auth_headers(client):
    """Create a user and return auth headers"""
    client.post("/users", json={"email": "t@example.com", "password": "Password123!"})
    response = client.post("/auth/login", json={"email": "t@example.com", "password": "Password123!"})
    token = response.json()["access_token"]
    return {"Authorization": f"Bearer {token}"}

class TestCreateOrder:
    def test_create_order_success(self, client, auth_headers):
        response = client.post(
            "/orders",
            json={"items": [{"product_id": 1, "quantity": 2}]},
            headers=auth_headers
        )
        assert response.status_code == 201
        data = response.json()
        assert data["status"] == "pending"
        assert len(data["items"]) == 1
        assert "id" in data
        assert "created_at" in data

    def test_create_order_requires_auth(self, client):
        response = client.post(
            "/orders",
            json={"items": [{"product_id": 1, "quantity": 2}]}
        )
        assert response.status_code == 401

    def test_create_order_validates_quantity(self, client, auth_headers):
        response = client.post(
            "/orders",
            json={"items": [{"product_id": 1, "quantity": 0}]},
            headers=auth_headers
        )
        assert response.status_code == 422
        errors = response.json()["errors"]
        assert any(e["field"] == "items[0].quantity" for e in errors)

    def test_cannot_read_other_users_order(self, client, auth_headers, other_user_order):
        response = client.get(f"/orders/{other_user_order.id}", headers=auth_headers)
        assert response.status_code == 404  # 404 not 403 — don't reveal existence

What integration tests catch that unit tests miss:

  • Middleware applying in wrong order (auth before rate limiting, or reverse)
  • SQL queries that are syntactically correct but return wrong data
  • Missing DB indexes causing correct-but-slow queries
  • Response serialization including fields it shouldn't (password hash in user response)
  • CORS headers missing on actual responses
  • Content-Type header wrong or missing

Use a test database, never production. Either a separate DB instance, or SQLite in-memory for fast tests.


5. Contract Testing — OpenAPI as Truth

A contract is the formal agreement between your API and its clients. OpenAPI (formerly Swagger) is the standard contract format for REST APIs.

5.1 What OpenAPI Is

OpenAPI is a YAML/JSON schema that describes your entire API:

yaml
# openapi.yaml
openapi: "3.1.0"
info:
  title: "Orders API"
  version: "1.0.0"

paths:
  /orders:
    post:
      summary: "Create an order"
      operationId: "createOrder"
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/CreateOrderRequest"
      responses:
        "201":
          description: "Order created"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Order"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "422":
          $ref: "#/components/responses/ValidationError"

  /orders/{orderId}:
    get:
      summary: "Get an order"
      parameters:
        - name: orderId
          in: path
          required: true
          schema:
            type: integer
      responses:
        "200":
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Order"
        "404":
          $ref: "#/components/responses/NotFound"

components:
  schemas:
    Order:
      type: object
      required: [id, status, items, created_at]
      properties:
        id:
          type: integer
        status:
          type: string
          enum: [pending, processing, shipped, delivered, cancelled]
        items:
          type: array
          items:
            $ref: "#/components/schemas/OrderItem"
        total:
          type: integer
          description: "Total in paise (smallest currency unit)"
        created_at:
          type: string
          format: date-time

    CreateOrderRequest:
      type: object
      required: [items]
      properties:
        items:
          type: array
          minItems: 1
          items:
            $ref: "#/components/schemas/OrderItemRequest"

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

5.2 Code-First vs Design-First

Design-first: Write OpenAPI spec → generate server stubs → implement logic.

code
openapi.yaml → code generator → app/routes/orders.py (skeleton)

Forces you to think about the API contract before implementation. Client teams can start building against the spec before the server is done.

Code-first: Write implementation → generate OpenAPI spec from code.

python
# FastAPI generates OpenAPI automatically from type hints
@app.post("/orders", response_model=OrderResponse, status_code=201)
def create_order(body: CreateOrderRequest, user: User = Depends(get_current_user)):
    ...

FastAPI, Spring Boot, and most modern frameworks generate the spec from code annotations. Faster to start, but the spec reflects what you built — not necessarily what you intended.

Recommendation: Design-first for public APIs (contract is the product). Code-first for internal APIs (speed matters more).

5.3 Contract Testing — Validating Both Sides

Contract testing validates that:

  1. Your API responses match the OpenAPI schema (server side)
  2. Client code correctly handles the API responses (client side)

Server-side contract validation:

python
# schemathesis: automatically generates test cases from OpenAPI spec
import schemathesis

schema = schemathesis.from_path("openapi.yaml", base_url="http://localhost:8000")

@schema.parametrize()
def test_api_matches_spec(case):
    """
    Schemathesis reads openapi.yaml, generates hundreds of test inputs
    (valid, invalid, edge cases), sends them, and validates responses
    match the declared schema.
    """
    response = case.call()
    case.validate_response(response)

This one test catches:

  • Missing required fields in responses
  • Wrong field types (string where integer expected)
  • Undeclared status codes being returned
  • Required fields absent in error responses

Client-side contract testing (Pact):

Pact lets the consumer (client) define what responses they expect. The provider (server) verifies it satisfies those expectations:

python
# Consumer (client) side — defines expectations
pact = Consumer("mobile-app").has_pact_with(Provider("orders-api"))

pact.given("order 99 exists").upon_receiving("a request for order 99").with_request(
    method="GET",
    path="/orders/99",
    headers={"Authorization": "Bearer valid-token"}
).will_respond_with(
    status=200,
    body={
        "id": 99,
        "status": Like("pending"),    # any string
        "created_at": Like("2024-01-15T10:30:00Z")
    }
)

# Provider (server) side — verifies it satisfies consumer expectations
# Run in CI: fetches consumer pacts from Pact Broker, runs against real server

Pact is powerful for microservice teams where the consumer and provider are different teams — each can test independently.


6. What to Test — The Complete Checklist

For every endpoint, verify:

Happy path:

  • Correct status code (201 for POST, 200 for GET, 204 for DELETE)
  • Response body matches schema
  • All required fields present
  • Field types correct (no string where integer expected)
  • Timestamps in ISO 8601 format
  • Created resource is actually persisted (GET after POST)

Authentication:

  • Returns 401 with no token
  • Returns 401 with expired token
  • Returns 401 with malformed token
  • Returns 401 with token signed by wrong key

Authorization:

  • Returns 404 (or 403) when accessing another user's resource
  • Admin can access all resources
  • Correct role required for privileged operations

Validation:

  • Returns 422 for each missing required field
  • Returns 422 for each field with invalid format
  • Returns 422 for out-of-range values
  • All validation errors returned at once (not one at a time)
  • Error response matches error schema

Edge cases:

  • What happens with an empty list in a batch request?
  • What happens with the maximum allowed input (100 items, 1000 char string)?
  • What happens when a referenced resource doesn't exist (FK constraint)?
  • What happens on concurrent modification (optimistic locking)?
  • What happens when an idempotency key is reused?

7. Load Testing — Does It Scale?

Functional tests tell you it works correctly. Load tests tell you it works at scale.

7.1 k6 — The Standard Tool

javascript
// k6 load test script
import http from "k6/http";
import { check, sleep } from "k6";

export const options = {
  stages: [
    { duration: "2m", target: 100 },   // ramp up to 100 virtual users
    { duration: "5m", target: 100 },   // stay at 100 for 5 minutes
    { duration: "2m", target: 200 },   // ramp up to 200
    { duration: "5m", target: 200 },   // stay at 200
    { duration: "2m", target: 0 },     // ramp down
  ],
  thresholds: {
    http_req_duration: ["p95<500"],     // 95th percentile response time < 500ms
    http_req_failed: ["rate<0.01"],     // error rate < 1%
  },
};

export default function () {
  const res = http.get("https://api.example.com/products", {
    headers: { Authorization: "Bearer test-token" },
  });

  check(res, {
    "status is 200": (r) => r.status === 200,
    "response time < 500ms": (r) => r.timings.duration < 500,
    "has data field": (r) => JSON.parse(r.body).data !== undefined,
  });

  sleep(1);  // think time between requests
}

Run it: k6 run load_test.js

7.2 What to Measure

code
Throughput:   requests per second (RPS) your API can handle
Latency:      P50, P95, P99 response times (50th, 95th, 99th percentile)
Error rate:   % of requests returning 5xx
Saturation:   at what RPS do P99 latencies start degrading?

The four golden signals (from Google SRE book):

  • Latency: How long requests take
  • Traffic: Requests per second
  • Errors: Rate of failed requests
  • Saturation: How "full" your service is (CPU, memory, DB connections)

7.3 What Load Tests Reveal

  • N+1 query problem: GET /orders issues 1 query for the list + N queries for each order's user. At 1 user it's fine. At 100 concurrent users with 20 orders each: 2100 queries per request.
  • Missing indexes: Tests pass with 100 rows in test DB. Load test with 10M rows shows 10-second query times.
  • Connection pool exhaustion: 200 concurrent requests each hold a DB connection. Pool size is 100. 100 requests queue and time out.
  • Memory leaks: After 30 minutes of load, memory grows and never releases. Server OOMs.

8. API Documentation — The Living Kind

8.1 What Good Documentation Contains

For every endpoint:

code
1. What it does (one clear sentence)
2. Authentication required? What scope?
3. Request: URL params, query params, request body (all fields, types, required/optional)
4. Response: all possible status codes with example responses for each
5. Error codes: every error code this endpoint can return, with fix instructions
6. Code examples: curl + one SDK language minimum
7. Rate limits that apply

8.2 Swagger UI — Auto-Generated from OpenAPI

If you have an OpenAPI spec, you get interactive documentation for free:

python
# FastAPI serves Swagger UI automatically
# Visit http://localhost:8000/docs

# Customize with rich descriptions
@app.post(
    "/orders",
    status_code=201,
    summary="Create an order",
    description="""
    Creates a new order for the authenticated user.
    
    **Idempotency:** Send an `Idempotency-Key` header to safely retry failed requests.
    
    **Inventory:** Stock is reserved at creation time. If payment fails, stock is released.
    """,
    response_description="The created order with server-assigned ID and timestamps"
)
def create_order(
    body: CreateOrderRequest,
    idempotency_key: Optional[str] = Header(None, alias="Idempotency-Key"),
    user: User = Depends(get_current_user)
):
    ...

8.3 The Three Levels of API Documentation

Level 1 — Reference docs (generated from OpenAPI):
Every endpoint, every field, every response. Accurate because it's generated from code. But not enough on its own — reference docs tell you what, not why or how.

Level 2 — Guides and tutorials:
Written prose walking through common use cases. "How to process a payment," "How to set up webhooks," "How to handle card declines." These must be written by humans.

Level 3 — Changelog:
Every breaking change, every new feature, every deprecation. Dated. Explicit. Clients read this to know if they need to update their integration.

Stripe's documentation is the gold standard. Study it: https://stripe.com/docs/api

8.4 Writing Great Code Examples

Every endpoint should have a curl example that works:

bash
# Create an order
curl -X POST https://api.example.com/v1/orders \
  -H "Authorization: Bearer sk_live_..." \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: order-req-$(uuidgen)" \
  -d '{
    "items": [
      {"product_id": 42, "quantity": 2}
    ],
    "shipping_address": {
      "street": "MG Road",
      "city": "Bengaluru",
      "postal_code": "560001"
    }
  }'

And the expected response:

json
{
  "id": 1234,
  "status": "pending",
  "items": [
    {
      "product_id": 42,
      "quantity": 2,
      "unit_price": 85000,
      "total": 170000
    }
  ],
  "total": 170000,
  "created_at": "2024-01-15T10:30:00Z"
}

If a developer can copy-paste your curl example and get a real response in under 2 minutes, your documentation is good.

8.5 Postman / Insomnia Collections

Export your API as a Postman collection — a clickable, executable set of all your API endpoints with pre-filled examples. Many API teams ship this alongside documentation.

json
// postman_collection.json (simplified)
{
  "info": {"name": "Orders API v1"},
  "item": [
    {
      "name": "Create Order",
      "request": {
        "method": "POST",
        "url": "{{base_url}}/v1/orders",
        "header": [
          {"key": "Authorization", "value": "Bearer {{api_key}}"},
          {"key": "Content-Type", "value": "application/json"}
        ],
        "body": {
          "raw": "{\"items\": [{\"product_id\": 42, \"quantity\": 2}]}"
        }
      }
    }
  ]
}

Developers import this, set their API key as an environment variable, and can immediately call every endpoint without reading the docs first.


9. Testing Webhooks

Webhooks are HTTP requests your server makes to a client's server (reverse of normal). Testing them requires:

Locally: Use webhook.site or ngrok to expose localhost:

bash
ngrok http 8000
# → https://a1b2c3d4.ngrok.io

# Register webhook URL in your API
curl -X POST https://api.example.com/webhooks \
  -d '{"url": "https://a1b2c3d4.ngrok.io/webhooks", "events": ["order.created"]}'

In integration tests: Spin up a minimal HTTP server to receive webhooks and assert on the payload:

python
import threading
from http.server import HTTPServer, BaseHTTPRequestHandler

received_webhooks = []

class WebhookReceiver(BaseHTTPRequestHandler):
    def do_POST(self):
        content_length = int(self.headers["Content-Length"])
        body = self.rfile.read(content_length)
        received_webhooks.append(json.loads(body))
        self.send_response(200)
        self.end_headers()

def test_order_created_webhook_delivered():
    server = HTTPServer(("localhost", 9999), WebhookReceiver)
    thread = threading.Thread(target=server.handle_request)
    thread.start()

    # Register webhook
    client.post("/webhooks", json={"url": "http://localhost:9999", "events": ["order.created"]})

    # Create order
    client.post("/orders", json={"items": [...]}, headers=auth_headers)

    # Wait for webhook delivery
    thread.join(timeout=5)

    assert len(received_webhooks) == 1
    assert received_webhooks[0]["event"] == "order.created"
    assert "order" in received_webhooks[0]["data"]

    # Verify signature (Chapter 9: HMAC)
    payload = received_webhooks[0]
    expected_sig = hmac.new(WEBHOOK_SECRET, json.dumps(payload).encode(), hashlib.sha256).hexdigest()
    assert received_webhooks[0]["signature"] == expected_sig

10. CI/CD Integration

Tests are only valuable if they run automatically. Wire everything into your CI pipeline:

yaml
# .github/workflows/api-tests.yml
name: API Tests

on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    services:
      postgres:
        image: postgres:15
        env:
          POSTGRES_PASSWORD: test
        options: >-
          --health-cmd pg_isready
          --health-interval 10s

    steps:
      - uses: actions/checkout@v4

      - name: Install dependencies
        run: pip install -r requirements.txt

      - name: Run unit tests
        run: pytest tests/unit/ -v --tb=short

      - name: Run integration tests
        run: pytest tests/integration/ -v --tb=short
        env:
          DATABASE_URL: postgresql://postgres:test@localhost/test_db

      - name: Contract test (schema validation)
        run: schemathesis run openapi.yaml --url http://localhost:8000 --checks all

      - name: Security scan
        run: bandit -r app/ -ll  # static security analysis

  load-test:
    runs-on: ubuntu-latest
    if: github.ref == 'refs/heads/main'  # only on main
    steps:
      - name: Run load test
        run: k6 run --vus 50 --duration 60s tests/load/smoke_test.js

The rule: Every PR must pass all tests before merge. Load tests run on every merge to main. Contract tests run on every deploy to production.


11. How Real Systems Test and Document

Stripe:

  • OpenAPI spec maintained at github.com/stripe/openapi
  • Test mode API keys — safe to run full integration tests with real API calls
  • stripe trigger payment_intent.succeeded CLI command fires test webhook events
  • Every SDK has its own test suite against the spec
  • Documentation includes interactive code examples in 8 languages that run in the browser

GitHub:

  • OpenAPI spec at github.com/github/rest-api-description
  • Octokit SDK generated from the spec
  • Every API change goes through spec review before code review
  • Breaking change detection: CI compares new spec to previous version, fails on breaking changes

Twilio:

  • Helper libraries (SDKs) generated from OpenAPI spec for 7 languages
  • Exhaustive error documentation: every error code has a docs page with common causes and fixes
  • Test credentials: separate API keys that don't actually send SMS/calls
  • Webhook testing with live event inspector in the dashboard

12. Vocabulary Map

TermDefinition
OpenAPIStandard specification format for describing REST APIs. Formerly Swagger. YAML or JSON.
Swagger UIAuto-generated interactive documentation from an OpenAPI spec.
Contract testTest validating that API responses match a defined schema (OpenAPI).
PactContract testing framework for consumer-driven contracts between services.
Integration testTest that exercises the full stack (HTTP → middleware → handler → DB → response).
Unit testTest of a single function in isolation, with dependencies mocked.
Load testTest simulating multiple concurrent users to measure throughput, latency, error rate.
k6Modern load testing tool. Tests written in JavaScript.
schemathesisTool that generates and runs tests from an OpenAPI spec.
P95 latencyThe response time below which 95% of requests complete. Better than average for capacity planning.
N+1 queryPerformance bug: fetching a list runs 1 query, then 1 more query per item. O(N) DB calls.
Design-firstWrite the OpenAPI spec before implementing. Spec is the contract, code follows.
Code-firstWrite the implementation, generate the OpenAPI spec from annotations.
WebhookHTTP request your server makes to a client's URL when an event occurs.
ngrokTool that creates a public URL tunneling to your localhost. Used for webhook testing.
Test clientAn HTTP client that calls your app directly without network, used in integration tests.
Golden signalsLatency, traffic, errors, saturation — the four metrics that define service health.
Smoke testMinimal load test (low VUs, short duration) to verify basic functionality under any load.
Postman collectionExported set of API requests with examples, shareable with developers.
ChangelogDated list of API changes. Breaking changes explicitly noted. Required for public APIs.

13. Practice Problems

Problem 1 — Failure Analysis: Your integration tests all pass. You deploy to production and GET /users?sort=created_at takes 45 seconds. Your test database had 500 rows. Production has 8 million rows. Identify the root cause. What type of test would have caught this before deployment? Write the specific test (schema, data volume, assertion) that would have caught it. What index is missing?

Problem 2 — Design From Scratch: Design the complete testing strategy for a payments API (POST /charges, GET /charges/{id}, POST /refunds). Write: (a) 3 unit tests for charge validation logic, (b) 3 integration tests covering auth + happy path + failure, (c) the OpenAPI schema snippet for the Charge response object, (d) the load test configuration (stages, thresholds) for an API expected to handle 500 RPS at P95 < 200ms, (e) the CI pipeline stages in order.

Problem 3 — Synthesis: Your team uses design-first OpenAPI. The spec says POST /orders returns a 201 with an Order object including id, status, total, and created_at as required fields. A developer implements the handler but accidentally omits total from the response. Three mechanisms should catch this before it reaches production. Name all three (hint: one is a test type, one is a tool, one is a CI check). How does each one catch it?

Problem 4 — Go Deeper: You are writing a load test for GET /orders with cursor-based pagination. The endpoint authenticates via JWT, queries Postgres with a keyset WHERE clause, and caches results in Redis for 30 seconds. Design a realistic load test that: (a) generates realistic JWTs for 1000 different test users, (b) simulates cursor pagination (first request has no cursor; subsequent requests use the cursor from the previous response), (c) measures separately: cache hit latency vs cache miss latency, (d) detects when Redis eviction causes a cache miss spike. Write the k6 script structure (pseudocode is fine).

Problem 5 — Connect to Previous (Full Circle): You have built a complete REST API over these 10 chapters. A junior engineer joins and asks: "Why do we need all these tests? The code looks right." Write the answer using one concept from each chapter — (Ch1) HTTP, (Ch2) REST constraints, (Ch3) resource design, (Ch4) response design, (Ch5) auth, (Ch6) errors, (Ch7) versioning, (Ch8) caching, (Ch9) security — showing how a test failure in each area manifests as a real production bug. This is your synthesis of the entire book.


The Book Is Complete

You started with raw bytes on a wire. You end with a complete, tested, documented, secure, performant REST API system.

What You Now Know

The Foundation (Chapters 1–2)

  • HTTP is structured text over TCP. Every API call is bytes. Latency is physics.
  • REST is 6 constraints, not a URL style. Statelessness, uniform interface, and cacheability are what make APIs scalable.

The Design (Chapters 3–4)

  • URLs are addresses for resources (nouns), not calls to functions (verbs).
  • Request and response bodies are contracts. Field types, null handling, date formats, and money representation are not cosmetic choices — they determine whether clients break.

The Trust (Chapter 5)

  • Authentication and authorization are separate. API keys for M2M, JWTs for stateless auth, OAuth 2.0 for delegated access.
  • IDOR — failing to check resource ownership — is the most common and most dangerous bug.

The Communication (Chapters 6–7)

  • Errors are for developers. RFC 7807, machine-readable codes, all validation errors at once.
  • APIs must evolve. URL versioning, additive-only changes, 12-month deprecation windows, cursor pagination for large datasets.

The Performance (Chapter 8)

  • Cache-Control, ETags, and 304 Not Modified can eliminate 90% of origin load with headers alone.
  • gzip/Brotli compress JSON by 70–85%. Vary: Accept-Encoding keeps caches correct.

The Safety (Chapter 9)

  • OWASP API Top 10 is the threat model. BOLA, mass assignment, and SSRF are the hardest to see.
  • CORS is a browser instruction, not a server security control. HSTS enforces HTTPS. Never store secrets in code.

The Proof (Chapter 10)

  • Unit tests verify logic. Integration tests verify the full stack. Contract tests verify the spec. Load tests verify scale.
  • OpenAPI is your contract with the world. Documentation is part of the product.

What's Next

This book made you a backend API engineer. The next level:

Go deeper on any one chapter:
Distributed transactions (Ch13 of the Distributed Systems book) is what POST /payments requires at scale. Kafka (Ch15) is what powers webhooks at millions of events/day. Caching at the DB layer (Ch9 of distributed systems) is what makes GET /products serve millions of users.

Build something:
Design a full API for a domain you know. Use every principle in this book. Implement it. Load test it. Document it. The gap between knowing and building is where the real learning lives.

Study great APIs:
Stripe, GitHub, Twilio, and Shopify are the best API designs in the world. Read their docs, not as a user, but as an engineer asking: why did they make this choice? Every decision you've read about in this book — you'll see it in their APIs.

The curriculum is complete.