Real-World API Design Patterns and Best Practices
Building an API that works is one thing. Building one that developers love to use, that stays stable as requirements grow, and that holds up under real-world traffic — that is a different challenge. This topic covers the design patterns and practices that separate good APIs from great ones.
Design Your API for the Developer, Not the Database
The biggest mistake in API design is building endpoints that mirror the database tables instead of the actions a developer actually needs.
❌ Database-centric design: GET /user_account_records GET /product_inventory_rows POST /order_line_items_insert ✅ Developer-centric design: GET /users GET /products POST /orders
A developer using your API does not care how you store data. They care about what they can do with it. Design endpoints around actions and resources, not tables and columns.
The Resource-Oriented Design Pattern
Every REST API should be built around resources — the nouns in your system. Resources map naturally to the things your application manages.
System: Online Learning Platform
Resources and Their Endpoints:
┌─────────────────────────────────────────────────────┐
│ Resource Base URL Collection of │
├─────────────────────────────────────────────────────┤
│ Course /courses all courses │
│ Lesson /courses/{id}/ lessons in a course│
│ lessons │
│ Student /students all students │
│ Enrollment /enrollments who enrolled where │
│ Review /courses/{id}/ reviews for course │
│ reviews │
└─────────────────────────────────────────────────────┘
Pattern: nested resources show relationships
/courses/42/lessons ← lessons INSIDE course 42
/courses/42/lessons/7 ← lesson 7 INSIDE course 42
HATEOAS — Navigable APIs
HATEOAS stands for Hypermedia As The Engine Of Application State. It is a REST principle where each API response includes links telling the client what it can do next. The client does not need to hardcode URLs — it discovers them from the response.
Without HATEOAS — client must know all URLs in advance:
GET /orders/99
Response: { "id": 99, "status": "pending", "total": 500 }
Client hardcodes: "to cancel, call DELETE /orders/99"
────────────────────────────────────────────────────────
With HATEOAS — client follows links from response:
GET /orders/99
Response:
{
"id": 99,
"status": "pending",
"total": 500,
"_links": {
"self": { "href": "/orders/99" },
"cancel": { "href": "/orders/99/cancel", "method": "POST" },
"invoice": { "href": "/orders/99/invoice", "method": "GET" },
"customer":{ "href": "/customers/12" }
}
}
Client reads "_links" — no hardcoded URLs needed.
Change the URL structure → client adapts automatically.
Idempotency — Safe to Retry
An operation is idempotent if calling it multiple times produces the same result as calling it once. This property is critical in unreliable networks where clients retry failed requests.
Method Idempotent? Why ──────────────────────────────────────────────────────── GET ✅ Yes Reading never changes data PUT ✅ Yes Replace always sets same state DELETE ✅ Yes Deleting twice = same as once PATCH ⚠️ Depends Depends on the operation POST ❌ No (usual) Each call creates a new resource Danger scenario without idempotency: 1. Client sends POST /payments (charge $100) 2. Network drops — client never gets response 3. Client retries POST /payments 4. Customer charged $200 instead of $100 Fix: Idempotency Keys Client sends a unique key with the request: POST /payments Idempotency-Key: "pay-req-abc-12345" Server stores the key. If it sees the same key again: "I already processed this — here is the original result." No double charge.
Pagination Patterns
An endpoint that returns a list must never return all records at once. Even a modest database can have millions of rows. Always paginate.
Offset Pagination
Request:
GET /products?limit=20&offset=0 ← first 20
GET /products?limit=20&offset=20 ← next 20
GET /products?limit=20&offset=40 ← next 20
Response:
{
"data": [ ...20 products... ],
"pagination": {
"total": 1240,
"limit": 20,
"offset": 0,
"next": "/products?limit=20&offset=20",
"prev": null
}
}
Problem with offset: slow on large datasets.
Database must count and skip 1 million rows to get offset=1000000.
Cursor Pagination
Request:
GET /products?limit=20 ← first page
GET /products?limit=20&after=cursor_xyz123 ← next page
Response:
{
"data": [ ...20 products... ],
"pagination": {
"has_next": true,
"next_cursor": "cursor_abc456",
"has_prev": false
}
}
Advantage: cursor points to an exact database position.
No counting, no skipping. Fast even at row 50,000,000.
Used by: Twitter, Facebook, Instagram, Stripe.
Page Number Pagination
Request:
GET /products?page=1&per_page=20
GET /products?page=5&per_page=20
Response:
{
"data": [ ...20 products... ],
"page": 5,
"per_page": 20,
"total_pages": 62,
"total_items": 1240
}
Best for: admin dashboards, small datasets.
Worst for: real-time feeds (new items shift page numbers).
Filtering, Sorting, and Searching
Give developers control over what data they get back. Hard-coded responses serve no one.
Filtering:
GET /products?category=electronics&in_stock=true&price_max=5000
Sorting:
GET /products?sort=price&order=asc
GET /products?sort=-created_at ← minus = descending
Field Selection (Sparse Fieldsets):
GET /products?fields=id,name,price ← only 3 fields returned
→ smaller response, faster
Search:
GET /products?q=wireless+keyboard
Combined:
GET /products
?category=electronics
&in_stock=true
&sort=-rating
&limit=10
&fields=id,name,price,rating
Returns: top 10 in-stock electronics sorted by rating,
only 4 fields each.
API Versioning Strategies
Your API will change. Clients that built against version 1 must not break when you release version 2. Versioning gives clients time to migrate.
URL Path Versioning
https://api.store.com/v1/products ← version 1 clients use this
https://api.store.com/v2/products ← version 2 clients use this
Advantages: Obvious, easy to test, works in browser
Disadvantage: "Not pure REST" — URL should identify resource,
not API version
Header Versioning
GET /products Accept: application/vnd.store.v2+json Advantages: Cleaner URLs Disadvantage: Harder to test (cannot paste URL in browser)
Query Parameter Versioning
GET /products?api_version=2 Advantages: Easy to switch while testing Disadvantage: Easy to forget, cache complications
Versioning Timeline
Timeline for deprecating v1:
Jan 2024 v2 released
Announcement sent to all v1 developers
│
Mar 2024 Warning header added to all v1 responses:
Deprecation: true
Sunset: Sat, 01 Jun 2024 00:00:00 GMT
│
Jun 2024 v1 returns 410 Gone
Migration guide still available in docs
Rate Limiting Patterns
Every public API needs rate limiting. Without it, one misbehaving client can consume all your server resources and starve everyone else.
Rate Limit Headers
Response headers your API should send: HTTP/1.1 200 OK X-RateLimit-Limit: 1000 ← max requests per hour X-RateLimit-Remaining: 743 ← requests left this window X-RateLimit-Reset: 1717200000 ← Unix timestamp of reset When limit is exceeded: HTTP/1.1 429 Too Many Requests Retry-After: 3600 ← seconds until reset
Rate Limit Strategies
Strategy 1: Fixed Window │ Window 1 hour │ Window 2 hour │ │ 1000 max │ 1000 max │ Problem: burst at end of window + start of next = 2000 requests. Strategy 2: Sliding Window Always counts the last 60 minutes regardless of clock hour. No burst problem. Strategy 3: Token Bucket Each client has a bucket of tokens (1000). Each request uses 1 token. Tokens refill at 16 per minute (960/hour, allowing short bursts). Client can burst up to 1000 in a moment if bucket is full. Good for: clients with uneven traffic patterns. Strategy 4: Tiered Limits Free tier: 100 req/hour Pro tier: 5,000 req/hour Enterprise: 100,000 req/hour
Caching Strategy
Caching reduces server load and speeds up responses. Design your API to work with HTTP caching from the start.
HTTP Caching Headers: Cache-Control: public, max-age=3600 → Cache this response for 1 hour, anyone can cache it Cache-Control: private, max-age=300 → Cache for 5 min, only the client (not CDN) caches it Cache-Control: no-store → Never cache (sensitive data, real-time prices) ETag: "abc123" → Fingerprint of the response content Last-Modified: Tue, 04 Jun 2024 12:00:00 GMT → When was this resource last updated?
Conditional Requests — Saving Bandwidth
First Request:
GET /products/55
Response: 200 OK, body: {...}, ETag: "v7abc"
Second Request (client asks: "changed since v7abc?"):
GET /products/55
If-None-Match: "v7abc"
If product unchanged:
Response: 304 Not Modified ← no body sent, saves bandwidth
If product changed:
Response: 200 OK, new body, ETag: "v8xyz"
Bulk Operations Pattern
When clients need to create, update, or delete many records, individual requests become expensive. Bulk endpoints solve this.
Without bulk endpoint:
Client needs to create 500 products
→ 500 separate POST /products requests
→ 500 round trips over the network
→ 500 × 50ms = 25 seconds minimum
With bulk endpoint:
POST /products/bulk
Body: { "items": [ ...500 products... ] }
→ 1 request
→ 1 round trip
→ 50ms total
Response:
{
"created": 498,
"failed": 2,
"errors": [
{ "index": 102, "error": "Duplicate SKU" },
{ "index": 387, "error": "Missing required field: price" }
]
}
Asynchronous Operations Pattern
Some operations take too long to complete within a single HTTP request. Use asynchronous processing for these.
Synchronous (bad for slow operations):
POST /reports/generate
Client waits 90 seconds...
→ Connection times out
→ Client has no idea if report is being made
────────────────────────────────────────────────────────
Asynchronous (correct approach):
Step 1 — Client submits job:
POST /reports/generate
Body: { "type": "annual", "year": 2023 }
Immediate response:
202 Accepted
{
"job_id": "job_abc123",
"status": "queued",
"status_url": "/jobs/job_abc123"
}
Step 2 — Client polls for progress:
GET /jobs/job_abc123
Response: { "status": "processing", "progress": 45 }
Step 3 — Job completes:
GET /jobs/job_abc123
Response: {
"status": "complete",
"result_url": "/reports/report_789.pdf"
}
Better than polling: webhooks (server notifies client when done).
Webhook Pattern
A webhook flips the direction of communication. Instead of your client repeatedly asking "done yet?", your server calls the client's URL when something happens.
Polling (inefficient):
Client asks every 5 seconds for 10 minutes = 120 requests
Server: "no... no... no... no... yes!"
Webhook (efficient):
Client registers: "call THIS URL when payment succeeds"
POST /webhooks
{ "url": "https://myapp.com/hooks/payment", "event": "payment.success" }
Server processes payment (takes 8 minutes)
Server calls client URL immediately on success = 1 request
POST https://myapp.com/hooks/payment
{ "event": "payment.success", "order_id": 99, "amount": 500 }
Client savings: 119 fewer requests
Securing Webhooks
Problem: Anyone can POST to your webhook URL and fake events. Solution: Webhook Signature Verification 1. Server generates a shared secret when webhook is created 2. Every webhook request includes a signature header: X-Webhook-Signature: sha256=abc123... 3. Client verifies on receipt: expected = HMAC-SHA256(secret, request_body) if expected != header_signature → reject (not from our server) This ensures only your server can send valid webhook events.
API Gateway Pattern
As your system grows, different services handle different parts of the API. An API Gateway sits in front of everything and acts as the single entry point.
Without Gateway:
Client ──→ User Service (port 3001)
Client ──→ Order Service (port 3002)
Client ──→ Product Service(port 3003)
Client must know 3 URLs, 3 auth systems, 3 rate limits
With API Gateway:
Client ──→ api.store.com (Gateway)
│
┌───────────┼──────────────┐
▼ ▼ ▼
User Service Order Service Product Service
Gateway handles for ALL services:
✓ Authentication
✓ Rate limiting
✓ SSL termination
✓ Request logging
✓ Response caching
✓ Load balancing
Client sees ONE URL, ONE auth system, ONE rate limit.
Consistent Error Response Format
Pick one error format and use it across every endpoint in your API. Inconsistent errors confuse developers and break error-handling code.
Consistent error format used everywhere:
{
"error": {
"code": "VALIDATION_FAILED",
"message": "Request data is invalid",
"details": [
{
"field": "email",
"issue": "Invalid email format"
},
{
"field": "age",
"issue": "Must be 18 or older"
}
],
"request_id": "req_abc123" ← for support tracing
}
}
The "code" field uses machine-readable strings
The "message" field is human-readable
The "details" array pinpoints each problem field
The "request_id" lets your support team find the exact log
Field Naming Conventions
Pick a naming convention and never mix them. Inconsistent field names are a constant source of developer frustration.
Convention Example Common in
──────────────────────────────────────────────────
snake_case user_name, created_at Python, Ruby APIs
camelCase userName, createdAt JavaScript APIs
PascalCase UserName, CreatedAt .NET APIs
kebab-case user-name, created-at HTML, some REST
Bad (mixed in same API):
{
"userName": "alice", ← camelCase
"email_address": "a@b.com",← snake_case
"PhoneNumber": "555-0100" ← PascalCase
}
Good (consistent snake_case throughout):
{
"user_name": "alice",
"email_address": "a@b.com",
"phone_number": "555-0100"
}
Date and Time Format
Always use ISO 8601 format for dates and times. Always use UTC. Ambiguous date formats cause data corruption across time zones.
❌ Ambiguous formats — avoid these: "06/07/2024" ← June 7 or July 6? Depends on country "Jun 7 2024 3pm" ← which time zone? 1717747200 ← Unix timestamp, not human-readable ✅ ISO 8601 UTC — always use this: "2024-06-07T15:00:00Z" ← Z means UTC "2024-06-07T15:00:00+05:30" ← with explicit offset Date only: "2024-06-07" Time only: "15:00:00"
Designing for Backward Compatibility
Changes that break existing clients are called breaking changes. Avoid them. Extend your API instead of changing it.
❌ BREAKING changes (break existing clients):
- Remove a field from a response
- Rename a field ("user_name" → "username")
- Change a field's type (string → integer)
- Remove an endpoint
- Change required fields
✅ NON-BREAKING changes (safe to ship anytime):
- Add a new optional field to a response
- Add a new optional request parameter
- Add a new endpoint
- Add a new valid value to an enum
- Relax a validation rule (allow longer strings)
Rule: add, never remove or rename.
When you must remove, do it in a new major version.
Security Best Practices
Security Layer What to Implement
──────────────────────────────────────────────────────────
Transport HTTPS only. Reject HTTP. Use TLS 1.2+.
Authentication Short-lived JWTs (15–60 min expiry).
Refresh tokens stored securely.
Authorization Check permissions on EVERY request.
Never trust client-side role claims.
Input Validation Validate type, length, format, range.
Whitelist allowed characters.
Output Filtering Never return fields the user cannot see.
Check field-level permissions.
Rate Limiting By IP and by authenticated user.
Separate limits for auth endpoints.
Error Messages Never reveal stack traces.
Never reveal which part of auth failed.
(Not "wrong password" — say "invalid credentials")
Audit Logging Log every write operation with:
who, what, when, from which IP.
API Design Review Checklist
Before publishing your API, verify each item: Resources and URLs ☐ Nouns in URLs, not verbs ☐ Consistent plural form (/users, not /user) ☐ Nested resources max 2 levels deep HTTP Semantics ☐ GET never modifies data ☐ POST for create, PUT/PATCH for update ☐ DELETE is idempotent (second call = 404 or 204) Response Codes ☐ 201 for successful POST (not 200) ☐ 204 for DELETE (no body) ☐ 400 for bad input, 422 for validation failures ☐ 404 for missing resource, 403 for no permission Data Format ☐ ISO 8601 UTC for all dates ☐ Consistent field naming convention throughout ☐ Error response is same format everywhere Performance ☐ All list endpoints paginated ☐ Filtering, sorting, field selection supported ☐ Cache-Control headers on GET responses Security ☐ HTTPS enforced ☐ Auth checked on every endpoint ☐ No sensitive data in URLs (no tokens in query strings) Documentation ☐ OpenAPI file is complete and current ☐ Every error code documented ☐ Examples in every schema
Real-World Examples of Good API Design
Stripe API — Industry Standard for Payments
What Stripe does well:
┌────────────────────────────────────────────────────────┐
│ Consistent resource naming: │
│ /customers, /charges, /subscriptions, /invoices │
│ │
│ Idempotency keys on every POST: │
│ Idempotency-Key: your-unique-key-here │
│ │
│ Expand related objects in one request: │
│ GET /charges/ch_abc?expand[]=customer │
│ Returns charge AND customer — no second request │
│ │
│ Consistent error codes: │
│ { "error": { "type": "card_error", │
│ "code": "card_declined", │
│ "message": "Your card was declined." } } │
│ │
│ Webhooks for every event: │
│ payment_intent.succeeded │
│ customer.subscription.deleted │
└────────────────────────────────────────────────────────┘
GitHub API — Resource-Oriented Done Right
Natural resource hierarchy:
/users/{username}
/users/{username}/repos
/repos/{owner}/{repo}
/repos/{owner}/{repo}/issues
/repos/{owner}/{repo}/issues/{number}/comments
HATEOAS in action:
GET /repos/torvalds/linux
Response includes:
"issues_url": "/repos/torvalds/linux/issues{/number}"
"commits_url": "/repos/torvalds/linux/commits{/sha}"
"labels_url": "/repos/torvalds/linux/labels{/name}"
No need to guess URLs — the API tells you.
Key Points
- Design endpoints around the actions developers need, not the structure of your database tables.
- Use HATEOAS links in responses so clients discover related URLs instead of hardcoding them.
- Make write operations idempotent and support idempotency keys for POST requests in critical flows.
- Always paginate list endpoints. Use cursor pagination for large or real-time datasets.
- Use asynchronous patterns with job status URLs or webhooks for any operation that takes more than a few seconds.
- An API Gateway centralizes authentication, rate limiting, and logging so individual services stay focused on business logic.
- Extend your API with new optional fields — never remove or rename fields without a major version change.
- Pick one error format, one date format, and one naming convention, then apply them everywhere without exception.
