API Design

Resource Design Patterns · Lesson 03

Custom methods — when CRUD isn't enough

Most resources live happily inside create, read, update, and delete. But some operations — completing a task, moving it between projects, archiving a whole workspace — carry meaning and side effects that a plain data write can't express. Custom methods give you a principled, spec-backed way to reach beyond CRUD without losing REST's identity.

⏱ 12 min Difficulty: core Prereq: Standard methods (Lesson 02)

By the end you'll be able to

Why CRUD breaks down

Imagine you run a postal sorting facility. The standard operations are obvious: add a parcel (create), look it up (read), update the delivery address (update), remove a cancelled shipment (delete). But what happens when a parcel ships? Shipping isn't just writing status = "shipped" — it triggers downstream effects: the carrier is notified, the tracking number is generated, the warehouse inventory is decremented, and the customer gets an email. The operation has a name — "ship this parcel" — and a contract — it either succeeds fully or rolls back. Squashing that into a PATCH call obscures intent, scatters side-effect logic, and gives your callers no signal that something significant just happened.

This is the gap that custom methods fill. They are purpose-named operations on a specific resource, distinct from its CRUD lifecycle, with their own request payload and response contract.

The colon-suffix convention

Google's API Improvement Proposals (AIPs) codify this pattern as AIP-136. The rule is simple: append a colon and a verb to the resource URL, and always use POST.

# Custom method on a single resource
POST /v1/projects/{project}/tasks/{task}:complete
POST /v1/projects/{project}/tasks/{task}:move
POST /v1/projects/{project}/tasks/{task}:assign

# Custom method on a collection or parent resource
POST /v1/projects/{project}:archive
POST /v1/projects/{project}:restore
POST /v1/documents/{document}:publish

Why always POST?

You might expect a "get suggestions" custom method to use GET, and an argument could be made. But AIP-136 mandates POST for all custom methods for three reasons:

  1. Side effects. Custom methods almost always mutate state or trigger external actions. POST is the correct semantic for non-safe, non-idempotent-by-default operations. Using GET for a side-effectful call violates the HTTP safety contract and risks being silently re-fetched by proxies and prefetch heuristics.
  2. Request body. Many custom methods need parameters — a destination project ID for :move, or a reason for :archive. GET has no defined body semantics. POST gives you a structured JSON body without resorting to query-string encoding of complex data.
  3. Uniformity. A single rule ("custom methods are always POST") is simpler to implement, document, and lint than per-method exceptions. Router logic and auth middleware only need one pattern to match.

Why a colon, not a slash?

A slash would make :complete look like a child resource: /tasks/task_77/complete — something you could GET, PUT, DELETE. The colon makes the operation/resource distinction syntactically unambiguous. The resource is /tasks/task_77. The colon-verb is an action on it, not a sub-resource of it. URL parsing is unambiguous: split on the last colon in the final path segment and you have the resource name on the left and the method name on the right.

Decision tree: custom method vs PATCH vs sub-resource

Before reaching for a custom method, run through this decision order:

Option Use when Example
PATCH a status field The operation is a pure data update — it only changes a field, has no observable side effects beyond the field change itself, and the new value could logically be anything the caller writes. PATCH /tasks/task_77 with {"due_time": "2025-07-01T17:00:00Z"}
Create a sub-resource The operation produces a persistent, independently addressable record that callers will later need to read, list, or delete — the operation is really "create a new thing that lives inside this thing." POST /tasks/task_77/comments to add a comment; POST /tasks/task_77/attachments to attach a file
Custom method The operation (a) changes state in a way that carries domain meaning beyond a raw field write, (b) triggers observable side effects, (c) enforces a state-machine transition, or (d) moves or copies a resource across ownership boundaries. POST /tasks/task_77:complete — sets status, stamps completed_time, may notify assignee; the semantics are "complete this task", not "write status=completed"

The sharpest test: could the caller achieve this by PATCHing a field, and would that be safe? If writing {"status": "completed"} via PATCH would skip the notification, leave completed_time unpopulated, or allow invalid transitions (e.g. completing an already-deleted task), then you need a custom method. PATCH is a data channel; custom methods are a behaviour channel.

SVG decision flowchart

Operation needed on a resource Pure data change, no side effects? Yes PATCH field update No Creates an addressable child record? Yes Sub-resource POST /…/children No Custom method POST :verb
Follow the two questions in sequence. Only reach for a custom method when the operation is neither a plain field write nor a child-resource creation.

Worked example: :complete

Completing a task in the Tasks API is the classic case for a custom method. It is not a pure field write — it must stamp completed_time atomically, enforce the state machine (you cannot complete an already-deleted task), and may trigger downstream notifications. Here is the full wire exchange:

# Request — empty body is valid; :complete needs no parameters
POST /v1/projects/proj_42/tasks/task_77:complete HTTP/1.1
Host: api.example.com
Authorization: Bearer <token>
Content-Type: application/json

{}

# Response — full resource returned, status and completed_time updated
HTTP/1.1 200 OK
Content-Type: application/json

{
  "name": "projects/proj_42/tasks/task_77",
  "title": "Finalise API spec",
  "status": "completed",
  "completed": true,
  "completed_time": "2025-06-18T14:32:00Z",
  "assignee": "users/ada",
  "due_time": "2025-06-20T17:00:00Z",
  "create_time": "2025-06-01T09:00:00Z",
  "update_time": "2025-06-18T14:32:00Z"
}

Several design choices are worth calling out:

Worked example: :move

Moving a task between projects is a cross-resource operation. It cannot be expressed as a PATCH (the resource's canonical URL changes), and it does not create a child record. It is exactly the kind of bounded domain action that custom methods exist for.

# Request — destination_project tells the server where to move the task
POST /v1/projects/proj_42/tasks/task_77:move HTTP/1.1
Host: api.example.com
Authorization: Bearer <token>
Content-Type: application/json

{
  "destination_project": "projects/proj_99"
}

# Response — task now lives at its new canonical name in proj_99
HTTP/1.1 200 OK
Content-Type: application/json

{
  "name": "projects/proj_99/tasks/task_77",
  "title": "Finalise API spec",
  "status": "open",
  "completed": false,
  "assignee": "users/ada",
  "due_time": "2025-06-20T17:00:00Z",
  "create_time": "2025-06-01T09:00:00Z",
  "update_time": "2025-06-18T14:33:00Z"
}

The name field in the response reflects the new canonical path — projects/proj_99/tasks/task_77 — not the URL the request was sent to. Any client that cached the old URL needs to update its reference. Note that status is reset to "open": the task enters the destination project's default state. This business rule belongs here, not in a PATCH handler, because it is part of the semantics of "moving" a task.

Under the hood: how it actually works

Understanding what happens in the router and handler helps you design the right contract and debug issues when they arise.

Router parsing

The colon-suffix is parsed as part of the final path segment. A typical routing table entry looks like:

// Express-style pseudo-code
// The router registers the full literal :complete suffix as a method name
app.post('/v1/projects/:project/tasks/:task\\:complete', handlers.completeTask);
app.post('/v1/projects/:project/tasks/:task\\:move',     handlers.moveTask);

// Framework-agnostic: split on the last colon in the final segment
function parseCustomMethod(path) {
  const lastSegment = path.split('/').pop();          // "task_77:complete"
  const colonIdx    = lastSegment.lastIndexOf(':');
  if (colonIdx === -1) return { resource: lastSegment, method: null };
  return {
    resource: lastSegment.slice(0, colonIdx),  // "task_77"
    method:   lastSegment.slice(colonIdx + 1)  // "complete"
  };
}

State machine and idempotency

Custom methods that implement state transitions must be written with idempotency in mind. The contract for :complete is: after this call, the task is in the completed state. If it was already there, that is not an error — it is the desired outcome, already achieved. The handler should check the current state and short-circuit:

// Pseudocode handler for :complete
async function completeTask(req, res) {
  const task = await db.tasks.findById(req.params.task);
  if (!task)           throw notFound('Task not found');
  if (task.deleted)    throw failedPrecondition('Cannot complete a deleted task');

  // Idempotent: already completed → return current state, no-op
  if (task.status === 'completed') {
    return res.json(toResource(task));  // 200, no mutation
  }

  const now = new Date().toISOString();
  const updated = await db.tasks.update(task.id, {
    status:         'completed',
    completed:      true,
    completed_time: now,
    update_time:    now,
  });
  await notifications.send(updated);   // side effect: notify assignee
  res.json(toResource(updated));
}

Always return the full resource

The contract for custom methods that mutate a resource is to return the complete, updated resource representation — not a status flag, not a partial object. This lets clients treat the response as a fresh GET of the resource and update their cache or local state accordingly. It also makes the API easier to reason about in logs and debugging sessions.

Curl commands and routing walkthrough

── Completing a task ───────────────────────────────────────────────── $ curl -s -X POST \ "https://api.example.com/v1/projects/proj_42/tasks/task_77:complete" \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{}' | jq '{status, completed, completed_time}' { "status": "completed", "completed": true, "completed_time": "2025-06-18T14:32:00Z" } ── Router receives POST /v1/projects/proj_42/tasks/task_77:complete ── → Last segment parsed: resource="task_77", method="complete" → Dispatched to: handlers.completeTask → Task loaded, state=open → transition to completed ← 200 OK {full task resource} ── Replaying :complete (idempotency test) ──────────────────────────── $ curl -s -X POST \ "https://api.example.com/v1/projects/proj_42/tasks/task_77:complete" \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{}' | jq .status "completed" # same state, no error — idempotent ✓ ── Moving a task ───────────────────────────────────────────────────── $ curl -s -X POST \ "https://api.example.com/v1/projects/proj_42/tasks/task_77:move" \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{"destination_project":"projects/proj_99"}' | jq .name "projects/proj_99/tasks/task_77" # canonical name updated

How to debug & inspect it

Custom method issues usually fall into a handful of recognisable patterns. Here is how to quickly isolate each one.

── Wrong HTTP method ───────────────────────────────────────────────── $ curl -s -X GET \ "https://api.example.com/v1/projects/proj_42/tasks/task_77:complete" HTTP/1.1 405 Method Not Allowed Allow: POST → Custom methods accept POST only. Use -X POST. ── Colon not URL-encoded in some clients ───────────────────────────── $ curl -s --path-as-is -X POST \ "https://api.example.com/v1/projects/proj_42/tasks/task_77%3Acomplete" HTTP/1.1 404 Not Found → %3A is the percent-encoded colon. Some routers don't decode it before matching. Pass the literal colon; most HTTP clients don't encode it. If using a proxy or API gateway, verify it passes the colon through. ── Missing Content-Type on a method that requires a body ───────────── $ curl -s -X POST \ "https://api.example.com/v1/projects/proj_42/tasks/task_77:move" \ -d '{"destination_project":"projects/proj_99"}' HTTP/1.1 400 Bad Request {"error": "missing or unsupported Content-Type"} → Always include -H "Content-Type: application/json" with a JSON body. ── Calling :complete on a deleted task ─────────────────────────────── $ curl -s -X POST \ "https://api.example.com/v1/projects/proj_42/tasks/task_deleted:complete" \ -H "Content-Type: application/json" -d '{}' HTTP/1.1 400 Bad Request {"error": {"code": 400, "status": "FAILED_PRECONDITION", "message": "Cannot complete a deleted task."}} → FAILED_PRECONDITION (AIP-193) is the right error for invalid state transitions. NOT_FOUND would be misleading — the task exists.

Symptom → cause → fix

SymptomLikely causeFix
405 Method Not Allowed on a custom method URL Client sent GET, PUT, or PATCH instead of POST Custom methods are always POST. Check client code for incorrect method.
404 Not Found for a URL you know exists The colon is percent-encoded as %3A by the client or an intermediate proxy Pass the literal colon in the URL. If behind an API gateway, verify colon pass-through is enabled. URL-decode the path before router matching.
400 Bad Request with "missing body" on :move No request body or missing Content-Type: application/json header Always send a JSON body and set the Content-Type header, even if the body is {}.
Second :complete call returns 409 Conflict Handler was not written idempotently — it errors on an already-completed task Check the current state first. If already in the target state, return 200 with the unchanged resource — do not error.
Response body is {"ok": true} instead of the full resource Handler returns a minimal ack instead of the updated resource Custom methods must return the full, updated resource. Clients use the response to refresh their local state.
🎯 Interview angle

"Design a task management API. How would you handle 'completing' a task?" The weak answer is PATCH /tasks/{id} with {"status":"completed"} — it works, but signals you haven't thought about side effects. The strong answer introduces a custom method: POST /v1/tasks/{task}:complete. Walk through why: (1) completing triggers server-side actions — notification, timestamp stamping, state-machine enforcement — that a plain PATCH would have to silently bake into its handler; (2) the colon-suffix convention (AIP-136) makes the intent unambiguous in logs, docs, and SDKs; (3) :complete should be idempotent — already-completed tasks return 200 without mutation. Interviewers listen for the PATCH-vs-custom-method distinction and whether you can explain the side-effect argument. Mentioning AIP-136 by name signals you've worked with real API standards.

⚠️ Pitfall: RPC-ifying your REST API

Custom methods are an escape valve — not a default. The moment they become your primary pattern, you have rebuilt RPC inside HTTP. Signs you've crossed the line: every endpoint is a custom method; verbs like :get, :list, or :update appear (those are standard methods in disguise); the API reads like a command interface rather than a resource graph. REST's value — uniform interface, hypermedia links, cacheability, discoverability — evaporates when 80% of your endpoints are colon-verbs. Reserve custom methods for operations that genuinely cannot be expressed as CRUD on a resource or sub-resource. A healthy API has dozens of standard methods and a handful of custom ones.

✅ The state-machine test

Before adding a custom method, ask: can this operation be fully described as a state machine transition? If yes, it probably deserves a custom method. "Completing" a task is a transition from open or in_progress to completed, and the transition carries domain rules. "Changing the due date" is not a state machine transition — it is a field update, so PATCH is correct. The state-machine lens also reveals idempotency requirements: if the system is already in the target state, the transition is a no-op, not an error.

🧠 Quick check

1. What HTTP method does a custom method always use?

AIP-136 mandates POST for all custom methods. Custom methods almost always have side effects and often need a request body — both of which make POST the correct semantic choice. Using GET would violate HTTP's safety contract and risk unintended re-execution by proxies and prefetch mechanisms.

2. Which URL pattern correctly represents a custom method to archive a project?

The colon-suffix on the resource path segment is the AIP-136 convention: /v1/projects/proj_42:archive. A slash before "archive" would make it look like a child resource. A query parameter encodes the action in a non-standard way. DELETE removes the resource rather than archiving it — a semantically different operation.

3. When should you prefer a PATCH on a status field over a custom :complete method?

PATCH is the right choice for pure data updates — changing a due date, updating notes, renaming a task. When the operation triggers side effects, enforces a state machine rule, or stamps server-computed fields, a custom method is the better fit because it makes those semantics explicit in the API surface rather than hiding them inside a generic PATCH handler.

4. What is the primary risk of overusing custom methods?

When custom methods become the dominant pattern, the API devolves into a command-oriented RPC interface. REST's benefits — uniform resource addressing, cache-friendliness, self-describing hypermedia, and discoverability — all rely on the noun/verb discipline of the resource model. An API full of colon-verbs has abandoned that discipline.

✍️ Exercise: design a :publish custom method for a Documents API (try before opening)
Scenario

You are designing a Documents API with a resource at /v1/workspaces/{workspace}/documents/{document}. A document has fields: title, body, status (draft / published / archived), published_time, create_time, update_time. Publishing a document: sets status to published, stamps published_time, and sends a notification to all workspace members. Design the custom method — URL, HTTP method, request body, response, idempotency behaviour, and error for invalid transitions.

Model answer:

  1. URL and method: POST /v1/workspaces/{workspace}/documents/{document}:publish. The colon-suffix convention per AIP-136; POST because publishing triggers side effects.
  2. Request body: {} for the minimal case. Optionally accept {"notify_external": true} if external notifications are configurable. Keep the body schema narrow — input specific to the publish action only.
  3. Success response: 200 OK with the full updated document resource: status: "published", published_time stamped server-side, update_time refreshed.
  4. Idempotency: If the document is already published, return 200 with the existing resource and do not re-send notifications. The method's postcondition is "document is published" — if already true, the call is a no-op, not an error.
  5. Invalid transition: If status is archived, return 400 Bad Request with "status": "FAILED_PRECONDITION" and message "An archived document cannot be published directly. Restore it first." A deleted document (soft-deleted / 404) should return 404 Not Found.
# Successful publish
POST /v1/workspaces/ws_1/documents/doc_88:publish
Content-Type: application/json

{}

# Response
HTTP/1.1 200 OK
{
  "name": "workspaces/ws_1/documents/doc_88",
  "status": "published",
  "published_time": "2025-06-18T15:00:00Z",
  "update_time": "2025-06-18T15:00:00Z"
}

Rubric: ✓ Colon-suffix URL ✓ POST method ✓ 200 with full resource ✓ server-stamped published_time ✓ idempotent (no re-notify on already-published) ✓ FAILED_PRECONDITION for archived state. Five out of five = senior-level answer; missing idempotency handling or returning {"ok":true} instead of the full resource drops to mid-level.

Key takeaways

Sources & further reading