⏱ 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 named sensibly and a versioning strategy that won't break your clients every quarter. It also needs an authentication pattern matching your 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 and 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. You issue a key to a client, who sends it 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. Base64 only obscures credentials; it doesn't encrypt them. Only acceptable over HTTPS.
The dominant pattern for modern APIs. You obtain a token through an authentication flow and include it 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 and why. It also needs to know 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.
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.
Hi! Welcome to my blog
.NET, messaging, and distributed systems - the trade-offs the docs leave out.
Form not loading? Subscribe here.