REST API Design Best Practices: Resources and Methods

REST API design best practices for modeling resources, choosing HTTP methods, safe retries, and success codes, with a sample API scored before and after.

On this page

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.

MethodSafeIdempotentUse it for
GETYesYesRead a resource or a collection. Filters go in the query string.
HEADYesYesSame as GET without a body, for checking existence or headers.
POSTNoNoCreate a resource in a collection, or ask a resource to process something.
PUTNoYesReplace the whole resource with the request body, or create it at a URL the client chose.
PATCHNoNo (can be made so)Change some fields of a resource.
DELETENoYesRemove the resource. A second DELETE has no further effect, even if it returns 404.
Safe and idempotent properties from RFC 9110 section 9.2; PATCH from RFC 5789.

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

  1. 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.

  2. 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.

  3. 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.

  4. 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

YAML
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 recorded

APIForge scored this spec 55.3 out of 100 and reported 15 issues.

CategoryBeforeAfterWhat the score flagged
Naming (max 20)1220Verb segments create and delete; camelCase in /getBooks and in the deep loans path
HTTP (max 20)12.420GET /getBooks carries a request body; action verbs in two paths
Structure (max 15)12.415The loans path is nested 7 segments deep
Consistency (max 15)7.515Both book and books appear as path segments
Versioning (max 10)110No /v1 or other version segment
Error handling (max 10)010None of the five operations documents a 4xx or default response
Documentation (max 10)1010Every operation already had a summary
Total55.3100
Output of npm run content:score on both files, which runs APIForge's deterministic scoring engine.

PreferAfter: resources, methods, versioning, and documented failures

YAML
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 limit

The 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

  1. Every path is a noun, and every action that needs a verb has become a resource or a documented exception.
  2. No resource is nested more than one level, and every resource has a URL of its own.
  3. GET and HEAD change nothing and carry no body. PUT and DELETE are idempotent in the implementation, not only on paper.
  4. Every POST that creates something accepts an idempotency key, and every PATCH supports If-Match.
  5. Each operation documents its success code and at least the error codes a client must handle.
  6. 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.

Frequently asked questions

Not by definition. RFC 5789 says PATCH is neither safe nor idempotent, because a patch such as add 1 to the counter changes the result each time. You can make a PATCH safe to retry by requiring If-Match with an ETag, so a repeated request fails with 412 instead of applying twice.

Rarely. Most actions become resources: lending a book creates a loan, and starting an export creates an export job. When no noun fits, a single POST endpoint for that action is acceptable if you document it and keep such endpoints few.

A safe method, such as GET or HEAD, does not ask the server to change anything. An idempotent method can change state, but repeating it has the same intended effect as sending it once, so PUT and DELETE are idempotent without being safe. All safe methods are also idempotent.

Sources

  1. RFC 9110: HTTP Semantics, section 9 (Methods)IETF
  2. RFC 5789: PATCH Method for HTTPIETF
  3. RFC 7396: JSON Merge PatchIETF
  4. RFC 9110: 201 Created and the Location header (POST, section 9.3.3)IETF