API Design Explained: REST, Idempotency, Versioning, Pagination and Status Codes
Introduction
Good API design comes down to one idea: your system should behave predictably even when the network does not. Requests get lost, clients retry, and old apps keep calling endpoints you have already changed. An API that survives all of that is designed, not just coded.
This post walks through the core ideas in the order they build on each other: REST, HTTP methods, idempotency, idempotency keys, REST vs gRPC vs GraphQL, versioning, pagination and status codes. The examples use a Spring Boot backend serving a React frontend for an electronics store, with resources like products, orders and payments.
If you are preparing for backend or system design interviews, each section ends with the reasoning interviewers look for, and there is a question-and-answer round near the end.
What an API is, and the four parts of a request
An API (Application Programming Interface) is the contract through which one piece of software talks to another. In a typical web app, the React frontend never talks to the database directly. It sends an HTTP request to the Spring Boot backend, and the backend runs SQL against MySQL. The backend's endpoints are the API.
Take this request:
GET /api/v1/products/101
Authorization: Bearer eyJhbG...
Content-Type: application/json
Every HTTP request has four parts:
Method: what you want to do.
GETmeans "give me data".URL: which resource you are acting on.
/api/v1/products/101points at product 101.Headers: extra context such as the auth token and the content type.
Body: the payload.
GETnormally has none; aPOSTcarries JSON such as{"name": "Arduino", "price": 599}.
Keep these four in mind. Almost every design decision below is a choice about one of them.
REST: design around resources, not actions
REST (Representational State Transfer) is an architectural style, not a language or a framework. Spring Boot can build REST APIs, but writing a controller does not automatically make an API RESTful.
The central idea is that everything important is a resource. In an electronics store the resources are users, products, carts, orders, payments, reviews and categories. URLs name resources; HTTP methods say what to do with them.
| Style | Examples |
|---|---|
| Action-based (avoid) | GET /getAllProducts, GET /getProduct?id=101, POST /createProduct, POST /deleteProduct |
| Resource-based (prefer) | GET /products, GET /products/101, POST /products, DELETE /products/101 |
In the second style the URL is a noun and the method is the verb. That keeps the API small and guessable: once a developer knows /products and /products/{id}, they can predict /orders and /orders/{id} without reading docs.
A Spring Boot controller in this style looks like this:
@RestController
@RequestMapping("/api/v1/products")
public class ProductController {
@GetMapping("/{id}")
public Product getProduct(@PathVariable Long id) {
return productService.getProduct(id);
}
}
HTTP methods and CRUD
The five methods you will use daily map onto create, read, update and delete (CRUD).
| Method | Purpose | Example | Typical response |
|---|---|---|---|
GET |
Read data; should not change server state | GET /api/v1/products/101 |
200 OK |
POST |
Create a new resource | POST /api/v1/products |
201 Created |
PUT |
Replace or update a whole resource | PUT /api/v1/products/101 |
200 OK |
PATCH |
Partially update specific fields | PATCH /api/v1/products/101 |
200 OK |
DELETE |
Remove a resource | DELETE /api/v1/products/101 |
204 No Content |
PUT vs PATCH. Suppose a product is {"id": 101, "name": "Arduino", "price": 599, "stock": 50} and only the price changes. With PATCH you send just {"price": 699} and the other fields stay as they are. With PUT the intent is to send the full representation, because the request replaces the resource.
The distinction matters later when we discuss retries, because the methods behave differently when the same request is sent twice.
The five REST constraints
REST is defined by a set of constraints. Five of them come up most often in interviews and in real design reviews.
Stateless
The server must not depend on earlier requests to understand the current one. Each request carries what the server needs, usually a JWT:
GET /orders
Authorization: Bearer JWT123
The next call, GET /cart with the same token, is understood on its own. Compare that with a stateful design, where the server keeps Session = ABC123 in memory and every later request depends on that stored state. Stateless servers are easier to scale, because any instance can handle any request.
Client-server separation
The frontend owns the UI and user interaction. The backend owns business logic, security and the database. Because the contract between them is the API, the same Spring Boot backend can serve a React web app and a mobile app at once.
Uniform interface
Consistency across the API. If products follow /products and /products/{id}, orders should follow /orders and /orders/{id}, not /fetchOrder or /removeProductById. Consistent patterns make an API cheap to learn.
Layered system
The client does not need to know which server, instance or database handled its request. It sends GET /orders, and the request may pass through an API gateway, a load balancer and an order service before reaching the database.
Cacheability
Some responses can be cached, such as GET /products/101 when the product rarely changes. A cache hit returns immediately and the backend is skipped, which means less database load, lower latency and better performance.
Idempotency: safe to repeat
An operation is idempotent if performing it many times leaves the system in the same final state as performing it once.
The everyday analogy is a light switch. Telling the room "set light = OFF" ten times ends with the light off, the same as saying it once.
| Method | Idempotent? | Why |
|---|---|---|
GET |
Yes | Reading does not change the resource. Ten reads leave the product exactly as it was. |
PUT |
Yes | PUT /users/101 with {"name": "Prathamesh"} sets the name to the same value every time. |
DELETE |
Yes | After the first call product 101 is gone. Later calls find nothing to delete, and the final state is unchanged. |
POST |
Generally no | Each call can create another resource. |
PATCH |
Not guaranteed | Depends on the patch. Setting a price is repeatable; "increase stock by 5" is not. |
The POST case is where real systems get hurt. Suppose a client sends POST /orders with {"productId": 101, "quantity": 1}. The server creates order #5001, but the response is lost on the way back. The client sees a timeout and retries. Now order #5002 exists. Another retry creates #5003. One purchase has become three orders.
Note that DELETE is idempotent in terms of server state even if the status code differs between calls (a first 204, then possibly a 404). Idempotency is about the final state, not about the responses being identical.
Idempotency keys: preventing duplicate payments
An idempotency key makes a non-idempotent operation safe to retry. The client generates a unique key for each logical operation and sends it in a header:
POST /payments
Idempotency-Key: PAY-ABC-123
The server stores the key together with the outcome:
PAY-ABC-123 -> Payment P1001 -> SUCCESS
If the network fails and the client retries with the same key, the server asks one question: have I already processed PAY-ABC-123? The answer is yes, so it returns the stored result instead of charging again:
{
"paymentId": "P1001",
"status": "SUCCESS"
}
Why this matters in distributed systems
Picture the path client, API gateway, payment service, bank. The bank approves the payment, but the response from the bank to the payment service is lost. The client never learns the outcome, assumes the payment failed and retries. Without idempotency the customer pays 1000 rupees twice. With it, the retry finds the key and gets the original result back.
A subtle point interviewers like
Idempotency is not the same as the server receiving only one request. Two requests may well reach the server. The first is processed. The second is recognised as a duplicate and answered from storage. What matters is that only one business operation happens.
In practice that means storing the key and result durably (a database table or a cache with sensible expiry), checking the key before doing any work, and treating a reused key with a different request body as an error rather than silently returning the old result.
REST vs gRPC vs GraphQL
REST is the default, but it is not the only way for software to talk. The three styles solve different problems.
| REST | gRPC | GraphQL | |
|---|---|---|---|
| Transport and format | HTTP + JSON | HTTP/2 + Protocol Buffers | Usually HTTP + JSON, one endpoint |
| Contract | Conventions, optionally OpenAPI | .proto file |
Typed schema |
| Best for | Public APIs, browsers | Internal service-to-service calls | Frontends that need flexible data shapes |
gRPC and Protocol Buffers
With gRPC you define the contract first in a .proto file. Instead of calling GET /inventory/101, the order service calls a method on the inventory service:
message ProductRequest {
int64 productId = 1;
}
message StockResponse {
int32 quantity = 1;
}
service InventoryService {
rpc CheckStock(ProductRequest)
returns (StockResponse);
}
That reads as: InventoryService has a CheckStock method that takes a ProductRequest and returns a StockResponse. Browsers work most naturally with HTTP and JSON, so REST is convenient for the browser-to-backend hop. For high-performance communication between internal microservices, gRPC is attractive. A useful heuristic, not an absolute rule: public API in REST, internal calls in gRPC.
GraphQL
Imagine a profile page that needs a user, their orders and their reviews. With REST you might make several calls:
GET /users/101
GET /users/101/orders
GET /users/101/reviews
With GraphQL the client describes exactly the shape it wants in one request:
query {
user(id: 101) {
name
email
orders {
id
total
}
}
}
The server returns only those fields. This addresses two classic REST problems:
Over-fetching: the endpoint returns id, name, email, address, phone and orders when the screen only needs the name.
Under-fetching: one endpoint does not return enough, so the client needs several round trips.
The trade-off is added complexity on the server, so reach for GraphQL when the data-fetching pain is real, not by default.
API versioning: evolve without breaking clients
Today's response for GET /api/v1/products/101 looks like this:
{
"id": 101,
"name": "Arduino",
"price": 599
}
A year later you want clearer field names:
{
"productId": 101,
"productName": "Arduino",
"sellingPrice": 599
}
If you change the existing endpoint in place, every client that reads price breaks. So you publish the new shape as a new version and keep the old one running:
/api/v1/products -> existing clients
/api/v2/products -> new clients
This matters because you cannot force everyone to upgrade overnight. If 100,000 users are on a mobile app that calls v1, many of them will not update the day you ship v2. The usual lifecycle is:
Ship v2 for new clients.
Maintain v1 while existing clients migrate.
Announce a deprecation, then retire v1.
The rule of thumb: adding optional fields is usually safe, while renaming or removing fields, or changing their meaning, is a breaking change that needs a new version. Path versioning (/v1/, /v2/) is the simplest to see and test, which is why this post uses it.
Pagination: never return everything
Imagine the store has 5,000,000 products. A bare GET /products that tries to return all of them would be slow, memory-hungry and useless to a screen that shows 20 items. Pagination returns the data in slices.
Offset pagination
The client asks for a page number and size:
GET /products?page=0&size=20
{
"content": [ ... ],
"page": 0,
"size": 20,
"totalElements": 5000000
}
Under the hood this is LIMIT 20 OFFSET 40 for page three. It is simple and lets users jump to any page, but a huge offset such as OFFSET 1,000,000 can force the database to scan and skip a large number of rows before returning anything.
Cursor pagination
Instead of a page number, the client says "continue from this position":
GET /products?limit=20&cursor=abc123
{
"data": [ ... ],
"nextCursor": "xyz789"
}
The next call passes cursor=xyz789. This is common for feeds and for data that changes while the user scrolls, because the position is anchored to a record rather than to a page count.
Keyset pagination
Keyset pagination is the database-level idea behind many cursors: continue after a specific key.
SELECT *
FROM products
WHERE id > 105
ORDER BY id
LIMIT 20;
If id is indexed, the database can jump straight to the starting point, which keeps it efficient on very large tables.
Choosing between them
| Type | Example | Good for |
|---|---|---|
| Offset | page=10 |
Simple APIs, page-number navigation |
| Cursor | cursor=abc |
Feeds and frequently changing data |
| Keyset | id > 100 |
Very large datasets |
A quick way to remember: offset says "go to page 10", cursor says "continue from this position", keyset says "continue after this key".
HTTP status codes: say what actually happened
Returning 200 for everything, with the real outcome buried in the body, forces every client to parse your messages just to know whether a call worked. The right status code tells clients, proxies and monitoring tools what happened before they read a single byte of the body.
| Code | Name | When to use it |
|---|---|---|
200 |
OK | GET /products/101 found the product |
201 |
Created | POST /products created a new product |
204 |
No Content | DELETE /products/101 succeeded and there is nothing to return |
400 |
Bad Request | The request itself is invalid, e.g. "age": "hello" where a number is expected |
401 |
Unauthorized | Authentication problem: missing, invalid or expired JWT |
403 |
Forbidden | Authenticated, but not allowed |
404 |
Not Found | GET /products/999999 and no such product exists |
409 |
Conflict | The request clashes with current state, e.g. signing up with an email that already exists |
429 |
Too Many Requests | Rate limit exceeded, e.g. 150 requests against a limit of 100 per minute |
500 |
Internal Server Error | Unexpected server-side failure |
400 vs 401 vs 403
This trio is a favourite interview question.
400: the request is malformed. The problem is what you sent.
401: "Who are you?" There is no valid proof of identity: no JWT, an invalid one, or an expired one.
403: "I know who you are, but you're not allowed to do this." A user with the
CUSTOMERrole calls/admin/productswith a perfectly valid JWT. Authentication passed; authorization failed.
Do not leak internals on a 500
A 500 should be a generic failure from the client's point of view. Never return something like this to public clients:
{
"error": "NullPointerException at ProductService.java line 87"
}
Log the details on the server, and return a short message with an error id the support team can look up.
Putting it together: a complete product API
Here is the product API for the store, using every idea above: resource-style URLs, a version prefix, cursor pagination and correct status codes.
List products (cursor pagination, 200 OK):
GET /api/v1/products?limit=20&cursor=abc123
{
"data": [
{ "id": 101, "name": "Arduino Uno", "price": 599 }
],
"nextCursor": "xyz789"
}
Get one product (200 OK, or 404 Not Found if it does not exist):
GET /api/v1/products/101
Create a product (201 Created):
POST /api/v1/products
{
"name": "Arduino Uno",
"price": 599,
"stock": 50
}
Update one field (200 OK):
PATCH /api/v1/products/101
{
"price": 649
}
Delete a product (204 No Content):
DELETE /api/v1/products/101
For operations that must not run twice, such as POST /payments, add an Idempotency-Key header. For errors, use 400 for bad input, 401 for a missing or invalid token, 403 for a valid user without permission, and 409 for conflicts such as duplicate emails.
The design is deliberately boring, and that is the point: every endpoint follows the same pattern, so clients can predict how the next one behaves.
Interview questions and model answers
Basic
What is REST? An architectural style for networked applications built on resources, HTTP methods, stateless communication and a uniform interface.
What is the difference between PUT and PATCH? PUT is generally used to replace or update a whole resource representation. PATCH applies a partial modification to specific fields.
What is statelessness? Each request carries enough information for the server to process it independently, without relying on earlier requests.
Intermediate
What is idempotency? An operation is idempotent when repeating it produces the same final state as performing it once.
Is POST idempotent? Generally not, because repeating it can create multiple resources. For operations like payment creation, an idempotency key makes retries safe.
Why do we need API versioning? To evolve an API without unexpectedly breaking existing clients.
System design
How would you prevent duplicate payments? The client sends POST /payments with a unique Idempotency-Key. The payment service checks whether that key has been seen. If it has, it returns the stored result. If not, it processes the payment and stores the key together with the result, so any retry gets the same answer and the customer is charged once.
Key takeaways
Do not memorize REST as "GET, POST, PUT, DELETE". That is only the surface. The ideas in this post connect into one chain:
API design -> idempotency -> safe retries -> a reliable distributed system.
Name resources with nouns; let the HTTP method be the verb.
Stay stateless so any server instance can handle any request.
Know which methods are idempotent:
GET,PUTandDELETEyes,POSTgenerally no.Use idempotency keys for operations like payments, where a retry must not repeat the effect.
Use REST for public APIs, consider gRPC between internal services, and consider GraphQL when clients need flexible data shapes.
Version the API so clients are not broken by change.
Paginate every list; prefer cursor or keyset pagination for large or changing data.
Return the correct status code, and never leak internal errors.
Once the link between API design and safe retries is clear, much of senior-level system design (retries, timeouts, rate limiting and API contracts) starts to make sense.


