Skip to content

REST API Design: A Contract People Can Guess

Design resource-oriented HTTP APIs that use methods, status codes, pagination, and idempotency consistently enough that clients can predict them.

Builds on Routing: How a Request Finds Its Code and Validation and Transformation: Making Requests Safe to Use

Stage 5core5 min read

REST is not a framework and it is not a checklist that magically makes an API good. It is an architectural style built around HTTP’s existing vocabulary: resources have names, representations move between client and server, and methods describe the intended operation.

The real value is predictability. A frontend developer should be able to make a reasonable guess that GET /projects/42/tasks lists tasks, POST creates one, and a 404 means the thing is not there. An API that requires a meeting before anyone can use it has already made work harder than necessary.

Start with the things your product has

A resource is a noun meaningful to the product: a project, task, user, invoice, or comment. The endpoint names the resource; HTTP describes what to do with it.

GET    /projects             # list projects
POST   /projects             # create a project
GET    /projects/42          # read one project
PATCH  /projects/42          # change part of it
DELETE /projects/42          # remove it

Avoid RPC-shaped URLs where the operation is hidden in the path:

POST /createProject
GET  /getAllProjects
POST /project/42/delete

Those can work technically, but they throw away useful HTTP conventions and tend to grow into a grab bag of verbs. There are legitimate actions that are not simple CRUD—POST /tasks/7/complete can be clearer than pretending completion is a generic field update—but make them explicit domain actions, not a default style.

Methods say something important

MethodUsual intentSafe to retry?
GETRead without changing server stateYes
POSTCreate under a collection or trigger a non-idempotent actionNot automatically
PUTReplace a known resource representationYes, when implemented as replacement
PATCHApply a partial changeDepends on the patch semantics
DELETERemove a resourceUsually yes; deleting an absent resource needs a defined policy

The phrase idempotent means that repeating the same request has the same intended end state as doing it once. It does not mean the response must be byte-for-byte identical. PUT /projects/42 with the same full representation can safely be retried after a network timeout. POST /payments cannot be assumed safe: sending it twice might charge twice.

For important creates, support an idempotency key. The client generates a random key, sends it with the request, and the server stores the result associated with that key. A retry receives the original outcome rather than creating a second payment or order.

Loading diagram…

PUT and PATCH are not interchangeable

Use PUT when the client sends the complete replacement representation and knows the resource URL. Use PATCH when the client changes selected fields.

PATCH /projects/42
Content-Type: application/json

{ "name": "Q4 launch" }

Do not silently treat missing fields in a PUT body as “leave unchanged”; that is PATCH behaviour wearing the wrong name. Whichever semantics you choose, document them and test what null, an absent field, and an empty array mean.

Status codes are part of the language

You do not need every HTTP status code memorised. Get the common ones right:

SituationResponse
Read succeeded200 OK
Resource created201 Created (often with a Location header)
Accepted for later work202 Accepted
Successful request with no body204 No Content
Malformed or invalid request400 Bad Request
No valid identity supplied401 Unauthorized
Identified caller lacks permission403 Forbidden
Resource does not exist, or should not be revealed404 Not Found
Conflict with current state, such as duplicate unique value409 Conflict
Valid shape but rejected by a business constraintcommonly 422 Unprocessable Content

The 401/403 distinction is worth preserving. 401 says “we do not know who you are yet”; 403 says “we know who you are and this is not allowed.”

Collections need a stable shape

Listing endpoints eventually need filtering, sorting, and pagination. Name the controls consistently across the API:

GET /tasks?status=open&assigneeId=me&sort=-updatedAt&cursor=eyJpZCI6...&limit=25

For small internal tools, numbered pages can be fine. For a changing feed or large table, cursor pagination avoids the “item moved between page 1 and page 2” problem that offset pagination creates. Return enough metadata for a client to continue, rather than making it reconstruct a cursor:

{
  "data": [{ "id": "t_123", "title": "Send proposal" }],
  "page": { "nextCursor": "eyJpZCI6...", "hasMore": true }
}

Choose a response envelope only if it earns its place. A consistent data field can leave room for metadata and error conventions; a naked object is perfectly readable for a small API. Consistency within one API matters more than copying a fashionable style.

Versions are for broken contracts

An API version is a migration tool, not a badge. If a change is backwards-compatible—say, adding an optional response field—prefer to evolve in place. If you must remove or redefine a field, a versioned path such as /api/v2/projects gives old clients a runway.

Publish the retirement date, track who still calls the old version, and offer a mechanical migration guide. Maintaining two versions forever merely turns one API into two products.

Design from a user journey

Before deciding endpoints, walk through a real screen or workflow. For a project board:

  1. load projects a user can see;
  2. open one project and fetch its tasks;
  3. create a task;
  4. change the task’s status;
  5. invite a collaborator.

Write the calls the UI genuinely needs. Then check for round trips, ambiguous nouns, duplicated filters, and permission boundaries. This produces simpler APIs than starting with database tables and exposing every CRUD operation.

The test of a good API

Hand the endpoint list to someone who did not design it. Can they explain what each call does, the expected status code, and what a retry means? If not, rename or document before the API gets consumers. Clear contracts are one of the few backend improvements that make every future team move faster.

Keep errors as intentional as success responses

Clients write code against failures too. A stable error shape means a form can highlight a field, a mobile app can decide whether to retry, and logs can group the same problem across releases.

{
  "error": {
    "code": "PROJECT_ARCHIVED",
    "message": "Tasks cannot be added to an archived project.",
    "requestId": "req_9d7a"
  }
}

The code is for programs; the message is for a human; the request id joins a customer report to server logs. Do not make the client parse English text to choose behaviour. Also, do not return every internal exception as 500. A duplicate unique field, a stale edit, and an unavailable dependency are different situations with different next steps.

REST is at its best when it remains boring. The endpoint names, methods, pagination, and errors should fade into the background so both teams can concentrate on the product behaviour they are building.

In this topic

Done with this topic?

How well do you know it?

PreviousValidation and Transformation: Making Requests Safe to Use