Building RESTful APIs That Don't Fight You

By Pasindu Kavinda ·

A few years back I inherited an API where deleting a task was a POST /task/delete?id=42. Creating one? POST /createTask. Fetching a list? GET /getAllTasks. Every endpoint was its own little snowflake, invented on the day someone needed it. Nothing was wrong exactly - it all worked - but every new feature meant reading the existing code to guess the naming convention, because there wasn’t one. That’s the thing about a bad API: it doesn’t crash. It just quietly taxes everyone who touches it, forever.

So here’s the stuff I actually care about now, after building a pile of these things. Not the textbook REST purity stuff. The bits that make an API pleasant to live with.

Nouns in the URL, verbs in the method

This is the one I’ll die on. The URL names a thing. The HTTP method says what you’re doing to it.

GET    /tasks          list them
POST   /tasks          create one
GET    /tasks/42       fetch one
PATCH  /tasks/42       update part of it
DELETE /tasks/42       delete it

No /getTasks, no /task/create, no /deleteTask. If I see a verb in a path I assume the person writing it was thinking in RPC and just slapped HTTP on top. Which, fine, sometimes RPC is what you actually want - but then call it that and be honest, don’t dress it up as REST.

Plural nouns, always. /tasks/42, not /task/42. Mixing singular and plural across a codebase is a special kind of hell where you’re forever guessing which one this particular resource used. Pick plural, never think about it again.

The one place I bend: genuine actions that aren’t CRUD. Publishing something, sending an email, retrying a job. You can twist those into resources (POST /tasks/42/publications) and sometimes it’s clean, but honestly POST /tasks/42/publish reads fine and everyone understands it instantly. I don’t lose sleep over that. Purity for its own sake helps nobody.

PATCH vs PUT, and why I basically never use PUT

PUT replaces the whole resource. Send the full object, it overwrites. PATCH updates the fields you send. In practice, clients almost never have the entire object to hand and just want to flip one field - mark a task done, rename it. So it’s PATCH nearly every time.

The trap with PUT is partial payloads. Somebody sends PUT /tasks/42 with just { "title": "new" }, and if your handler naively replaces the record, every other field gets wiped to null. I’ve seen that ship. Descriptions vanishing into the void because a “quick edit” screen only sent the title. Use PATCH, mean PATCH.

Status codes people can actually reason about

The split that matters most: 4xx means you (the client) messed up, 5xx means I (the server) messed up. Get that boundary right and everything downstream - monitoring, retries, on-call sanity - gets easier.

Where I see it go wrong constantly: returning 200 OK with { "success": false } in the body. Now every client has to parse the body to know if the thing worked, and your error dashboards are blind because as far as they’re concerned every request succeeded. A validation failure is a 400. Not found is 404. Not logged in is 401; logged in but not allowed is 403 (people mix those two up endlessly). Created something? 201. Deleted with nothing to return? 204.

And please - a 500 is your fault. If someone sends garbage input and you throw a 500, that’s a bug in your validation, not their problem. I’ve had alerting go quiet for a genuinely broken endpoint because a validation gap was firing 500s all day and we’d trained ourselves to ignore them. 4xx for bad input keeps your 5xx graph honest, and a quiet 5xx graph is a thing you can actually trust.

One error shape. Every time.

This one saves the most pain for the least effort. Decide on an error shape on day one and never deviate. Doesn’t hugely matter what it is, matters that it’s the same everywhere:

{
  "error": {
    "code": "TASK_NOT_FOUND",
    "message": "No task with id 42",
    "details": []
  }
}

The machine-readable code is the part that earns its keep. Clients switch on code, never on the human message - because the moment someone rewords a message for clarity, every client doing string-matching quietly breaks. Been there. The message is for the developer reading logs; the code is the contract.

The failure mode I keep running into is inconsistency - auth errors come back one shape, validation errors another, some random 500 dumps a raw stack trace as plain text. Then frontend has to special-case all three. In NestJS I lean on a global exception filter so literally every error exits through one funnel and comes out the same shape. Whatever framework you’re in, find that one choke point and own it.

Pagination, before you have 10,000 rows

GET /tasks returning everything is fine with 50 rows. At 50,000 it’s a slow query, a fat payload, and eventually a timeout. Add pagination early, because retrofitting it is a breaking change and breaking changes are the whole thing we’re trying to avoid.

Offset pagination (?page=2&limit=20) is the easy one and it’s genuinely fine for most stuff - admin tables, dashboards, anywhere a human clicks through. It only falls apart on huge datasets or fast-changing data, where deep offsets get slow and rows shift between pages so you see duplicates or skips. For infinite-scroll feeds or big exports, cursor pagination (?cursor=abc123&limit=20) is the move - you hand back an opaque pointer to “where you left off.” More annoying to build. Skip it until you need it.

Either way, wrap list responses so there’s room for metadata:

{
  "data": [ /* ... */ ],
  "meta": { "page": 2, "limit": 20, "total": 137 }
}

Which drags me to the envelope fight.

Envelope or not? My take: only on lists

An “envelope” is wrapping every response in { "data": ... } instead of returning the object bare. People argue about this like it’s religion.

My actual position: single resources go bare. GET /tasks/42 returns the task, straight up, no wrapper. Lists get the envelope because they legitimately need somewhere to hang pagination metadata, and you can’t bolt meta onto a raw array without it getting weird. Wrapping everything just so it’s uniform means every client digs through response.data on every single call for no payoff on the single-resource endpoints. Consistency’s great right up until it’s ceremony.

Idempotency, or how to stop double-charging people

Client fires POST /orders, the network hiccups before the response lands, client retries. Now there are two orders. On anything that costs money or sends a notification, this will happen - flaky mobile connections make it routine, not rare.

The fix that’s saved me: an idempotency key. Client generates a unique key per intended action and sends it in a header.

POST /orders
Idempotency-Key: 7f3a-1c9e-...

Server stashes the key with the result. Same key comes in again, you return the stored result instead of doing the work twice. GET/PUT/DELETE are already idempotent by definition - it’s POST that bites, and POST is exactly where the expensive irreversible stuff lives. Worth the plumbing.

Version it before you need to, or accept you never will

The honest truth: most APIs never get a clean v2 because nobody put a version in the URL up front, and by the time you need to break something there are forty clients you can’t coordinate. Put /v1/ in the path from day one.

GET /v1/tasks

Costs nothing today, buys you an escape hatch later. I prefer it right in the URL over header-based versioning - you can eyeball it in a browser, in logs, in a curl command, and there’s zero ambiguity about which one you hit. Header versioning is more “correct” and I find it more annoying to debug every single time.

That’s basically it

None of this is clever. It’s just the set of small decisions that, made consistently, mean nobody has to think about your API - they guess an endpoint and they’re right. Guessable is the whole game. The APIs that fight you are the ones where every endpoint was a fresh invention; the ones that don’t are boring in the best way. Boring and predictable is the compliment I’m going for.

Kavinda

© 2026 Pasindu Kavinda

LinkedIn Medium 𝕏 GitHub