The API Design Survival Guide: How to Build APIs People Actually Want to Use


We’ve all been there: staring at documentation for an API so poorly designed that you start wondering if it was created specifically to torture developers. Inconsistent naming. Unclear error messages. Authentication that feels like solving a Rubik’s cube blindfolded.

The sad truth? Most APIs suck. But yours doesn’t have to.

Let’s look at how to design APIs that developers actually want to use—instead of ones they’re forced to use while muttering curses under their breath.

Why Most APIs Are Terrible

Before we get to the solutions, let’s diagnose the common problems:

  1. Designed for computers, not humans: Optimized for the system architecture instead of developer experience
  2. Inconsistent patterns: Different endpoints follow different conventions
  3. Poor error handling: Cryptic messages like “Error 57” with no context
  4. Overdone complexity: Simple operations require multiple calls
  5. Terrible documentation: Missing examples, outdated info, or no explanation of the “why”

Let’s fix all of these.

REST API Design Principles That Matter

1. Noun-Based Resource URLs

URLs should identify resources (nouns), not actions (verbs):

# BAD ❌
GET /api/getUsers
POST /api/createUser
DELETE /api/deleteUser/123

# GOOD ✅
GET /api/users
POST /api/users
DELETE /api/users/123

Let the HTTP methods do their job. That’s literally why they exist.

2. Consistent Naming Conventions

Pick a convention and stick with it ruthlessly:

# Inconsistent mess ❌
GET /api/users
GET /api/tickets
GET /api/get-system-settings
GET /api/CompanyProfiles

# Consistent goodness ✅
GET /api/users
GET /api/tickets
GET /api/settings
GET /api/companies

Choose snake_case, camelCase, or kebab-case—but be consistent in URLs, parameters, and response fields.

3. Use HTTP Status Codes Correctly

Status codes exist for a reason. Use them properly:

# Common useful status codes:
200 OK - Success
201 Created - Resource successfully created
204 No Content - Success but nothing to return (e.g., after DELETE)
400 Bad Request - Client error (invalid parameters)
401 Unauthorized - Authentication required
403 Forbidden - Authentication succeeded but user lacks permission
404 Not Found - Resource doesn't exist
409 Conflict - Request conflicts with current state (e.g., duplicate)
422 Unprocessable Entity - Validation errors
429 Too Many Requests - Rate limit exceeded
500 Internal Server Error - Something broke on the server

But don’t return 200 OK with an error message in the body. That’s just cruel.

4. Provide Meaningful Error Messages

Error responses should help developers fix the problem:

// BAD ❌
{
  "error": "Invalid request"
}

// GOOD ✅
{
  "error": {
    "code": "VALIDATION_ERROR",
    "message": "Invalid request parameters",
    "details": [
      {
        "field": "email",
        "message": "Must be a valid email address"
      },
      {
        "field": "age",
        "message": "Must be a positive number"
      }
    ]
  }
}

Include what went wrong, why, and how to fix it.

5. Version Your API

APIs evolve. Don’t break existing clients:

# Options for versioning:
/api/v1/users         # URL path (most explicit)
/api/users?v=1        # Query parameter
Accept: application/vnd.myapp.v1+json  # Custom media type (header)

URL path versioning is the most developer-friendly approach, even if purists argue otherwise.

GraphQL API Design Principles

If you’re going the GraphQL route, different principles apply:

1. Design Around the Product, Not the Database

Your schema should reflect what clients need, not your data storage:

# BAD: Exposing database structure ❌
type User {
  user_id: ID!
  first_name: String
  last_name: String
  password_hash: String  # Security nightmare
  user_account_type_id: Int
}

# GOOD: Designed for clients ✅
type User {
  id: ID!
  name: String!
  accountType: AccountType!
  profileImage: String
}

enum AccountType {
  BASIC
  PRO
  ENTERPRISE
}

2. Use Custom Scalars for Special Types

Don’t just use String for everything:

# Add at the top of your schema
scalar DateTime
scalar EmailAddress
scalar URL

type User {
  id: ID!
  email: EmailAddress!
  website: URL
  createdAt: DateTime!
}

This helps with validation and makes your schema self-documenting.

3. Pagination Done Right

The connection pattern handles pagination elegantly:

type Query {
  users(first: Int, after: String): UserConnection!
}

type UserConnection {
  edges: [UserEdge!]!
  pageInfo: PageInfo!
}

type UserEdge {
  node: User!
  cursor: String!
}

type PageInfo {
  hasNextPage: Boolean!
  endCursor: String
}

This approach supports cursor-based pagination, which is more reliable than simple offset/limit.

4. Think in Graphs

GraphQL shines when you leverage its graph nature:

# Example of nested relationships
type User {
  id: ID!
  name: String!
  posts(first: Int = 10): [Post!]!
  followers(first: Int = 10): [User!]!
  following(first: Int = 10): [User!]!
}

type Post {
  id: ID!
  title: String!
  author: User!
  comments(first: Int = 10): [Comment!]!
  likes(first: Int = 10): [User!]!
}

This lets clients fetch exactly what they need in a single request.

Universal API Design Principles

Whether REST, GraphQL, or something else, these principles always apply:

1. Design for Developer Experience

Every API decision should consider the developer’s experience:

# Developer-hostile ❌
GET /api/entities?type=4&status=A&paging_current=1&paging_size=20

# Developer-friendly ✅
GET /api/products?status=active&page=1&limit=20

If you can’t explain your endpoint’s purpose in a simple sentence, rethink it.

2. Be Consistent with Response Formats

Don’t make developers guess your response structure:

// Inconsistent formats ❌
// GET /api/users/123
{
  "id": 123,
  "name": "Jane Doe",
  "isActive": true
}

// GET /api/products/456
{
  "product": {
    "product_id": 456,
    "product_name": "Widget",
    "active": 1
  }
}

// Consistent format ✅
// GET /api/users/123
{
  "data": {
    "id": 123,
    "name": "Jane Doe",
    "isActive": true
  }
}

// GET /api/products/456
{
  "data": {
    "id": 456,
    "name": "Widget",
    "isActive": true
  }
}

Consistency lets developers build reliable clients.

3. Rate Limiting That Doesn’t Suck

Rate limits are necessary, but be transparent about them:

# Include rate limit headers with every response
X-RateLimit-Limit: 5000
X-RateLimit-Remaining: 4985
X-RateLimit-Reset: 1612778567

And when a limit is reached, explain clearly and provide guidance:

{
  "error": {
    "code": "RATE_LIMIT_EXCEEDED",
    "message": "You've exceeded the rate limit of 5000 requests per hour",
    "details": {
      "resetAt": "2025-05-19T15:45:10Z",
      "upgradeUrl": "https://example.com/pricing"
    }
  }
}

4. Authentication That Makes Sense

Don’t reinvent security. Use established standards:

# OAuth 2.0 for authorization
# API Keys for simple authentication
# JWT for stateless sessions

# And please support Bearer token authentication
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...

And document the authentication flow with clear examples.

5. The HATEOAS Principle (When It Makes Sense)

Help API consumers discover what they can do next:

// A REST response with HATEOAS links
{
  "data": {
    "id": 123,
    "status": "processing"
  },
  "links": {
    "self": "/api/orders/123",
    "cancel": "/api/orders/123/cancel",
    "customer": "/api/customers/456"
  }
}

This approach lets APIs evolve more gracefully.

Real-World API Patterns to Steal

1. The Search Pattern

Searching often requires complex parameters. Use a POST endpoint for advanced searches:

# Simple search via GET
GET /api/products?search=keyboard&category=electronics

# Advanced search via POST
POST /api/products/search
{
  "query": "mechanical keyboard",
  "filters": {
    "category": "electronics",
    "inStock": true,
    "priceRange": {
      "min": 50,
      "max": 200
    }
  },
  "sort": {
    "field": "rating",
    "order": "desc"
  }
}

2. The Bulk Operations Pattern

Allow developers to perform bulk operations efficiently:

# Bulk create
POST /api/contacts/batch
{
  "operations": [
    { "first_name": "John", "last_name": "Doe", "email": "john@example.com" },
    { "first_name": "Jane", "last_name": "Smith", "email": "jane@example.com" }
  ]
}

# Response includes individual results
{
  "results": [
    { "id": 123, "status": "created" },
    { "error": "Email already exists", "index": 1 }
  ],
  "summary": {
    "total": 2,
    "successful": 1,
    "failed": 1
  }
}

This prevents clients from having to make multiple API calls.

3. The Partial Update Pattern

Support updating specific fields without requiring the entire resource:

# PATCH allows updating just specific fields
PATCH /api/users/123
{
  "email": "newemail@example.com"
}

# Contrast with PUT which replaces the entire resource
PUT /api/users/123
{
  "name": "Jane Doe",
  "email": "newemail@example.com",
  "role": "admin",
  "settings": {
    // Must include ALL fields
  }
}

4. The Expansion Pattern

Let clients request related resources in a single call:

# Without expansion
GET /api/orders/123
{
  "id": 123,
  "customer_id": 456,
  "items": [789, 790]
}

# With expansion
GET /api/orders/123?expand=customer,items
{
  "id": 123,
  "customer": {
    "id": 456,
    "name": "Jane Doe",
    "email": "jane@example.com"
  },
  "items": [
    {
      "id": 789,
      "name": "Keyboard",
      "price": 59.99
    },
    {
      "id": 790,
      "name": "Mouse",
      "price": 24.99
    }
  ]
}

This significantly reduces the number of API calls needed.

Documentation That Doesn’t Make Developers Cry

Great API design is wasted without great documentation:

1. Interactive Documentation

Use tools like Swagger/OpenAPI, Redoc, or GraphiQL to create interactive documentation:

# OpenAPI example
paths:
  /users:
    get:
      summary: List all users
      parameters:
        - name: limit
          in: query
          schema:
            type: integer
            default: 20
          description: Maximum number of users to return
      responses:
        '200':
          description: A list of users
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/User'

This creates documentation that lets developers try your API right from the browser.

2. Real-World Examples

Show complete request/response examples for every endpoint:

### Create a user

POST /api/users

**Request:**
```json
{
  "name": "Jane Smith",
  "email": "jane@example.com",
  "role": "editor"
}

Response: (201 Created)

{
  "data": {
    "id": "usr_123abc",
    "name": "Jane Smith",
    "email": "jane@example.com",
    "role": "editor",
    "createdAt": "2025-05-19T12:00:00Z"
  }
}

### 3. Error Catalog

Document all possible error codes:

```markdown
## Error Codes

| Code | Description | Resolution |
|------|-------------|------------|
| VALIDATION_ERROR | Request parameters failed validation | Fix the specified fields according to requirements |
| AUTHENTICATION_REQUIRED | No valid authentication provided | Include a valid API key or token |
| RESOURCE_NOT_FOUND | The requested resource doesn't exist | Check the ID or create the resource first |
| RATE_LIMIT_EXCEEDED | Too many requests | Retry after the specified time or upgrade your plan |

4. Changelogs

Keep a detailed changelog for API versions:

## API Changelog

### v2.1 (2025-05-15)
- Added `sort` parameter to `/api/products`
- Deprecated `customer_email` field (use `email` instead)
- Fixed validation for phone numbers

### v2.0 (2025-03-01)
- Breaking change: Authentication now requires OAuth 2.0
- Added user management endpoints
- Removed legacy XML support

Conclusion

Designing great APIs isn’t just about following RESTful principles or using the latest technology. It’s about empathy for the developers who will use your API.

Remember:

  • Consistency is more important than perfect adherence to theory
  • Good documentation is as important as good design
  • Think about the developer experience at every step
  • Be predictable—surprises are rarely welcome in APIs

By following these principles, you’ll create APIs that developers actually want to use—not ones they’re forced to use while frantically searching Stack Overflow for workarounds.

And the next time someone calls your API “a joy to work with,” you’ll know you’ve done your job well.