Resource or Action?
POST /cancelOrder, POST /orders/{id}/cancellations, PATCH {status: "cancelled"} — three shapes for one operation, each promising something different. Actions with their own data and lifecycle are domain concepts worth modeling; the rest can stay verbs or field updates.
Frame the contract
API design starts with a consumer, a design question and a guarantee — never with a URL.
Three shapes, three different promises
Take "cancel an order". PATCH /orders/{id} with {"status": "cancelled"} says: this is a field edit, and the client owns the transition. POST /orders/{id}/cancel says: this is a command the server interprets — the client requests, the server decides. POST /orders/{id}/cancellations says: cancellation is a *thing* — it has data (reason, initiator, refund amount), it can be looked up later, there might someday be more than one attempt.
None of these is the correct answer; they answer different questions about the domain. The failure mode is choosing by aesthetics — "PATCH is more RESTful", "verbs are forbidden" — instead of by what cancellation actually *is* in this business. A dogmatic answer here is exactly the trap The "REST Purity" Anti-Pattern warns about, in both directions.
The discriminating questions: Does the operation carry its own data beyond the new state? Does anyone need to reference it later ("show me the cancellation, who did it, was the refund issued")? Can it fail halfway or take time? Can it happen more than once? Each "yes" pushes toward modeling the action as a resource; all "no" means a state edit or a simple command verb is honest and cheaper.
| Shape | What it promises | Honest when | Lies when |
|---|---|---|---|
PATCH /orders/{id} {"status":"cancelled"} | A field edit the client controls; no extra semantics | The transition is trivial, synchronous, carries no data, leaves no record worth addressing | Cancellation triggers refunds, emails and inventory release the shape never mentions |
POST /orders/{id}/cancel | A command; the server owns the decision and side effects | The operation is atomic, unrepeatable, and nobody references it afterwards | Consumers later ask "why was it cancelled, by whom, where is the refund?" and there is nothing to point at |
POST /orders/{id}/cancellations | Cancellation is a domain entity with data, identity and possibly a lifecycle of its own | The action has a reason, an actor, an outcome to track, or can be pending/failed | Used for trivial toggles — ceremony without a requirement behind it |
When the action is a domain concept
Real cancellations are rarely field flips. They carry a reason code the support team reports on; they trigger a refund that can fail; they may need approval above a threshold; the customer emails three weeks later asking about "the cancellation". Every one of those is a consumer task pointing at the cancellation itself — which is the referential test from From Domain to Resources returning a "yes".
Modeling the action as a resource also solves problems the other shapes cannot. Retryability: POST /orders/42/cancellations with an idempotency key returns the same cancellation on retry (see Idempotency Keys: The Mechanism); a bare PATCH retried after a timeout gives no way to ask "did my cancellation go through, and was it *mine*?". Asynchrony: a cancellation that takes time is a resource with a status the client polls — the The Async Job Pattern falls out for free. History: "this order was cancelled, un-cancelled by support, cancelled again" is three rows, not one overwritten field.
1PATCH /orders/422{ "status": "cancelled" }3→ 200 OK4 5# Where does the reason go? An overloaded field.6# Refund failed at the processor — but the PATCH already said 200.7# Support asks "who cancelled this?" — no record exists.8# Client retries after timeout — did it apply once? Twice? No way to ask.1POST /orders/42/cancellations2Idempotency-Key: 7f9c…3{ "reason": "customer_request", "restock": true }4→ 201 Created5{6 "id": "can_81x",7 "status": "refund_pending",8 "initiated_by": "usr_7",9 "refund": { "amount": 1999, "status": "processing" }10}11 12GET /orders/42/cancellations/can_81x # support, three weeks laterThe resource shape did not add complexity — it gave the complexity that already existed a place to live. Reason, actor, refund state and retryability were all requirements; the PATCH shape just refused to acknowledge them.
Keeping both ditches in view
The failure on one side is verb explosion: /cancelOrder, /uncancelOrder, /cancelOrderWithRefund — operations minted ad hoc, nothing addressable, every variation a new endpoint (the phrasebook problem from From Domain to Resources). The failure on the other side is CRUD flattening: every domain operation disguised as a field update, semantics smuggled through status values, side effects undocumented (the The "Everything Is CRUD" Trap trap).
Between them sits a defensible middle: model actions as resources when they carry data or need addressing; use a command-style sub-path (POST /orders/{id}/ship — see Designing State Transitions) when the operation is atomic but server-owned; allow plain field edits for attributes that really are just attributes. What keeps the middle honest is writing down *which question each operation answered* — the next designer extends a reasoned pattern instead of guessing which of your three shapes was the precedent.
One pragmatic note on naming: POST /orders/{id}/cancellations reads oddly to teams expecting verbs, and POST /orders/{id}/cancel offends noun purists. Both objections are aesthetic. The operational differences — addressability, retry story, history — are real. Spend the review time there.
- Carries data beyond the new state (reason, amount, actor) → action-as-resource.
- Referenced later by support, audits, webhooks → action-as-resource.
- Asynchronous or can fail midway → action-as-resource with a
status(see Long-Running Operations: 202 and the Job Resource). - Atomic, server-owned, unaddressed afterwards → command verb on a sub-path.
- A genuine attribute edit (rename, toggle a flag) →
PATCHand stop there.
Key points
- PATCH-a-status, command verb, and action-as-resource are three different promises — choose by the operation's semantics, not by REST aesthetics.
- Actions that carry data, need referencing, take time, or can repeat are domain concepts deserving resource modeling.
- Action resources give you retry semantics, async status and history for free; field flips give you none of them.
- Verb explosion and CRUD flattening are the two ditches; the middle is held by writing down which question each shape answered.
- The naming debate (verb vs noun) is aesthetic; the addressability and retry differences are operational. Review the operational ones.
Follow the failure
How the contract fails or gets misused, hop by hop — and what it costs when it completes.
- 1Team → API: models cancellation as
PATCH {status}because "we already have update". - 2Product → requirement: cancellations need a reason and trigger refunds; reason is stuffed into a nullable column, refund into a side effect.
- 3Client → API: PATCH times out; client retries; no way exists to ask whether the first attempt applied or whose cancellation won.
- 4Refund processor → order: refund fails after the PATCH returned 200; the order says cancelled, the customer has no money back, no resource records the discrepancy.
- 5Support → team: "who cancelled order 42 and where is the refund?" becomes a log-diving exercise instead of a GET.
- Retried transitions double-fire side effects (refunds, emails, restocks) because nothing deduplicates an anonymous field edit.
- Outcome tracking is impossible: the operation succeeded as an HTTP call but failed as a business process, and the contract has no place to say so.
- Audit and support workflows fall back to log archaeology because the domain event was never addressable.
Design, observe, evolve
A contract decision is incomplete until you know how you would notice it failing and how it changes later.
- • Run the four discriminating questions (own data? referenced later? async/failable? repeatable?) in review before choosing the shape.
- • Model qualifying actions as sub-resources with ids, status and idempotency-key support.
- • Reserve `PATCH` on status fields for transitions with no data, no side effects and no audit need — and say so in the field docs.
- • Keep one convention per API for command verbs vs action resources, recorded where reviewers look (see [[naming-and-consistency]]).
- • Overloaded `status` fields accumulating adjacent columns (`cancel_reason`, `cancelled_by`, `refund_state`) show an action wanting to be a resource.
- • Duplicate side effects after client retries (two refund attempts, two emails) reveal an unaddressable, non-idempotent transition.
- • Support tooling screen-scraping logs for "who did X" is the missing-resource smell at the org level.
- • A `PATCH`-shaped transition can be upgraded additively: introduce the action resource, keep accepting the field edit as a deprecated alias, migrate consumers with telemetry (see [[api-migration]]).
- • Action resources absorb new requirements as fields (approval, partial refunds, scheduled cancellation) where a command verb would need new endpoints.
- • If an action resource never grows past a bare `POST` with empty body, it can be honestly simplified to a command verb in the next version — ceremony is also a cost to walk back.
- • Action resources are more surface: ids to mint, GETs to serve, retention to decide. A trivial toggle does not earn that.
- • Server-owned commands centralize logic but hide it: consumers cannot predict side effects from the shape alone and must read docs.
- • Supporting a deprecated field-edit alias during migration means two write paths to keep consistent for the whole window.
Misconceptions
POST /orders/{id}/cancel is a fine contract for an atomic, server-owned, never-referenced operation. What is wrong is choosing the shape by grammar instead of by the operation's data, retry and audit needs.