API Design: Lessons from Five Years of Building APIs

February 25, 2019

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:

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:

HTTP Status Codes

Use them correctly:

CodeMeaningWhen to Use
200OKSuccessful GET, PUT
201CreatedSuccessful POST
204No ContentSuccessful DELETE
400Bad RequestInvalid request body
401UnauthorizedNo/invalid authentication
403ForbiddenAuthenticated but not allowed
404Not FoundResource doesn’t exist
409ConflictConflicting modification
422Unprocessable EntitySemantic validation errors
429Too Many RequestsRate limited
500Internal Server ErrorServer-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)

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

Good APIs are an investment. The extra time spent on design pays dividends in reduced support burden and happier developers.