"URIs are the smallest, most visible piece of your API contract. They show up in logs, dashboards, docs, billing reports, customer error reports. Get them right; you won't regret it. Get them wrong; you'll be living with it for years."
The Six Rules That Cover 95% of Decisions
1. Nouns, not verbs. URIs name things, not actions. /users/42 not /getUser/42. The verb is the HTTP method. GET /users/42 already says "get me user 42"; GET /getUser/42 says it twice and confuses the protocol's verb.
2. Plural collections, singular items. A collection is a set of things: /users. An item is a specific thing in that set: /users/{id}. Stick to plural for collections — /user/42 reads weird and breaks consistency.
3. Nesting reflects ownership, not relationships. /users/{id}/orders means "orders that belong to this user." If the relationship is many-to-many or doesn't imply ownership, use a top-level resource and query params: /orders?user_id={id} rather than /users/{id}/orders.
4. Lowercase, hyphens between words. /account-settings/{id} not /AccountSettings/{id} or /account_settings/{id}. URLs are case-sensitive in most contexts; lowercase removes ambiguity. Hyphens win over underscores because underscores are visually hidden under hyperlinks (try seeing account_settings versus account-settings).
5. No file extensions. /users/42 not /users/42.json. The format belongs in Content-Type, not the URI. .json in the URI couples the representation to the resource identity, which is exactly what content negotiation exists to avoid.
6. Don't nest deeper than 2-3 levels. /users/{u}/orders/{o}/items/{i} is at the edge. /users/{u}/orders/{o}/items/{i}/comments/{c}/replies/{r} is a sign you've stopped designing and started arguing with the API. Flatten by promoting deep resources to top-level when they have stable IDs (/replies/{r}).
The ID Choice — Opaque Beats Sequential
Two common ID strategies:
- Sequential integers (
/users/1,/users/2): cheap, sortable, debuggable. Leaks growth rate to anyone watching the IDs increment. Easy to enumerate (/users/1,/users/2, ...). - Opaque strings (
/users/usr_8x3kPq, UUIDs, ULIDs, Stripe-style prefixed IDs): no enumeration, no growth rate leak, easy to grep in logs. Slightly less debuggable (harder to remember).
For anything user-facing or public, prefer opaque. Stripe's prefix convention (cus_ for customers, pay_ for payments) is gold — it makes ID confusion impossible. Internal-only IDs can stay integer.
Action URIs — The One Exception to Rule 1
When you genuinely have an operation that isn't a resource, the convention is POST /resources/{id}/actionName. POST /payments/42/refund, POST /jobs/abc/cancel. The action name appears in the URI because there's no noun to use. This is the hybrid pattern from Lesson 3.1 — coherent with the resource-oriented rest of the API.
Bad → Good Walkthrough
Five common smells and their fixes:
BAD: GET /getUserById?id=42 (verb in path, query for ID)
GOOD: GET /users/42
BAD: POST /createOrder (verb in path)
GOOD: POST /orders
BAD: DELETE /users/42/delete (verb redundant with method)
GOOD: DELETE /users/42
BAD: GET /User/42.json (case + extension)
GOOD: GET /users/42 (Accept: application/json)
BAD: PATCH /user_settings_for/42 (snake + underscore + awkward)
GOOD: PATCH /users/42/settings
cwkPippa's URI Choices
POST /api/council/{id}/finalize and POST /api/heartbeat/cron/{id}/run-now, which use verb-suffixes deliberately because they're actions, not resources. That deliberate exception is fine; the bar for adding one is "is there genuinely no noun for this operation?"