Bad APIs create support tickets. Good APIs create fans. Here’s how to design APIs that developers will actually enjoy using.

The Fundamentals

Use Nouns, Not Verbs

#PGP#PGDOEOOEEBSTSGSTLaTToTEdoTg/duEcedusrtese/eUleruasersstetseererUsUsss/ee1rr2/3123

The HTTP method IS the verb. The URL is the noun.

Plural Resource Names

#GGGG#GGEEEEEECTTTTNTToon////t//suuuuuuisssstssseeeeheetrrrrirresssss/sn///1/t11121222323333//oorrrdddeeerrrss//445566

Always plural. No exceptions. Consistency beats “correctness.”

Nest Resources Logically

#G#G#GGEEEEUTOTBTTsrue/dt/rueouo'srrdsrse'doedrsenreosr'srr/ist/-d1t/1ie2e4g2tr3m5o3es/s6/mo/tosrior/dtod7eee8rmdr9ssesevp4a5r6i/ainttesms/#78B9e/tvtaerriants#Toomuch

Two levels of nesting is usually the limit. Beyond that, use top-level resources.

HTTP Methods

Use them correctly:

MethodPurposeIdempotentSafe
GETRead resourceYesYes
POSTCreate resourceNoNo
PUTReplace resourceYesNo
PATCHPartial updateYesNo
DELETERemove resourceYesNo

Idempotent: Same request multiple times = same result. Safe: Doesn’t modify state.

PUT vs PATCH

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
# PUT: Replace entire resource
PUT /users/123
{
  "name": "Alice",
  "email": "alice@example.com",
  "role": "admin"
}

# PATCH: Update specific fields
PATCH /users/123
{
  "role": "admin"
}

PUT requires the full resource. PATCH only changes what you send.

Status Codes That Make Sense

Success (2xx)

222000014OCNKroeaCtoendtent---GPDEOETSL,TETPcEUrTes,autcPecAdeTeCadHerdse,usconcuoertechdeiendgtoreturn

Client Errors (4xx)

444444400000220134929BUFNCUTanooonodartnpoubfrRtiFloMehdoicaqoducenurentsyeindsszaRtebedlqeu-----e-sINVRStnoaetVsvlsaaaviotl-ladueiilrdRdicccadreostsenyeycddfnnreoltlteneiaiadtscxmxeint,i,na'ttlt(bevisdudaa,eutllxpisbilsdusiea(ttcmtsaaihntnoooetnutilecfdamaalaliblilleolyew,d"ewUderntoacnu.gt)henticated")

Server Errors (5xx)

555500000234IBSGnaeatdrteverGiwnacaateylewUTSanieyamrvevaoeiurlt-aEb-rUlrpeUosprt-srt-eTraeeSmmaopmmsoeerttraihvrmiiiencldgeyobfduraotoiwklneed

Don’t return 200 with {"error": "something broke"}. Use proper status codes.

Request & Response Design

Consistent Response Structure

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
// Success
{
  "data": {
    "id": "123",
    "name": "Alice",
    "email": "alice@example.com"
  }
}

// Error
{
  "error": {
    "code": "VALIDATION_ERROR",
    "message": "Email is required",
    "details": [
      {
        "field": "email",
        "message": "This field is required"
      }
    ]
  }
}

// Collection
{
  "data": [...],
  "pagination": {
    "total": 100,
    "page": 1,
    "per_page": 20,
    "next_cursor": "abc123"
  }
}

Same structure every time. Clients shouldn’t have to guess.

Use camelCase (Usually)

1
2
3
4
5
6
// JavaScript-friendly
{
  "userId": "123",
  "createdAt": "2024-03-12T10:00:00Z",
  "isActive": true
}

Match the convention of your primary consumers. JavaScript APIs use camelCase. Python APIs might use snake_case. Pick one and stick to it.

ISO 8601 for Dates

1
2
3
4
{
  "createdAt": "2024-03-12T10:30:00Z",
  "expiresAt": "2024-03-19T10:30:00Z"
}

Always UTC. Always ISO 8601. No ambiguity.

Pagination

Cursor-Based (Preferred)

1
GET /users?limit=20&cursor=eyJpZCI6MTIzfQ

Response:

1
2
3
4
5
6
7
{
  "data": [...],
  "pagination": {
    "next_cursor": "eyJpZCI6MTQzfQ",
    "has_more": true
  }
}

Cursors are:

  • Stable with real-time data
  • Efficient for large datasets
  • Opaque (implementation can change)

Offset-Based (Simpler)

1
GET /users?page=3&per_page=20

Response:

1
2
3
4
5
6
7
8
9
{
  "data": [...],
  "pagination": {
    "total": 156,
    "page": 3,
    "per_page": 20,
    "total_pages": 8
  }
}

Simpler for clients, but unstable if data changes between requests.

Filtering and Sorting

Filtering

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
# Simple equality
GET /users?role=admin

# Multiple values
GET /users?role=admin,moderator

# Operators
GET /orders?total_gte=100&total_lte=500
GET /users?created_after=2024-01-01

# Search
GET /users?q=alice

Sorting

1
2
3
4
5
6
7
8
# Single field
GET /users?sort=created_at

# Descending
GET /users?sort=-created_at

# Multiple fields
GET /users?sort=-created_at,name

The - prefix for descending is a common convention.

Field Selection

1
2
3
4
5
# Only return specific fields
GET /users?fields=id,name,email

# Nested
GET /users?fields=id,name,orders.total

Reduces payload size. Especially useful for mobile clients.

Versioning

GGEETTv12//uusseerrss

Simple, explicit, easy to route.

GAEcTce/puts:eraspplication/vnd.myapi.v2+json

Cleaner URLs, but harder to test and debug.

Query Parameter

GET/users?version=2

Works, but feels wrong.

My recommendation: URL path versioning. It’s the most pragmatic.

Authentication

Bearer Tokens

1
2
curl -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIs..." \
  https://api.example.com/users

Standard, works everywhere, easy to implement.

API Keys

1
2
3
4
5
6
# Header (preferred)
curl -H "X-API-Key: sk_live_abc123" \
  https://api.example.com/users

# Query param (avoid - appears in logs)
curl https://api.example.com/users?api_key=sk_live_abc123

Use headers for API keys. Query params leak into access logs.

Error Handling

Be Specific

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// Bad
{
  "error": "Invalid request"
}

// Good
{
  "error": {
    "code": "VALIDATION_ERROR",
    "message": "Validation failed",
    "details": [
      {
        "field": "email",
        "code": "INVALID_FORMAT",
        "message": "Email must be a valid email address"
      },
      {
        "field": "age",
        "code": "OUT_OF_RANGE",
        "message": "Age must be between 18 and 120"
      }
    ]
  }
}

Machine-readable codes for programmatic handling. Human-readable messages for debugging.

Document Error Codes

1
2
3
4
5
6
7
8
9
## Error Codes

| Code | HTTP Status | Description |
|------|-------------|-------------|
| VALIDATION_ERROR | 400 | Request body failed validation |
| AUTHENTICATION_REQUIRED | 401 | No valid credentials provided |
| PERMISSION_DENIED | 403 | Insufficient permissions |
| RESOURCE_NOT_FOUND | 404 | Requested resource doesn't exist |
| RATE_LIMIT_EXCEEDED | 429 | Too many requests |

Rate Limiting

Return Limit Headers

HXXXT---TRRRPaaa/ttt1eee.LLL1iiimmm2iii0ttt0---LRROieeKmmsiaetit:n:i1n10g70:100929494800

Return Retry-After on 429

HR{}TeTt"}Pre/yr""1-rcm.Aooe1frdst"es4e:"a2r:g9:{e""T6R:o0AoT"ETM_oaLonIyMmIaRTne_yqEuXreCesEqtEusDeEsDt"s,.Retryafter60seconds."

Documentation

OpenAPI/Swagger

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
openapi: 3.0.0
info:
  title: My API
  version: 1.0.0
paths:
  /users:
    get:
      summary: List users
      parameters:
        - name: role
          in: query
          schema:
            type: string
            enum: [admin, user, moderator]
      responses:
        '200':
          description: List of users
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/UserList'

Generate docs, SDKs, and mock servers from the same spec.

Examples Everywhere

1
2
3
4
5
6
7
8
# Show real examples, not just types
examples:
  createUser:
    summary: Create a new user
    value:
      name: "Alice Smith"
      email: "alice@example.com"
      role: "admin"

Developers copy-paste examples. Make them work.

The Checklist

Before shipping your API:

  • Consistent resource naming (plural nouns)
  • Proper HTTP methods and status codes
  • Consistent response structure
  • Clear error messages with codes
  • Pagination for collections
  • Rate limiting with headers
  • Authentication documented
  • OpenAPI spec available
  • Working examples for every endpoint

Start Here

  1. Today: Audit one endpoint for consistency
  2. This week: Add proper error responses
  3. This month: Generate OpenAPI spec
  4. This quarter: Build a developer portal

The best APIs are the ones developers figure out without reading the docs. Design for intuition.


A great API is invisible. Developers just use it and it works exactly how they expected.