REST API Design Best Practices: Build Developer-Friendly APIs
Design REST APIs developers love using resource-based URLs, consistent error handling, versioning, pagination, and OpenAPI documentation for scalable integrations.

TL;DR
- Use resource-based URLs (
/users/123, not/getUser?id=123); HTTP verbs express actions. - Return structured JSON with status codes; errors include
error.code,error.message,error.details. - Version APIs via URL (
/v1/users) or header (Accept: application/vnd.api+json; version=1).
Jump to URL design · Jump to Request/response format · Jump to Error handling · Jump to Versioning
# REST API Design Best Practices: Build Developer-Friendly APIs
Your API is your product's interface to the world -poor design creates friction, support burden, and limits adoption. These REST API design best practices create intuitive, scalable APIs developers actually enjoy using.
Key takeaways - Resource-based URLs + HTTP verbs = self-documenting API surface. - Consistent error responses (codes, messages, details) reduce integration time 50%. - OpenAPI documentation enables auto-generated SDKs and interactive testing.
URL design principles
Use nouns for resources, verbs for actions
Pattern: /resources/{id}/subresources/{id}
Good URLs:
GET /users # List users
GET /users/123 # Get user 123
POST /users # Create user
PUT /users/123 # Update user 123
DELETE /users/123 # Delete user 123
GET /users/123/posts # Get posts by user 123Bad URLs:
GET /getUser?id=123 # Verb in URL (use GET /users/123)
POST /users/delete # DELETE method exists
GET /user-posts?userId=123 # Use /users/123/postsPlural vs singular
Recommendation: Always plural (/users, /posts), even for singleton resources.
Why: Consistency. Exception: Singleton resources like /me (current user), /status (health check).
Query parameters for filtering/sorting
GET /users?status=active&sort=created_at:desc&limit=50&offset=100Standard params:
- Filtering:
?status=active&role=admin - Sorting:
?sort=created_at:desc,name:asc - Pagination:
?limit=50&offset=100or?cursor=abc123 - Field selection:
?fields=id,name,email(reduce payload)
<figure>
<svg role="img" aria-label="REST API URL structure" viewBox="0 0 720 160" xmlns="http://www.w3.org/2000/svg">
<rect width="720" height="160" fill="#0f172a" />
<text x="30" y="40" fill="#10b981" font-size="18">REST API URL Structure</text>
<rect x="60" y="70" width="140" height="60" rx="8" fill="#22d3ee" />
<text x="80" y="105" fill="#0f172a" font-size="12">/users (collection)</text>
<rect x="230" y="70" width="140" height="60" rx="8" fill="#a855f7" />
<text x="250" y="105" fill="#fff" font-size="12">/users/123 (item)</text>
<rect x="400" y="70" width="180" height="60" rx="8" fill="#10b981" />
<text x="420" y="105" fill="#0f172a" font-size="11">/users/123/posts (nested)</text>
</svg>
<figcaption>Resource-based URLs: collections, individual items, nested subresources.</figcaption>
</figure>
"The developer experience improvements we've seen from AI tools are the most significant since IDEs and version control. This is a permanent shift in how software gets built." - Emily Freeman, VP of Developer Relations at AWS
Request/response format
Standard JSON structure
Success response (200 OK):
{
"data": {
"id": "usr_123",
"name": "Alice Chen",
"email": "alice@example.com",
"created_at": "2025-04-25T10:30:00Z"
}
}Collection response (200 OK):
{
"data": [
{ "id": "usr_123", "name": "Alice Chen" },
{ "id": "usr_456", "name": "Bob Smith" }
],
"meta": {
"total": 1247,
"limit": 50,
"offset": 100
},
"links": {
"next": "/users?limit=50&offset=150",
"prev": "/users?limit=50&offset=50"
}
}Why wrap in `data`: Allows adding meta, links, included fields without breaking clients.
HTTP status codes
| Code | Meaning | Use case |
|---|---|---|
| 200 OK | Success | GET, PUT, PATCH succeeded |
| 201 Created | Resource created | POST succeeded |
| 204 No Content | Success, no body | DELETE succeeded |
| 400 Bad Request | Client error | Validation failed, malformed JSON |
| 401 Unauthorized | Auth failed | Missing/invalid API key |
| 403 Forbidden | Insufficient permissions | User lacks access to resource |
| 404 Not Found | Resource doesn't exist | GET /users/999 (no user 999) |
| 429 Too Many Requests | Rate limit exceeded | Client hit 100 req/min limit |
| 500 Internal Server Error | Server error | Unhandled exception |
Rule: 2xx = success, 4xx = client error, 5xx = server error.
Error handling framework
Consistent error response
{
"error": {
"code": "VALIDATION_ERROR",
"message": "Email address is invalid",
"details": [
{
"field": "email",
"issue": "Must be valid email format"
}
],
"request_id": "req_abc123"
}
}Fields:
- `code`: Machine-readable (e.g.,
RATE_LIMIT_EXCEEDED,RESOURCE_NOT_FOUND). - `message`: Human-readable description.
- `details`: Field-level validation errors (for 400 responses).
- `request_id`: Trace logs for debugging.
Common error codes
| Code | HTTP Status | Example message |
|---|---|---|
INVALID_REQUEST | 400 | "Missing required field: name" |
UNAUTHORIZED | 401 | "API key invalid or expired" |
FORBIDDEN | 403 | "Insufficient permissions to delete user" |
NOT_FOUND | 404 | "User usr_123 not found" |
RATE_LIMIT_EXCEEDED | 429 | "Rate limit: 100 requests/minute exceeded" |
INTERNAL_ERROR | 500 | "An unexpected error occurred" |
Example (Stripe-style):
{
"error": {
"type": "invalid_request_error",
"code": "parameter_invalid_integer",
"message": "Invalid integer: abc",
"param": "limit"
}
}For integration best practices, see /blog/zapier-vs-make-vs-n8n-ai-ops.
API versioning strategy
Option 1: URL versioning
GET /v1/users/123
GET /v2/users/123 # Breaking change: response schema differsPros: Explicit, easy to route, clear deprecation path.
Cons: Version in every URL; harder to evolve incrementally.
When to use: Major versions (v1, v2); breaking changes (field removed, renamed).
Option 2: Header versioning
GET /users/123
Accept: application/vnd.myapi.v1+jsonPros: Clean URLs, version decoupled from resource.
Cons: Less discoverable; clients must set headers.
When to use: SaaS platforms with long-lived API contracts.
Option 3: No versioning (additive changes only)
Strategy: Never break compatibility; only add fields, endpoints, query params.
Example:
- ✅ Add new field
phone_number(clients ignore unknown fields). - ❌ Rename
email→email_address(breaking).
When to use: Internal APIs, rapid iteration pre-GA.
Recommendation: Start with URL versioning (/v1); transition to header-based for mature APIs.
<figure>
<svg role="img" aria-label="API versioning lifecycle" viewBox="0 0 680 180" xmlns="http://www.w3.org/2000/svg">
<rect width="680" height="180" fill="#0f172a" />
<text x="30" y="40" fill="#34d399" font-size="18">API Versioning Lifecycle</text>
<rect x="60" y="80" width="120" height="70" rx="12" fill="#22d3ee" />
<text x="80" y="120" fill="#0f172a" font-size="12">v1 (stable)</text>
<rect x="210" y="80" width="120" height="70" rx="12" fill="#a855f7" />
<text x="230" y="120" fill="#fff" font-size="12">v2 (current)</text>
<rect x="360" y="80" width="120" height="70" rx="12" fill="#10b981" />
<text x="380" y="120" fill="#0f172a" font-size="12">v3 (beta)</text>
<rect x="510" y="80" width="120" height="70" rx="12" fill="#e11d48" opacity="0.5" />
<text x="530" y="120" fill="#fff" font-size="11">v1 (deprecated)</text>
</svg>
<figcaption>Maintain 2 versions simultaneously; deprecate old versions with 12-month notice.</figcaption>
</figure>
Pagination & rate limiting
Pagination strategies
Offset-based:
GET /users?limit=50&offset=100- Pros: Simple, stateless.
- Cons: Slow for large offsets; inconsistent if data changes during pagination.
Cursor-based:
GET /users?limit=50&cursor=usr_123_encoded- Pros: Consistent results, performant for large datasets.
- Cons: Can't jump to arbitrary page.
Recommendation: Offset for small datasets (<10K records), cursor for large or real-time data.
Rate limiting headers
HTTP/1.1 200 OK
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 73
X-RateLimit-Reset: 1714041600When limit exceeded:
HTTP/1.1 429 Too Many Requests
Retry-After: 60
{
"error": {
"code": "RATE_LIMIT_EXCEEDED",
"message": "Rate limit: 100 requests/minute exceeded. Retry after 60 seconds."
}
}Tiers:
- Free: 100 req/min
- Pro: 1,000 req/min
- Enterprise: Custom limits
Documentation with OpenAPI
Why OpenAPI (Swagger):
- Auto-generate SDKs (TypeScript, Python, Go).
- Interactive API explorer (Swagger UI, Redocly).
- Validation: Test requests match schema.
Example OpenAPI spec:
openapi: 3.0.0
info:
title: Users API
version: 1.0.0
paths:
/v1/users:
get:
summary: List users
parameters:
- name: limit
in: query
schema:
type: integer
default: 50
responses:
'200':
description: Success
content:
application/json:
schema:
type: object
properties:
data:
type: array
items:
$ref: '#/components/schemas/User'
components:
schemas:
User:
type: object
properties:
id:
type: string
name:
type: string
email:
type: stringTools: Stoplight Studio (visual editor), Redocly (docs hosting), Postman (import OpenAPI).
Call-to-action (API design) Audit existing API endpoints against these principles; refactor top 3 inconsistencies before adding new endpoints.
FAQs
REST vs GraphQL -which to choose?
REST: Simpler, cacheable, better for public APIs, CRUD operations.
GraphQL: Flexible queries, reduces over-fetching, better for complex UIs with varied data needs.
Recommendation: Start with REST; add GraphQL if frontend has 10+ bespoke queries.
Should you use HATEOAS (hypermedia links)?
HATEOAS example:
{
"data": {
"id": "usr_123",
"name": "Alice"
},
"links": {
"self": "/users/123",
"posts": "/users/123/posts"
}
}Pros: Discoverability, decouples clients from URL construction.
Cons: Verbose, rarely implemented fully.
Recommendation: Include links for pagination (next, prev); skip for every resource (overkill).
How to handle file uploads?
Multipart form-data:
POST /users/123/avatar
Content-Type: multipart/form-data
--boundary
Content-Disposition: form-data; name="file"; filename="avatar.jpg"
...Alternative: Direct upload to S3, return URL to API:
- GET
/uploads/presigned-url→{url, fields} - POST to S3 with file
- POST
/users/123/avatarwith{url: "s3://..."}
What about webhooks?
Design: POST to customer-configured URL with event payload.
Best practices:
- Include
event.type(user.created,payment.succeeded). - Retry failed webhooks (exponential backoff: 1s, 5s, 25s, 2m, 10m).
- Sign payloads (HMAC) for verification.
- Support webhook logs (customer can debug delivery).
Summary and next steps
Design REST APIs with resource-based URLs, consistent JSON responses, structured errors, versioning, and OpenAPI documentation for excellent developer experience.
Next steps
- Define URL schema for your core resources (users, posts, etc.).
- Standardise error response format and document common error codes.
- Generate OpenAPI spec and publish interactive docs (Swagger UI, Redocly).
Internal links
- /blog/typescript-vs-python-startup-stack
- /blog/database-postgres-vs-mongodb-startups
- /blog/zapier-vs-make-vs-n8n-ai-ops
- /blog/vercel-vs-netlify-vs-railway-deployment
External references
- Stripe API Design – gold standard for developer experience.
- Microsoft REST API Guidelines – comprehensive best practices.
- OpenAPI Specification – API documentation standard.
Crosslinks
More from the blog
OpenHelm vs runCLAUDErun: Which Claude Code Scheduler Is Right for You?
A direct comparison of the two most popular Claude Code schedulers, how each works, what each costs, and which fits your workflow.
Claude Code vs Cursor Pro: Real Developer Cost Comparison
An honest look at what developers actually spend on Claude Code, Cursor Pro, and GitHub Copilot, and how to get the most from each.
Stop doing the work around the work
OpenHelm connects to your tools, reads the context, and does the steps, so you sign off on the result instead of producing it. See how it covers an entire role’s weekly workload, check the pricing, or run it yourself with the free local app.