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.
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
/getOrdersor/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
Deprecationheader, 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
Validating Input With Zod: One Schema for Forms, APIs, and Types
Every trust boundary — request bodies, query strings, webhooks, environment variables, AI output — needs runtime validation TypeScript can't provide. Using Zod schemas at each boundary, sharing them between client and server, stripping unknown keys, and useful errors.
Soft Deletes and Audit Logs: Keeping History Without Making a Mess
Deleting rows is irreversible; hiding them has costs too. When to use soft deletes, how to implement them without leaking 'deleted' data (partial indexes, unique constraints, views, RLS), the privacy tension with erasure requests, and how to build an audit log with triggers or application events.