API-first means designing your API contract before writing any implementation code. This approach eliminates the “build it and see” cycle that creates breaking changes, inconsistent naming, and developer frustration. The alternative — code-first APIs — leads to contracts that leak implementation details, naming that changes with refactors, and consumers that break whenever you ship.
Companies with API-first practices ship 40% fewer breaking changes and onboard new API consumers 3x faster. The investment is a spec file and a review process. The return is an API that lasts years instead of months.
Step 1: Design the Contract First
1.1 OpenAPI Specification
# openapi.yaml
openapi: 3.1.0
info:
title: Customer Management API
version: 2.0.0
description: Manage customer records and their associated orders.
servers:
- url: https://api.company.com/v2
description: Production
- url: https://api-staging.company.com/v2
description: Staging
paths:
/customers:
get:
summary: List customers
operationId: listCustomers
parameters:
- name: page
in: query
schema: { type: integer, default: 1, minimum: 1 }
- name: per_page
in: query
schema: { type: integer, default: 20, maximum: 100 }
- name: sort
in: query
schema: { type: string, enum: [created_at, name, revenue] }
responses:
'200':
description: Customer list
content:
application/json:
schema:
$ref: '#/components/schemas/CustomerList'
'401':
$ref: '#/components/responses/Unauthorized'
post:
summary: Create a customer
operationId: createCustomer
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/CreateCustomer'
responses:
'201':
description: Customer created
content:
application/json:
schema:
$ref: '#/components/schemas/Customer'
components:
schemas:
Customer:
type: object
required: [id, name, email, created_at]
properties:
id:
type: string
format: uuid
name:
type: string
example: "Acme Corp"
email:
type: string
format: email
revenue:
type: number
format: double
created_at:
type: string
format: date-time
CreateCustomer:
type: object
required: [name, email]
properties:
name:
type: string
minLength: 1
maxLength: 200
email:
type: string
format: email
CustomerList:
type: object
properties:
data:
type: array
items:
$ref: '#/components/schemas/Customer'
meta:
$ref: '#/components/schemas/Pagination'
Pagination:
type: object
properties:
page: { type: integer }
per_page: { type: integer }
total: { type: integer }
total_pages: { type: integer }
1.2 Validate Before Building
# Validate your OpenAPI spec
npx @redocly/cli lint openapi.yaml
# Generate API docs
npx @redocly/cli build-docs openapi.yaml -o docs/index.html
# Generate client SDK
npx @openapitools/openapi-generator-cli generate \
-i openapi.yaml \
-g typescript-axios \
-o ./sdk
1.3 API Design Conventions
| Convention | Good | Bad | Why |
|---|
| Resource naming | /customers/{id}/orders | /getCustomerOrders | REST uses nouns, not verbs |
| Plural resources | /customers | /customer | Consistent collection naming |
| Nested resources max 2 levels | /customers/{id}/orders | /customers/{id}/orders/{id}/items | Deep nesting is hard to navigate |
| Consistent casing | snake_case or camelCase (pick one) | Mix of both | Reduces consumer confusion |
| ISO 8601 dates | 2026-03-02T14:30:00Z | 03/02/2026 or Unix timestamp | Universal, timezone-aware |
| Envelope responses | { "data": [...], "meta": {} } | Raw array [...] | Room for pagination, metadata |
Step 2: Choose Your Versioning Strategy
| Strategy | URL Path | Header | Query Param |
|---|
| Example | /v2/customers | API-Version: 2 | ?version=2 |
| Discoverability | ✅ Obvious | ❌ Hidden | ⚠️ Easy to forget |
| Caching | ✅ Cache-friendly | ❌ Breaks caches | ✅ Cache-friendly |
| Implementation | Simple routing | Middleware | Middleware |
| Recommendation | ✅ Best for REST | Good for internal | Avoid |
Breaking vs Non-Breaking Changes
Non-Breaking (no version bump):
✅ Adding a new optional field to a response
✅ Adding a new endpoint
✅ Adding a new optional query parameter
✅ Deprecating a field (but keeping it)
Breaking (requires version bump):
❌ Removing a field from a response
❌ Renaming a field
❌ Changing a field's type
❌ Making an optional field required
❌ Changing error response format
Deprecation Policy
| Phase | Action | Duration |
|---|
| Announce | Add Deprecation header + changelog entry | Day 0 |
| Warning | API returns Sunset header with date | 30 days |
| Sunset | Old version returns 410 Gone | 90 days after announcement |
| Remove | Clean up old code | After sunset |
HTTP/1.1 200 OK
Deprecation: true
Sunset: Sat, 01 Jun 2026 00:00:00 GMT
Link: <https://api.company.com/v3/customers>; rel="successor-version"
Step 3: Implement Authentication
JWT + API Key Pattern
from fastapi import FastAPI, Depends, HTTPException, Security
from fastapi.security import HTTPBearer, APIKeyHeader
import jwt
app = FastAPI()
bearer = HTTPBearer()
api_key_header = APIKeyHeader(name="X-API-Key", auto_error=False)
async def authenticate(
token: str = Depends(bearer),
api_key: str = Security(api_key_header)
):
"""Support both JWT and API Key authentication"""
if api_key:
# Validate API key against database
key_record = await db.api_keys.find_one({"key": api_key, "active": True})
if not key_record:
raise HTTPException(401, "Invalid API key")
return {"type": "api_key", "client": key_record["client_id"]}
if token:
try:
payload = jwt.decode(token.credentials, SECRET, algorithms=["HS256"])
return {"type": "jwt", "user_id": payload["sub"]}
except jwt.InvalidTokenError:
raise HTTPException(401, "Invalid token")
raise HTTPException(401, "Authentication required")
@app.get("/v2/customers")
async def list_customers(auth=Depends(authenticate)):
# auth contains the authenticated identity
...
Authentication Method Selection
| Method | Best For | Security | Complexity |
|---|
| API Key | Server-to-server, simple integrations | Medium (rotate regularly) | Low |
| JWT | User-facing apps, fine-grained permissions | High (short-lived, signed) | Medium |
| OAuth 2.0 | Third-party integrations, delegated access | Highest (scoped, revocable) | High |
| mTLS | Infrastructure, zero-trust internal APIs | Highest (cert-based) | High |
Step 4: Implement Rate Limiting
from fastapi import Request
from collections import defaultdict
import time
# Simple in-memory rate limiter
class RateLimiter:
def __init__(self, max_requests: int = 100, window_seconds: int = 60):
self.max_requests = max_requests
self.window = window_seconds
self.requests = defaultdict(list)
def is_allowed(self, client_id: str) -> tuple[bool, dict]:
now = time.time()
# Clean old entries
self.requests[client_id] = [
t for t in self.requests[client_id]
if now - t < self.window
]
remaining = self.max_requests - len(self.requests[client_id])
if remaining <= 0:
return False, {
"X-RateLimit-Limit": str(self.max_requests),
"X-RateLimit-Remaining": "0",
"X-RateLimit-Reset": str(int(self.requests[client_id][0] + self.window))
}
self.requests[client_id].append(now)
return True, {
"X-RateLimit-Limit": str(self.max_requests),
"X-RateLimit-Remaining": str(remaining - 1),
}
rate_limiter = RateLimiter(max_requests=100, window_seconds=60)
Rate Limit Tiers
| Tier | Limit | Window | Use Case |
|---|
| Free | 100 requests | Per minute | Public API, trial users |
| Pro | 1,000 requests | Per minute | Paid customers |
| Enterprise | 10,000 requests | Per minute | Dedicated customers |
| Internal | No limit | — | Internal services (but monitor) |
Step 5: Error Response Standards (RFC 7807)
{
"type": "https://api.company.com/errors/validation-error",
"title": "Validation Error",
"status": 422,
"detail": "The 'email' field must be a valid email address.",
"instance": "/v2/customers",
"errors": [
{
"field": "email",
"message": "Must be a valid email address",
"value": "not-an-email"
}
]
}
Standard HTTP Status Codes
| Code | Meaning | When to Use |
|---|
| 200 | OK | Successful GET, PUT, PATCH |
| 201 | Created | Successful POST (resource created) |
| 204 | No Content | Successful DELETE |
| 400 | Bad Request | Invalid JSON, malformed request |
| 401 | Unauthorized | Missing or invalid authentication |
| 403 | Forbidden | Authenticated but not authorized |
| 404 | Not Found | Resource doesn’t exist |
| 409 | Conflict | Duplicate resource, version conflict |
| 422 | Unprocessable Entity | Validation errors |
| 429 | Too Many Requests | Rate limited |
| 500 | Internal Server Error | Unexpected server failure |
Step 6: Contract Testing
# Schemathesis — automated API contract testing
# Install: pip install schemathesis
import schemathesis
schema = schemathesis.from_url(
"https://api-staging.company.com/openapi.yaml"
)
@schema.parametrize()
def test_api_contract(case):
"""
Automatically generates test cases from your OpenAPI spec.
Tests: valid inputs, edge cases, invalid inputs, auth requirements.
"""
response = case.call()
case.validate_response(response)
# CLI alternative
schemathesis run https://api-staging.company.com/openapi.yaml \
--checks all \
--base-url https://api-staging.company.com \
--header "Authorization: Bearer $TOKEN"
API Design Checklist
:::note[Source]
This guide is derived from operational intelligence at Garnet Grid Consulting. For API architecture reviews, visit garnetgrid.com.
:::