API Design Best Practices: REST, GraphQL, gRPC, and Beyond
Master API design with REST principles, GraphQL trade-offs, gRPC for microservices, pagination strategies, rate limiting, and authentication patterns. Learn why Stripe's API is the gold standard.
What you'll learn
- ✓Compare REST, GraphQL, and gRPC and know when to use each
- ✓Apply RESTful design principles with proper resources, verbs, and status codes
- ✓Choose between pagination strategies: offset, cursor, and keyset
- ✓Implement rate limiting and throttling patterns
- ✓Understand API authentication: API keys, OAuth 2.0, and JWT
- ✓Learn from Stripe why consistent API design builds developer trust
Prerequisites
- •Basic understanding of HTTP methods and status codes
- •Familiarity with JSON data format
An API is a contract between your system and its consumers. A well-designed API is intuitive, consistent, and hard to misuse. A poorly designed one generates support tickets, causes integration bugs, and makes developers dread working with your platform. This article covers the principles that separate great APIs from frustrating ones.
REST vs GraphQL vs gRPC: Choosing Your Protocol
REST: The Lingua Franca
REST (Representational State Transfer) models your system as a collection of resources, each identified by a URL, manipulated through standard HTTP methods.
GET /api/users/123 → Retrieve user 123
POST /api/users → Create a new user
PUT /api/users/123 → Replace user 123 entirely
PATCH /api/users/123 → Update specific fields of user 123
DELETE /api/users/123 → Delete user 123
REST works well when your data model maps naturally to resources and your clients need different views of the same data. It is the default choice for public APIs because every programming language and tool speaks HTTP.
Strengths: Universal client support, cacheable (HTTP caching works natively), easy to debug with curl, well-understood by developers.
Weaknesses: Over-fetching (getting more data than you need) and under-fetching (needing multiple requests to assemble a view). No built-in schema or type system.
GraphQL: Ask for Exactly What You Need
GraphQL lets clients specify exactly which fields they want in a single request. Instead of the server defining fixed response shapes, the client sends a query describing its data requirements.
# Instead of GET /api/users/123 + GET /api/users/123/orders
# One request gets exactly what the mobile app needs:
query {
user(id: "123") {
name
email
orders(last: 5) {
id
total
status
}
}
}
Strengths: Eliminates over-fetching and under-fetching. Excellent for mobile clients where bandwidth matters. Self-documenting schema with built-in introspection. One endpoint for everything.
Weaknesses: Caching is harder (every request is a POST with a different body). Complex queries can overload the server (query depth attacks). Learning curve for backend teams. Monitoring and rate limiting are trickier because all requests hit the same endpoint.
gRPC: Speed for Service-to-Service
gRPC uses Protocol Buffers (protobuf) for schema definition and binary serialization, running on HTTP/2 for multiplexed streaming. It is designed for low-latency, high-throughput communication between services.
// user.proto — schema definition
service UserService {
rpc GetUser (GetUserRequest) returns (User);
rpc ListUsers (ListUsersRequest) returns (stream User);
}
message GetUserRequest {
string user_id = 1;
}
message User {
string id = 1;
string name = 2;
string email = 3;
}
Strengths: Binary serialization is much faster than JSON. Strong typing with code generation. Built-in streaming (unary, server-streaming, client-streaming, bidirectional). Excellent for polyglot microservices (generate client libraries in any language from the proto file).
Weaknesses: Not browser-friendly without a proxy (grpc-web). Binary format is not human-readable — harder to debug with curl. Requires proto file management across teams.
When to Use Which
| Scenario | Best Choice | Why |
|---|---|---|
| Public API for third-party developers | REST | Universal support, easy to learn |
| Mobile app with varied data needs | GraphQL | Minimize bandwidth, avoid multiple requests |
| Internal microservice communication | gRPC | Low latency, strong typing, streaming |
| Simple CRUD application | REST | Simplest to build and maintain |
| Real-time data subscriptions | GraphQL (subscriptions) or gRPC (streaming) | Built-in streaming support |
Many companies use all three: REST for public APIs, GraphQL for frontend-backend communication, and gRPC between backend services.
RESTful API Design Principles
Resources and URLs
URLs should represent resources (nouns), not actions (verbs). The HTTP method conveys the action.
Good:
GET /api/articles → List articles
POST /api/articles → Create article
GET /api/articles/42 → Get article 42
DELETE /api/articles/42 → Delete article 42
Bad:
GET /api/getArticles
POST /api/createArticle
POST /api/deleteArticle/42
For nested resources, keep URLs shallow. Deep nesting makes APIs rigid and hard to evolve.
Good: GET /api/articles/42/comments
OK: GET /api/comments?article_id=42
Bad: GET /api/users/5/articles/42/comments/7/replies
HTTP Status Codes That Matter
Use status codes consistently. Clients should be able to handle your API without reading the response body for common cases.
200 OK → Successful GET, PUT, PATCH
201 Created → Successful POST that created a resource
204 No Content → Successful DELETE
400 Bad Request → Client sent invalid data
401 Unauthorized → Missing or invalid authentication
403 Forbidden → Authenticated but not authorized
404 Not Found → Resource does not exist
409 Conflict → Resource conflict (duplicate email, etc.)
422 Unprocessable → Validation error (semantic, not syntactic)
429 Too Many Reqs → Rate limit exceeded
500 Internal Error → Server bug (never intentional)
503 Service Unavail → Temporary overload or maintenance
Consistent Error Responses
Every error response should follow the same structure so clients can parse errors programmatically.
{
"error": {
"type": "validation_error",
"message": "The email field is required.",
"code": "missing_required_field",
"param": "email",
"doc_url": "https://api.example.com/docs/errors#missing_required_field"
}
}
API Versioning Strategies
APIs evolve. Breaking changes are inevitable. Versioning gives clients time to migrate.
URL path versioning is the most common and most visible approach:
GET /api/v1/users/123
GET /api/v2/users/123
Header versioning keeps URLs clean but is less discoverable:
GET /api/users/123
Accept: application/vnd.example.v2+json
Query parameter versioning is simple but can clash with other parameters:
GET /api/users/123?version=2
The pragmatic choice for most teams is URL path versioning. It is explicit, easy to route at the load balancer level, and clients can clearly see which version they are using. Stripe uses date-based versioning (Stripe-Version: 2024-06-20) where each date represents a stable API snapshot — an elegant approach for APIs with frequent incremental changes.
Pagination: Offset, Cursor, and Keyset
Any endpoint that returns a list needs pagination. Returning thousands of records in one response wastes bandwidth and memory.
Offset Pagination
GET /api/articles?offset=20&limit=10
Simple to implement and understand. The client says “skip 20 rows, give me the next 10.” The problem: as offset grows, the database still has to scan and discard all skipped rows. Offset 10,000 with limit 10 means the database reads 10,010 rows and throws away 10,000.
Additionally, if new items are inserted while paginating, items can be skipped or duplicated.
Cursor-Based Pagination
GET /api/articles?cursor=eyJpZCI6NDJ9&limit=10
The cursor is an opaque token (typically a base64-encoded pointer) that tells the server where to continue from. The client does not need to understand the cursor — it just passes it back to get the next page.
{
"data": [...],
"pagination": {
"next_cursor": "eyJpZCI6NTJ9",
"has_more": true
}
}
Pros: Stable results even with concurrent inserts. Consistent performance regardless of page depth. Cons: Cannot jump to page 47 directly. Cursor tokens can expire.
Keyset Pagination
-- Instead of OFFSET, use WHERE clause on indexed columns
SELECT * FROM articles
WHERE id > 42
ORDER BY id ASC
LIMIT 10;
Keyset pagination is cursor-based pagination implemented with a direct WHERE clause on an indexed column. It is the fastest approach for large datasets because the database uses the index to jump directly to the right position.
For most APIs, cursor-based pagination is the best default. It handles concurrent modifications gracefully and performs well at any page depth.
Rate Limiting and Throttling
Rate limiting protects your API from abuse and ensures fair usage across clients. Common algorithms:
Token Bucket
Each client has a bucket that fills with tokens at a steady rate. Each request consumes one token. When the bucket is empty, requests are rejected until tokens refill.
class TokenBucket:
def __init__(self, capacity, refill_rate):
self.capacity = capacity
self.tokens = capacity
self.refill_rate = refill_rate # tokens per second
self.last_refill = time.time()
def allow_request(self):
self._refill()
if self.tokens >= 1:
self.tokens -= 1
return True
return False
def _refill(self):
now = time.time()
elapsed = now - self.last_refill
self.tokens = min(self.capacity,
self.tokens + elapsed * self.refill_rate)
self.last_refill = now
Rate Limit Headers
Always tell clients about their rate limit status in response headers:
X-RateLimit-Limit: 1000 → total requests allowed per window
X-RateLimit-Remaining: 847 → requests remaining in current window
X-RateLimit-Reset: 1625097600 → when the window resets (Unix timestamp)
Retry-After: 30 → seconds to wait (when rate limited)
When a client exceeds the limit, return 429 Too Many Requests with these headers. Well-behaved clients will back off automatically.
API Authentication Patterns
API Keys
The simplest approach: a long random string that identifies the client. Sent in a header with each request.
GET /api/users
Authorization: Bearer sk_live_abc123def456
Good for: Server-to-server communication, simple integrations. Limitations: No user-level scoping. If the key leaks, full access until revoked.
OAuth 2.0
OAuth 2.0 lets users grant third-party applications limited access to their data without sharing their password. It is the standard for “Login with Google” and similar flows.
1. User clicks "Connect with GitHub" on your app
2. Your app redirects to GitHub's authorization page
3. User approves the requested scopes
4. GitHub redirects back with an authorization code
5. Your server exchanges the code for an access token
6. Your server uses the access token to call GitHub's API
OAuth is complex but necessary when you need user-delegated access. Do not implement it from scratch — use a library.
JWT (JSON Web Tokens)
JWTs are self-contained tokens that encode claims (user ID, roles, expiration) in a signed JSON payload. The server can verify the token without a database lookup because the signature proves authenticity.
// JWT payload (decoded)
{
"sub": "user_123",
"name": "Alice Chen",
"role": "admin",
"exp": 1625097600,
"iss": "api.example.com"
}
Pros: Stateless verification (no database lookup). Can carry custom claims. Cons: Cannot be revoked until they expire (unless you maintain a blocklist, which defeats the stateless benefit). Token size is larger than opaque tokens.
Stripe’s API: The Gold Standard
Stripe’s API is widely considered the best-designed API in the industry. Here is why:
Consistency. Every resource follows the same patterns. If you know how to create a Customer, you know how to create a PaymentIntent. Same URL structure, same request/response format, same error handling.
Idempotency. Every mutating request accepts an Idempotency-Key header. If a network failure causes a retry, the second request returns the same result. This eliminates an entire class of bugs.
Expandable resources. Instead of always including nested objects (which wastes bandwidth) or never including them (which requires extra requests), Stripe lets you specify which nested resources to expand:
GET /v1/charges/ch_123?expand[]=customer&expand[]=invoice
Versioning with dates. Each API version is pinned to a date. When you create an account, it is pinned to the current version. You can upgrade at your own pace. Breaking changes only affect new API versions, never existing integrations.
Exceptional documentation. Every endpoint has code examples in multiple languages, explains edge cases, and provides test mode data. The documentation is as much a product as the API itself.
The meta-lesson from Stripe: API design is product design. The developers using your API are your users, and their experience matters as much as end-user experience.
Wrapping Up
Good API design is about empathy for the developer who will integrate with your system at 2 AM with a deadline. Choose REST for public APIs, GraphQL when clients need flexible data fetching, and gRPC for internal service communication. Use cursor-based pagination, implement rate limiting with clear headers, and pick an authentication scheme that matches your security requirements. Above all, be consistent — a predictable API is a usable API.
Related articles
- REST APIs REST vs GraphQL vs gRPC: API Styles Compared
Compare REST, GraphQL, and gRPC for API design. Understand tradeoffs in performance, flexibility, and developer experience to pick the right API style.
- System Design Idempotency Patterns: Keys, Deduplication, and Exactly-Once Semantics
Learn how to design idempotent APIs and achieve exactly-once semantics using idempotency keys, deduplication stores, and transactional outbox patterns.
- GraphQL GraphQL Authentication and Authorization
Implement secure auth in GraphQL using context-based authentication, custom directives, and field-level permission patterns.
- GraphQL GraphQL Error Handling Patterns
Implement robust error handling in GraphQL using error extensions, union-based result types, and structured error responses.