On this page

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 (200 OK, 404 Not 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.

GoodBadWhy
GET /usersGET /getUsersThe method is already the verb
POST /usersPOST /createUserSame
GET /users/42GET /user?id=42IDs belong in the path
GET /users/42/ordersGET /getOrdersOfUser/42Nesting shows the relationship
GET /order-itemsGET /orderItems, GET /order_itemsUse lowercase and hyphens
/users (plural)/userUse plural nouns, consistently

The Standard CRUD Endpoints

MethodURLActionSuccess code
GET/usersList users200 OK
GET/users/42Get one user200 OK
POST/usersCreate a user201 Created
PUT/users/42Replace the whole user200 OK or 204 No Content
PATCH/users/42Update some fields200 OK
DELETE/users/42Delete the user204 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=100
GET /products?sort=-price # "-" means descending
GET /products?page=2&limit=20
GET /products?fields=id,name,price

Other REST Principles

PrincipleMeaning
StatelessEach request carries everything the server needs (like the auth token). The server doesn't remember earlier requests
VersioningPut the version in the URL (/api/v1/users) so you can change the API without breaking old clients
Consistent responsesUse the same shape for every success and every error
Actions that aren't CRUDModel 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

RangeMeaningWho caused it
1xxInformational—
2xxSuccess—
3xxRedirection—
4xxClient errorThe request was wrong. Retrying the same request won't help
5xxServer errorThe server failed. Retrying may help

The Codes You'll Use Most

CodeNameUse when
200OKA successful GET, PUT, or PATCH that returns data
201CreatedA POST created something. Send a Location header with its URL
204No ContentSuccess with no body, often for DELETE
301 / 308Moved PermanentlyThe resource has a new URL forever
304Not ModifiedThe client's cached copy is still fresh
400Bad RequestThe request is malformed or fails validation
401UnauthorizedNot logged in: missing or invalid token
403ForbiddenLogged in, but not allowed to do this
404Not FoundThe resource doesn't exist
405Method Not AllowedThe URL exists, but not with this method
409ConflictClashes with current state, like an email already registered
422Unprocessable ContentValid format, but breaks a business rule (some APIs use this for validation errors)
429Too Many RequestsRate limit hit. Send a Retry-After header
500Internal Server ErrorAn unexpected bug on the server
502Bad GatewayA server behind a proxy returned a bad response
503Service UnavailableServer is down or overloaded for now
504Gateway TimeoutA server behind a proxy took too long

The Commonly Confused Pairs

PairDifference
401 vs 403401: "Who are you?" (not authenticated). 403: "I know who you are, but you can't do this" (not authorized)
400 vs 422400: the request is broken (bad JSON, wrong types). 422: the request is well-formed but logically invalid
404 vs 403To hide that a private resource exists, some APIs return 404 instead of 403
200 vs 204204 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.
MethodSafeIdempotentWhy
GETYesYesOnly reads
HEAD, OPTIONSYesYesOnly reads
PUTNoYesReplacing with the same data again gives the same result
DELETENoYesAfter the first call, it's gone. Further calls change nothing
PATCHNoNot guaranteedDepends on what the patch does
POSTNoNoEach 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 value
PATCH /users/42
{ "city": "Pune" }
# Send it 3 times: city is still "Pune"
# NOT idempotent: changes relative to the current value
PATCH /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.

RequestRetry is safe?What could go wrong
GET /orders/9YesNothing
PUT /users/42YesSame data written again
DELETE /users/42YesAlready deleted
POST /paymentsNoThe 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 /payments
Idempotency-Key: 7f3c9a2e-1b4d-4e8a-9c6f-2d5e8b1a0f47
Content-Type: application/json
{ "amount": 500, "currency": "INR" }
// In production, use Redis or a database table, with an expiry time
const 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 sent
const 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:

  1. First request: the key is new, so it is marked in-progress, the payment runs, and the response is saved under the key.
  2. Retry with the same key: the server finds the saved result and returns it again. No second charge.
  3. 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, and DELETE are idempotent. POST is not, and PATCH isn't guaranteed to be.
  • Idempotency makes retries safe. Make POST safe with an Idempotency-Key header.

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.

Next TopicJWT Auth, Sessions, Cookies, and Refresh Tokens