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_tableororder_line_items_v2as API resources. Resources represent business concepts, not storage details. - Inconsistent pluralization —
/users/123but/order/456. Pick plural and stick with it. - Using POST for everything —
POST /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/ordersmeans "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/456with a status field). - Use opaque, prefixed IDs for resource identity. Sequential integers leak information and are easy to enumerate.