Skip to content
routebase
API DesignRESTGuide

API Design Principles: A Practical Guide

· The Routebase Team

Most APIs don't fail because of technology choices. They fail because of a hundred small design decisions made inconsistently, under deadline pressure, by different people who never agreed on the rules. The result is an API that works but fights its consumers: three different pagination styles, error responses that change shape between endpoints, and a PUT that isn't safe to retry.

This guide covers the six decisions that matter most. None of them are new — that's the point. Good API design is mostly about picking a convention and applying it everywhere.

1. Model resources, not procedures

The single most common design mistake is exposing your implementation instead of your domain. An endpoint like POST /runSyncJob?mode=full tells consumers how your system works internally — and breaks the moment that internal changes.

Model the things your consumers talk about, and let HTTP verbs carry the action:

POST   /projects/42/sync-jobs        # start a sync
GET    /projects/42/sync-jobs/7      # check its status
DELETE /projects/42/sync-jobs/7      # cancel it

The test: could a consumer guess the endpoint for a new operation without reading your docs? If creating a project is POST /projects, then creating an environment had better be POST /environments (or POST /projects/{id}/environments) — not POST /createEnv.

Nesting deserves restraint. One level (/projects/{id}/environments) expresses ownership; three levels deep, URLs become brittle and clients end up hardcoding IDs they shouldn't know. When a resource has its own identity, give it a top-level collection and link to the parent by ID.

2. Be boringly consistent with names

Every naming argument has more than one defensible answer — plural vs. singular collections, camelCase vs. snake_case fields, createdAt vs. created_at. What's not defensible is mixing them. Pick once, write it down, enforce it:

  • Collections are plural nouns: /projects, /api-specs, /test-runs.
  • Fields use one case, everywhere. If your JSON is camelCase, your query parameters should be too.
  • The same concept keeps the same name across endpoints. If it's status on one resource, it isn't state on the next.
  • Timestamps are ISO 8601 in UTC, suffixed consistently: createdAt, updatedAt, deletedAt.

This sounds trivial. It's not: naming drift is the earliest and most visible form of contract drift, and it's the one your consumers notice first — usually in a code review, asking why your SDK has both userId and user_id.

3. Make retries safe with idempotency

Networks fail mid-request. When they do, clients retry — and if your API isn't designed for that, retries create duplicate orders, double charges, and support tickets.

The HTTP verbs already define the contract: GET, PUT, and DELETE are idempotent by definition; repeating them must produce the same result. The hard case is POST. For creates that must not double-execute, accept an idempotency key:

POST /orders
Idempotency-Key: 8e03978e-40d5-43e8-bc93-6894a57f9324
Content-Type: application/json
 
{ "projectId": "42", "plan": "team" }

The server stores the key with the first response and replays that response for any retry carrying the same key. Consumers can then retry fearlessly — which means their timeout handling gets simpler, which means fewer bugs that look like your API's fault.

4. Paginate from day one

Every collection endpoint will eventually return more data than anyone wants in one response. Retrofitting pagination is a breaking change; shipping it on day one is a few lines of design.

Cursor-based pagination is the safer default — offsets skip or duplicate rows when the underlying data changes between pages:

{
  "data": [ ... ],
  "pagination": {
    "nextCursor": "eyJpZCI6MTAyNH0",
    "hasMore": true
  }
}

Whatever you choose, choose it once. A response envelope that's identical across every list endpoint means consumers write their pagination loop one time and reuse it everywhere.

5. Return errors machines can read

Error handling is API design's most neglected surface. A 400 with "something went wrong" forces every consumer to parse prose. Use a structured, uniform error shape — RFC 9457 Problem Details is the standard worth adopting:

{
  "type": "https://api.example.com/errors/validation",
  "title": "Validation failed",
  "status": 400,
  "detail": "The 'email' field is not a valid address.",
  "errors": [
    { "field": "email", "message": "must be a valid email address" }
  ]
}

Two rules make error design pay off: the same error shape on every endpoint, and error types that are stable identifiers consumers can branch on — never strings they have to fuzzy-match.

6. Version deliberately, break rarely

Versioning is where design meets governance. The mechanics (URL prefix, header, media type) matter less than the policy: know what counts as a breaking change, additive-change your way around most of them, and when a break is unavoidable, give consumers a version boundary and a deprecation window.

That's a topic with enough depth for its own post — see Versioning APIs Without Breaking Your Consumers.

Principles need enforcement

Everything above fits on one page, and that's exactly the problem: knowing the principles was never the hard part. The hard part is holding a hundred endpoints to them, across teams, releases, and reviewer moods.

That's an argument for putting the API contract — the spec — at the center of the workflow instead of leaving design decisions buried in code. When the spec is the source of truth, conventions become lintable, drift becomes detectable, and review happens where design happens. That workflow shift is what design-first development is about — and building it is what we do at Routebase.

Be first to ship on it

Routebase is launching soon. Join the waitlist and we'll email you the moment it's ready.

One email at launch. No spam, unsubscribe anytime.