You inherit an HTTP API whose mutations are exposed as GET — for example `GET /orders/42/cancel` and `GET /users/9/delete?confirm=true`. Why is that a contract defect, and how would you redesign those routes and migrate existing callers off them?
answer
- verb in path, method lies
- DELETE /users/9 + If-Match, not ?confirm=true
- POST /orders/42/cancellations → 201 + Location
- 405 + Allow: POST on the old route
- no redirect turns GET into POST
basics
~20 sThe verb lives in the path, not in the method, so the contract misdeclares what the request does. Redesign as DELETE /users/9 and POST /orders/42/cancel (or a cancellation sub-resource). Retire the old GETs with HTTP 405 plus an Allow header after a deprecation window.
solid answer
~50 sThe defect is that the **method no longer describes the request**. The verb has migrated into the path (`/cancel`, `/delete`), so nothing in the contract — not the gateway, not the client SDK generator, not the retry policy — can tell a read from a write; on top of that GET is the one method everything on the wire is free to fire on its own. Redesign: - Deletion → `DELETE /users/9`, returning **204**; move `?confirm=true` into a precondition (`If-Match` on the ETag) rather than a query flag. - Cancellation is a state transition, not a deletion → `POST /orders/42/cancel`, or better `POST /orders/42/cancellations` returning **201 + Location** so the cancellation has an identity, a reason and an audit trail. **409** if already cancelled, **412** on a stale `If-Match`. Migration: keep the path, add the new method, dual-run while logging callers; advertise `Deprecation`/`Sunset` headers; then make the old `GET` return **405 with `Allow: POST`**. Do *not* "helpfully" redirect — no redirect status turns a GET into a POST.
code
http · 22 lines# inherited
GET /orders/42/cancel HTTP/1.1
Host: api.example.com
HTTP/1.1 200 OK
Content-Type: application/json
{"status":"cancelled"}
# redesigned: the cancellation becomes a resource
POST /orders/42/cancellations HTTP/1.1
Host: api.example.com
Content-Type: application/json
Idempotency-Key: 8f1c0b6e-4b1a-4f2e-9a11-0c2b7d5a3e10
{"reason":"customer_request","refund":"full"}
HTTP/1.1 201 Created
Location: /orders/42/cancellations/7
Content-Type: application/json
{"id":7,"orderId":42,"reason":"customer_request","state":"completed"}go deeper
Say why the method must match the action, and give the two corrected routes: DELETE /users/9 and POST /orders/42/cancel. Knowing that GET is meant to be read-only is the core recall.
Add the response contract — 204 for the delete, 201 with Location or 200 for the cancel, 409 when already cancelled — and note that cancelling is a state change, not a deletion.
Own the migration: dual-run, log the caller inventory, Deprecation/Sunset headers, then 405 with Allow, plus the point that no redirect can convert a GET into a POST. Mention If-Match replacing ?confirm=true and an Idempotency-Key for retried POSTs.
Frame it as contract risk and blast radius: which gateway, cache, replica-routing and CSRF policies currently key off the method and are therefore wrong today; how long the window is versus the cost of carrying two shapes; whether to version the whole surface or migrate route-by-route; and what evidence (zero hits over a full reporting cycle) authorizes deleting the shim.
## What is actually wrong In HTTP, a request is a *method* applied to a *resource*: the method says what you want done, the path says what you want it done to. `GET /orders/42/cancel` inverts that. The action word has moved into the URL and the method slot has been filled with GET — the method that means "give me a representation, I am asking for nothing to change". The contract now lies about every request that hits it. That lie is not merely aesthetic, because a great deal of machinery reads the method and never reads the path: - **Routing and infrastructure policy.** Gateways, service meshes and CDNs route, cache, mirror and rate-limit *by method*. A read/write split that sends GET to a read replica will send your cancellation to a replica. A WAF configured to require CSRF tokens on writes will not challenge a GET. - **Retry and timeout policy.** Client libraries, proxies and load balancers retry GET automatically because GET is defined as safe; your mutation therefore inherits an at-least-once retry policy nobody designed for it. - **The parameter channel.** GET has no body by contract, so every input is stuffed in the query string, where it lands in access logs, browser history, `Referer` headers and APM traces. `?confirm=true` is a confirmation that anyone can type. - **Generated clients and docs.** OpenAPI/SDK generators produce a `getOrderCancel()` read function; consumers reasonably call it in a loop, from a health check, from a UI render. - **Everything on the wire that fetches URLs unprompted** — prefetchers, link-preview bots, crawlers — will fire these endpoints (covered in depth by the wire-safety topic; one clause is enough here). ## The redesign Work resource-first, then pick the method. **Deletion.** `GET /users/9/delete?confirm=true` → `DELETE /users/9`. Return **204 No Content** for a completed delete, or **202 Accepted** with a status resource if erasure is asynchronous (GDPR-style deletes usually are). The `confirm=true` flag should not survive: confirmation is a UI concern, and if you want server-side protection against a stale or blind delete, express it as a **precondition** — the client sends `If-Match: "<etag>"` and you answer **412 Precondition Failed** if the resource moved on. That is a real, machine-checkable guard; a query flag is not. **Cancellation.** This is *not* a delete: the order still exists, its state changed. Two idiomatic shapes: 1. **Action sub-resource (preferred when the action has data):** `POST /orders/42/cancellations` with a body carrying reason, actor and refund intent; respond **201 Created** with `Location: /orders/42/cancellations/7`. The cancellation is now a first-class thing you can GET, audit and reference. 2. **State patch:** `PATCH /orders/42` with `{"status": "cancelled"}`, guarded by `If-Match`. Cheaper, but it hides the business event and gives you nowhere to hang cancellation attributes. A plain `POST /orders/42/cancel` is the pragmatic middle ground and is widely accepted; it is verb-in-path, but the method is now honest, which was the actual defect. **Status codes as part of the contract.** 200/201/202 on success; **409 Conflict** when the order is already cancelled or is in a state that forbids cancelling; **422** for a domain rule violation with a valid syntax; **412** for a failed precondition. Because POST is not idempotent, accept an `Idempotency-Key` request header if callers retry. ## The migration path 1. **Add, don't move.** Register the new method on the *same path* first (`POST /orders/42/cancel`). Keeping the path means the old callers hit a route you still control. 2. **Dual-run and inventory.** Serve both for a window while logging every GET hit with client id, User-Agent and source IP. You cannot deprecate callers you cannot name. 3. **Signal deprecation in-band.** On the old GET, return `Deprecation` and `Sunset` response headers plus a `Link` header pointing at the replacement, alongside your out-of-band changelog and direct outreach to the top callers. 4. **Break loudly, on the announced date.** The old `GET` returns **405 Method Not Allowed** with `Allow: POST` — RFC 9110 requires the `Allow` header on a 405, and it is exactly the machine-readable "use this instead" the caller needs. If the path itself is retired in favour of a sub-resource, use **410 Gone** with a `Link` to the successor. 5. **Do not redirect.** 307 and 308 preserve the method (a GET stays a GET); 301, 302 and 303 let clients rewrite to GET. No redirect status converts GET into POST or DELETE, so a redirect either loops or silently keeps the broken call alive. Fail; don't forward. 6. **Last-resort clients.** For a caller genuinely stuck behind a proxy that blocks DELETE, allow `POST` plus `X-HTTP-Method-Override: DELETE` — narrowly, allowlisted, and only if your gateway does not make authorization decisions on the observed method, since an override header that is honoured after the policy check is a straight authz bypass. 7. **Delete the shim.** Drop the old route when its hit counter has been zero for a full billing/reporting cycle.
- Why not just 301-redirect `GET /orders/42/cancel` to the new endpoint so old clients keep working?No redirect status changes a GET into a POST or DELETE. 301, 302 and 303 permit (and in practice cause) the client to follow with GET, and 307/308 explicitly preserve the original method — so a redirect either leaves you with the same mutating GET on a new URL or produces a request the new route rejects. The point of the cutover is to make the broken call visible; 405 with `Allow` tells the caller exactly what to change, while a redirect hides the problem and keeps the migration open forever.
- You chose `POST /orders/42/cancellations` over `PATCH /orders/42` with `{"status":"cancelled"}`. When would you pick the PATCH instead?PATCH is the right call when cancelling is genuinely just a field flip with no attributes of its own, no downstream workflow, and no need to audit each attempt — a simple status machine on a small resource. The sub-resource wins when the cancellation carries data (reason, actor, refund policy), is asynchronous, can fail partially, or must be queryable and auditable later. The sub-resource also gives you a natural place to return 202 and a status URL when the cancellation triggers refunds or external calls.
- A legacy batch client sits behind a corporate proxy that strips DELETE. How would you unblock it without reintroducing the defect?Allow `POST` to the same resource carrying `X-HTTP-Method-Override: DELETE`, restricted to that client's credentials and documented as temporary. The critical constraint is ordering: the override must be applied before any authorization or WAF policy evaluates the method, or an attacker can send POST to pass a method-based rule and have it executed as DELETE. Log override usage separately so you can prove when the shim can be removed.
The old routes are like a door labelled "EXIT" that actually detonates the building: everyone in the corridor — cleaners, fire drills, a curious visitor — is entitled to push it because of what the label promises. You fix it by moving the action behind a door labelled for what it does, then bricking up the old one with a sign saying which door replaced it.
saying these in an interview costs you the question
- "Just change GET to POST and we're done" — leaving `?confirm=true` in the query string and skipping preconditions, status-code design and the caller migration.
- Redirecting the old GET (301/302/307) to the new endpoint, not realising no redirect status converts a GET into a POST or DELETE.
- Returning 404 or 400 on the retired route instead of 405 with an `Allow` header (or 410 with a `Link` when the path itself is gone).
- Modelling cancellation as `DELETE /orders/42` — the order still exists; deleting it destroys history and confuses reporting.