REST is a set of tradeoffs, not a rulebook
Your agent writes textbook REST endpoints and one of them will double-charge a customer. Here's how to reason about HTTP constraints — search bodies, idempotency keys, pagination — and write the tradeoffs down where your agent will read them.
I'll research the current state of REST-related debates and specs before writing.Your agent just generated fourteen endpoints. They're all plural nouns, they all return the right-ish status codes, and one of them is going to double-charge a customer the first time a mobile client retries on a flaky train connection.
That's not an AI problem. That's what happens when you treat REST as a checklist — plural nouns, verbs in the method, 201 on create, PATCH for partial updates — instead of a set of tradeoffs you're consciously making. LLMs are extremely good at reproducing the median blog post on a topic, and the median blog post about REST is a rulebook. So you get the rulebook, applied uniformly, with no sense of what any of the rules were buying you.
The rulebook is a compression artefact
Roy Fielding's dissertation isn't an API style guide. It's an argument about why the web scaled, written by someone who was simultaneously editing HTTP/1.1 and using those principles to decide which proposals made it in. The Two-Bit History piece on the dissertation points out that Fielding rejected a proposal for batched MGET/MHEAD requests specifically because batching broke the properties he cared about — messages needed to stay easy for intermediaries to proxy and cache.
That's the whole shape of the thing. Every REST constraint is a cost you pay to buy a property. Statelessness costs you request size and buys you horizontal scaling and cheap failover. A uniform interface costs you expressiveness and buys you intermediaries that understand your traffic without knowing your domain. Cacheability costs you freshness and buys you latency.
The bit almost everyone skips is hypermedia. Fielding got annoyed enough about this to write a 2008 post insisting REST APIs must be hypertext-driven — that a client should start from one URI and a set of standardised media types and discover everything else from the responses. By that definition, approximately none of the APIs you have ever shipped are RESTful, including the ones your linter is happy with. HATEOAS is part of the uniform interface constraint, not an optional extra.
So we're all non-compliant already. Which means the interesting question was never "is this RESTful". It's "which constraints am I keeping, what am I getting for them, and what am I paying instead".
Let me work one through properly.
The search endpoint that outgrew its URL
Real shape of a real problem. You're building a product catalogue with faceted search. Your agent writes this, because of course it does:
GET /api/v1/products?category=outdoor&brand=acme&brand=zephyr
&price_min=50&price_max=400&colour=green&colour=charcoal
&in_stock=true&sort=-rating&page=2&per_page=50
This is fine. Genuinely fine. It's cacheable, it's shareable, it shows up legibly in your logs, a CDN can serve it, and a browser can bookmark it. Keep it.
Six weeks later product wants saved searches with nested boolean logic. Now the filter is a tree:
{
"all": [
{ "field": "category", "in": ["outdoor", "camping"] },
{ "any": [
{ "field": "brand", "eq": "acme" },
{ "all": [
{ "field": "rating", "gte": 4.5 },
{ "field": "price", "lte": 400 }
]}
]}
]
}
Ask Cursor to "add advanced filtering to the products endpoint, keep it RESTful" and you will get that tree base64'd or JSON-encoded into a query parameter, because GET-for-reads is the loudest rule in the training data. And it works. It works in dev, it works in staging, it works right up until a user builds a filter with two hundred SKU IDs in it and the request dies somewhere in your infrastructure with a 414 or a truncated header.
That failure is horrible to debug because it's not in your application logs. Your app never saw the request. Default request-line and header buffer limits sit around 8 KB in common setups — nginx's large_client_header_buffers, Apache's LimitRequestLine — and CDNs, API gateways and corporate proxies each apply their own caps. Check yours; don't assume. The point is that the limit exists, it's not in one place you control, and a URL-encoded query tree is an unbounded input pointed straight at it.
Pick the tradeoff, then say it out loud
The obvious move is POST:
POST /product-searches
Content-Type: application/json
{ "all": [ ... ] }
Here's the part people skip. You just gave up four things: shared caching, URL shareability, safe automatic retries, and the ability for any intermediary to know this request didn't change anything. POST is not safe and not idempotent, so nothing between you and the origin will retry it or cache it.
If you can name those four costs, you're doing REST correctly — you're reasoning about the constraints. If you just wrote POST because the URL was too long, you're going to be surprised in three months when your cache hit rate craters and nobody knows why.
So spend it deliberately. What we'd actually ship:
- Simple filters stay on
GET /productswith query params. Cached, shareable, boring. - Complex filters go to POST, and you compensate: return
Cache-Controlon the response, and consider a two-step where POST creates a saved search resource and returns aLocationpointing at a cacheableGET /product-searches/{id}/results. Now the expensive read is cacheable again, at the cost of a round trip and some storage.
There's a third option worth knowing about. The IETF has an active draft for an HTTP QUERY method — a request that is safe and idempotent but carries a body, authored by Julian Reschke, James Snell and Mike Bishop. Exactly the semantics we want here. As of the latest draft I could find it's still an Internet-Draft working through the process, not a published RFC, so treat it as something to watch rather than something to ship to third-party clients this quarter. I haven't verified whether that status changed recently; check the datatracker page before you rely on it.
Two-step-with-cacheable-GET is the boring answer and it's the one we'd pick. The tradeoff we're accepting: one extra round trip on complex searches, and some garbage collection for stale search resources.
The refund that fired twice
Second failure, and this one costs money.
POST /orders/{orderId}/refunds
Textbook. Verb is a method, resource is a noun, returns 201 with a Location. Every REST rulebook approves. And it will double-refund, because POST is not idempotent and mobile clients retry.
The sequence: client POSTs, your server processes the refund, the response is lost to a dead cellular connection, the client's retry policy fires, you refund again. The client did nothing wrong. HTTP's semantics say a client may not automatically retry a POST, but every real HTTP library ships with retry policies people enable, and humans hammer buttons.
PUT is idempotent, which tempts people into PUT /refunds/{clientGeneratedId}. That's defensible — the client generates a UUID, PUTs to it, retries land on the same URI. The cost is that you've now made refund IDs client-controlled, which is a security and data-modelling decision you should make on purpose rather than as a side effect of wanting retry safety.
What we do instead:
POST /orders/ord_1a2b/refunds
Idempotency-Key: 7f3c9e1d-4b2a-4c11-9f6e-2d8a5b0c7e31
Content-Type: application/json
{ "amount_cents": 4500, "reason": "damaged_on_arrival" }
Server side, the shape that matters:
CREATE TABLE idempotency_keys (
key TEXT PRIMARY KEY,
endpoint TEXT NOT NULL,
request_hash TEXT NOT NULL,
status_code INTEGER,
response_body TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
Insert the key inside the same transaction as the refund. On a duplicate key, compare request_hash: if it matches, replay the stored response; if it differs, return 422, because the client reused a key for a different payload and that's a bug you want them to see. Expire rows after 24 hours or whatever your retry window is.
That's maybe forty lines. Agents will write it correctly if you ask — but they will not ask you first, because "add a refund endpoint" pattern-matches to the textbook version and the textbook version doesn't mention retries. This is the failure mode in one sentence: the rulebook has nothing to say about the things that actually break.
An Idempotency-Key header has been circulating in IETF drafts for years and has been convention across payment APIs for longer. Confirm the current standardisation status yourself before you cite it as a standard; what I can say is the pattern is well-established in practice.
Statelessness is a bill, and you're paying it somewhere
One more, quickly, because it's the constraint people break without noticing.
Offset pagination (?page=2&per_page=50) is stateless and gets slow and wrong on big, mutating tables — rows shift between pages as data changes. Server-side cursors are fast and consistent and break statelessness: now request N+1 must reach the same process holding that cursor, and your load balancer, your autoscaler and your deploy strategy all have opinions about that.
Keyset pagination is the usual escape hatch — encode the sort position into an opaque token:
{ "next_cursor": "eyJyYXRpbmciOjQuNSwiaWQiOiJwcmRfOTkxIn0" }
Stateless, fast, consistent-ish. Costs: no jumping to page 47, and the token is now a compatibility surface you have to version. Fine tradeoff for an infinite-scroll feed, bad tradeoff for an admin table where someone wants page 47.
Notice the pattern across all three examples. There's no rule that resolves them. There's a property you want, a cost attached to it, and a call you make with your specific traffic in mind.
Write the tradeoffs down where the agent will read them
Here's the practical bit for anyone building with Claude Code, Cursor or similar. Your agent re-litigates these decisions every session because it has no memory of why you chose what you chose. It sees POST-for-search, decides that's un-RESTful, and helpfully "fixes" it.
So put the reasoning in the repo. Not rules — reasoning:
## API decisions (read before touching routes)
- Simple reads: GET with query params. Cacheable and shareable; keep it.
- Complex filter trees: POST /{resource}-searches returns 201 + Location
to a cacheable results GET. We accept the extra round trip. Do NOT
"simplify" this back to a GET with an encoded body — it blows the
8 KB header limit at the edge. See incident 2024-11-03.
- Any endpoint that moves money or sends a message: requires
Idempotency-Key. Replay stored response on duplicate key + matching
request hash; 422 on key reuse with a different payload.
- Pagination: keyset by default. Offset only where the UI has
numbered pages (admin tables), and say so in the handler comment.
- We are not doing HATEOAS. Clients are ours, they hardcode paths,
and we version with /v1. Accepted cost: coupling.
That last line matters more than it looks. Saying "we are not doing HATEOAS, and here's what that costs us" stops the argument permanently. An agent given a stated tradeoff will respect it. An agent given no context will default to whatever the internet says most often.
The failure I still see constantly: people fill CLAUDE.md with style rules (two-space indent, no default exports) and zero architectural context. Style is the thing formatters already handle. Architecture is the thing that needs you.
"So you're saying anything goes"
No. The opposite, actually.
There's a lazy reading of "REST is tradeoffs" that lands on "REST is vibes, just build RPC over POST and call it a day". That's how you end up with POST /api/doThing and forty endpoints that no proxy, cache, log aggregator or client library can reason about generically. The uniform interface is doing real work: it's why your CDN can cache without knowing your domain, why your logs are greppable by method and status, why curl and fetch and every HTTP client on earth can talk to you without a bespoke SDK.
Break constraints deliberately and you pay a known price. Break them by accident and you pay an unknown one, usually at 2am.
The rule of thumb we use: you may violate any REST constraint, provided you can state in one sentence what you're buying. "POST for search, buying unbounded filter size, paying shared cacheability." That's a decision. "POST because GET felt wrong" isn't.
And REST isn't going anywhere. One fact-check of GraphQL-vs-REST claims cites Postman's 2025 survey putting REST adoption around 93%, with GraphQL widely used alongside rather than instead of it — that's second-hand, so go to the primary source if you're putting it in a deck. The directional point holds: this is a shared vocabulary problem, not a dying-technology problem.
Do this this week
Pick your single highest-risk write endpoint — the one that moves money, sends an email, or provisions something. Open a terminal and hit it twice with the same payload:
for i in 1 2; do
curl -sS -X POST https://api.yourthing.dev/v1/refunds \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"order_id":"ord_1a2b","amount_cents":4500}' \
-w '\n%{http_code}\n'
done
If you get two resources, you have a bug that a retry will find eventually. Add the idempotency key table, then add five lines to your CLAUDE.md explaining why it's there. That's an afternoon, and it's worth more than any amount of arguing about whether PATCH should return 200 or 204.
If you want to argue about that anyway, or post the horrifying thing your agent generated this week, we're in the Discord: https://discord.gg/3scUHe7B. Someone in there has already had your outage.

Timothy Yang
Founder & CEO, DrillCall
Four businesses built and exited, including a micro-task marketplace with 170,000+ users. Now building DrillCall and running Vibe Coding Club from Sydney.
Build with us
Vibe Coding Club is where people who ship with AI tools compare notes. Bring what you're building.
JOIN THE DISCORD →