⏱ 9 min read
In the first post in this series, I covered REST's architectural constraints and the HTTP semantics - methods, status codes - that most APIs get subtly wrong. Constraints and semantics get you a technically correct API. They don't get you a good one.
A good API needs resources that are named sensibly, a versioning strategy that won't break your clients every quarter, an authentication pattern that matches your actual threat model, and errors that tell the caller something useful. That's what this post covers.
Resources are the nouns of your API. Good resource design is the single biggest factor in how usable and evolvable your API will be.
# Good - noun-based, resource-centric
GET /orders
GET /orders/789
POST /orders
PUT /orders/789
DELETE /orders/789
# Bad - verb-based, RPC-style
POST /getOrder
POST /createOrder
POST /deleteOrder
The HTTP method carries the action. The URI carries the identity of the resource the action applies to.
Use plurals for collections consistently:
/users # collection
/users/42 # single resource
/users/42/posts # sub-collection
Mixing singular and plural is a maintenance and documentation headache.
Nest resources to express ownership or containment, but limit nesting depth:
/users/42/orders # orders belonging to user 42
/users/42/orders/789 # order 789 belonging to user 42
Deep nesting (more than two levels) is a sign that you may need to reconsider your resource model. /users/42/orders/789/items/1/reviews/5 is hard to work with. In my opinion, it's better to use query parameters to filter at the top-level collection:
GET /orders?userId=42
Prefer opaque, stable identifiers. UUIDs or ULIDs are better than auto-increment integers for several reasons:
Once you publish a URI, it is a contract. Change it only with proper versioning and redirect support.
Some operations don't map cleanly onto CRUD. Use controller resources for these - named with a verb, invoked with POST:
POST /orders/789/cancel
POST /accounts/42/verify-email
POST /payments/refund
This is better than bending PUT or PATCH into something unnatural.
APIs change. Resources get new fields, old fields get removed, behavior changes. Versioning is how you manage change without breaking existing clients.
/v1/users
/v2/users
Pros: explicit, easy to route at the infrastructure level, easy to document and test separately, shows up clearly in logs.
Cons: technically violates REST's uniform interface (the same resource should have one canonical URI), pollutes the URI space, encourages "big bang" version increments rather than incremental evolution.
This is the most pragmatic approach and the most widely used.
GET /users
Accept: application/vnd.myapi.v2+json
Pros: URIs remain stable, aligns with HTTP content negotiation, cleaner theoretically.
Cons: harder to test in a browser, not visible in logs without extra work, requires clients to set headers correctly, harder to cache with some intermediaries.
GET /users?version=2
Pros: easy to test, explicit, no header management.
Cons: version in the query string is semantically awkward (it's not a filter parameter), can interfere with caching.
The best versioning strategy is to not break clients in the first place. Additive changes are generally safe:
Breaking changes require a new version:
Be pragmatic about it, and don't take it to the extreme. An API version number is not a build number. Don't do that.
# Requesting a specific version via header
curl -H "Accept: application/vnd.myapi.v2+json" https://api.example.com/users
# URI versioning
curl https://api.example.com/v2/users
Maintain multiple versions simultaneously during a deprecation window. Communicate deprecation timelines clearly via Deprecation and Sunset response headers:
Deprecation: true
Sunset: Sat, 31 Dec 2026 23:59:59 GMT
Link: <https://api.example.com/v2/users>; rel="successor-version"
REST is stateless, which means credentials travel with every request. There are several established patterns.
The simplest approach. A key is issued to a client and sent with every request, typically in a header:
curl -H "X-API-Key: abc123xyz" https://api.example.com/data
Or as a Bearer token variant:
curl -H "Authorization: ApiKey abc123xyz" https://api.example.com/data
Pros: simple to implement and use, easy to rotate. Cons: no expiration built in, broad access (all-or-nothing unless scoped), key must be kept secret.
Username and password encoded as Base64 in the Authorization header:
curl -u username:password https://api.example.com/data
# Which sends:
# Authorization: Basic dXNlcm5hbWU6cGFzc3dvcmQ=
Never use Basic auth over plain HTTP. Credentials are only obscured (Base64), not encrypted. Only acceptable over HTTPS.
The dominant pattern for modern APIs. A token is obtained through an authentication flow and included as a Bearer token:
curl -H "Authorization: Bearer eyJhbGciOiJSUzI1NiJ9..." https://api.example.com/data
JWTs (JSON Web Tokens) are self-contained - they encode claims (user ID, roles, expiry) and are cryptographically signed. The server can validate them without a database lookup.
Key properties to verify when processing JWTs:
exp claim (not expired)iss claim (issued by the expected authority)aud claim (intended for your service)OAuth 2.0 defines flows for obtaining tokens. The authorization code flow is appropriate for delegated access on behalf of a user. Client credentials flow is appropriate for machine-to-machine APIs.
Both client and server present certificates. Strong authentication with no bearer token to steal, but operationally complex (certificate management, rotation). Common in zero-trust internal service meshes.
Error responses deserve as much design attention as success responses. A client that receives an error needs to know: what went wrong, why it went wrong, and what (if anything) it can do about it.
Ad-hoc error messages are hard to handle programmatically. Define a consistent error structure and use it everywhere:
{
"type": "https://api.example.com/errors/validation-failed",
"title": "Validation Failed",
"status": 422,
"detail": "The request payload contains invalid values.",
"instance": "/orders/create",
"errors": [
{
"field": "email",
"code": "INVALID_FORMAT",
"message": "Email address is not in a valid format."
},
{
"field": "quantity",
"code": "OUT_OF_RANGE",
"message": "Quantity must be between 1 and 100."
}
]
}
This follows RFC 7807 (Problem Details for HTTP APIs), a standard worth adopting. The type field is a URI that identifies the error type (it should resolve to documentation). The instance field identifies the specific occurrence.
A 400 with "bad request" is nearly useless. Tell the client:
Error responses must never expose:
Log those internally. Return only what the client needs to understand and act on the error.
Make this distinction explicit in your API documentation and, where possible, in the response itself.
Return a correlation ID in every error response (and ideally every response):
X-Correlation-Id: 7f3a2b1c-4d5e-6f7g-8h9i-0j1k2l3m4n5o
This allows support teams to trace a client-reported error back to server-side logs without guessing.
Resource design, versioning, and auth get your API to a place where it's usable and safe to evolve. What's still missing is what happens once real traffic hits it: pagination, discoverability, rate limits, and idempotency.
That's what the last post in this series covers.
PS: Let me know if I missed anything - ping me on Twitter/X or LinkedIn, and follow along there if you'd like more of this. Let's chat.