REST Design, Status Codes, and Idempotency
A good API is predictable. If a developer knows one endpoint, they can guess how the others work. REST design gives URLs and methods a consistent shape, status codes tell the client what happened, and idempotency makes it safe to retry a request.
Definition
- REST (Representational State Transfer): a style of API design where everything is a resource (users, orders) with its own URL, and you act on it with standard HTTP methods.
- Status code: a 3-digit number in every response that tells the client what happened (
200OK,404Not Found). - Idempotency: a request is idempotent if sending it once or many times has the same effect on the server.
Analogy: think of a library.
- REST: every book has a fixed shelf address (URL), and there are only a few actions: look at it, add, replace, edit, remove (HTTP methods).
- Status codes: the librarian's short replies: "here you go" (
200), "added" (201), "no such book" (404), "you need a card" (401). - Idempotency: pressing a lift button is idempotent. Pressing it 5 times calls the lift once. Ordering a pizza is not: 5 orders means 5 pizzas.
Part 1: REST Design
Resources and URLs
Use nouns for resources, and let the HTTP method be the verb.
| Good | Bad | Why |
|---|---|---|
GET /users | GET /getUsers | The method is already the verb |
POST /users | POST /createUser | Same |
GET /users/42 | GET /user?id=42 | IDs belong in the path |
GET /users/42/orders | GET /getOrdersOfUser/42 | Nesting shows the relationship |
GET /order-items | GET /orderItems, GET /order_items | Use lowercase and hyphens |
/users (plural) | /user | Use plural nouns, consistently |
The Standard CRUD Endpoints
| Method | URL | Action | Success code |
|---|---|---|---|
GET | /users | List users | 200 OK |
GET | /users/42 | Get one user | 200 OK |
POST | /users | Create a user | 201 Created |
PUT | /users/42 | Replace the whole user | 200 OK or 204 No Content |
PATCH | /users/42 | Update some fields | 200 OK |
DELETE | /users/42 | Delete the user | 204 No Content |
PUT vs PATCH:
# Current user: { "name": "Asha", "email": "asha@mail.com", "city": "Pune" }PUT /users/42{ "name": "Asha K", "email": "asha@mail.com" }# Result: { "name": "Asha K", "email": "asha@mail.com" } (city is gone)PATCH /users/42{ "name": "Asha K" }# Result: { "name": "Asha K", "email": "asha@mail.com", "city": "Pune" }
PUT sends the complete resource. PATCH sends only the changes.
Filtering, Sorting, and Paging
Use the query string, not new endpoints:
GET /products?category=books&minPrice=100GET /products?sort=-price # "-" means descendingGET /products?page=2&limit=20GET /products?fields=id,name,price
Other REST Principles
| Principle | Meaning |
|---|---|
| Stateless | Each request carries everything the server needs (like the auth token). The server doesn't remember earlier requests |
| Versioning | Put the version in the URL (/api/v1/users) so you can change the API without breaking old clients |
| Consistent responses | Use the same shape for every success and every error |
| Actions that aren't CRUD | Model them as a sub-resource: POST /orders/42/cancel |
// Success{"data": { "id": 42, "name": "Asha" }}// Error{"error": {"code": "USER_NOT_FOUND","message": "User 42 does not exist"}}
Part 2: Status Codes
The Five Groups
| Range | Meaning | Who caused it |
|---|---|---|
| 1xx | Informational | — |
| 2xx | Success | — |
| 3xx | Redirection | — |
| 4xx | Client error | The request was wrong. Retrying the same request won't help |
| 5xx | Server error | The server failed. Retrying may help |
The Codes You'll Use Most
| Code | Name | Use when |
|---|---|---|
| 200 | OK | A successful GET, PUT, or PATCH that returns data |
| 201 | Created | A POST created something. Send a Location header with its URL |
| 204 | No Content | Success with no body, often for DELETE |
| 301 / 308 | Moved Permanently | The resource has a new URL forever |
| 304 | Not Modified | The client's cached copy is still fresh |
| 400 | Bad Request | The request is malformed or fails validation |
| 401 | Unauthorized | Not logged in: missing or invalid token |
| 403 | Forbidden | Logged in, but not allowed to do this |
| 404 | Not Found | The resource doesn't exist |
| 405 | Method Not Allowed | The URL exists, but not with this method |
| 409 | Conflict | Clashes with current state, like an email already registered |
| 422 | Unprocessable Content | Valid format, but breaks a business rule (some APIs use this for validation errors) |
| 429 | Too Many Requests | Rate limit hit. Send a Retry-After header |
| 500 | Internal Server Error | An unexpected bug on the server |
| 502 | Bad Gateway | A server behind a proxy returned a bad response |
| 503 | Service Unavailable | Server is down or overloaded for now |
| 504 | Gateway Timeout | A server behind a proxy took too long |
The Commonly Confused Pairs
| Pair | Difference |
|---|---|
| 401 vs 403 | 401: "Who are you?" (not authenticated). 403: "I know who you are, but you can't do this" (not authorized) |
| 400 vs 422 | 400: the request is broken (bad JSON, wrong types). 422: the request is well-formed but logically invalid |
| 404 vs 403 | To hide that a private resource exists, some APIs return 404 instead of 403 |
| 200 vs 204 | 204 must have no body |
router.post("/users", async (req, res) => {const existing = await findUserByEmail(req.body.email);if (existing) {return res.status(409).json({ error: { message: "Email already registered" } });}const user = await createUser(req.body);res.status(201).location(`/api/users/${user.id}`).json({ data: user });});router.delete("/users/:id", async (req, res) => {const deleted = await deleteUser(req.params.id);if (!deleted) {return res.status(404).json({ error: { message: "User not found" } });}res.status(204).end();});
Part 3: Idempotency
Safe vs Idempotent
- Safe: the request doesn't change anything on the server (read-only).
- Idempotent: repeating the request has the same effect as sending it once.
| Method | Safe | Idempotent | Why |
|---|---|---|---|
GET | Yes | Yes | Only reads |
HEAD, OPTIONS | Yes | Yes | Only reads |
PUT | No | Yes | Replacing with the same data again gives the same result |
DELETE | No | Yes | After the first call, it's gone. Further calls change nothing |
PATCH | No | Not guaranteed | Depends on what the patch does |
POST | No | No | Each call usually creates a new resource |
Idempotent is about the server's state, not the response. The first DELETE /users/42 returns 204, and the second returns 404. It is still idempotent, because the user is deleted either way.
Why PATCH Isn't Always Idempotent
# Idempotent: sets a valuePATCH /users/42{ "city": "Pune" }# Send it 3 times: city is still "Pune"# NOT idempotent: changes relative to the current valuePATCH /posts/7{ "op": "increment", "field": "views" }# Send it 3 times: views goes up by 3
Why Idempotency Matters: Retries
Networks fail. A client sends a request, the server processes it, but the response is lost. The client doesn't know whether it worked, so it retries.
| Request | Retry is safe? | What could go wrong |
|---|---|---|
GET /orders/9 | Yes | Nothing |
PUT /users/42 | Yes | Same data written again |
DELETE /users/42 | Yes | Already deleted |
POST /payments | No | The customer is charged twice |
Making POST Safe: The Idempotency Key
The client generates a unique key for each logical action and sends it in a header. If the server sees the same key again, it returns the saved result instead of doing the work again. Payment APIs like Stripe use this pattern.
POST /paymentsIdempotency-Key: 7f3c9a2e-1b4d-4e8a-9c6f-2d5e8b1a0f47Content-Type: application/json{ "amount": 500, "currency": "INR" }
// In production, use Redis or a database table, with an expiry timeconst idempotencyStore = new Map();function idempotency(req, res, next) {const key = req.get("Idempotency-Key");if (!key) {return res.status(400).json({ error: { message: "Idempotency-Key header is required" } });}const saved = idempotencyStore.get(key);if (saved?.status === "in-progress") {return res.status(409).json({ error: { message: "Request already in progress" } });}if (saved?.status === "done") {return res.status(saved.code).json(saved.body); // replay the first result}idempotencyStore.set(key, { status: "in-progress" });// Save the response when it is sentconst originalJson = res.json.bind(res);res.json = (body) => {if (res.statusCode < 500) {idempotencyStore.set(key, { status: "done", code: res.statusCode, body });} else {idempotencyStore.delete(key); // let the client retry server errors}return originalJson(body);};next();}router.post("/payments", idempotency, async (req, res) => {const payment = await chargeCustomer(req.body);res.status(201).json({ data: payment });});
How it works:
- First request: the key is new, so it is marked in-progress, the payment runs, and the response is saved under the key.
- Retry with the same key: the server finds the saved result and returns it again. No second charge.
- Two copies arrive at the same time: the second sees in-progress and gets a 409.
The client's job: create one key per action (for example, per "Pay" button click), and reuse the same key for every retry of that action.
Summary
- REST treats everything as a resource with a URL. Use plural nouns, and let the HTTP method be the verb.
- PUT replaces the whole resource. PATCH changes some fields.
- Use the query string for filtering, sorting, and paging. Keep requests stateless and responses consistent.
- 2xx = success, 4xx = client's fault (don't retry as-is), 5xx = server's fault (may retry).
- 201 for created, 204 for no body, 401 for not logged in, 403 for not allowed, 409 for conflicts, 429 for rate limits.
- Safe methods don't change data. Idempotent methods have the same effect no matter how many times they run.
GET,PUT, andDELETEare idempotent.POSTis not, andPATCHisn't guaranteed to be.- Idempotency makes retries safe. Make
POSTsafe with anIdempotency-Keyheader.
Interview Answer: What Is Idempotency, and How Does It Relate to REST Design?
In REST, every resource has a URL, like /users/42, and I act on it with HTTP methods: GET to read, POST to create, PUT to replace, PATCH to partly update, and DELETE to remove. I use plural nouns instead of verbs in URLs, the query string for filtering and paging, and the right status codes: 201 for created, 204 for no content, 400 for invalid input, 401 when not logged in, 403 when not allowed, 404 when not found, and 409 for conflicts.
A request is idempotent if sending it once or many times leaves the server in the same state. GET, PUT, and DELETE are idempotent. POST is not, because each call can create something new, and PATCH depends on the operation. This matters for retries: if a response is lost, the client can safely retry an idempotent request, but retrying a POST to a payment endpoint could charge the customer twice.
To make POST safe, I use an Idempotency-Key header. The client sends a unique key per action, and the server stores the result under that key. If the same key arrives again, the server returns the saved response instead of processing it a second time.