Most developers agree that naming things is one of the hardest parts of our job. Designing a clear, effective RESTful API comes in as a close runner-up.
On the surface, REST makes a lot of sense. Compared to its dominant predecessor, SOAP, which involved hammering a POST endpoint with unwieldy XML requests, REST feels like a breath of fresh air. By combining an HTTP Method with a URI (Uniform Resource Identifier), you can create (POST), read (GET), update (PUT or PATCH), and delete (DELETE) resources. Simple and intuitive, right?
Not quite. HTTP methods were originally designed to manage documents, not to model complex business processes. They assume that all you need to do with a resource is create, read, update, or delete it. In reality, business applications often require more: resources might need to be approved, cancelled (without being deleted), paid, or moved between different states. Sometimes, a process even spans several resources at once. Mapping these kinds of business actions to a RESTful API is not always straightforward.
The good news is you don’t need to give up on REST to address these challenges. In this guide, I’ll walk you through how to design RESTful APIs that clearly communicate business intent. We’ll start by choosing which resources to expose, then move on to modeling more complex operations as your API grows.
By the end, you’ll have a practical framework you can apply to your next RESTful API design.
Choose Your Resources Carefully
RESTful APIs are built around the concept of resources. A resource is any entity you expose as an endpoint, such as a Customer at /customers or an Invoice at /invoices. Choosing the right resources is essential for designing an API that feels intuitive and communicates intent clearly. If you get this step wrong, your API will be awkward and confusing for consumers.
A common pitfall is to expose every table in your database as a resource. Unless you’re building a simple CRUD API, this approach usually leads to problems. Not all entities in your database are equal. Some are primary entities with their own lifecycles, while others only exist as part of a parent entity.
Consider a billing system that has two entities: Invoices and InvoiceLineItems. A naive, database-oriented approach would be to map each entity to its own root-level endpoint:
GET /invoices/{invoice_id}
GET /invoice-lines/{line_id}
PATCH /invoice-lines/{line_id}
DELETE /invoice-lines/{line_id}
This flat structure introduces several problems.
First, it breaks encapsulation. A line item can’t exist on its own; it always belongs to an invoice. With this URI structure, that relationship is hidden. There’s no way to tell which invoice a line item belongs to just by looking at the endpoint. This also complicates authorization, since you now need extra logic to check whether the caller has permission to access the parent invoice for a given line item.
Second, it hides important business rules. For example, once an invoice is finalized, it should be locked, and no further changes should be allowed, including changes to its line items. If line items are exposed as root resources, it suggests they have their own lifecycle and can be changed at any time, regardless of the invoice’s state. This can lead to confusion and errors.
Stripe avoids these problems by only ever exposing line items as a sub-resource on an invoice:
GET /v1/invoices/{invoice_id}/lines
There’s no /invoice-lines endpoint for fetching, updating, or deleting line items directly. All operations must go through the parent invoice. By nesting line items under invoices, the URI makes the relationship between the two entities clear.
This design brings additional benefits. Your authorization middleware can extract the invoice ID from the URL and check access before the request reaches your business logic. Validation can also check the invoice’s state and reject changes if the invoice is finalized. API consumers can immediately see the relationship between invoices and line items just by reading the URL.
| 📐 Principle 1 |
|---|
| Identify the primary entities in your domain and expose them as root-level resources. You should then scope every other entity as a sub-resource of its parent. If an entity can’t exist without another, the URI should reflect that dependency. |
Break Resources Into Sub-Resources
Once you have identified your main resources, take a closer look to see if any can be broken down further. Sometimes, even if a resource matches a single database table, there are groups of fields that represent a separate concept and are usually read or updated together.
When you spot these groupings, consider breaking them out into their own sub-resources. This makes your API much more intuitive. Instead of sending generic updates to the parent resource, where the intent can be lost in the request body, API consumers can use a dedicated endpoint that clearly shows what they are trying to do.
GitHub’s REST API does this well with repository Pages configuration. A repository has core settings, its name, description, visibility, whether issues are enabled, merge strategies, etc. These are all managed through a single endpoint:
PATCH /repos/{owner}/{repo}
{
"description": "An updated description",
"homepage": "https://example.com",
"has_issues": true,
"allow_squash_merge": true
}
GitHub also lets you host a dedicated website for your repository using GitHub Pages. Setting this up involves a different set of fields: the source branch for the website, the directory to publish from, and an optional custom domain. Since each repository can only have one Pages site, you might be tempted to include these fields on the main resource:
PATCH /repos/{owner}/{repo}
{
"description": "An updated description",
"pages_source_branch": "main",
"pages_source_path": "/docs",
"pages_cname": "www.example.com"
}
The problem is that these are two separate processes. Updating a repository’s core settings is not the same as configuring GitHub Pages. If you combine them, the server has to inspect every PATCH request to decide whether to trigger a Pages deployment or just update the repository’s description. This also hides the intent from anyone reading the request URL.
GitHub avoids this by breaking Pages out into its own sub-resource:
POST /repos/{owner}/{repo}/pages
GET /repos/{owner}/{repo}/pages
PUT /repos/{owner}/{repo}/pages
DELETE /repos/{owner}/{repo}/pages
This way, configuring a Pages site becomes a focused, self-contained call:
PUT /repos/{owner}/{repo}/pages
{
"source": {
"branch": "main",
"path": "/docs"
},
"cname": "www.example.com"
}
The request above makes the intent much clearer. The business process is obvious from the URI: you are updating the Pages site for the repository. The server-side endpoint is also simpler, since it only needs to validate fields related to Pages.
| 💡 Sub-Resources vs. Query Parameters |
|---|
A common point of confusion when organizing child entities is deciding between a sub-resource path (GET /invoices/{invoice_id}/payments) and a query parameter (GET /payments?invoice_id={invoice_id}).
It boils down to context and global relevance. If a child entity strictly belongs to its parent and you will only ever access it through that parent’s context, use a sub-resource. However, if the entity needs to be queried globally, for example, if an accounting team needs to search across all payments regardless of which invoice they belong to, it should be a root-level resource. In that case, use query parameters to filter the global list down to a specific parent ID. |
| 📐 Principle 2 |
|---|
| Look for groups of fields on a resource that serve a different purpose or process from the rest. If updating those fields is a distinct action (i.e., performed by different people, done at different times, or for different reasons), promote them to a sub-resource with its own URI. |
Model Actions as Sub-Resources
At this point, you’ve identified your resources and sub-resources. So far, everything fits neatly into standard CRUD operations: create, read, update, and delete. But what if you need to support actions that go beyond these basics?
Many business processes require actions beyond simple field updates. For example, a customer might pay an invoice, or an admin might issue a refund. These aren’t simple field updates. They’re events that happen to a resource, often with their own data and side effects.
PayPal’s Invoicing API handles this well. Consider what happens when a customer pays an invoice. A naive approach might model this as a status update:
PATCH /v2/invoicing/invoices/{invoice_id}
{
"status": "paid",
"payment_method": "bank_transfer",
"amount": 150.00
}
The approach above has several drawbacks. First, the request URL suggests you’re just updating the invoice, but the fact that you’re recording a payment is hidden in the request body. The server has to inspect every request to determine whether a payment’s being made and which side effects to trigger. This method also only supports single payments, even though invoices can often be paid in multiple installments.
PayPal avoids this by turning the action of paying an invoice into a noun and modeling it as a sub-resource:
POST /v2/invoicing/invoices/{invoice_id}/payments
{
"method": "BANK_TRANSFER",
"amount": {
"currency_code": "USD",
"value": "150.00"
}
}
Now, the intent is clear from the request URL, and the server-side logic only needs to handle payments. Using a POST to this endpoint also makes it clear that you can record multiple payments for the same invoice.
This approach encourages you to think of a payment not as an update to the invoice’s status, but as creating a new payment on the invoice. The verb ‘pay’ becomes the noun ‘payment.’ Once you identify this noun, you may realize it deserves its own data model, with its own fields, history, and lifecycle.
You can also apply this technique to actions that only happen once. For example, if an invoice needs to be approved before being sent, you can model this state change with the following endpoint:
PUT /v2/invoicing/invoices/{invoice_id}/approval
{
"notes": "Reviewed and approved."
}
Notice how the example uses a PUT request. This is because an invoice can only be approved once. The PUT verb signals that this action is idempotent: if you try to approve an invoice that is already approved, nothing changes.
A good example of this in action is the GitHub REST API, which uses PUT /repos/{owner}/{repo}/pulls/{pull_number}/merge to merge a PR. The PUT method clearly indicates that this can be done only once. Sending the same request again won’t create multiple merges.
| 📐 Principle 3 |
|---|
When modeling custom actions on a resource, try to identify a noun to represent that action and then map it as a sub-resource. Use the POST verb if calling the same endpoint is not idempotent, meaning it triggers the action with every call. If an endpoint is idempotent and produces no additional changes when called multiple times, the PUT verb can convey this. |
Promote Complex Actions to Root-Level Resources
The previous approach works well when an action is tied to a single resource. But sometimes, an action doesn’t fit neatly under one resource and instead coordinates several resources at once. If you try to attach such an action as a sub-resource, the API becomes awkward. The action is larger than its parent, and the URI doesn’t make this clear.
Consider sending money internationally. You need to lock in an exchange rate with a quote, define the recipient, and then actually execute the transfer. You could try modeling this as a sub-resource on one of the entities involved:
POST /v1/quotes/{quote_id}/execute
This doesn’t feel right. A quote is just a snapshot of an exchange rate; it’s not something you execute. The real action is the transfer itself.
Wise’s API models this particular action as a root-level resource: a transfer. Take a look at their endpoint implementation:
POST /v1/transfers
{
"targetAccount": 12345678,
"quoteUuid": "4da8f7f2-6f73-4dad-b1dd-0a5e67bf2fc1",
"customerTransactionId": "b07d1633-60ef-4852-bd8e-1a321b8f25c4",
"details": {
"reference": "Invoice 12345"
}
}
The transfer refers to the quote and the recipient, but it’s not a child of either. It’s an entity with its own ID, status, and lifecycle. You can retrieve it later using GET /v1/transfers/{transfer_id}. It also appears in both the sender’s and receiver’s transaction history.
A helpful way to decide whether an action should be its own root-level resource is to consider how people refer to it in the business. If you say things like ‘check the status of transfer X,’ ‘cancel order Y,’ or ‘look up booking Z,’ you’re talking about root-level entities. If you say ‘close the customer’s account,’ the closure is an action on the customer and should probably be a sub-resource.
| 📐 Principle 4 |
|---|
| When an action doesn’t naturally belong to a single parent resource, don’t force it into one. If the action can have its own identity, status, and lifecycle, promote it to a root-level resource. |
Fall Back to Verbs When Nouns Don’t Fit
The last two sections showed how to turn an action verb into a noun, such as turning ‘pay’ into ‘payments.’ However, sometimes there’s no meaningful noun for a particular action.
In those cases, it’s acceptable to use the verb directly.
Stripe’s Invoicing API is a good example. They let you void an invoice that’s no longer valid and shouldn’t be paid. If you try to map this to a noun, you might end up coming up with nouns that actually don’t clearly communicate the action anymore.
For example,
POST /v1/invoices/{invoice_id}/voidances
What’s a voidance? Most business users wouldn’t use that term. They simply refer to the action itself. In this situation, forcing a noun actually makes the API less clear.
To solve this, Stripe just uses the verb directly:
POST /v1/invoices/{invoice_id}/void
This URI clearly conveys the action as business users would describe it.
While this isn’t strictly RESTful, leaning more toward an RPC (Remote Procedure Call) design, it is a pragmatic, highly effective alternative when nouns fail. Remember, RESTful principles are meant to help you create a clear API contract. If following them strictly makes your API harder to understand, it’s better to make a practical compromise.
If you really don’t want verbs in your URI path, you can also consider following Google’s AIP-136, which advocates for splitting the URI resource and action with a colon, like this:
POST /v1/invoices/{invoice_id}:void
This is another clear way to add the verb to the URL, and it separates the resource from the action. While this pattern isn’t as common as the slash syntax, it’s a solid alternative you can consider.
| 📐 Principle 5 |
|---|
| Not every action has a noun hiding behind it. If a noun doesn’t clearly communicate the action anymore, just stick to the verb. |
Conclusion
The five patterns in this guide give you a set of steps to follow when designing API endpoints. Each pattern is a tool for a specific situation:
- Start with your root resources. Identify the main entities in your domain and scope child entities beneath them.
- Break out sub-resources when a group of fields on a resource represents a different process from the rest.
- Model actions as sub-resources when a verb can become a noun. In these instances, your action will usually have its own data and parameters.
- Promote to root-level resources when an action doesn’t map neatly to a single resource.
- Fall back to the verb when there’s no meaningful noun behind a verb.
It’s best to follow these steps in order. Start by identifying your high-level resources before moving on to the other steps. Only use verbs in the URI as a last resort, after you’ve tried all other options.
Remember, your API should communicate business intent to its consumers, not just reflect implementation details. Your API is not simply a wrapper around your database. It’s an interface for your business processes.
