REST API design best practices come down to a few decisions you make for every endpoint. Model the domain as resources named with nouns, such as /v1/books and /v1/loans, and let the HTTP method say what happens to them. Use each method the way RFC 9110 defines it: GET reads without side effects, PUT replaces, DELETE removes, POST creates or processes. Design every write so a client can retry it safely after a timeout. Return a status code that tells the client what happened without reading the body. Naming, versioning, and pagination follow from those choices and have their own guides, linked below.
Model resources, not actions
A resource is anything a client can name and act on: a book, a member, a loan, an export job. Each one gets two URL shapes, a collection such as /v1/books and an item such as /v1/books/{id}. The set of operations then falls out of the methods: list, create, read, replace, update, delete. When you catch yourself writing a verb into a path, like /book/create or /books/{id}/lend, there is usually a noun hiding in the verb.
- Turn actions into resources. Lending a book creates a loan, so POST /v1/loans with the book and member IDs in the body. Returning it updates the loan: PATCH /v1/loans/{id} with a returnedAt timestamp. The loan now has its own history, permissions, and URL.
- Turn long-running work into a job resource. POST /v1/exports returns 202 Accepted with a Location pointing to /v1/exports/{id}, which the client polls until the status is done.
- Nest only to show ownership, and only one level. /v1/members/{id}/loans reads well. /branches/{branchId}/shelves/{shelfId}/books/{id}/loans forces the client to know three IDs to reach one loan, and breaks the day a book moves shelves. Give every resource a top-level URL by its own ID and use nesting or query filters for the views.
- Keep a resource's shape the same everywhere it appears. If a book embedded in a loan has different field names from GET /v1/books/{id}, clients need two models for one thing.
Some operations do not map to create, read, update, or delete, such as sending a password reset email or recalculating a quote. A sub-resource that records the action, POST /v1/password-resets, usually reads better than a verb segment and gives you a place to return status. If no noun fits, a single controller-style POST endpoint is acceptable; document it clearly and keep it rare.
Use each HTTP method for its defined meaning
HTTP clients, caches, proxies, and crawlers make decisions from the method alone. RFC 9110 defines a method as safe when the client does not request any state change, and idempotent when sending the same request several times has the same intended effect on the server as sending it once. PATCH is defined separately in RFC 5789, which states that PATCH is neither safe nor idempotent.
| Method | Safe | Idempotent | Use it for |
|---|---|---|---|
| GET | Yes | Yes | Read a resource or a collection. Filters go in the query string. |
| HEAD | Yes | Yes | Same as GET without a body, for checking existence or headers. |
| POST | No | No | Create a resource in a collection, or ask a resource to process something. |
| PUT | No | Yes | Replace the whole resource with the request body, or create it at a URL the client chose. |
| PATCH | No | No (can be made so) | Change some fields of a resource. |
| DELETE | No | Yes | Remove the resource. A second DELETE has no further effect, even if it returns 404. |
A GET request with a JSON body is the most common method mistake. RFC 9110 says content in a GET request has no generally defined semantics and might lead some implementations to reject the request, and it tells clients not to send it unless the origin server has said it accepts one. Put filters in query parameters. If a search query is too large or too sensitive for a URL, make it a POST to a search resource, such as POST /v1/book-searches, and accept that it is not cacheable.
PUT or PATCH
PUT sends the complete new state, and the server replaces what it had. Any field the client leaves out is gone or reset, so a client must read the resource first and send it all back. PATCH sends instructions for a change. With JSON Merge Patch (RFC 7396, application/merge-patch+json) the body is a partial object: fields you omit stay as they are, and a field set to null is removed. Offer PATCH for normal edits, because clients rarely hold the full resource and two clients editing different fields would otherwise overwrite each other. Offer PUT when the client owns the whole document, such as a settings object or a file at a known path.
Design every write for retries
Networks fail after the server has done the work but before the client sees the response. The client then has to guess whether to send the request again. RFC 9110 notes that idempotent requests can be repeated automatically when a communication failure occurs before the client reads the response, so automatic retry logic in clients and gateways should stay limited to idempotent methods. A design that is safe to retry saves you from duplicate orders and double charges. The idempotency glossary entry has a short definition you can share with your team.
Make each write method safe to repeat
PUT and DELETE
Keep them idempotent in your implementation. A PUT that also appends an audit row is fine, since RFC 9110 allows side effects such as logging. A PUT that increments a counter is not.
POST
Accept a client-generated key in a request header, commonly named Idempotency-Key. Store the key with the response for a fixed period. When the same key arrives again, return the stored response instead of creating a second resource. Reject a reused key that comes with a different body.
PATCH
RFC 5789 recommends conditional requests: the client sends the ETag it last saw in If-Match, and the server applies the patch only if the resource has not changed. A stale ETag gets 412 Precondition Failed, which also stops two editors from silently overwriting each other.
Client-chosen IDs
If the client can generate the ID, PUT /v1/books/{id} becomes an idempotent create. Sending it twice leaves one book.
Return status codes that match the method
Success codes carry information too. Use 200 for a GET, PUT, or PATCH that returns the resource. Use 201 when a POST creates something; RFC 9110 says the server SHOULD send a Location header that identifies the new resource. Use 202 when work is accepted but not finished, with a URL to poll. Use 204 when there is nothing to return, such as a DELETE. Never send 200 with an error object inside: monitoring will count the failure as a success. Error codes, the 400 versus 422 question, and the problem details body format are covered in REST API error handling, and every code is listed in the HTTP status codes reference.
Naming, versioning, and pagination in brief
- Naming: plural nouns for collections, lowercase kebab-case for multi-word segments such as /v1/book-searches, and one casing for JSON fields across the whole API. The API naming conventions guide covers paths, fields, query parameters, and headers.
- Versioning: decide before launch where the major version lives, in the path as /v1 or in a header, and what counts as a breaking change. The API versioning guide compares the options.
- Pagination: never return an unbounded collection. Pick offset or cursor pagination before the first list endpoint ships, because changing it later breaks every client. The pagination guide explains the trade-offs.
Worked example: a library API, scored before and after
Here is a small library API written the way many first versions are: verbs in paths, a GET with a body, a delete done with POST, singular and plural mixed, and loans buried under branches and shelves.
AvoidBefore: actions in paths, no versioning, no error responses
openapi: 3.0.3
info:
title: Library API
version: 1.0.0
paths:
/getBooks:
get:
summary: List books
requestBody:
content:
application/json:
schema:
type: object
properties:
author: { type: string }
responses:
"200":
description: Books
/book/create:
post:
summary: Add a book
responses:
"200":
description: Created
/books/{id}:
get:
summary: Get a book
parameters:
- name: id
in: path
required: true
schema: { type: string }
responses:
"200":
description: The book
/book/delete/{id}:
post:
summary: Delete a book
parameters:
- name: id
in: path
required: true
schema: { type: string }
responses:
"200":
description: Deleted
/branches/{branchId}/shelves/{shelfId}/books/{id}/loans:
post:
summary: Lend a book
parameters:
- { name: branchId, in: path, required: true, schema: { type: string } }
- { name: shelfId, in: path, required: true, schema: { type: string } }
- { name: id, in: path, required: true, schema: { type: string } }
responses:
"200":
description: Loan recordedAPIForge scored this spec 55.3 out of 100 and reported 15 issues.
| Category | Before | After | What the score flagged |
|---|---|---|---|
| Naming (max 20) | 12 | 20 | Verb segments create and delete; camelCase in /getBooks and in the deep loans path |
| HTTP (max 20) | 12.4 | 20 | GET /getBooks carries a request body; action verbs in two paths |
| Structure (max 15) | 12.4 | 15 | The loans path is nested 7 segments deep |
| Consistency (max 15) | 7.5 | 15 | Both book and books appear as path segments |
| Versioning (max 10) | 1 | 10 | No /v1 or other version segment |
| Error handling (max 10) | 0 | 10 | None of the five operations documents a 4xx or default response |
| Documentation (max 10) | 10 | 10 | Every operation already had a summary |
| Total | 55.3 | 100 |
PreferAfter: resources, methods, versioning, and documented failures
openapi: 3.0.3
info:
title: Library API
version: 1.0.0
paths:
/v1/books:
get:
summary: List books, optionally filtered by author
parameters:
- name: author
in: query
schema: { type: string }
responses:
"200":
description: A page of books
"400":
description: Invalid filter value
post:
summary: Add a book
responses:
"201":
description: Created. The Location header holds the new book URL.
"409":
description: A book with this ISBN already exists
"422":
description: The ISBN checksum is wrong
/v1/books/{id}:
parameters:
- name: id
in: path
required: true
schema: { type: string }
get:
summary: Get a book
responses:
"200":
description: The book
"404":
description: No book with this id
delete:
summary: Remove a book
responses:
"204":
description: Removed
"404":
description: No book with this id
/v1/loans:
post:
summary: Lend a book to a member
responses:
"201":
description: Loan created
"409":
description: The book is already on loan
"422":
description: The member has reached the loan limitThe author filter moved to a query parameter, creation became POST on the collection, deletion became DELETE on the item, and lending became a loans resource. The same spec scored 100.
The score flagged /getBooks for camelCase rather than for a verb, because it matches verbs only as whole segments; renaming it to /get-books would clear the casing issue and still be wrong. The score also cannot see several fixes in the after version: switching the create response from 200 to 201, using 204 for DELETE, and choosing 409 versus 422 earn no points, and it has no view of idempotency at all. Treat 100 as the floor for a design review, then check methods, success codes, and retry behavior by hand.
A design review before you ship
- Every path is a noun, and every action that needs a verb has become a resource or a documented exception.
- No resource is nested more than one level, and every resource has a URL of its own.
- GET and HEAD change nothing and carry no body. PUT and DELETE are idempotent in the implementation, not only on paper.
- Every POST that creates something accepts an idempotency key, and every PATCH supports If-Match.
- Each operation documents its success code and at least the error codes a client must handle.
- Collections are paginated, the major version is visible, and naming is consistent across paths and fields.
Paste your spec into the API quality score to get the same seven-category breakdown for your own API, then run a few of the changed endpoints in the API testing workbench to confirm the server returns the codes the spec now promises.