5 min read
On this page

Resource Modeling

REST APIs are organized around resources, not actions. A resource is a thing — a user, an order, a product, an invoice. The URL is its address. HTTP methods are the verbs. This separation is the foundation of REST, and getting it right determines whether your API is intuitive or confusing.

If you find yourself naming endpoints like /getUser, /createOrder, or /sendEmail, you are thinking in actions. REST already has verbs — they are HTTP methods. The endpoint names should be nouns.

Think in Resources, Not Actions

A resource is any concept your API exposes. It does not need to map one-to-one to a database table. It represents a business concept that consumers care about.

Good (resources):          Bad (actions):
GET    /users              GET  /getUsers
POST   /users              POST /createUser
GET    /users/123          GET  /fetchUserById?id=123
PATCH  /users/123          POST /updateUser
DELETE /users/123          POST /deleteUser

The left column uses HTTP methods to express the action and URL paths to identify the resource. The right column duplicates the verb in the URL, which is redundant and inconsistent.

HTTP Methods Map to CRUD

Each HTTP method has a well-defined meaning:

Method Operation Idempotent Safe Example
GET Read a resource or list Yes Yes GET /orders/456
POST Create a new resource No No POST /orders
PUT Replace a resource entirely Yes No PUT /orders/456
PATCH Update specific fields No* No PATCH /orders/456
DELETE Remove a resource Yes No DELETE /orders/456

*PATCH can be made idempotent with careful design, but the HTTP spec does not require it.

A full lifecycle with Stripe's customer resource:

POST /v1/customers
  Body: {"email": "jane@example.com", "name": "Jane Doe"}
  Response: 201 Created
{
  "id": "cus_abc123",
  "object": "customer",
  "email": "jane@example.com",
  "name": "Jane Doe",
  "created": 1677000000
}
GET /v1/customers/cus_abc123
  Response: 200 OK  (returns the same object)

PATCH /v1/customers/cus_abc123
  Body: {"name": "Jane Smith"}
  Response: 200 OK  (returns the updated object)

DELETE /v1/customers/cus_abc123
  Response: 200 OK  (returns the deleted object with "deleted": true)

Naming Conventions

Use Plural Nouns

Use /users, not /user. The collection is plural; a specific item within it is identified by ID. This is consistent regardless of whether you are listing or fetching:

GET  /users        # list of users (plural makes sense)
GET  /users/123    # one user (still part of the users collection)
POST /users        # add to the users collection

Use Lowercase with Hyphens

URLs are case-sensitive in practice. Stick to lowercase. Use hyphens for multi-word resources:

Good: /line-items, /payment-methods, /shipping-addresses
Bad:  /lineItems, /PaymentMethods, /shipping_addresses

Stripe uses underscores in URL paths (/payment_intents), which is also acceptable — the key is consistency within your API.

Avoid Deep Nesting

Two levels of nesting is usually the maximum before URLs become unwieldy:

Good:  /users/123/orders
Fine:  /users/123/orders/456/items
Bad:   /users/123/orders/456/items/789/reviews/101/comments

For deeply nested resources, promote them to top-level with a filter:

Instead of: /users/123/orders/456/items/789/reviews
Use:        /reviews?order_item_id=789

Sub-Resources

Sub-resources represent relationships. An order belongs to a user. A comment belongs to a pull request. The URL structure expresses this ownership:

GET  /users/123/orders          # all orders for user 123
POST /users/123/orders          # create an order for user 123
GET  /users/123/orders/456      # order 456 for user 123

GitHub uses sub-resources extensively:

GET /repos/octocat/Hello-World/issues                    # list issues
GET /repos/octocat/Hello-World/issues/42                 # get issue 42
GET /repos/octocat/Hello-World/issues/42/comments        # comments on issue 42
POST /repos/octocat/Hello-World/issues/42/comments       # add a comment
GET /repos/octocat/Hello-World/issues/42/labels          # labels on issue 42

The URL reads like a sentence: "In the repo Hello-World owned by octocat, get issue 42's comments."

Collection Resources

List endpoints return collections. A well-designed collection response includes metadata for pagination and the items themselves:

{
  "object": "list",
  "url": "/v1/charges",
  "has_more": true,
  "data": [
    {
      "id": "ch_abc",
      "object": "charge",
      "amount": 2000,
      "currency": "usd",
      "status": "succeeded"
    },
    {
      "id": "ch_def",
      "object": "charge",
      "amount": 5000,
      "currency": "usd",
      "status": "pending"
    }
  ]
}

Collections should support filtering, sorting, and pagination through query parameters:

GET /orders?status=shipped&sort=-created_at&limit=20&starting_after=ord_xyz

Singleton Sub-Resources

Some sub-resources are not collections — they are one-to-one relationships. A user has one profile, not many:

GET  /users/123/profile        # get the profile (not a list)
PUT  /users/123/profile        # replace the profile
PATCH /users/123/profile       # update the profile

There is no POST because the profile is created with the user. There is no DELETE because the profile exists as long as the user exists.

Actions That Do Not Map to CRUD

Some operations do not map cleanly to create, read, update, or delete. Sending an email, canceling an order, or merging a pull request are actions, not resource manipulations.

Two common approaches:

Sub-Resource as Action

Treat the action as creating a sub-resource:

POST /orders/456/cancellations     # cancel the order
POST /emails/789/sends             # send the email
POST /repos/owner/repo/pulls/42/merge  # merge the pull request

GitHub uses this pattern for merging pull requests:

PUT /repos/octocat/Hello-World/pulls/42/merge

State Transition via PATCH

Update the resource's state field:

PATCH /orders/456
Body: {"status": "canceled"}

Stripe uses this for confirming payment intents:

POST /v1/payment_intents/pi_abc/confirm

Both approaches work. The sub-resource pattern is better when the action has its own data (cancellation reason, merge strategy). The PATCH pattern is better when the action is a simple state change.

Resource Identity

Every resource needs a stable, unique identifier. Use opaque IDs, not sequential integers:

Good: "id": "cus_abc123"     (prefixed, opaque)
Good: "id": "550e8400-e29b-41d4-a716-446655440000"  (UUID)
Bad:  "id": 42               (sequential, guessable)

Stripe prefixes IDs with the resource type (cus_ for customers, ch_ for charges, sub_ for subscriptions). This makes debugging easier — you can identify the resource type from the ID alone without any context.

Common Pitfalls

  • Verbs in URLs/getUser, /createOrder, /deleteItem. HTTP methods already provide the verb. The URL should identify the resource, not the action.
  • Mapping to database tables — exposing user_settings_join_table or order_line_items_v2 as API resources. Resources represent business concepts, not storage details.
  • Inconsistent pluralization/users/123 but /order/456. Pick plural and stick with it.
  • Using POST for everythingPOST /api/getUsers, POST /api/deleteUser. This ignores HTTP semantics entirely and makes caching, logging, and monitoring harder.
  • Exposing internal IDs — auto-incrementing database IDs leak information (how many users you have, how fast you are growing) and are trivially enumerable.
  • Deeply nested URLs/users/123/orders/456/items/789/reviews/101. Flatten deep hierarchies by promoting sub-resources to top-level endpoints with filters.
  • Ignoring resource relationships — returning only IDs for related resources instead of URLs or expandable objects, forcing consumers to guess how to fetch related data.

Key Takeaways

  • REST APIs are organized around resources (nouns), not actions (verbs). HTTP methods provide the verbs.
  • Use plural nouns for collection endpoints: /users, /orders, /products.
  • Sub-resources express ownership: /users/123/orders means "orders belonging to user 123."
  • Keep URL nesting shallow — two levels is usually enough. Promote deeply nested resources to top-level with query filters.
  • Actions that do not map to CRUD can be modeled as sub-resources (POST /orders/456/cancellations) or state transitions (PATCH /orders/456 with a status field).
  • Use opaque, prefixed IDs for resource identity. Sequential integers leak information and are easy to enumerate.