Resources, Not Remote Procedures
Model the nouns, let HTTP methods be the verbs, and pick the status code that tells the client what to do next.
POST /orders -> 201 Created, Location: /orders/{id}A resource-oriented API names things and uses HTTP methods as the verbs: GET /orders/42, POST /orders, DELETE /orders/42. An RPC-style API names actions: POST /getUserOrders. The reason to prefer resources is not purity. It is that every cache, proxy, client library and log pipeline already knows GET is safe and cacheable and DELETE is idempotent, and knows nothing at all about /getUserOrders.
When a verb is the honest answer
Some operations are not a field change on one row. A refund, a cancellation, a bulk import: each has its own lifecycle, failure modes and audit trail. Model those as resources of their own — POST /refunds with a charge id, or POST /orders/42/cancellations — and you get an id to retry against, poll and reconcile. If the action genuinely leaves no record worth keeping, a verb endpoint is a wart, not a sin.
Status codes that tell the truth
| Code | Means |
|---|---|
| 200 | Done; the body is the result |
| 201 | Created; Location says where it lives |
| 202 | Accepted, not finished |
| 204 | Done, and deliberately no body |
| 409 | Conflicts with current state |
| 422 | Syntax understood, instructions unprocessable |
The 409/422 split matters because the client acts differently: 422 means fix the request, retrying it unchanged will never work. 409 means the request was fine and the world moved, so re-read and decide again.
Example
// routes/orders.js — resource-oriented routing in Express 5
import express from 'express';
const router = express.Router();
// Filters are query parameters, never path segments.
// GET /orders?status=open not GET /getOpenOrders
router.get('/orders', async (req, res) => {
const page = await orders.list({ status: req.query.status, limit: 50 });
res.json(page); // shorthand for res.set('Content-Type','application/json').send(JSON.stringify(page))
});
router.post('/orders', async (req, res) => {
const order = await orders.create(req.account.id, req.body);
// 201 must say where the thing now lives. res.location(x) === res.set('Location', x).
res.status(201).location(`/orders/${order.id}`).json(order);
});
// A cancellation has a time and a reason, so it is a resource,
// not POST /orders/42/cancel.
router.post('/orders/:id/cancellations', async (req, res) => {
const order = await orders.get(req.params.id);
if (!order) return res.status(404).end();
if (order.status === 'shipped') {
// Request was well formed; the state moved on. 409, not 422.
return res.status(409).json(problem('order-already-shipped', 409));
}
const c = await orders.cancel(order, req.body.reason);
res.status(201).location(`/cancellations/${c.id}`).json(c);
});
// Nothing to return, and that is deliberate.
router.delete('/orders/:id/items/:itemId', async (req, res) => {
await orders.removeItem(req.params.id, req.params.itemId);
res.status(204).end();
});
export default router;When to use it
- Designing the first version of an internal service's HTTP surface before other teams depend on it
- Reviewing a PR that adds POST /approveInvoice and deciding what it should be instead
- Deciding whether a failed state transition should return 409 or 422 so the mobile client can stop retrying
More examples
Rewriting an RPC surface as resources
The three rewritten calls gain things the originals cannot have: the GET is cacheable and safe to retry, the cancellation gets an id and a 201, and the PATCH can carry an If-Match precondition. Reach for this when you inherit an API where every call is a POST — you can usually map it mechanically, then keep the old paths as thin forwarders until clients migrate.
# Before: the verb is in the path and the method says nothing
POST /getUserOrders {"user_id": 42, "status": "open"}
POST /cancelOrder {"order_id": 9001}
POST /setOrderAddress {"order_id": 9001, "address": {...}}
# After: the method is the verb, the path is the noun
GET /users/42/orders?status=open
POST /orders/9001/cancellations {"reason": "customer_request"}
PATCH /orders/9001 {"shipping_address": {...}}Choosing between 409 and 422
Two failures that look identical at the call site but mean opposite things to the caller. Use this split whenever a handler validates input and then checks state: putting both behind 400 forces clients to parse your prose to decide whether a retry is pointless.
async function applyDiscount(orderId, body) {
if (typeof body.percent !== 'number' || body.percent <= 0 || body.percent > 100) {
// Server understood the JSON but cannot act on it. The client must
// change the request; retrying it unchanged will never succeed.
throw new HttpError(422, 'invalid_discount_percent');
}
const order = await orders.get(orderId);
if (order.status !== 'open') {
// The request was fine. The resource's state is incompatible with it.
// The client should re-read the order and decide again.
throw new HttpError(409, 'order_not_open');
}
return orders.applyDiscount(order, body.percent);
}The endpoint that cannot finish in time
202 is the honest answer when the work outlives the request: a nine-million-row export cannot return 200 with a body. The resource created is the export job, not the report, which is why it has its own id and status URL.
POST /exports HTTP/1.1
Content-Type: application/json
{"report": "transactions", "from": "2026-01-01", "to": "2026-09-30"}
HTTP/1.1 202 Accepted
Location: /exports/exp_01HV8Z/status
Retry-After: 5
Content-Type: application/json
{"id": "exp_01HV8Z", "status": "pending"}
Discussion