How-to › Describe your API

How to decide if an endpoint is an entity operation or an action#

Apply one test to every proposed endpoint, tabulate the verdicts, and catch the PATCH that refunds a card and sends an email while looking like a safe update.

Audience
API producer
Level
intermediate
Topic
Model resources, relations and actions
Verified

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

ApproachWhen it fitsWhat it costs youWhen to pick something else
Google AIP-136 custom methodsYou want actions visibly separate from resources in the URLA colon in the path that some routers and proxies handle badly, and camelCase verbs beside snake_case fieldsYour framework cannot route :cancel
GitHub merge and lock endpointsAn action that is a state with an inverse, so PUT and DELETE fit itA method per state, and a reader who has to learn that PUT on /lock means lockThe action has no inverse, such as a refund
Slack RPC methodsEvery call is a named method and no reader expects resourcesNo HTTP caching, no idempotent reads by method, and a method list that grows without structureYou want generated SDK classes per resource
Smithy resource operationsA model where lifecycle bindings and actions are different fieldsA Java toolchain to build, and a model that has to be kept beside the specYour team writes OpenAPI by hand and will not adopt a modeling language
Stripe verb sub-pathsActions on one resource, each a POST to a verb under the itemEvery action is a POST, so retry safety has to come from an idempotency keyAn action has no single resource to hang under
TypeSpec @actionYou already model in TypeSpec and want the routing derived from the decisionAn @autoRoute interface and a compile step before the OpenAPI existsThe 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.

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

  1. 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

  2. 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 camelCase where a name needs separating. :cancel passes all three and :update_status keeps 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

Read this page as markdown · All how-to guides

Generate the client instead of writing it#

Retries, timeouts, pagination and auth are the same problems in every client. Voxgig generates them from your OpenAPI description, in 23 languages, from one model.

Get the Voxgig dispatch

Short notes on building SDKs, CLIs, REPLs, and MCPs for API-first teams, plus the occasional Fireside episode pick.

By signing up you agree to our Terms and Conditions.