APIs are contracts. Once published and adopted, they’re difficult to change. The decisions you make early become constraints you live with for years. After building and evolving several public APIs, here’s what I’ve learned about getting it right.
Design Philosophy
Design for the Consumer
APIs exist to serve clients, not to expose internal structure:
// Bad - exposes database schema
{
"usr_id": 12345,
"usr_nm": "Alice",
"crt_ts": 1551052800,
"lst_lgn_ts": 1551052800
}
// Good - designed for consumers
{
"id": "usr_12345",
"name": "Alice",
"createdAt": "2019-02-25T00:00:00Z",
"lastLoginAt": "2019-02-25T00:00:00Z"
}
Think about what clients need, not what your database contains.
Consistency Over Cleverness
Boring, predictable APIs are good APIs:
# Consistent patterns
GET /users # List users
POST /users # Create user
GET /users/{id} # Get user
PUT /users/{id} # Update user
DELETE /users/{id} # Delete user
# Same pattern for all resources
GET /orders
POST /orders
GET /orders/{id}
...
Developers learn the pattern once, then apply it everywhere.
Plan for Evolution
APIs change. Design for it:
- Version from day one
- Use forwards-compatible formats
- Document deprecation policies
- Build monitoring for usage patterns
Naming Conventions
Resources Are Nouns
# Good - nouns
/users
/orders
/products
# Bad - verbs
/getUsers
/createOrder
/fetchProducts
HTTP methods provide the verbs: GET, POST, PUT, DELETE.
Plural Resource Names
# Good - consistent plurals
/users
/users/123
/users/123/orders
# Bad - inconsistent
/user
/user/123
/users/123/order
Hierarchical Relationships
# User's orders
GET /users/123/orders
# Specific order
GET /orders/456 # If orders are independent
GET /users/123/orders/456 # If orders belong to users
Choose based on whether the sub-resource makes sense standalone.
Query Parameters for Filtering
# Filtering
GET /orders?status=pending
GET /orders?createdAfter=2019-01-01
GET /orders?userId=123&status=shipped
# Sorting
GET /orders?sort=createdAt&order=desc
# Pagination
GET /orders?page=2&limit=20
Response Design
Envelope Pattern
Wrap responses consistently:
{
"data": {
"id": "usr_123",
"name": "Alice"
},
"meta": {
"requestId": "req_abc123"
}
}
// Collections
{
"data": [
{"id": "usr_123", "name": "Alice"},
{"id": "usr_456", "name": "Bob"}
],
"meta": {
"page": 1,
"limit": 20,
"total": 45
}
}
Consistent Error Responses
{
"error": {
"code": "validation_error",
"message": "Request validation failed",
"details": [
{
"field": "email",
"code": "invalid_format",
"message": "Email must be a valid email address"
}
]
},
"meta": {
"requestId": "req_abc123"
}
}
Always include:
- Machine-readable error code
- Human-readable message
- Request ID for debugging
- Field-level details when applicable
HTTP Status Codes
Use them correctly:
| Code | Meaning | When to Use |
|---|---|---|
| 200 | OK | Successful GET, PUT |
| 201 | Created | Successful POST |
| 204 | No Content | Successful DELETE |
| 400 | Bad Request | Invalid request body |
| 401 | Unauthorized | No/invalid authentication |
| 403 | Forbidden | Authenticated but not allowed |
| 404 | Not Found | Resource doesn’t exist |
| 409 | Conflict | Conflicting modification |
| 422 | Unprocessable Entity | Semantic validation errors |
| 429 | Too Many Requests | Rate limited |
| 500 | Internal Server Error | Server-side error |
Timestamps in ISO 8601
// Good - ISO 8601
{
"createdAt": "2019-02-25T14:30:00Z"
}
// Bad - Unix timestamp
{
"created_at": 1551105000
}
// Bad - custom format
{
"createdAt": "02/25/2019 2:30 PM"
}
Always UTC. Always consistent.
Versioning
URL Path Versioning
GET /v1/users
GET /v2/users
Pros: Explicit, easy to understand, cache-friendly Cons: URL changes, can’t partially upgrade
Header Versioning
GET /users
Accept: application/vnd.api+json; version=2
Pros: Clean URLs, partial upgrades possible Cons: Harder to test, less visible
Recommendation: Use URL versioning. It’s explicit and simple.
Version Strategy
v1: Original release
v2: Breaking changes
v2.1: Non-breaking additions (no URL change)
- New fields: Add to existing version
- Removed fields: New major version
- Changed field types: New major version
- Changed behavior: Usually new major version
Pagination
Offset Pagination
GET /orders?page=3&limit=20
{
"data": [...],
"meta": {
"page": 3,
"limit": 20,
"total": 145,
"totalPages": 8
}
}
Pros: Simple, allows jumping to any page Cons: Inconsistent with changing data, slow on large offsets
Cursor Pagination
GET /orders?limit=20&after=cursor_abc123
{
"data": [...],
"meta": {
"hasMore": true,
"cursor": "cursor_def456"
},
"links": {
"next": "/orders?limit=20&after=cursor_def456"
}
}
Pros: Consistent with changing data, efficient Cons: Can’t jump to arbitrary pages
Recommendation: Cursor pagination for feeds and large datasets.
Rate Limiting
Headers
HTTP/1.1 200 OK
X-RateLimit-Limit: 1000
X-RateLimit-Remaining: 998
X-RateLimit-Reset: 1551109200
Rate Limit Response
HTTP/1.1 429 Too Many Requests
Retry-After: 60
{
"error": {
"code": "rate_limit_exceeded",
"message": "Rate limit exceeded. Try again in 60 seconds.",
"retryAfter": 60
}
}
Document limits clearly. Provide ways to request higher limits.
Authentication and Authorization
Use Standard Mechanisms
# Bearer token (OAuth 2.0)
Authorization: Bearer eyJhbGciOiJIUzI1NiIs...
# API key (simpler scenarios)
X-API-Key: sk_live_abc123
# Or query parameter for webhooks
GET /webhook?api_key=sk_live_abc123
Scope-Based Authorization
// Token with limited scopes
{
"sub": "user_123",
"scopes": ["read:orders", "write:orders"]
}
# Endpoint requires specific scope
POST /orders
# Requires: write:orders scope
Documentation
Include Examples
## Create Order
POST /orders
### Request
```json
{
"items": [
{"productId": "prod_123", "quantity": 2}
],
"shippingAddress": {
"line1": "123 Main St",
"city": "San Francisco",
"state": "CA",
"postalCode": "94102"
}
}
Response (201 Created)
{
"data": {
"id": "ord_456",
"status": "pending",
"total": 49.98,
...
}
}
### Document Error Cases
```markdown
### Errors
| Code | Description |
|------|-------------|
| `insufficient_inventory` | Not enough inventory for requested quantity |
| `invalid_address` | Shipping address could not be validated |
| `payment_required` | No valid payment method on account |
Testing APIs
Contract Tests
Verify API contracts are maintained:
def test_user_response_contract():
response = client.get('/users/123')
assert response.status_code == 200
data = response.json()['data']
assert 'id' in data
assert 'name' in data
assert 'email' in data
# Check types
assert isinstance(data['id'], str)
Backward Compatibility Tests
Run old clients against new API:
# v1 client should still work against v2 API
def test_v1_client_compatibility():
old_client = ClientV1()
result = old_client.get_user('123')
assert result.name == 'Alice'
Key Takeaways
- Design for consumers, not your internal structure
- Be boringly consistent in naming and patterns
- Plan for versioning from day one (use URL versioning)
- Use cursor pagination for large or changing datasets
- Include rate limit headers and document limits
- Error responses should be machine-readable with request IDs
- Document with real examples and error cases
- Test API contracts and backward compatibility
Good APIs are an investment. The extra time spent on design pays dividends in reduced support burden and happier developers.