The design review reaches cancel and stalls. One side wants PATCH /orders/{id} with
status=cancelled, because an order has a status and PATCH changes fields. The other side points
out that cancelling refunds a card, emails the customer and fires a webhook. Both sides are
describing the same call, and the whiteboard has no rule for choosing between them.
What you get
You will end up with one question that settles every endpoint, a table of the whole inventory against it, and a check that fails when an update method hides an action. This is for you if you are designing an API and want the entity-versus-action argument to happen once.
Short answer
Ask one question of each endpoint: does it read or write exactly one resource with no side effect beyond that resource. Yes makes it an entity operation, one of list, get, create, update, or delete. Anything else, a second resource, an email, a charge, a webhook, is an action, and it gets a verb path and a POST. Tabulate every endpoint against the question before the spec exists, and reject any PATCH whose verdict is action.
You will need
Node 22 or later, and a list of the endpoints you intend to ship, each with the resources it touches and the side effects it causes. The distinction rests on RFC 9110, which makes GET, PUT and DELETE idempotent and leaves POST and PATCH out. RFC 5789 adds that PATCH is neither safe nor idempotent unless you make it so.1 Google’s AIP-134 states the rule this page applies: an update method should not trigger other side effects, which belong in custom methods.
Approaches compared
| Approach | When it fits | What it costs you | When to pick something else |
|---|---|---|---|
| Google AIP-136 custom methods | You want actions visibly separate from resources in the URL | A colon in the path that some routers and proxies handle badly, and camelCase verbs beside snake_case fields | Your framework cannot route :cancel |
| GitHub merge and lock endpoints | An action that is a state with an inverse, so PUT and DELETE fit it | A method per state, and a reader who has to learn that PUT on /lock means lock | The action has no inverse, such as a refund |
| Slack RPC methods | Every call is a named method and no reader expects resources | No HTTP caching, no idempotent reads by method, and a method list that grows without structure | You want generated SDK classes per resource |
| Smithy resource operations | A model where lifecycle bindings and actions are different fields | A Java toolchain to build, and a model that has to be kept beside the spec | Your team writes OpenAPI by hand and will not adopt a modeling language |
| Stripe verb sub-paths | Actions on one resource, each a POST to a verb under the item | Every action is a POST, so retry safety has to come from an idempotency key | An action has no single resource to hang under |
| TypeSpec @action | You already model in TypeSpec and want the routing derived from the decision | An @autoRoute interface and a compile step before the OpenAPI exists | The spec is the source and nothing generates it |
Stripe and Google draw the same line and spell the separator differently: a slash under the resource, or a colon after it. GitHub uses the method itself where an action has an inverse, so lock is PUT and unlock is DELETE, at the cost of a reader learning that. Slack skips resources altogether and pays with a flat list of methods and no idempotent GET. The modeling languages record the same decision as a field. TypeSpec uses a decorator for it and Smithy a binding.
Apply the test to every proposed endpoint
The inventory carries two lists per endpoint: the resources it reads or writes, and the side effects beyond them. The test reads those two lists and nothing else.
export function classify(endpoint) {
const { method, touches, effects, hint } = endpoint
const oneResource = touches.length === 1 && effects.length === 0
// The shape names the operation; a `hint` in the inventory stands in where the shape cannot.
const shape = shapeOf(endpoint) ?? hint
if (oneResource) {
return {
kind: 'entity',
op: shape ?? 'update',
// A verb path that only changes one resource is an update wearing a costume.
note: shape ? '' : 'verb path for a plain update',
retry: IDEMPOTENT.has(method) ? 'safe' : method === 'PATCH' ? 'safe if the body is absolute' : 'needs a key',
}
}
// A read that joins resources and changes nothing is a view. A GET with a side effect is not.
const view = method === 'GET' && effects.length === 0
return {
kind: 'action',
op: null,
// The pitfall: an update or delete method whose effects say action. It reads as safe to retry and is not.
note: RETRIED.has(method) ? `hidden behind ${method}` : view ? 'read-only, a view' : '',
retry: view ? 'safe' : 'needs a key',
}
}
The path shape names the operation once the verdict is entity, and only then. A verdict of action
ignores the shape entirely, which is the point: PATCH /orders/{id} with a body of
status=cancelled is an action because of what it does, not despite how it is spelled.
The retry column follows the verdict. A PATCH that sets one field to an absolute value can be sent twice. A PATCH that refunds a card cannot, and the only thing that makes the second safe is an idempotency key the server honors. The same goes for a DELETE that refunds, and a GET that sends an email is no view: the method’s promise of a safe retry is what the effects list overrides.
Tabulate the inventory
Twelve proposed endpoints for an orders API, including the two that started the argument.
node tabulate.mjs
endpoint touches effects verdict retry
GET /orders order - entity: list safe
GET /orders/{id} order - entity: get safe
POST /orders order - entity: create needs a key
PATCH /orders/{id} shipping_address order - entity: update safe if the body is absolute
DELETE /orders/{id} order - entity: delete safe
PATCH /orders/{id} status=cancelled order, refund refund, email, webhook action needs a key (hidden behind PATCH)
POST /orders/{id}/cancel order, refund refund, email, webhook action needs a key
POST /orders/{id}/archive order - entity: update needs a key (verb path for a plain update)
POST /orders/{id}/capture order, payment charge action needs a key
PUT /orders/{id}/status status=shipped order, shipment email action needs a key (hidden behind PUT)
POST /orders/{id}/invoice order, invoice email action needs a key
GET /orders/{id}/receipt order, payment, customer - action safe (read-only, a view)
12 endpoints: 6 entity operations, 6 actions, 2 hidden behind an update method
PATCH /orders/{id} status=cancelled performs refund, email, webhook: give it a verb path
PUT /orders/{id}/status status=shipped performs email: give it a verb path
Read the two PATCH rows together. Same method, same path, and the verdicts differ, because the second one touches a refund and sends mail. Retrying the first after a timeout changes nothing, but retrying the second may refund twice, because nothing in the method told the client to be careful.
Two rows are worth a second look. archive is a verb path that touches one resource and nothing
else, so it is an update in costume, and the table says so rather than promoting it to an action.
receipt joins three resources without writing any, which the test calls an action; a read-only
action is a view, and AIP-136 lets a custom method use GET for
exactly that case.
Record the decision in the model
A modeling language makes the verdict a declaration rather than a convention. In TypeSpec the five entity operations come from a template and each action is decorated.
@autoRoute
interface Orders extends ResourceOperations<Order, Error> {
@action("cancel")
cancel(...ResourceParameters<Order>): Order | Error;
@action("refund")
@actionSeparator(":")
refund(...ResourceParameters<Order>, @body body: { amount: int32 }): Refund | Error;
}
Resource routing derives the paths:
the template gives the five operations, @action appends a verb under the item, and
@actionSeparator chooses Stripe’s slash or Google’s colon per action.
npx tsp compile main.tsp --emit @typespec/openapi3 --option "@typespec/openapi3.file-type=json"
Compilation completed successfully.
node routes.mjs
POST /orders Orders_create
GET /orders Orders_list
GET /orders/{id} Orders_get
PATCH /orders/{id} Orders_update
DELETE /orders/{id} Orders_delete
POST /orders/{id}/cancel Orders_cancel
POST /orders/{id}:refund Orders_refund
Both actions came out as POST without being told to, because that is what an action is. The update came out as PATCH and carries no way to cancel anything.
Smithy records the same split as two kinds of binding on a resource.
resource Order {
identifiers: { orderId: OrderId }
read: GetOrder
list: ListOrders
create: CreateOrder
update: UpdateOrder
delete: DeleteOrder
operations: [CancelOrder, RefundOrder]
}
The lifecycle bindings are
the entity operations and operations holds the actions, each with its own
@http route. The
@idempotent trait sits on
UpdateOrder and DeleteOrder and is absent from CancelOrder, which is the retry column of the
table written into the model.
Check it worked
The table exits non-zero while any update method hides an action, so it can sit in the review pipeline beside the linter. The tests pin each branch of the verdict.
node --test classify.test.mjs
1..6
# tests 6
# suites 0
# pass 6
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 109.152123
When it goes wrong
Every endpoint comes out as an action. The inventory lists every table a handler reads, including lookup tables, as a resource it touches. Touches means the resource whose state the call is about; reading a currency table to format a total is not touching a currency.
A nested create shows as a verb path for a plain update. POST /orders/{id}/shipments touches
one resource and has no side effects, and the shape check cannot tell a nested collection from a
verb. Add a create hint to that endpoint in the inventory, which classify reads where the
shape says nothing, rather than loosening the test.
An action is named with a standard verb, such as :updateStatus. AIP-136 says a custom method
name should not contain get, list, create, update, or delete.2 The reason is the one this page is
about: a name that says update makes the reader expect an update.
The inverse of an action is missing. Lock without unlock, archive without restore. GitHub models those pairs as PUT and DELETE on one sub-path, which is worth copying when the pair exists and wrong when it does not.
When not to do this
Do not model a state change as PATCH with status=cancelled when the change does anything
beyond the row. The method promises a field update, generated clients will treat it as one, and a
retry loop will send it again after a timeout without asking. A refund, an email or a webhook
behind that call is a side effect nobody consented to twice.
Do not promote every verb to an action either. archive that flips one flag on one resource is an
update, and giving it a POST sub-path costs a reader the cache, the idempotent retry and the
generated update() method for no gain. The test is about effects, not vocabulary.
Do not adopt a modeling language to settle one argument. TypeSpec and Smithy record the decision well, and each brings a compile step, a toolchain and a second artifact to keep in step with the spec. A twelve-line table in the design review records it too.
Do not retry an action without a key, whichever spelling you chose. A verb path makes the danger visible; it does not remove it.
Related how-tos
Last verified
Verified 2026-09-24 against Node 22.22.2, @typespec/compiler 1.16.0, @typespec/http 1.16.0,
@typespec/rest 0.86.0 and @typespec/openapi3 1.16.0. Every output block is what the preceding
command printed, run in the page’s code directory after npm ci. The Smithy model was written
against the specification and not compiled, because the Smithy build needs a Java toolchain that is
not part of this page.
Footnotes
-
RFC 5789 says in section 2 that PATCH is neither safe nor idempotent. The next paragraph allows that a PATCH request can be issued in such a way as to be idempotent. The specification hands the property back to whoever writes the body, which is how a status field became a way of doing anything at all under a method that sounds harmless. ↩︎ Back to text
-
The custom method rules in AIP-136 come as three bullets. One advises against the standard verbs in a custom name, one requires a colon before the verb, and one requires
camelCasewhere a name needs separating.:cancelpasses all three and:update_statuskeeps only the colon. The GitHub merge endpoint takes a different route to the same lesson. It answers 405 Method Not Allowed when the pull request cannot be merged, so the method was allowed and the state was not, and the status code says the opposite. ↩︎ Back to text