An API is a promise. Once something consumes it, every change is a negotiation with people who won't upgrade on your schedule.
These are the decisions that decide whether that negotiation is easy or awful — and they're nearly all made in the first week, before there's any pressure to think about them.
Resources, not remote procedures
The URL should name a thing. The method should say what you're doing to it.
✗ POST /getUserOrders
✗ POST /api/updateOrderStatus
✗ GET /api/deleteOrder?id=42
✓ GET /users/42/orders
✓ PATCH /orders/42
✓ DELETE /orders/42Plural nouns, consistently. /users/42/orders, not /user/42/order. It's arbitrary — but pick one and never deviate, because mixed conventions mean every consumer has to check the docs for every endpoint.
Some operations genuinely aren't CRUD. Don't contort them:
POST /orders/42/refund
POST /articles/17/publishA sub-resource that names the action is clearer than PATCH /orders/42 { "status": "refunded" } — and it gives you a distinct place to enforce permissions and write an audit entry.
One error shape, everywhere
Nothing costs consumers more than error handling that varies per endpoint. Pick a shape on day one:
{
"error": {
"code": "validation_failed",
"message": "The request could not be processed.",
"details": [
{ "field": "email", "issue": "must be a valid email address" },
{ "field": "tags", "issue": "must contain at most 8 items" }
],
"requestId": "req_9f2c1b7e"
}
}codeis a stable machine-readable string. Clients branch on this. Never change one.messageis for humans and may change freely.detailsis for field-level validation.requestIdcorrelates to your logs — the single most useful field in the whole envelope.
And use the status codes properly:
| Code | Meaning |
|---|---|
| 400 | Malformed request |
| 401 | Not authenticated |
| 403 | Authenticated, not allowed |
| 404 | Doesn't exist (or you may not know it does) |
| 409 | Conflict with current state |
| 422 | Well-formed but semantically invalid |
| 429 | Rate limited |
Returning 200 { "success": false } is the one to avoid outright. It defeats HTTP caching, breaks every generic client, and means monitoring can't distinguish success from failure without parsing bodies.
Paginate from the start
Retrofitting pagination is a breaking change. Include it in v1 even when the collection has nine rows.
Offset pagination is fine for small, stable, page-numbered UIs:
GET /articles?page=2&limit=20It has two real problems: it degrades linearly (the database walks and discards every skipped row), and it skips items when rows are inserted between requests — which shows up as users reporting missing records in an infinite scroll.
Cursor pagination fixes both:
GET /articles?limit=20
GET /articles?limit=20&cursor=eyJwdWJsaXNoZWRBdCI6IjIwMjYtMDYtMTQifQ{
"data": [ /* … */ ],
"pageInfo": {
"nextCursor": "eyJwdWJsaXNoZWRBdCI6IjIwMjYtMDUtMzAifQ",
"hasMore": true
}
}The cursor encodes the sort key of the last item, so the query becomes an indexed range scan rather than a skip. Opaque-encode it (base64) so nobody builds a client that parses it — that keeps you free to change the internals.
Always cap limit server-side. ?limit=100000 should silently become 100, not attempt to serialise the collection.
Idempotency for anything that costs money
Networks time out after the server has already committed. The client can't tell "never arrived" from "arrived, response lost" — so it retries, and you charge someone twice.
POST /payments
Idempotency-Key: 8f14e45f-ea51-4d3a-9c2b-1d7a0e3c9b52export async function POST(req: Request) {
const key = req.headers.get('idempotency-key');
if (!key) {
return error(400, 'idempotency_key_required');
}
const existing = await IdempotencyRecord.findOne({ key });
if (existing) {
// Replay the original response — do not re-execute
return NextResponse.json(existing.response, { status: existing.status });
}
const result = await createPayment(await req.json());
await IdempotencyRecord.create({
key,
response: result,
status: 201,
expiresAt: addHours(new Date(), 24),
});
return NextResponse.json(result, { status: 201 });
}GET, PUT and DELETE are naturally idempotent. POST is not, and that's exactly where it matters.
Version at the edge, not everywhere
/v1/articles
/v2/articlesURL versioning wins on practicality: visible in logs, easy to route, trivial to curl. Header-based versioning (Accept: application/vnd.api.v2+json) is more theoretically correct and more annoying in every real debugging session.
But the important part isn't the scheme — it's not needing it. Most changes can be additive:
| Safe | Breaking |
|---|---|
| Adding a response field | Removing or renaming a field |
| Adding an optional parameter | Making a parameter required |
| Adding a new endpoint | Changing a field's type |
| Adding an enum value clients ignore | Changing status code semantics |
| Relaxing validation | Tightening validation |
Two habits keep you on the left column: never return a bare array at the top level (wrap it in { "data": [...] } so you can add pageInfo later without breaking parsers), and treat every field as permanent once shipped.
When you must break, run both versions, add a Sunset header to the old one, and give consumers a real window:
Sunset: Wed, 31 Dec 2026 23:59:59 GMT
Link: </v2/articles>; rel="successor-version"Small things that compound
ISO 8601, UTC, always. "2026-06-20T14:32:00Z". Never Unix timestamps in seconds — someone will parse them as milliseconds.
Strings for money and IDs. JavaScript's Number loses precision past 2^53. A 19-digit ID silently corrupts. Send "12345678901234567890".
One casing convention. camelCase for a JavaScript-first API. Consistency matters far more than which you pick.
Sparse fieldsets for heavy resources. GET /articles?fields=id,title,publishedAt — a list view shouldn't transfer full article bodies.
Rate limit headers on every response, so clients can back off before being rejected:
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 43
X-RateLimit-Reset: 1750082400Cache-Control on reads. A public list endpoint with s-maxage=300, stale-while-revalidate=600 removes most of its own load at the CDN.
The rule underneath all of it
Every response field, every error code, every parameter name is a promise you're making to code you don't control and can't update.
Ship less surface area. Wrap your responses so you have room to grow. Make errors boringly predictable. Paginate before you need to.
The API you can still evolve in year three is the one you were slightly conservative with in week one.