Skip to content
CAMPUX Cloud Bootcamp
Field notes · Architecture
API design principles

Seven API design principles that keep an API clean and scalable

By Captain O8 min read

Good API design comes down to a handful of principles: use nouns for resources and HTTP verbs for actions, return the right status codes, version from day one, page and filter large collections, validate input strictly, handle errors consistently, and secure every endpoint. Get these right and an API stays predictable as it grows.

New to cloud? CAMPUX is a free, build-first course. Start here →

An API is a contract. Once another team, a mobile app, or a paying customer writes code against your endpoints, every quirk you shipped becomes a promise you have to keep. That is why the design choices you make on day one matter far more than they seem to: a name you picked casually, a status code you returned out of habit, a field you forgot to validate — each one hardens into something you cannot change without breaking a client. What follows is seven principles I keep coming back to. None of them are exotic. They are the boring, standard choices that separate an API people enjoy consuming from one they curse.

A clean REST endpoint uses an HTTP verb for the action and a noun resource path, versioned from day one.GET/v1/users/123verb = actionversionresource (noun)idnouns for resources, HTTP verbs for actions, version from day one
Figure — A clean REST endpoint reads itself: the HTTP verb is the action, the path names a resource as a noun, and the version sits up front. Get that shape right — plus correct status codes, pagination, consistent errors, validation, and auth on every route — and the API stays predictable as it grows.

Why API design matters

Most APIs do not fall over because of a clever algorithm gone wrong. They rot because of small inconsistencies that pile up: one endpoint returns 200 on an error with a message buried in the body, another uses a query string verb, a third pages its results while its sibling dumps ten thousand rows in one response. Each decision was defensible in isolation. Together they force every client to special-case your service, and the support burden grows with every new consumer.

The fix is not more documentation. It is consistency. When every endpoint follows the same shape, a developer who has used one of your routes can guess the rest correctly. That predictability is what "scalable" really means here — not requests per second, but the ability to add the fiftieth endpoint without the API feeling like fifty different APIs stapled together.

Principles 1 and 2: resources, verbs, and status codes

Principle 1 — name resources with nouns, act on them with HTTP verbs. A URL should identify a thing, not an action. The action belongs in the HTTP method. So you do not invent getUser, createUser, and deleteUser as separate endpoints — you have one resource, /users, and let GET, POST, and DELETE supply the verb.

# Don't: verbs baked into the path, actions as query strings
GET  /getUser?id=123
POST /createUser
GET  /deleteUser?id=123

# Do: a noun resource, the verb carried by the HTTP method
GET    /users/123
POST   /users
DELETE /users/123

Use plural nouns for collections (/users, /orders) and nest to show relationships (/users/123/orders for one user's orders). Keep the depth shallow; more than two levels usually means a resource is hiding in there wanting its own top-level route.

Principle 2 — return the status code that matches what actually happened. The status line is the first thing a client reads, and machines read it before humans do. A response that says 200 OK while carrying an error object in its body is worse than useless — it lies to every retry loop and monitoring rule pointed at you.

# Don't: 200 for everything, real outcome hidden in the payload
POST /orders  ->  200 OK   { "error": "out of stock" }

# Do: let the status code carry the outcome
POST /orders            ->  201 Created   (Location: /orders/998)
GET  /orders/998        ->  200 OK
DELETE /orders/998      ->  204 No Content
POST /orders (bad json) ->  400 Bad Request
POST /orders (no token) ->  401 Unauthorized
GET  /orders/000        ->  404 Not Found

The full reference is at the end of this note, but the short version: 201 when you create something and hand back its location, 204 when a call succeeds with nothing to return, and the 4xx family for anything the caller got wrong. Save 500 for when you broke, not when the request did.

Principles 3 and 4: versioning, pagination and filtering

Principle 3 — version the API from the first release. The cheapest time to add versioning is before you have a single consumer; the most expensive is after. Put the version in the path so the very first thing you ship is already v1. When the day comes that you need to rename a field or change a response shape, you introduce v2 and leave v1 answering exactly as it always did.

# Don't: an unversioned path you can never safely change
GET /users/123

# Do: version from day one, so v2 can differ while v1 keeps its promise
GET /v1/users/123
GET /v2/users/123    # new shape, old clients untouched

A path segment is not the only option — some teams version with a header or a media type — but a visible /v1 is the easiest to reason about, easiest to route, and easiest for a consumer to see at a glance.

Principle 4 — page and filter large collections instead of returning everything. A collection that is small in testing will be enormous in production, and an endpoint that returns every row will eventually time out or blow up a client's memory. Decide up front that list endpoints are paginated, and expose filtering through query parameters so callers ask for only what they need.

# Don't: hand back the entire table in one response
GET /v1/orders          ->  [ ...50000 orders... ]

# Do: page it, and let filters narrow the set
GET /v1/orders?page=2&limit=50
GET /v1/orders?status=shipped&sort=-created_at&page=1&limit=50

Return the page of data alongside enough metadata for the client to walk the rest — a total count, or a link to the next page. Pick sane defaults (a limit of 20 to 50 is common) and cap the maximum a caller can request, so nobody can ask for a million rows in one shot.

Principles 5 and 6: consistent errors and strict validation

Principle 5 — return every error in one consistent shape. Clients write error-handling code once and reuse it everywhere, but only if your errors look the same everywhere. Pick a JSON error object, put a stable machine-readable code in it, a human message beside it, and details when you have them — then return that same object from every endpoint, every time.

# Don't: a different error format on every route
{ "error": "not found" }
"Bad request"
{ "msg": "nope", "ok": false }

# Do: one shape, everywhere
{
  "error": {
    "code": "order_not_found",
    "message": "No order exists with id 998.",
    "details": []
  }
}

The code is the part clients branch on, so keep it stable even if you reword the human message later. A request identifier in the body or a header is worth adding too — it turns "your API is broken" into a support ticket you can actually trace.

Principle 6 — validate input strictly and reject bad payloads early. Never trust what arrives. Check every field before it touches your business logic, and when something is wrong, say so precisely. Use 400 when the request is malformed — broken JSON, a missing required field — and 422 when the body parsed fine but fails a business rule, such as an email that is not an email or a quantity below one.

# Don't: accept junk, fail later with a 500
POST /v1/orders  { "qty": -3, "email": "nope" }  ->  500 Internal Server Error

# Do: reject it at the door, and name what was wrong
POST /v1/orders  { "qty": -3, "email": "nope" }  ->  422 Unprocessable Entity
{
  "error": {
    "code": "validation_failed",
    "message": "The request body failed validation.",
    "details": [
      { "field": "qty",   "issue": "must be 1 or greater" },
      { "field": "email", "issue": "must be a valid email address" }
    ]
  }
}

Returning all the validation problems at once, rather than one at a time, saves the caller a frustrating round of guess-and-retry. It also happens to be a small security win: input you validated is input an attacker cannot smuggle a surprise through.

Consistency is the whole game. A developer who has used one of your endpoints should be able to guess the next one and be right.

Principle 7: secure every endpoint

Principle 7 — require authentication and least privilege on every endpoint. There is no such thing as an endpoint that does not need thinking about. Even a read-only route leaks data if anyone on the internet can call it. Require a credential — a bearer token is the usual choice — on every route, and check not just who the caller is but whether they are allowed to do this particular thing.

# Don't: an open endpoint that trusts a client-supplied id
GET /v1/users/123/invoices        # no credential required

# Do: require a token, then authorize the specific action
GET /v1/users/123/invoices
Authorization: Bearer <token>

-> 401 Unauthorized   # no or invalid token
-> 403 Forbidden      # valid token, but not this user's invoices
-> 200 OK             # authenticated and allowed

Notice the difference between 401 and 403: the first says "I do not know who you are," the second says "I know exactly who you are, and you cannot do that." Mixing them up is a classic bug. Beyond that, the standard hygiene applies — serve only over HTTPS, scope tokens to the least they need, and never let a caller reach another tenant's data just by editing an id in the URL.

The habit that ties it together

Before you add an endpoint, write down its method, path, success code, error codes, and error shape — five lines — and check them against the routes you already have. If the new one does not match the pattern, either it is wrong or your pattern needs a deliberate update. Either way you caught the inconsistency before a client did, which is the only cheap time to catch it.

A quick REST verbs reference

Two properties are worth committing to memory. A verb is safe if it does not change server state, and idempotent if calling it many times has the same effect as calling it once. These are what let clients and proxies retry a failed request without fear — which is exactly why POST, the one non-idempotent verb here, is the one you cannot blindly replay.

VerbMeaningIdempotent?
GETRead a resource or list; changes nothingYes
POSTCreate a new resource under a collectionNo
PUTReplace a resource in full at a known idYes
PATCHApply a partial update to a resourceNo (in general)
DELETERemove a resourceYes

None of these seven principles is clever, and that is the point. An API earns trust by being unsurprising: the same naming, the same codes, the same error object, the same auth check, on every route you ship. Decide the pattern once, write it down, and hold new endpoints to it. The reward is an API that a stranger can pick up and use correctly without reading much documentation at all — which is the highest compliment a design like this can get.

Questions people also ask

What are the principles of good API design?

Good API design comes down to a short list: name resources with plural nouns and act on them with HTTP verbs, return the status code that matches what happened, version the API from the first release, page and filter large collections, validate input and reject bad payloads, return errors in one consistent JSON shape, and require authentication and least privilege on every endpoint. Get those right and the API stays predictable as it grows.

Should I version my API?

Yes, and from day one. Put a version in the path such as /v1/orders so that the first release is already v1. Once other teams depend on your responses you cannot change a field's meaning without breaking them, so a version segment gives you room to ship v2 with different behavior while v1 keeps working. Adding versioning after launch is far more painful than starting with it.

What HTTP status code should I return?

Match the code to the outcome. Use 200 for a successful read, 201 when you create a resource, and 204 when a request succeeds with no body. Use 400 for a malformed request, 401 when the caller is not authenticated, 403 when they are authenticated but not allowed, 404 when the resource does not exist, 409 for a conflict such as a duplicate, and 422 when the body parses but fails validation. Reserve 500 for genuine server faults.

What is a RESTful API?

A RESTful API models your system as resources identified by URLs, such as /users/123, and uses standard HTTP verbs to act on them: GET to read, POST to create, PUT or PATCH to update, and DELETE to remove. It is stateless, so each request carries everything the server needs, and it relies on HTTP status codes to report results. The style is predictable because it reuses conventions clients already know.

Further reading — the specifications
Your next class · free
You've read the idea. Class 16 — REST APIs & ARM is where you build it, hands-on — no account needed.Start Class 16 →
Captain O
Founder & instructor · CAMPUX Cloud Engineering Bootcamp
Drilled in Class 16 — REST APIs and ARM. Related notes: What is an ARM template? · What does a cloud DevOps engineer do? →