Open any codebase older than two years and you’ll find the scars: /getUser, /user_list, /Users/fetchAll, three different pagination schemes, and a customerID field sitting next to order_id in the same response. None of it breaks anything. All of it slows everyone down.
Naming is the cheapest API design decision you’ll ever make and the most expensive one to reverse. Once clients depend on /getOrders, you’re stuck supporting it for years. This guide gives you a concrete rule for every naming decision a REST API forces on you, with an example and an anti-example for each. It follows the same thinking as our broader REST API guidelines for developers, but zooms in on the part teams fight about most: what to call things.
If you’d rather enforce these rules with tooling than with code review comments, Apidog lets you define every endpoint visually against a shared schema before anyone writes code. More on that at the end.
Use plural nouns for collections
A URL names a resource, not an operation. Collections are sets of things, so name them as plural nouns.
Do:
GET /v1/products
GET /v1/products/89
GET /v1/orders
Don’t:
GET /v1/getProducts
GET /v1/product
GET /v1/productList
The plural form works at both levels. /products reads as “the collection of products” and /products/89 reads as “product 89 within the collection.” Singular naming forces awkward URLs like /product/89 for one item but /product for many, which reads wrong. The Microsoft REST API guidelines settled on plural nouns for exactly this reason, and most public APIs (Stripe, GitHub, Shopify) followed the same path.
One exception: singleton resources. If a user has exactly one cart, /users/42/cart is fine. Don’t pluralize something with a cardinality of one.
Keep verbs out of paths
The HTTP method is the verb. Putting another verb in the path duplicates information and breaks the resource model.
Do:
GET /v1/orders/42 (read it)
DELETE /v1/orders/42 (delete it)
PATCH /v1/orders/42 (update it)
Don’t:
GET /v1/fetchOrder/42
POST /v1/deleteOrder/42
POST /v1/updateOrderStatus
Verb-based paths also multiply your surface area. One resource with four methods becomes four endpoints to document, test, and cache separately. Cache invalidation gets worse too: a CDN can cache GET /v1/orders/42 and invalidate on DELETE /v1/orders/42 because both point at the same URL. It can’t connect /fetchOrder/42 to /deleteOrder/42.
Use kebab-case in URL paths
Multi-word path segments need a separator, and hyphens are the right one.
Do:
/v1/gift-cards
/v1/shipping-addresses
Don’t:
/v1/giftCards
/v1/gift_cards
/v1/GiftCards
Three reasons. Google treats hyphens as word separators for indexing, so public API docs rank better with kebab-case. Underscores disappear when a URL gets underlined in an email or doc. And camelCase in URLs invites case-sensitivity bugs: /giftCards and /giftcards are different URLs on most servers, and someone will type the wrong one. The Zalando RESTful API guidelines make kebab-case a MUST rule, and they’ve run this playbook across hundreds of internal services.
Pick one JSON casing and write it down
For field names inside request and response bodies, the honest answer is: camelCase and snake_case both work. What doesn’t work is mixing them.
Do (either one, consistently):
{ "orderId": 42, "createdAt": "2026-08-30T09:15:00Z", "totalAmount": 4999 }
{ "order_id": 42, "created_at": "2026-08-30T09:15:00Z", "total_amount": 4999 }
Don’t:
{ "orderId": 42, "created_at": "2026-08-30T09:15:00Z", "TotalAmount": 4999 }
camelCase maps cleanly to JavaScript and Java clients. snake_case is easier to scan and matches Ruby, Python, and most SQL column names; Stripe uses it everywhere. Pick based on who consumes your API most, then put the choice in your style guide so the debate happens once instead of on every pull request. Mixed casing is the single most common inconsistency in real-world APIs because different teams ship different endpoints. That’s a governance failure, not a taste failure.
Limit nesting to two levels
Nesting expresses ownership: /users/42/orders means “orders belonging to user 42.” That’s useful. Past two levels it stops being useful.
Do:
GET /v1/users/42/orders
GET /v1/orders/1337/refunds
Don’t:
GET /v1/users/42/orders/1337/refunds/7/status
Deep nesting forces clients to carry every ancestor ID to reach a leaf resource, even when the leaf has a globally unique ID of its own. If a refund has ID 7, expose it at /refunds/7 or /orders/1337/refunds/7 and stop there. A good smell test: if a URL contains three or more IDs, flatten it. Once an order exists, it doesn’t need its user in the path; /orders/1337 stands on its own.
Put filtering, sorting, and pagination in query parameters
Paths identify resources. Query parameters modify how you view them. Never encode a filter into the path.
Do:
GET /v1/orders?status=active&sort=-created_at&limit=50&cursor=eyJpZCI6NDJ9
GET /v1/products?category=electronics&min_price=1000
Don’t:
GET /v1/orders/active
GET /v1/orders/sorted-by-date-desc
GET /v1/getOrdersByStatusAndDate
The sort=-created_at pattern (minus prefix for descending) comes from the JSON:API spec and saves you a second order=desc parameter. Filter paths like /orders/active look harmless until you need to combine filters, and then you’re minting a new endpoint per combination. Pagination parameter names deserve the same discipline: pick limit/cursor or page/per_page once and reuse them on every collection. Our API pagination guide covers the cursor-versus-offset trade-off in depth; the naming rule here is simply to be uniform about it.
Version in the path
You have two mainstream options: a path segment (/v1/products) or a header (Accept: application/vnd.myapi.v1+json). Header versioning is more “pure” REST, since the URL keeps naming the same resource across versions, and the Google API design guidance notes both approaches exist in the wild. But path versioning wins on operational grounds: it’s visible in every log line, testable from a browser, cacheable without Vary gymnastics, and impossible for a client to forget. Every developer who’s debugged a “works in curl, fails in prod” issue caused by a missing version header knows the cost of the alternative. Use /v1/ with a major version only, no /v1.2/; minor changes should be additive and non-breaking. For the full decision tree, including content negotiation, see our comparison of API versioning strategies.
Treat resource IDs as opaque, and don’t leak sequential integers carelessly
/orders/41, /orders/42, /orders/43: sequential integer IDs tell anyone who looks exactly how many orders you process, and they invite enumeration attacks where an attacker walks the ID space probing for authorization gaps. This class of bug, broken object level authorization, sits at number one on the OWASP API Security Top 10.
Do:
GET /v1/orders/ord_9f8e2a71b3
GET /v1/users/550e8400-e29b-41d4-a716-446655440000
Don’t (when enumeration matters):
GET /v1/orders/42
GET /v1/invoices/10883
Prefixed random IDs like Stripe’s ord_9f8e2a71b3 are the strongest pattern: unguessable, self-describing in logs, and safe to expose. Authorization checks are still mandatory either way. Opaque IDs reduce the blast radius of a missing check; they don’t replace it. Internally you can keep integer primary keys; the rule is about what you expose in URLs.
Model non-CRUD actions as controller resources
Sooner or later you need an action with no clean CRUD mapping: cancel an order, retry a payment, resend an email. Don’t tunnel it through PATCH on a status field, and don’t put a verb at the top level.
Do:
POST /v1/orders/42/cancel
POST /v1/payments/pay_88a1/retry
Don’t:
PATCH /v1/orders/42 { "status": "cancelled" }
POST /v1/cancelOrder { "orderId": 42 }
This is the controller pattern, and it’s the one sanctioned exception to the no-verbs rule: the verb goes at the end of the path, scoped under the resource it acts on. The PATCH approach looks RESTful but hides a state machine inside a field update. Cancelling an order triggers refunds, releases inventory, and sends notifications; pretending it’s a field write forces your server to diff payloads to detect intent. A /cancel endpoint states the intent, gives the action its own permissions and audit trail, and leaves room for action-specific inputs like a cancellation reason.
Keep casing consistent for headers and query parameters
Two smaller surfaces, same discipline. Custom headers use Hyphenated-Pascal-Case, matching HTTP convention: Idempotency-Key, Request-Id. Skip the old X- prefix; it was deprecated by RFC 6648 in 2012. Header names are case-insensitive on the wire, but your docs and SDKs should still spell them one way.
Query parameters should match your JSON body casing. If your bodies use snake_case, write ?min_price=1000&created_after=2026-01-01, not ?minPrice=1000. A developer who reads created_at in a response and must type createdAfter in a query will get it wrong on the first try, and so will everyone after them.
The full rule set at a glance
| # | Rule | Do | Don’t |
|---|---|---|---|
| 1 | Plural nouns for collections | /products, /products/89 |
/getProducts, /productList |
| 2 | No verbs in paths | DELETE /orders/42 |
POST /deleteOrder/42 |
| 3 | kebab-case path segments | /gift-cards |
/giftCards, /gift_cards |
| 4 | One JSON casing, documented | order_id everywhere |
orderId and order_id mixed |
| 5 | Max two nesting levels | /orders/1337/refunds |
/users/42/orders/1337/refunds/7 |
| 6 | Filters and pagination in query params | ?status=active&sort=-created_at |
/orders/active |
| 7 | Major version in the path | /v1/products |
/v1.2/products, version headers |
| 8 | Opaque resource IDs | /orders/ord_9f8e2a71b3 |
/orders/42 (public, enumerable) |
| 9 | Controller pattern for actions | POST /orders/42/cancel |
PATCH with {"status":"cancelled"} |
| 10 | Consistent header and param casing | Idempotency-Key, ?min_price= |
X-IDEMPOTENCY_KEY, ?minPrice= mixed in |
Enforcing conventions at scale
A style guide in a wiki changes nothing. The teams whose APIs stay consistent share one habit: they design first and enforce the conventions before code exists, which is the core of API governance in practice.
This is where Apidog earns its place in the workflow. Endpoints are defined in a schema-first visual designer, so the path, casing, and parameter names are explicit design artifacts instead of strings buried in controller code. Shared components mean Pagination, Error, and Money schemas get defined once and reused across every endpoint; nobody re-invents per_page as pageSize on a new service. And because designs live in team workspaces with review built in, a lead can catch /getUserOrders at design time, when renaming costs one click, instead of after three clients have integrated against it. The spec then drives docs, mock servers, and tests, so the names you approved are the names everyone ships. Download Apidog and try it free with your next new endpoint; retrofitting an old API is hard, but holding the line on new ones is not.
FAQ
Should REST URLs be plural or singular?
Plural, for any resource with more than one instance: /products, /orders, /users. The plural form stays natural for both the collection (/orders) and one member (/orders/42). Reserve singular names for true singletons like /users/42/cart. If you want the deeper reasoning behind resource modeling, our guide on what a REST API is walks through it from first principles.
Is camelCase or snake_case better for JSON field names?
Neither wins on merit. camelCase suits JavaScript-heavy consumers; snake_case is more readable and matches Python, Ruby, and Stripe’s public API. The rule with teeth: choose one, write it into your style guide, and enforce it in schema review. Mixed casing across endpoints hurts more than either choice.
Should I put the API version in the URL or a header?
Use the path (/v1/orders) unless you have a strong hypermedia requirement. Path versions show up in logs, caches, and browser tests with zero client effort. Header versioning keeps URLs stable across versions but fails silently when clients forget the header. Major versions only; ship minor changes as additive, non-breaking updates.
Are verbs ever acceptable in a REST API path?
Yes, in one place: controller endpoints for non-CRUD actions, like POST /orders/42/cancel or POST /payments/pay_88a1/retry. The verb sits at the end of the path, scoped under its resource, and the method is always POST. Everywhere else, the HTTP method carries the verb and the path stays nouns-only.



