All posts
6 min read

Designing a REST API That Won't Embarrass You Later

APIs are hard to change once clients depend on them. The conventions that keep a REST API predictable — resource naming, methods, status codes, errors, pagination, validation, idempotency, and versioning — with the specific mistakes AI-generated APIs tend to make.

architecturedeveloper experiencesecurityintermediate

Internal code can be refactored any time. An API — once a mobile app, a partner, or an old browser tab depends on it — is a contract, and contracts are expensive to change. A few decisions made carefully at the start save years of workarounds.

AI tools will happily generate an API for you, and it will usually work. It will also often be inconsistent: /getUsers next to /orders/create, errors returned as 200 OK, and endpoints that trust whatever the client sends. Here are the conventions worth enforcing.

Resources, not actions

REST models your API as resources (nouns) acted on with HTTP methods (verbs):

Method Path Meaning
GET /orders List orders
POST /orders Create an order
GET /orders/{id} Get one order
PATCH /orders/{id} Update part of an order
DELETE /orders/{id} Delete an order
GET /users/{id}/orders Orders belonging to a user

Conventions:

  • Plural nouns (/orders), lowercase, hyphens for multiple words (/line-items).
  • No verbs in paths — not /getOrders or /orders/create.
  • Nest only one level for clear ownership; beyond that, use filters (/orders?userId=42).
  • For genuine actions that aren't CRUD, a sub-resource reads well: POST /orders/{id}/refund.

GET must never change anything — browsers, crawlers, and prefetchers call GET freely.

Status codes that mean something

Return the code that matches what happened, so clients can handle outcomes without parsing text:

Code Use for
200 Success with a body
201 Created — include the new resource (and ideally a Location header)
204 Success, no body (e.g. a delete)
400 Malformed or invalid input
401 Not authenticated
403 Authenticated, but not allowed
404 Doesn't exist — or exists but the caller may not know it does
409 Conflict — duplicate, or a version clash
422 Well-formed but fails validation (a common alternative to 400)
429 Rate limited
500 Server bug — never deliberately

Two AI-generated anti-patterns to reject: returning 200 with {"success": false}, and returning 500 for bad input. Both break client error handling and monitoring. (What Is an API?)

On 403 vs 404: when a user requests another user's resource, returning 404 avoids confirming that the resource exists at all.

One error format everywhere

Pick a single error shape and use it for every failure. RFC 9457 "Problem Details" is a good standard:

{
  "type": "https://api.example.com/errors/validation",
  "title": "Invalid request",
  "status": 422,
  "detail": "quantity must be between 1 and 100",
  "errors": [{ "field": "quantity", "message": "must be between 1 and 100" }]
}

Include enough to act on, never internals: no stack traces, SQL, or file paths in production responses. Log those server-side with a request ID, and return the request ID so support can find the log. (Structured Logging.)

Validate every input at the boundary

Never trust the request body, query parameters, or headers. Validate them against a schema before any logic runs — Zod, Valibot, Pydantic, or your framework's equivalent — and reject unknown fields rather than silently accepting them.

The classic AI-API vulnerability is mass assignment: an update handler that writes the whole request body to the database.

// ❌ lets a user set { "role": "admin" } or { "userId": 7 }
await db.user.update({ where: { id }, data: req.body })

// ✅ only the fields a user may change
const input = UpdateProfile.parse(req.body)   // { name?, avatarUrl? }
await db.user.update({ where: { id: session.userId }, data: input })

Note the second fix too: the user ID comes from the session, not the request. (Authentication vs Authorization.) The same principle applies to prices: never accept an amount from the client. (How to Add Stripe Payments.)

Pagination from day one

Any list endpoint will eventually return too much. Paginate from the start — adding it later is a breaking change.

  • Offset pagination (?limit=20&offset=40) — simple, supports page numbers, but slow for deep pages and skips/duplicates items when data changes between requests.
  • Cursor pagination (?limit=20&cursor=eyJpZCI6MTIzfQ) — the response includes an opaque cursor for the next page. Stable under inserts and fast at any depth. Prefer it for feeds and large collections.

Always enforce a maximum limit, and index the columns you sort by. (Database Indexes.)

Idempotency for anything that costs money

Networks fail mid-request. A client that times out on POST /orders doesn't know whether the order was created, and retrying might create two. Support an Idempotency-Key header on unsafe operations: the client sends a unique key per logical operation; the server stores the key with the result, and returns the stored result if the same key arrives again. Stripe's API is the well-known example of this pattern.

Versioning and evolution

Plan for change:

  • Additive changes are safe: new endpoints, new optional fields, new response fields. Clients must ignore fields they don't know.
  • Breaking changes — removing or renaming fields, changing types or meanings — need a new version. The simplest scheme is a path prefix: /v1/orders, /v2/orders.
  • Deprecate before removing: announce, add a Deprecation header, watch traffic to old versions, then remove.

If your only client is your own frontend deployed alongside the API, you have more freedom — but still watch for old browser tabs during deploys. (Zero-Downtime Deploys.)

Document it with OpenAPI

Describe the API in an OpenAPI spec — ideally generated from your route schemas, so it can't drift. It gives you documentation, typed client generation, contract tests, and a precise reference an AI agent can read instead of guessing endpoint shapes.

Security checklist for every endpoint

  • Authentication required (unless deliberately public)
  • Authorization checked against the session user — ownership or role
  • Input validated against a schema; unknown fields rejected
  • No mass assignment; writable fields listed explicitly
  • Rate limited, especially login, sign-up, and expensive operations
  • Errors in the standard format; no internals leaked
  • Lists paginated with a maximum limit

Telling your agent the rules

Put the conventions in writing where your agent will read them:

## API conventions
- REST: plural nouns, no verbs in paths, standard methods.
- Errors: RFC 9457 problem details via `sendProblem()`. Never 200 for failures.
- Validate all input with Zod schemas in `src/api/schemas/`. Never pass req.body to the DB.
- User identity from the session only.
- All list endpoints use cursor pagination via `paginate()`, max limit 100.

(How to Write a CLAUDE.md.) Then review new endpoints against it. (How to Review a Pull Request Written by an AI Agent.)


EasySpawn runs your API as a real server with a managed database alongside it, so Claude Code can call the endpoints it builds and verify status codes, validation, and permissions end to end. See how it works or join the waitlist.

Related: What Is an API? · What Is a Webhook? · API Pagination · Validating Input With Zod

Keep reading