Skip to main content

Command Palette

Search for a command to run...

API Design Explained: REST, Idempotency, Versioning, Pagination and Status Codes

Updated
•16 min read•View as Markdown

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:

  1. Method: what you want to do. GET means "give me data".

  2. URL: which resource you are acting on. /api/v1/products/101 points at product 101.

  3. Headers: extra context such as the auth token and the content type.

  4. Body: the payload. GET normally has none; a POST carries 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:

  1. Ship v2 for new clients.

  2. Maintain v1 while existing clients migrate.

  3. 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 CUSTOMER role calls /admin/products with 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, PUT and DELETE yes, POST generally 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.