Skip to content

API Design Cheat Sheet

Design decisions and tradeoffs for building APIs that survive contact with real clients, real networks, and real scale.

CriteriaRESTGraphQLgRPC
Best forPublic APIs, CRUD servicesClient-driven data fetchingInternal microservices
StrengthsSimple, cacheable, toolingFlexible queries, no over-fetchBinary, typed, streaming
WeaknessesOver-fetching, many round tripsCaching hard, query complexityBrowser support poor, steep learning
TransportHTTP/1.1+HTTP/1.1+HTTP/2
SchemaOpenAPI (optional)SDL (required)Protobuf (required)
CachingHTTP caching works nativelyRequires application-levelNo HTTP caching
Error modelHTTP status codes200 with errors arrayStatus codes + details
Is the client a browser or mobile app with varied data needs?
├── Yes → Do clients need to compose queries across many entities?
│ ├── Yes → GraphQL
│ └── No → REST
└── No → Is this service-to-service on a fast network?
├── Yes → Do you need streaming or high throughput?
│ ├── Yes → gRPC
│ └── No → REST or gRPC (team preference)
└── No → REST (widest compatibility)

Heuristic: REST is the safe default. Choose GraphQL when you have many client types with different data needs. Choose gRPC when you control both ends and need speed.

Resources are things. The HTTP method is the verb.

GET /users # list users
POST /users # create user
GET /users/42 # get one user
PUT /users/42 # replace user
PATCH /users/42 # partial update
DELETE /users/42 # delete user

Wrong: /getUser, /createUser, /deleteUser/42

Nest only one level deep to express direct ownership:

GET /users/42/orders # orders belonging to user 42
GET /orders/99 # order by its own ID (not /users/42/orders/99)

Heuristic: If the child resource has its own identity, give it a top-level endpoint. Deep nesting (/a/1/b/2/c/3) couples clients to your data model and makes URLs brittle.

UsePath parameterQuery parameter
Identity/users/42Never — identity goes in path
FilteringNo/users?role=admin
SortingNo/users?sort=-created_at
PaginationNo/users?page=2&limit=20
RequiredUsually (identifies resource)Usually optional
StrategyHow It WorksStrengthsWeaknesses
Offset/Limit?offset=40&limit=20Simple, random page accessSlow at large offsets, unstable on inserts
Cursor?cursor=eyJpZCI6NDJ9Stable across inserts, fastOpaque, no random page access
Keyset?after_id=42&limit=20Fast (index scan), transparentRequires sortable column, no random access

Offset works until it does not. At 1M rows, OFFSET 999980 scans and discards nearly a million rows.

Cursor-based pagination encodes the position opaquely (often base64). The server decodes it to a keyset query internally. Clients cannot jump to page N but can always get the next page efficiently.

Keyset is the transparent version of cursor. Clients send the last seen value: WHERE id > 42 ORDER BY id LIMIT 20. Fast at any depth because the database uses the index directly.

Heuristic: Use offset for admin UIs with small datasets. Use cursor or keyset for anything user-facing or large.

A standard format so every error looks the same:

{
"type": "https://api.example.com/errors/insufficient-funds",
"title": "Insufficient Funds",
"status": 422,
"detail": "Account 12345 has $10.00; transfer requires $50.00.",
"instance": "/transfers/abc-123"
}
FieldPurposeRequired
typeURI identifying the error classYes
titleHuman-readable summaryYes
statusHTTP status code (redundant, useful)No
detailSpecific explanation for this caseNo
instanceURI for this specific occurrenceNo
ApproachProsCons
String enumsSelf-documenting, greppableLonger payloads
Numeric codesCompact, familiar (SQL, HTTP)Requires lookup table

Heuristic: String enums (INSUFFICIENT_FUNDS) for public APIs — clients should not need a lookup table. Numeric codes only when bandwidth matters or convention demands it.

StrategyExampleStrengthsWeaknesses
URL path/v1/usersObvious, easy to routeProliferates base URLs
Query parameter/users?version=1Keeps URL cleanEasy to forget, caching confusion
HeaderAccept: application/vnd.api.v1+jsonCleanest URLHidden, harder to test in browser

Heuristic: URL versioning for public APIs — visibility and simplicity win. Header versioning for internal APIs where teams control clients. Query parameters are the worst of both worlds.

The deeper truth: Versioning is failure management. The best strategy is to evolve the schema without breaking changes and version only when you must.

AlgorithmHow It WorksBest For
Token bucketBucket fills at steady rate, requests drain itAllowing bursts within a rate
Sliding windowCount requests in a moving time windowStrict, even distribution
Fixed windowCount resets at interval boundariesSimple, but allows edge bursts
X-RateLimit-Limit: 100 # max requests per window
X-RateLimit-Remaining: 42 # requests left in current window
X-RateLimit-Reset: 1625097600 # UTC epoch when window resets
Retry-After: 30 # seconds to wait (on 429 response)
LevelKeyUse Case
Per-userAPI key or auth tokenDefault for most APIs
Per-endpointUser + endpointProtect expensive operations
GlobalNoneEmergency circuit breaker
TieredSubscription planFree vs paid differentiation

Networks fail. Clients retry. Without idempotency, retries create duplicates — double charges, duplicate orders, repeated notifications.

MethodIdempotentSafeNotes
GETYesYesRead-only by definition
HEADYesYesLike GET but no body
PUTYesNoFull replacement — same input, same state
DELETEYesNoDeleting twice yields same result
POSTNoNoEach call may create a new resource
PATCHNoNoDepends on patch semantics

Make non-idempotent operations safe by requiring a client-generated key:

POST /payments
Idempotency-Key: a1b2c3d4-e5f6-7890-abcd-ef1234567890
{...payment body...}

The server stores the key with the response. On retry, it returns the stored response instead of re-executing. Keys should expire (24-48 hours is common).

PatternComplexitySecurityBest For
API keysLowLow-mediumServer-to-server, internal tools
OAuth 2.0HighHighThird-party access, user delegation
JWTMediumMediumStateless auth, microservices

API keys are simple but blunt — they identify the application, not the user. Rotate them regularly. Never embed them in client-side code.

OAuth 2.0 solves delegation (“let this app read my data”) with scoped tokens and refresh flows. Complex to implement, but the right choice when third parties need access.

JWT encodes claims into the token itself. The server verifies the signature without a database lookup. Tradeoff: you cannot revoke a JWT before expiry without maintaining a blacklist, which defeats the statelessness.

Heuristic: API keys for internal/server calls. OAuth for third-party integrations. JWT for stateless microservice auth with short expiry times.

Change TypeBackward CompatibleForward Compatible
Add optional fieldYesYes
Add required fieldNoYes
Remove fieldYesNo
Rename fieldNoNo
Change field typeNoNo
Widen enum (add value)YesNo
Narrow enum (remove)NoYes

Backward compatible means old clients work with the new API. Forward compatible means new clients work with the old API.

  1. Additive changes are safe. Add fields, add endpoints, add enum values.
  2. Removal and rename break clients. Deprecate first, remove later.
  3. Robustness principle: Be liberal in what you accept, conservative in what you send. Accept unknown fields silently; never add unexpected fields to responses without versioning.
  4. Deprecation window: Announce the change, give clients a migration period (weeks to months depending on audience), then remove.
Anti-PatternWhy It FailsBetter Approach
Verbs in URLsDuplicates HTTP methods, inconsistent namingUse HTTP methods; resources are nouns
Nested URLs 3+ levels deepCouples clients to data model, fragile pathsFlatten; give child resources own endpoints
200 OK with error bodyBreaks HTTP semantics, tools can’t detect failuresUse proper status codes
Exposing database IDs as integersSequential IDs leak count, enable enumerationUse UUIDs or opaque identifiers
Version in every URL from day onePremature complexity, multiple code pathsDesign for evolution; version only when forced
God endpoint (one RPC does all)Untyped blob, impossible to document or cacheSeparate endpoints per operation
Inconsistent naminguser_name here, userName therePick one convention, enforce it everywhere
No pagination on list endpointsWorks in dev, OOMs in productionAlways paginate; default limit with max cap
Breaking changes without warningClients fail silently or loudly with no recourseDeprecation headers, changelogs, sunset dates
Auth tokens in query stringsLogged in server logs, browser history, refererUse Authorization header