Your domain has operations that are not create/read/update/delete — cancel an order, resend an invitation, retry a failed payment. How do you fit them into an HTTP API contract, and which method do you choose?
answer
- POST = process, not only create
- POST /orders/42/cancel — verb sub-resource
- Action with attributes → POST /…/cancellations, 201
- Illegal transition → 409 with current state
- Money/messages → Idempotency-Key
basics
~20 sUse POST on an action sub-resource, such as POST /orders/42/cancel — POST covers any processing, not just creation. Alternatively model the outcome as a resource (POST /orders/42/cancellations) or as a state field changed by PATCH. Never put the action on GET.
solid answer
~50 sThree legitimate shapes, in rough order of how often they fit: 1. **Action sub-resource with POST** — `POST /orders/42/cancel`. Pragmatic, universally understood, and honest: POST means "process this", which is exactly what is happening. The verb in the path is a small purity cost that essentially every large API accepts. 2. **Model the event as a resource** — `POST /orders/42/cancellations` creating a cancellation record with `201` and a URL. Better when the action has attributes (reason, actor, timestamp), needs auditing, or can happen more than once. 3. **State transition via PATCH** — `PATCH /orders/42` with `{"status":"cancelled"}`. Clean when the operation genuinely is "set this field", poor when it triggers refunds, emails and inventory returns, because the body understates what happens and validating legal transitions gets tangled. Whatever you choose: POST, never GET; return the affected resource's new state; answer `409` when the transition is illegal for the current state; and add an idempotency mechanism for anything that costs money, since POST retries are not free.
go deeper
Know that non-CRUD operations go on POST, typically as an action sub-resource, and never on GET.
Compare the action-endpoint, action-as-resource and PATCH-state approaches and say when each fits.
Add 409 for illegal transitions, idempotency keys for money and messages, 202 for long-running actions, and per-action authorization scopes.
Set the convention across services so action modelling, permission naming and retry semantics are uniform rather than per-team.
## The mismatch REST's uniform interface gives you a small set of methods over resources. Most business systems also have *operations* — cancel, refund, publish, archive, resend, retry, approve, merge. They are not naturally "set a field", and forcing them into pure CRUD produces contracts nobody can read. The good news is that POST is not "create". POST is defined as: process this representation according to the resource's own semantics. Creation is the most common case, not the definition. So a POST that performs an action is entirely within the protocol — the design question is only what URL it targets. ## Shape 1: action sub-resource ``` POST /orders/42/cancel Content-Type: application/json {"reason":"customer_request"} HTTP/1.1 200 OK {"id":42,"status":"cancelled","cancelledAt":"2026-08-12T10:00:00Z"} ``` Why it works: the intent is explicit in the URL, so logs, metrics, rate limits and authorization policies can all target the specific operation (`orders:cancel` scope). It reads well in documentation and in client code. Why purists object: a verb appears in the path. In practice this is the dominant pattern across mature public APIs, and the readability gain outweighs the theory. Keep it disciplined — a small, named set of actions, not a general RPC-over-HTTP layer where every endpoint is a verb. ## Shape 2: the action as a resource ``` POST /orders/42/cancellations {"reason":"customer_request"} HTTP/1.1 201 Created Location: /orders/42/cancellations/9 ``` This is the stronger design when the action is itself an entity: it has attributes, an actor, a timestamp, maybe an approval workflow; you want to list past attempts (`GET /payments/42/retries`); or the operation is asynchronous and needs something to poll. Refunds are the canonical example — a refund is a real business object, not a state flag. It also solves multiplicity naturally: three resend attempts are three `invitations/{id}/deliveries` records, whereas `POST .../resend` gives you nothing to inspect. ## Shape 3: state field via PATCH `PATCH /orders/42` with `{"status":"cancelled"}` keeps the interface purely resource-oriented. It is a reasonable fit when the transition really is just a field change with modest consequences, and when clients benefit from a single update endpoint. It goes wrong when the transition has heavy side effects. The request body says "set a string"; the reality is refund, email, inventory, ledger entry. Authorization gets awkward too — you now need field-level rules inside a generic update endpoint, instead of a distinct permission on a distinct endpoint. And multiple legal transitions with different preconditions turn into a switch statement inside the patch handler. ## Cross-cutting rules for action endpoints **Method.** POST. Not GET — actions triggered by prefetchers, crawlers and caches is exactly the failure mode covered by GET's safety requirement. PUT only if you can honestly express it as "make this sub-resource have this state" (`PUT /orders/42/status` is a defensible middle ground, idempotent by construction). **Response.** Return the affected resource's new representation with `200`, or `201` + `Location` if you created a record. That saves the client a refetch and makes the outcome unambiguous. **Illegal transitions.** Cancelling a shipped order is a conflict with current state → `409` with a stable code and the current status in the body, so the UI can say something useful. Not `400`, which suggests the request itself was malformed. **Idempotency.** POST is not idempotent, so a timed-out retry may cancel twice, refund twice, or send two emails. For money or messages, accept an `Idempotency-Key` and return the original response for a repeat. Alternatively design the action to be naturally idempotent ("cancel an already-cancelled order" returns 200 with the same state rather than erroring) — but be careful that this does not mask duplicate refunds. **Async actions.** If the work is long (a bulk archive, a large refund reconciliation), return `202` with a job/status resource rather than holding the connection. **Authorization.** One of the strongest arguments for distinct action endpoints: `orders:cancel` is a separate permission from `orders:update`, and separate endpoints make that trivially expressible and auditable. ## Choosing quickly Does the action produce a record anyone will want to inspect or list? → resource (shape 2). Is it a genuine one-off command with side effects? → action sub-resource (shape 1). Is it truly just a field change? → PATCH (shape 3). All three are respectable; inconsistency between them within one API is not.
- Isn't a verb in the URL un-RESTful?Strictly, the uniform interface prefers nouns, and you can always model the action as a resource — a cancellation, a refund, a delivery — which is often the better design anyway because those things have attributes and history. But POST is defined as "process this representation", so an action endpoint is within the protocol, and readability plus per-action authorization usually justify it. The real anti-pattern is turning every endpoint into a verb with a single generic POST.
- What should happen if a client cancels an order twice?Decide and document it. Either return 200 with the current cancelled state, treating the operation as idempotent because the caller's goal already holds, or return 409 with a stable code such as ORDER_ALREADY_CANCELLED and the current status. The first is friendlier to retries, the second is more informative; what you must avoid is performing the side effects — refund, email, inventory return — a second time.
saying these in an interview costs you the question
- Exposing an action as GET because it is easy to link to
- Assuming POST can only mean create
- Returning 400 for an action that is illegal in the resource's current state, where 409 is the accurate signal
- Building a generic RPC surface where every endpoint is a verb and the resource model disappears
- Ignoring retry safety on actions that move money or send messages