Skip to main content

Custom Actions: When CRUD Does Not Fit

What This Concept Is

Standard methods (GET, POST, PUT, PATCH, DELETE) cover most of what consumers need. They do not cover everything. Some operations:

  • are verbs the domain actually names (publish, archive, refund, cancel, send, rotate)
  • have side effects CRUD cannot express cleanly (sending an email is not "updating an email field")
  • act on a collection, not an item (bulk re-index, empty trash)
  • require inputs that are not part of the resource state (a search query, a coupon code)

A custom method is a named action on a resource or collection, expressed as a verb after a colon:

POST /orders/ord_1:cancel
POST /projects/p_1:archive
POST /tickets:bulkResolve

This syntax is Google's AIP pattern, widely adopted. Alternative syntaxes exist (POST /orders/ord_1/cancel, POST /orders/ord_1/actions/cancel) - pick one for the whole API.

Why It Matters Here

Without a custom-action pattern, teams twist CRUD until it breaks:

  • PATCH /orders/ord_1 { status: "cancelled" } turns cancellation into a state field edit, losing the ability to validate "cancellable only if not shipped"
  • DELETE /orders/ord_1 gets reused to mean "cancel," making actual deletion impossible
  • POST /cancelOrders/ord_1 invents a parallel URL hierarchy that grows into a mess

Custom actions let you say "this is an operation, not a field edit." They make side effects explicit and auditable.

Concrete Example

A ticketing system with state transitions. Compare the two designs.

Bad (overloaded PATCH):

PATCH /tickets/t_1
{ "status": "resolved", "resolution_note": "duplicate of t_17" }

Problems:

  • client must know which state transitions are valid
  • server must interpret "changing status to resolved" as "this is the resolution action"
  • side effects (notifying the reporter, closing child tickets) are hidden in an UPDATE handler
  • cannot validate "resolution requires note" without special-casing PATCH code

Good (custom method):

POST /tickets/t_1:resolve
Content-Type: application/json

{
"resolution_note": "duplicate of t_17",
"duplicate_of": "t_17"
}

HTTP/1.1 200 OK
Content-Type: application/json

{
"id": "t_1",
"status": "resolved",
"resolved_at": "2026-04-10T15:20:00Z",
"resolved_by": "u_42",
"resolution": { "note": "duplicate of t_17", "duplicate_of": "t_17" }
}

Properties:

  • POST because non-idempotent (resolving twice returns 409 Conflict)
  • URL names the action, not the state field
  • request body is the input for the action, not the resource shape
  • response is the updated resource
  • server can reject resolve on already-resolved tickets with 409, which PATCH could not cleanly express
  • audit log reads "user u_42 resolved t_1" instead of "user u_42 updated field status"

More examples in the same style:

POST /orders/ord_1:cancel         { "reason": "customer_request" }
POST /documents/d_7:publish { "scheduled_for": "2026-05-01T09:00:00Z" }
POST /keys/k_3:rotate {}
POST /subscriptions/s_2:pause { "until": "2026-06-01" }
POST /reports:export { "format": "csv", "filter": "..." }

Collection-level custom actions work the same:

POST /tickets:bulkResolve
{
"ticket_ids": ["t_1", "t_2", "t_3"],
"resolution_note": "closed by automation"
}

Search as a custom action avoids the GET-with-body trap:

POST /orders:search
{
"filter": "status eq \"paid\" and total gt 10000",
"order_by": "created_at desc",
"page_size": 100
}

This works when the query would not fit in a URL (long filter, bulk predicate, sensitive parameters).

Common Confusion / Misconception

"Any state change belongs in PATCH." PATCH is for partial data edits. Domain actions with validation rules or side effects belong in custom methods. archive, publish, refund are never just field edits.

"Custom methods violate REST." They are an extension, not a violation. Geewax and the major style guides (Google AIP, Microsoft) explicitly endorse them. The thing to avoid is GET /createOrder, not POST /orders:cancel.

"Any URL with a colon is a custom method." Only for the final segment. POST /projects/p_1/tickets/t_1:resolve is valid; POST /projects:p1/tickets is not.

"Use DELETE for cancel." DELETE means "remove the resource from addressable state." Cancel is a state transition on a resource that continues to exist. Use a custom method.

How To Use It

When an endpoint does not obviously fit CRUD, ask:

  1. Is this a state transition with business rules? -> custom method.
  2. Does it have inputs not part of the resource? -> custom method.
  3. Does it act on a collection? -> custom method on the collection.
  4. Is the query too complex or sensitive for a URL? -> :search custom method.
  5. Would calling this twice be a problem? -> POST, non-idempotent, with an Idempotency-Key.
  6. Would calling this twice be harmless? -> still POST, but consider whether it should be a PUT on a status sub-resource instead.

Keep the list of custom methods short. If you have more custom methods than standard methods on a resource, the resource model is wrong.

Check Yourself

  1. When would you model archive as a custom method :archive vs as a field archived: true edited via PATCH?
  2. Why is POST /orders:cancel preferred over DELETE /orders/{id} for order cancellation?
  3. How should the server respond to POST /orders/ord_1:cancel when ord_1 is already cancelled?

Mini Drill or Application

For a content-management system with article, comment, and user resources, list every verb the domain uses that is not CRUD (e.g., publish, unpublish, flag, ban, resetPassword). For each:

  1. Write the URL using the :verb pattern.
  2. Write the HTTP method (POST almost always).
  3. Write the request body shape.
  4. Write the success and conflict responses (including status code).
  5. Note whether it is idempotent and how you enforce it.

Read This Only If Stuck