API design quality compounds. Good APIs reduce support tickets, speed up integrations, and survive years of evolution. The discipline is mostly boring conventions applied consistently. This post is the working playbook.

Resource naming

GET    /users
POST   /users
GET    /users/{id}
PATCH  /users/{id}
DELETE /users/{id}
GET    /users/{id}/posts
POST   /users/{id}/posts
  • Plural for collections.
  • IDs in path, not body.
  • Sub-resources nested.
  • HTTP verbs match action.

Avoid /getUserList etc. — REST means the verb is HTTP, not in the path.

Error responses

HTTP/1.1 400 Bad Request
Content-Type: application/json

{
  "error": {
    "type": "validation_error",
    "message": "email is invalid",
    "details": [
      {"field": "email", "message": "must be a valid email"}
    ],
    "request_id": "req_abc123",
    "doc_url": "https://docs.example.com/errors/validation"
  }
}
  • HTTP status reflects category.
  • Body has machine-readable type, human message, details, request id.
  • Doc link helps integrators.

Don’t:

  • Return 200 with {"error": "..."} — clients can’t tell.
  • Different shapes per endpoint.

Pagination — cursor

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

{
  "data": [...],
  "next_cursor": "eyJpZCI6MTQzfQ==",
  "has_more": true
}

Cursor encodes state (“after id 123”). Stable under inserts. Server-controlled.

async def list_users(cursor=None, limit=20):
    after_id = decode_cursor(cursor) if cursor else 0
    rows = await db.fetch(
        "SELECT id, email FROM users WHERE id > $1 ORDER BY id LIMIT $2",
        after_id, limit + 1
    )
    has_more = len(rows) > limit
    rows = rows[:limit]
    next_cursor = encode_cursor(rows[-1]["id"]) if has_more else None
    return {"data": rows, "next_cursor": next_cursor, "has_more": has_more}

Pagination — keyset (alternative)

GET /users?limit=20&after_id=143

Simpler. Exposes id type but that’s rarely a problem. Common alternative to opaque cursors.

Idempotency keys

POST /payments
Idempotency-Key: client-generated-uuid-here

Server stores key + result. Same key replayed → same response, no duplicate side effect.

async def charge(idempotency_key, ...):
    if existing := await get_by_key(idempotency_key):
        return existing
    result = await stripe.create(...)
    await store(idempotency_key, result)
    return result

Stripe-style. Universal pattern for safe retries. See Idempotency .

Partial responses (sparse fieldsets)

GET /users?fields=id,email

For mobile / bandwidth-sensitive clients. Server returns only requested fields.

{
  "data": [
    {"id": 1, "email": "..."}
  ]
}

GraphQL gives this for free. REST: implement sparingly.

Filtering

GET /users?status=active&created_after=2026-01-01

Query params for simple filters. For complex: introduce a search endpoint:

GET /users/search?q=...

Don’t try to express SQL in URLs.

Sorting

GET /users?sort=created_at&order=desc

Or comma-separated for multi-field:

GET /users?sort=-created_at,email

- prefix for desc. Pick one; stick with it.

Versioning

URL or header. URL is more common for public APIs:

/v1/users
/v2/users

See API Versioning .

Date formats

ISO 8601 always: 2026-05-05T07:30:00Z or 2026-05-05T13:00:00+05:30.

Never:

  • Unix timestamps (less self-describing).
  • US-style 05/05/2026 (ambiguous).
  • No timezone.

Naming conventions

{
  "user_id": 1,           // snake_case OR
  "userId": 1,            // camelCase (pick one!)
  "created_at": "...",    // not createdAt
  "is_active": true       // not isActive
}

Snake_case is common in REST APIs (matches DB conventions). Camel matches JS conventions. Either fine; pick one and stay consistent.

Empty responses

HTTP/1.1 204 No Content

For DELETE, etc. that don’t need a body.

HTTP/1.1 200 OK
{ "data": [] }

For GET with no results — empty array, not null.

Counts

GET /users?limit=20

{
  "data": [...],
  "next_cursor": "...",
  "has_more": true,
  "total_count": 1543   // optional; expensive to compute on big datasets
}

Expose totals only when cheap. Don’t COUNT(*) on every request.

Bulk operations

POST /users/bulk_create
{ "users": [...] }

# Response
{
  "created": [...],
  "errors": [
    {"index": 3, "error": "..."}
  ]
}

Per-item success/error. Don’t fail the whole batch on one error (usually).

Webhooks

If your API sends webhooks: signed payloads, retry semantics, replay protection. See Webhook Design .

SDK considerations

Design with SDK ergonomics in mind:

  • Predictable response shapes: SDK can type them.
  • Pagination iterators: SDK can hide cursor mechanics.
  • Error classes: SDK can map status codes.
  • Idempotency built in: SDK can auto-generate keys.

If you’ll publish SDKs: design API → design SDK → adjust API based on SDK awkwardness.

Documentation

  • OpenAPI / Swagger spec auto-generated from code.
  • Examples for every endpoint (curl + at least one SDK language).
  • Authentication guide with concrete steps.
  • Errors reference.
  • Pagination guide.
  • Rate limits guide.

Generate where possible (FastAPI, NestJS, etc. ship OpenAPI generators). Augment with hand-written guides.

Common mistakes

1. POST for everything

Including reads. Breaks browser navigation, caching, GET semantics. Use GET for reads.

2. Inconsistent error shapes

Each endpoint different. Clients can’t share parsing logic.

3. Returning 500 for client errors

User sent bad input → 500 → looks like server bug. Use 400.

4. No pagination

Endpoint returns all rows. Works at 100; OOMs at 100k.

5. Hidden side effects in GET

GET that increments counter / triggers email. GETs are supposed to be safe + idempotent.

What I’d ship today

For a new public API:

  • REST conventions with consistent verbs / nouns.
  • Cursor pagination universally.
  • Standardized error envelope.
  • Idempotency keys for unsafe operations.
  • OpenAPI spec generated.
  • Per-endpoint rate limits with headers.
  • Versioning in URL.
  • Examples + SDK in primary docs.

Read this next

If you want my API design checklist, it’s at rajpoot.dev .


Building something AI-, backend-, or data-heavy and want a second pair of eyes? I do consulting and freelance work — see my projects and ways to reach me at rajpoot.dev .