A well-designed REST API is predictable, consistent, and easy to consume. This guide covers the conventions and patterns that separate production-grade APIs from ad-hoc endpoints — including URI design, versioning, pagination, error handling, and authentication strategies. These practices are drawn from real-world API standards at Stripe, GitHub, and Google Cloud.
How Should You Structure Resource URIs?
URIs should identify resources (nouns), not actions (verbs). Use plural nouns for collections and nest resources only when there is a true parent-child relationship. Keep nesting shallow — two levels maximum.
| Pattern | Example | Notes |
|---|---|---|
| Collection | /users | Plural noun, lowercase |
| Single resource | /users/42 | Identifier in the path |
| Nested resource | /users/42/orders | Max 2 levels of nesting |
| Filtering | /orders?status=pending | Query params for filters |
| Avoid | /getUser, /createOrder | Verbs belong in HTTP methods |
GET /articles → List articles (collection)
GET /articles/7 → Get article 7 (single resource)
POST /articles → Create a new article
PUT /articles/7 → Replace article 7 entirely
PATCH /articles/7 → Partially update article 7
DELETE /articles/7 → Delete article 7What Are the Best API Versioning Strategies?
Version your API from day one. The three common approaches are URI path versioning, header versioning, and query parameter versioning. URI path versioning is the most widely used because it is visible and easy to route.
# URI path (most common — used by Stripe, GitHub)
GET /v1/users/42
GET /v2/users/42
# Custom header (used by GitHub as alternative)
GET /users/42
Accept: application/vnd.myapi.v2+json
# Query parameter
GET /users/42?version=2Tip: Only increment the major version when you introduce breaking changes (removed fields, renamed endpoints, changed response shapes). Additive changes (new fields, new endpoints) are backward-compatible and do not require a new version.
Cursor vs Offset Pagination — Which Should You Use?
Offset-based pagination is simpler but breaks when data changes between requests. Cursor-based pagination is stable and performs better on large datasets because the database can use an index seek instead of counting rows.
// Request
// GET /articles?page=3&per_page=20
// Response
{
"data": [ /* 20 articles */ ],
"meta": {
"page": 3,
"per_page": 20,
"total": 542,
"total_pages": 28
}
}
// Problem: if a new article is inserted while the client
// is paginating, items shift and duplicates appear.// Request
// GET /articles?limit=20&after=eyJpZCI6MTAwfQ
// Response
{
"data": [ /* 20 articles */ ],
"meta": {
"has_next": true,
"next_cursor": "eyJpZCI6MTIwfQ",
"has_previous": true,
"previous_cursor": "eyJpZCI6MTAxfQ"
}
}
// Cursor is an opaque, base64-encoded token (e.g. the last seen ID).
// Stable even when new rows are inserted or deleted.How Should You Handle Errors Consistently?
Return a consistent error envelope with a machine-readable code, human-readable message, and optional field-level details. Always use appropriate HTTP status codes — do not return 200 for errors.
// 422 Unprocessable Entity
{
"error": {
"code": "validation_error",
"message": "Request body failed validation",
"details": [
{ "field": "email", "message": "must be a valid email address" },
{ "field": "age", "message": "must be at least 18" }
]
}
}
// 404 Not Found
{
"error": {
"code": "not_found",
"message": "User with ID 999 does not exist"
}
}
// 429 Too Many Requests
{
"error": {
"code": "rate_limited",
"message": "Rate limit exceeded. Retry after 30 seconds.",
"retry_after": 30
}
}Which HTTP Status Codes Matter Most?
| Code | Meaning | When to Use |
|---|---|---|
| 200 | OK | Successful GET, PUT, PATCH |
| 201 | Created | Successful POST that creates a resource |
| 204 | No Content | Successful DELETE with no response body |
| 400 | Bad Request | Malformed JSON, missing required params |
| 401 | Unauthorized | Missing or invalid authentication |
| 403 | Forbidden | Authenticated but lacks permission |
| 404 | Not Found | Resource does not exist |
| 422 | Unprocessable Entity | Valid JSON but failed business validation |
| 429 | Too Many Requests | Rate limit exceeded |
| 500 | Internal Server Error | Unhandled server exception |
How Should You Handle Authentication?
Use Bearer tokens (JWT or opaque) for stateless auth and API keys for server-to-server communication. Always transmit credentials over HTTPS. Include rate limiting and token expiration.
# Bearer token (JWT)
GET /v1/users/me
Authorization: Bearer eyJhbGciOiJSUzI1NiIs...
# API key (server-to-server)
GET /v1/data/export
X-API-Key: sk_live_abc123def456
# OAuth 2.0 token exchange
POST /oauth/token
Content-Type: application/x-www-form-urlencoded
grant_type=authorization_code&code=AUTH_CODE&redirect_uri=...What Is HATEOAS and Should You Use It?
HATEOAS (Hypermedia As The Engine Of Application State) embeds navigational links in responses so clients can discover related actions without hardcoding URLs. It is part of the REST maturity model (Level 3) but adds complexity — adopt it when your API has complex workflows or many interlinked resources.
// GET /v1/orders/42
{
"id": 42,
"status": "shipped",
"total": 99.99,
"_links": {
"self": { "href": "/v1/orders/42" },
"cancel": { "href": "/v1/orders/42/cancel", "method": "POST" },
"customer": { "href": "/v1/users/7" },
"items": { "href": "/v1/orders/42/items" },
"invoice": { "href": "/v1/orders/42/invoice", "type": "application/pdf" }
}
}Key Takeaways
- • Use plural nouns for URIs and let HTTP methods express the action
- • Version your API from day one — URI path versioning is the most common approach
- • Prefer cursor-based pagination for large, frequently-changing datasets
- • Return consistent error envelopes with machine-readable codes and field-level details
- • Use appropriate HTTP status codes — never return 200 for errors
- • Authenticate with Bearer tokens or API keys over HTTPS
- • Consider HATEOAS for APIs with complex resource relationships