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 itAvoid RPC-shaped URLs where the operation is hidden in the path:
POST /createProject
GET /getAllProjects
POST /project/42/deleteThose 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
| Method | Usual intent | Safe to retry? |
|---|---|---|
GET | Read without changing server state | Yes |
POST | Create under a collection or trigger a non-idempotent action | Not automatically |
PUT | Replace a known resource representation | Yes, when implemented as replacement |
PATCH | Apply a partial change | Depends on the patch semantics |
DELETE | Remove a resource | Usually 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.
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:
| Situation | Response |
|---|---|
| Read succeeded | 200 OK |
| Resource created | 201 Created (often with a Location header) |
| Accepted for later work | 202 Accepted |
| Successful request with no body | 204 No Content |
| Malformed or invalid request | 400 Bad Request |
| No valid identity supplied | 401 Unauthorized |
| Identified caller lacks permission | 403 Forbidden |
| Resource does not exist, or should not be revealed | 404 Not Found |
| Conflict with current state, such as duplicate unique value | 409 Conflict |
| Valid shape but rejected by a business constraint | commonly 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=25For 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:
- load projects a user can see;
- open one project and fetch its tasks;
- create a task;
- change the task’s status;
- 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.