You have learned how to build a web application. You can write routes, query a database, and return JSON. But the moment another developer tries to consume your API — or you come back to your own project three months later — everything falls apart. The endpoints are named inconsistently, error responses change shape depending on which route threw them, there is no pagination, and nobody knows which version of the API they are supposed to be calling.
API design is not about making things work. It is about making things work predictably. A well-designed API is one where a developer can guess the endpoint for a resource they have never used before — and be right. This guide covers the conventions, patterns, and mistakes that separate amateur APIs from professional ones. Everything here applies whether you are building a side project, a startup backend, or a service that thousands of developers will depend on.
1. REST Done Right — Resource Naming, HTTP Methods, and Status Codes
REST is not a protocol. It is not a standard you can install. REST is a set of architectural conventions that, when followed consistently, make your API intuitive. The problem is that most tutorials teach you just enough to get endpoints returning data, but not enough to get the design right. Here is what matters.
Resource Naming
URLs should describe resources (nouns), not actions (verbs). The HTTP method already tells the consumer what action is being performed. Putting the action in the URL is redundant and creates inconsistency.
GET /getUser/42
POST /createNewUser
GET /fetchAllOrders
POST /deleteUser/42
# Good — plural nouns, HTTP methods express the action
GET /users/42 ← fetch one user
POST /users ← create a user
GET /users ← list all users
DELETE /users/42 ← delete a user
PUT /users/42 ← replace a user entirely
PATCH /users/42 ← update specific fields
Use plural nouns for all resource names. /users, not /user. Even when fetching a single resource (/users/42), the collection name stays plural. This is the convention that the vast majority of production APIs follow, and it eliminates the mental overhead of remembering which resources are singular and which are plural.
For relationships between resources, use nesting. If a user has orders, the endpoint to get a specific user's orders is:
GET /users/42/orders/7 ← order 7 belonging to user 42
POST /users/42/orders ← create an order for user 42
Do not nest more than two levels deep. /users/42/orders/7/items/3/reviews is unreadable and hard to maintain. If you need to go deeper, provide the resource at its own top-level endpoint with filtering: /reviews?order_id=7.
HTTP Methods
Each HTTP method has a specific semantic meaning. Using them correctly is not pedantic — it is how clients, caches, proxies, and monitoring tools understand what your API is doing.
- GET — Read a resource. Must be safe (no side effects) and idempotent (calling it 10 times produces the same result as calling it once). Never use GET to modify data.
- POST — Create a new resource. Not idempotent — calling it twice creates two resources. Returns the created resource with a
201 Createdstatus. - PUT — Replace a resource entirely. Idempotent. The request body should contain the complete resource representation. If a field is missing from the body, it gets set to null or its default.
- PATCH — Partially update a resource. Only the fields included in the body are updated; everything else stays the same. This is what you usually want when a user edits their profile or updates a setting.
- DELETE — Remove a resource. Idempotent — deleting something twice should not return an error the second time (return
204or404, both are acceptable patterns).
Common mistake: Using POST for everything. Students often create POST /updateUser and POST /deleteUser because it works. But it breaks caching, confuses API consumers, and makes your API impossible to document automatically with tools like OpenAPI.
Status Codes Reference
Status codes tell the client what happened without having to parse the response body. Here are the codes every API should use correctly:
| Code | Meaning | When to Use |
|---|---|---|
| 200 | OK | Successful GET, PUT, or PATCH request |
| 201 | Created | Successful POST that created a new resource |
| 204 | No Content | Successful DELETE (nothing to return) |
| 400 | Bad Request | Malformed JSON, missing required fields, invalid data types |
| 401 | Unauthorized | No authentication provided, or credentials are invalid |
| 403 | Forbidden | Authenticated, but does not have permission for this action |
| 404 | Not Found | The resource does not exist at this URL |
| 422 | Unprocessable Entity | Valid JSON but business logic validation failed (e.g., email already taken) |
| 429 | Too Many Requests | Rate limit exceeded |
| 500 | Internal Server Error | Something broke on the server — never return internal stack traces to the client |
The most common student mistake with status codes is returning 200 OK for everything, including errors. This forces the client to parse the response body to figure out if the request actually succeeded. Monitoring tools, load balancers, and retry logic all depend on status codes being correct. A 200 that contains an error message is invisible to every layer of infrastructure between your server and the client.
2. Pagination, Filtering, and Error Responses
Any endpoint that returns a list of resources needs pagination. Without it, a request for /users on a database with 500,000 users returns all 500,000 records. The response takes 30 seconds, consumes enormous bandwidth, and probably crashes the client. Pagination is not optional for production APIs.
Offset-Based Pagination
Offset pagination uses page and limit (or offset and limit) query parameters. It is the simplest approach and works well for most use cases.
Response:
{
"data": [ ... 20 users ... ],
"pagination": {
"page": 3,
"limit": 20,
"total_count": 482,
"total_pages": 25
}
}
When to use it: When you need to jump to arbitrary pages (like a "page 5 of 25" UI), when your dataset changes infrequently, or when the total count matters to the user.
The problem: If a new record is inserted while someone is paginating, items shift. The user might see the same record twice or miss one entirely. On large tables, OFFSET 100000 in SQL is slow because the database has to scan and discard 100,000 rows before returning results.
Cursor-Based Pagination
Cursor pagination uses an opaque token (usually a Base64-encoded ID or timestamp) that points to the last item the client saw. The server returns items after that cursor.
Response:
{
"data": [ ... 20 users ... ],
"cursors": {
"after": "eyJpZCI6NjJ9",
"has_next": true
}
}
When to use it: Infinite scroll UIs, real-time feeds, large datasets, or any case where data changes frequently. Cursor pagination is consistent — no matter how many records are inserted or deleted, the client picks up exactly where it left off.
The tradeoff: You cannot jump to "page 12." The client can only go forward (and sometimes backward) one page at a time. For most modern UIs (feeds, lists, dashboards), this is fine. For traditional numbered pagination, you need offset-based.
Link headers: The HTTP Link header is an alternative way to communicate pagination URLs. Instead of putting pagination data in the response body, you put it in a header: Link: </users?after=abc>; rel="next", </users?before=xyz>; rel="prev". GitHub's API uses this pattern. It keeps pagination metadata out of the response body, but many frontend developers find it harder to work with than a JSON pagination object.
Filtering and Sorting
Filtering and sorting should use query parameters on the collection endpoint. Do not create separate endpoints for filtered views.
GET /users?role=admin
# Filter by multiple criteria
GET /users?role=admin&status=active&created_after=2026-01-01
# Sort by field (prefix with - for descending)
GET /users?sort=-created_at
# Combine filtering, sorting, and pagination
GET /users?role=admin&sort=-created_at&page=2&limit=20
Keep filter names consistent with the field names in your response objects. If the response includes created_at, the filter parameter should be created_at or created_after, not creationDate or dateCreated.
Consistent Error Responses
Every error your API returns should follow the same structure. This is one of the most impactful things you can do for developer experience. When errors have a predictable shape, clients can write a single error handler that works for every endpoint.
{ "message": "Not found" }
{ "error": "Invalid email" }
{ "errors": ["Name required", "Email required"] }
{ "success": false, "reason": "Unauthorized" }
# Good — every error follows the same structure
{
"error": {
"code": "VALIDATION_ERROR",
"message": "The request body contains invalid fields.",
"details": [
{
"field": "email",
"message": "Must be a valid email address.",
"value": "not-an-email"
},
{
"field": "age",
"message": "Must be a positive integer.",
"value": -5
}
]
}
}
The code field is a machine-readable string the client can switch on. The message is a human-readable explanation. The details array provides field-level information for validation errors. This structure works for 400s, 401s, 403s, 404s, 422s, and 500s. The only thing that changes is the status code and the content of the error object.
Never expose internal errors. A 500 response should never contain a database stack trace, SQL query, or internal file path. In development, log those to the server console. In the API response, return a generic message: "An internal error occurred. Please try again or contact support." Include a request ID so your team can look up the specific error in your logs.
3. When GraphQL Makes Sense (And When It Doesn't)
GraphQL is not a replacement for REST. It is a different tool that solves different problems. The mistake students make is assuming GraphQL is "newer, therefore better." In practice, GraphQL adds complexity that is only justified in specific scenarios.
When GraphQL Is the Right Choice
Complex, nested data requirements. Imagine a mobile app that needs a user's profile, their 10 most recent orders, the items in each order, and the review status of each item — all on a single screen. With REST, this requires 3-4 separate API calls (users, orders, items, reviews), or you have to build a custom endpoint that bundles everything together. With GraphQL, the client specifies exactly the data shape it needs in a single query:
user(id: 42) {
name
orders(last: 10) {
id
total
items {
name
price
reviewStatus
}
}
}
}
One request. No over-fetching (you only get the fields you asked for). No under-fetching (you get everything in one round trip). For mobile apps with limited bandwidth and high latency, this is a significant advantage.
Multiple consumer types. If a web app, mobile app, and third-party integration all consume the same API but need different data shapes, GraphQL shines. Each client queries exactly the fields it needs. The web app might request 30 fields for a detailed dashboard, while the mobile app requests 8 fields for a compact list view. With REST, you either over-fetch for some clients or build multiple endpoints per resource.
Rapidly evolving frontends. When frontend requirements change frequently, GraphQL avoids the back-and-forth between frontend and backend teams. The frontend developer can add or remove fields from their query without waiting for a backend deployment.
When GraphQL Is the Wrong Choice
Simple CRUD with a single consumer. If you are building a straightforward API for one frontend, REST is simpler to implement, simpler to cache, simpler to monitor, and simpler to debug. Adding GraphQL for a basic blog or todo app is like using a forklift to move a chair.
File uploads. GraphQL does not have native support for file uploads. You end up using multipart form data workarounds or building a separate REST endpoint for uploads anyway.
HTTP caching. REST endpoints have unique URLs that can be cached by CDNs, browsers, and proxies with zero configuration. GraphQL sends everything as a POST to a single /graphql endpoint, which means traditional HTTP caching does not work. You need application-level caching (like Apollo's normalized cache), which adds complexity.
The N+1 Problem
This is the most common performance trap in GraphQL. Consider this query:
users {
name
orders {
total
}
}
}
If there are 100 users, the naive resolver executes 1 query to get all users, then 100 individual queries to get each user's orders. That is 101 database queries for what should be 2 (one for users, one join for orders). This is the N+1 problem.
The solution is the DataLoader pattern. DataLoader batches and deduplicates data-loading calls within a single request. Instead of 100 individual queries for orders, DataLoader collects all the user IDs and makes one batch query: SELECT * FROM orders WHERE user_id IN (1, 2, 3, ... 100). Facebook created DataLoader specifically to solve this problem, and every serious GraphQL implementation uses it.
// Resolver for User.orders
resolve(user) {
return db.query('SELECT * FROM orders WHERE user_id = ?', [user.id]);
}
// With DataLoader: 2 queries
const orderLoader = new DataLoader(async (userIds) => {
const orders = await db.query(
'SELECT * FROM orders WHERE user_id IN (?)', [userIds]
);
return userIds.map(id => orders.filter(o => o.user_id === id));
});
resolve(user) {
return orderLoader.load(user.id);
}
The complexity tradeoff: GraphQL gives the client more power, but that power moves complexity to the server. You need to handle query depth limiting (to prevent deeply nested queries that crash your database), query cost analysis (to prevent expensive queries), and the N+1 problem. If your team is small and your API is straightforward, REST gives you more for less effort.
4. Authentication and Security
Every API needs to answer two questions: "Who is making this request?" (authentication) and "Are they allowed to do this?" (authorization). There are three main patterns, each suited for different use cases.
API Keys
An API key is a long random string that the client includes in every request, usually in a header. It is the simplest authentication method.
Host: api.example.com
X-API-Key: sk_live_abc123def456ghi789
Best for: Server-to-server communication, third-party integrations, public APIs with rate limits. API keys are easy to issue, easy to revoke, and easy to understand.
Not for: Browser-based applications. API keys embedded in frontend JavaScript are visible to anyone who opens DevTools. They are also not tied to a specific user — they identify the application, not the person.
JWT (JSON Web Tokens)
A JWT is a signed token that contains claims (like user ID, role, and expiration time). The server generates it at login and the client sends it with every request. The server verifies the signature without hitting a database.
{ "email": "student@example.com", "password": "..." }
Response:
{
"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"token_type": "Bearer",
"expires_in": 3600
}
# Subsequent requests:
GET /users/me HTTP/1.1
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
Best for: Web and mobile applications where you need stateless authentication. JWTs scale well because the server does not need to store session data — the token itself contains all the information needed to verify the user.
Watch out for: JWTs cannot be revoked individually. If a token is compromised, it remains valid until it expires. Use short expiration times (15-60 minutes) with a refresh token mechanism. Never store JWTs in localStorage — use httpOnly cookies to protect against XSS attacks.
OAuth 2.0
OAuth 2.0 is not an authentication protocol — it is an authorization framework. It allows users to grant third-party applications limited access to their resources without sharing their password. "Sign in with Google" and "Sign in with GitHub" use OAuth 2.0.
Best for: When your API needs to let third-party applications access user data on behalf of the user. Think of how Slack can post to your Google Calendar, or how a CI/CD tool accesses your GitHub repositories.
Not for: Simple first-party authentication where the only client is your own app. OAuth 2.0 involves multiple redirects, authorization codes, and token exchanges. If you are the only consumer of your API, JWT is simpler.
CORS: Cross-Origin Resource Sharing
CORS is not an authentication mechanism, but it is a security feature that every API developer encounters and most students misunderstand. Browsers block JavaScript from making requests to a different origin (domain, port, or protocol) than the one that served the page. CORS is the mechanism that tells the browser which cross-origin requests are allowed.
Access-Control-Allow-Origin: https://myapp.example.com
Access-Control-Allow-Methods: GET, POST, PUT, DELETE
Access-Control-Allow-Headers: Content-Type, Authorization
Access-Control-Max-Age: 86400
The critical rule: never use Access-Control-Allow-Origin: * in production for APIs that require authentication. The wildcard allows any website to make requests to your API. If a user is logged in to your app and visits a malicious website, that site could make API calls on the user's behalf. Always specify the exact origins that are allowed to access your API.
Rate Limiting
Rate limiting protects your API from abuse, accidental infinite loops, and denial-of-service attacks. Every API should return rate limit information in response headers so clients can adjust their behavior proactively.
X-RateLimit-Limit: 1000
X-RateLimit-Remaining: 847
X-RateLimit-Reset: 1711238400
# When rate limited:
HTTP/1.1 429 Too Many Requests
X-RateLimit-Limit: 1000
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1711238400
Retry-After: 45
X-RateLimit-Limit is the maximum number of requests allowed in the window. X-RateLimit-Remaining is how many the client has left. X-RateLimit-Reset is a Unix timestamp when the window resets. Retry-After tells the client exactly how many seconds to wait before retrying. A well-behaved client reads these headers and throttles itself before hitting the limit.
Students often skip rate limiting entirely because their projects have low traffic. But the first time someone writes a while(true) loop that calls your API, or a bot discovers your unprotected endpoint, you will wish you had it. Most web frameworks have rate limiting middleware that takes five minutes to set up. There is no excuse not to include it.
5. API Versioning and Idempotency
APIs evolve. Fields get renamed, endpoints get restructured, response formats change. Without versioning, every change you make is a breaking change for every consumer. With versioning, you can evolve the API while giving consumers time to migrate.
Three Versioning Strategies
There are three common approaches, each with different tradeoffs:
| Strategy | Example | Pros | Cons |
|---|---|---|---|
| URL path | /v1/users/v2/users |
Simple, visible, easy to test in browser | Pollutes URL space, makes routing more complex |
| Header | Accept: application/vnd.api+json;version=1 |
Clean URLs, proper HTTP semantics | Harder to test (cannot paste into browser), less discoverable |
| Query param | /users?v=1 |
Easy to add, testable in browser | Mixes versioning with query logic, can break caching |
URL versioning is the simplest and most widely used. When you see /v1/ in a URL, you immediately know which version of the API you are calling. It is easy to route in any web framework, easy to test with curl or a browser, and easy to document. Most major APIs (Stripe, Twilio, GitHub) use URL versioning or a close variant.
Header versioning is the cleanest from an HTTP standards perspective. The URL represents the resource, and the version is metadata about the representation you want. But in practice, it is harder to work with. You cannot simply paste a URL into your browser to test it — you need a tool like Postman or curl to set the header. Many developers will never even notice the versioning exists.
Query parameter versioning is a middle ground but has caching issues. CDNs and proxies may not distinguish between /users?v=1 and /users?v=2 in their cache keys unless specifically configured.
Our recommendation: Start with URL versioning (/v1/). It is the simplest to implement, the easiest for consumers to understand, and the most battle-tested in production. You can always migrate to header versioning later if you have a specific reason to. Most teams never need to.
Idempotency for Safe Retries
Network failures happen. A client sends a POST request to create an order, the server processes it, but the response is lost in transit. The client does not know if the order was created, so it retries. Now there are two orders. This is the problem idempotency solves.
An idempotent operation produces the same result whether it is executed once or multiple times. GET, PUT, and DELETE are naturally idempotent. POST is not — each POST creates a new resource.
The solution is the Idempotency-Key header. The client generates a unique key (usually a UUID) and includes it with the request. The server stores the key and its associated response. If the same key is sent again, the server returns the stored response instead of processing the request a second time.
POST /orders HTTP/1.1
Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000
Content-Type: application/json
{ "product_id": 42, "quantity": 1 }
# Response: 201 Created
{ "id": 789, "product_id": 42, "quantity": 1, "status": "confirmed" }
# Retry with same key — server returns the stored result
POST /orders HTTP/1.1
Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000
Content-Type: application/json
{ "product_id": 42, "quantity": 1 }
# Response: 201 Created (same response, no duplicate order)
{ "id": 789, "product_id": 42, "quantity": 1, "status": "confirmed" }
Stripe popularized this pattern, and it is now considered best practice for any API that handles payments, creates resources, or triggers side effects. Idempotency keys are typically stored with a TTL of 24-48 hours — long enough to handle retries but short enough that storage does not grow indefinitely.
6. Documentation That Developers Actually Read
An API without documentation is an API nobody uses. But documentation that is wrong is worse than no documentation at all. The goal is documentation that stays in sync with the actual API, requires minimal manual effort, and gives developers what they need to make their first successful request within minutes.
OpenAPI and Swagger
OpenAPI (formerly known as Swagger) is the industry standard for describing REST APIs. It is a machine-readable specification file (YAML or JSON) that describes every endpoint, parameter, request body, response, and authentication method in your API. The key advantage is that once you have an OpenAPI spec, you can auto-generate:
- Interactive documentation — Tools like Swagger UI and Redoc generate a browsable documentation website directly from your spec file. Developers can read descriptions, see examples, and even make live API calls from the documentation page.
- Client SDKs — OpenAPI Generator can produce client libraries in 50+ languages. Instead of your API consumers writing HTTP calls manually, they import a generated library with typed methods.
- Server stubs — You can generate boilerplate server code from the spec, ensuring the implementation matches the documented contract.
- Automated tests — Tools like Schemathesis can generate test cases directly from your OpenAPI spec, testing every endpoint against its documented contract.
openapi: 3.0.3
info:
title: Student Portal API
version: 1.0.0
paths:
/users:
get:
summary: List all users
parameters:
- name: role
in: query
schema:
type: string
enum: [student, trainer, admin]
responses:
'200':
description: A list of users
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/User'
Most modern web frameworks can generate OpenAPI specs automatically from your route definitions. In Express, you use swagger-jsdoc. In FastAPI (Python), it is built in — FastAPI generates a complete OpenAPI spec and interactive docs at /docs with zero configuration. In Spring Boot, you add springdoc-openapi. The documentation stays in sync with the code because it is generated from the code.
Postman Collections
A Postman collection is a shareable file that contains pre-configured API requests. Instead of reading documentation and manually constructing requests, a developer imports the collection and immediately has working examples for every endpoint, complete with headers, authentication, and sample request bodies.
The best approach is to maintain both: an OpenAPI spec as the source of truth, and a Postman collection generated from that spec for hands-on testing. Postman can import OpenAPI specs directly, so you do not need to maintain them separately.
What Good Documentation Includes
Regardless of the tools you use, effective API documentation has these elements:
- A getting-started guide that gets developers from zero to their first successful API call in under five minutes. Show the authentication step, one GET request, and one POST request with actual copy-paste examples.
- Real response examples, not just schemas. Developers learn by reading examples, not by parsing JSON Schema definitions. Show what the actual response looks like with realistic data.
- Error examples. Document what happens when things go wrong. Show the 400 response for a missing field, the 401 for expired tokens, the 429 for rate limiting. Most API documentation only shows the happy path.
- Authentication instructions with code snippets in at least three languages (curl, JavaScript, Python). Do not make developers figure out where to put the API key.
- Rate limit and pagination documentation. Explain the limits, the headers, and how to paginate through results. Include a complete example of fetching all pages of a resource.
- Changelog. When the API changes, document what changed, what version it affects, and what consumers need to update. A dated list of changes builds trust.
The golden rule of API documentation: Examples over descriptions. A developer should be able to copy an example from your documentation, paste it into their terminal or code, and have it work. If the example in your docs does not work, developers will stop trusting your documentation entirely — and then they stop using your API.
Common Mistakes Students Make
To wrap up, here are the patterns that mark a student-built API. Every one of these is fixable in an afternoon, and fixing them makes the difference between a portfolio project that impresses and one that does not.
- Inconsistent naming. Mixing
camelCaseandsnake_casein the same response. Mixing singular and plural resource names. UsinguserIdin one endpoint anduser_idin another. Pick a convention and enforce it everywhere. - Using 200 for everything. Returning
200 OKwith{ "error": "User not found" }in the body. Use the correct status codes. It takes the same amount of effort and makes your API dramatically easier to work with. - No pagination. Returning all records from a collection endpoint. This works with 20 test records and breaks catastrophically in production.
- No rate limiting. One runaway script can take down your entire API. Add rate limiting middleware. It takes five minutes.
- No versioning. Making breaking changes to a live API with no way for consumers to stay on the old version. Add
/v1/to your base path from day one. - Leaking internal errors. Returning raw database errors, stack traces, or SQL queries in API responses. This is both a security vulnerability and a poor developer experience.
- No CORS configuration. Either leaving it wide open (
*) or forgetting it entirely and wondering why the frontend cannot reach the API. - Inventing new conventions. Using
POST /executeActioninstead of standard HTTP methods. Creating custom status codes. Building a query language in the URL instead of using query parameters. The REST conventions exist because millions of developers already understand them. Use them.
Good API design is not about perfection. It is about consistency and predictability. If a developer can use one endpoint of your API and correctly guess how every other endpoint works, you have done your job. The conventions in this guide are not arbitrary rules — they are the shared language that lets developers build on each other's work without having to read every line of documentation first.