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_1gets reused to mean "cancel," making actual deletion impossiblePOST /cancelOrders/ord_1invents 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
UPDATEhandler - cannot validate "resolution requires note" without special-casing
PATCHcode
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:
POSTbecause non-idempotent (resolving twice returns409 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
resolveon already-resolved tickets with409, whichPATCHcould not cleanly express - audit log reads "
user u_42resolvedt_1" instead of "user u_42updated fieldstatus"
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:
- Is this a state transition with business rules? -> custom method.
- Does it have inputs not part of the resource? -> custom method.
- Does it act on a collection? -> custom method on the collection.
- Is the query too complex or sensitive for a URL? ->
:searchcustom method. - Would calling this twice be a problem? ->
POST, non-idempotent, with anIdempotency-Key. - Would calling this twice be harmless? -> still
POST, but consider whether it should be aPUTon 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
- When would you model
archiveas a custom method:archivevs as a fieldarchived: trueedited viaPATCH? - Why is
POST /orders:cancelpreferred overDELETE /orders/{id}for order cancellation? - How should the server respond to
POST /orders/ord_1:cancelwhenord_1is 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:
- Write the URL using the
:verbpattern. - Write the HTTP method (
POSTalmost always). - Write the request body shape.
- Write the success and conflict responses (including status code).
- Note whether it is idempotent and how you enforce it.
Read This Only If Stuck
- Geewax: Custom methods - motivation, why not just standard methods
- Geewax: Overview and side effects
- Geewax: Resources vs collections, stateless, final definition, tradeoffs
- Google AIP-136: Custom methods - canonical external reference for the
:verbpattern