Mert Özen Further With Every Line
Software Architecture REST API Design — #03

HTTP Method and Status Code Semantics: Safe, Idempotent, PUT or PATCH, and Choosing 200/201/202/204/409/412/422

Mert Özen Nov 15, 2026 12 min 5 views
HTTP Method and Status Code Semantics: Safe, Idempotent, PUT or PATCH, and Choosing 200/201/202/204/409/412/422

Choosing methods and status codes should rest on RFC 9110's definitions of safe and idempotent and on a decision rule the team writes down once: 400 for a malformed request, 422 for a valid request a business rule rejects, 409 for a conflict with the resource's state, 412 for an If-Match mismatch. With real responses from Bookstore we cover 201 and Location with POST, creating with PUT, 415 and Accept-Patch with JSON Merge Patch, and the split between 401, 403, 404 and 405.

Version note: The code in this post was built and run with C# 14 and .NET 10 (ASP.NET Core 10.0.15); the HTTP examples are real responses from this application, and the OpenAPI 3.2 document was linted with Redocly CLI 2.78.0 and Spectral 6.17.1.

Guess First

Ayşe from the catalog team of Bookstore, the online bookstore used as the shared example across this site's series, adds the newly arrived "The Count of Monte Cristo" to the system and then corrects its price. In the same hour the customer Ada puts books into her cart. Guess which status code the server returns for each of these requests:

# 1. Ayşe adds the same book a second time
curl -X POST -H "Authorization: Bearer ayse" -H "Content-Type: application/json" -d '{"isbn":"9780140449266","title":"The Count of Monte Cristo","author":"Alexandre Dumas","category":"classics","price":{"amount":399.00,"currency":"TRY"}}' http://127.0.0.1:52318/books

# 2. The Angular form sends the price as plain JSON
curl -X PATCH -H "Authorization: Bearer ayse" -H "Content-Type: application/json" -d '{"price":{"amount":379.00}}' http://127.0.0.1:52318/books/9780140449266

# 3. Ada sends the same cart line twice (second request)
curl -X PUT -H "Authorization: Bearer ada" -H "Content-Type: application/json" -d '{"quantity":2}' http://127.0.0.1:52318/customers/me/cart/items/9780441569595

# 4. Ada asks for 5 copies of a book with 2 in stock
curl -X PUT -H "Authorization: Bearer ada" -H "Content-Type: application/json" -d '{"quantity":5}' http://127.0.0.1:52318/customers/me/cart/items/9780441569595

# 5. Ayşe deletes a book a second time
curl -X DELETE -H "Authorization: Bearer ayse" http://127.0.0.1:52318/books/9780140444308

Each request has at least two reasonable answers. The goal of this post is to write the rule down once, so that we do not argue about it again on every endpoint.

Safe and Idempotent: What RFC 9110 Says

The meaning of HTTP methods is defined in RFC 9110, HTTP Semantics, and two properties are the basis of the design. A safe method is one where the client does not ask for any change on the server: GET, HEAD, OPTIONS and TRACE. An idempotent method is one where the intended effect on the server is the same whether you send the request once or ten times: PUT, DELETE and all safe methods. RFC 10008, published in June 2026, added QUERY to the list: a query method that can carry a body but is safe and idempotent. OpenAPI 3.2 also knows the query operation; we discuss it for search endpoints in post 05.

MethodSafeIdempotentUse at Bookstore
GET, HEADYesYesReading books, orders, carts; reading only the ETag with HEAD
PUTNoYesCreating or fully replacing a book by ISBN, setting the quantity of a cart line
DELETENoYesRemoving a book from the catalog, a line from the cart
POSTNoNoAdding a book, a return request, custom methods such as :cancel
PATCHNoNo (not guaranteed)Changing some fields of a book
QUERYYesYesNot used yet; a candidate for search with a body (RFC 10008)

Idempotency is about the effect, not the response. Deleting a book twice leaves the server in the same state (no book), but the second response is a 404. That is why the answer to question 5 is 404, and it does not break idempotency. Its practical value is this: when a connection drops, the client, a reverse proxy or an HTTP library can safely resend an idempotent request. Resending a POST, on the other hand, may mean a second order; the contract side of that problem is the Idempotency-Key header in post 07, and the implementation is in the "Idempotency Keys" post of the Solution Patterns series.

Creating with POST: 201, Location and 409

A POST to a collection means the server creates a new member. The successful response is a 201, the Location header gives the address of the new resource and the body carries the created representation; the client does not need a second GET:

$ curl -i -X POST -H "Authorization: Bearer ayse" -H "Content-Type: application/json" -d '{"isbn":"9780140449266","title":"The Count of Monte Cristo","author":"Alexandre Dumas","category":"classics","price":{"amount":399.00,"currency":"TRY"}}' http://127.0.0.1:52318/books
HTTP/1.1 201 Created
Content-Type: application/json; charset=utf-8
Date: Sat, 14 Nov 2026 22:37:01 GMT
Server: Kestrel
ETag: "1"
Location: /books/9780140449266
Transfer-Encoding: chunked

{"isbn":"9780140449266","title":"The Count of Monte Cristo","author":"Alexandre Dumas","category":"classics","price":{"amount":399.00,"currency":"TRY"},"inStock":false}

$ curl -s -X POST -H "Authorization: Bearer ayse" -H "Content-Type: application/json" -d '{"isbn":"9780140449266","title":"The Count of Monte Cristo","author":"Alexandre Dumas","category":"classics","price":{"amount":399.00,"currency":"TRY"}}' http://127.0.0.1:52318/books | jq -c '{status, title, detail}'
{"status":409,"title":"Book already exists","detail":"A book with ISBN 9780140449266 already exists. Use PUT /books/9780140449266 to replace it."}

The second request is the answer to question 1: 409. According to RFC 9110, a 409 says that the request conflicts with the current state of the target resource and that the user may resolve the conflict and try again. This ISBN is already in the collection, and the detail says how to proceed. Had we returned a 400, the client would look for a typo in its request, while the request is flawless.

PUT: Full Replacement and a Client-Known Id

PUT means replacing the state of the resource at the target URL with the representation sent. If the client knows the id (the ISBN for a book), PUT can also create: RFC 9110 asks for a 201 when a new resource is created and a 200 or 204 when an existing one changes. Sending the same PUT twice shows it; the first creates, the second replaces with the same content:

$ curl -s -o /dev/null -w '%{http_code}\n' -X PUT -H "Authorization: Bearer ayse" -H "Content-Type: application/json" -d '{"title":"Les Miserables","author":"Victor Hugo","category":"classics","price":{"amount":529.00,"currency":"TRY"}}' http://127.0.0.1:52318/books/9780140444308
201

$ curl -s -o /dev/null -w '%{http_code}\n' -X PUT -H "Authorization: Bearer ayse" -H "Content-Type: application/json" -d '{"title":"Les Miserables","author":"Victor Hugo","category":"classics","price":{"amount":529.00,"currency":"TRY"}}' http://127.0.0.1:52318/books/9780140444308
200

$ curl -s -X PUT -H "Authorization: Bearer ayse" -H "Content-Type: application/json" -d '{"title":"Les Miserables","price":{"amount":499.00,"currency":"TRY"}}' http://127.0.0.1:52318/books/9780140444308 | jq -c '{status, errors}'
{"status":400,"errors":{"Author":["The Author field is required."],"Category":["The Category field is required."]}}

The third request shows the most common misuse of PUT: sending only the changed fields and expecting the rest to be kept. PUT is a full replacement; a missing field does not mean "keep the old one", it means an invalid request. The Azure guidelines also ask for a 400 when a required field is missing in a PUT. You may also have noticed that the error keys (Author) are not written like the field names in the JSON (author); that is a contract bug and the subject of post 04. The right tool for a partial update is PATCH.

On the cart line, the same design gives the answer to question 3. PUT /customers/me/cart/items/{isbn} sets the quantity of the line rather than incrementing it; the first request creates the line (201), the second identical request leaves the same cart (200). Even if Ada double-clicks, her cart holds 2 books, not 4. Had we designed an incrementing POST like "add one more to the cart", a double click would really add two books.

$ curl -i -X PUT -H "Authorization: Bearer ada" -H "Content-Type: application/json" -d '{"quantity":2}' http://127.0.0.1:52318/customers/me/cart/items/9780441569595
HTTP/1.1 201 Created
Content-Type: application/json; charset=utf-8
Date: Sat, 14 Nov 2026 22:37:01 GMT
Server: Kestrel
Location: /customers/me/cart/items/9780441569595
Transfer-Encoding: chunked

{"isbn":"9780441569595","title":"Neuromancer","quantity":2,"unitPrice":{"amount":279.00,"currency":"TRY"}}

$ curl -s -o /dev/null -w '%{http_code}\n' -X PUT -H "Authorization: Bearer ada" -H "Content-Type: application/json" -d '{"quantity":2}' http://127.0.0.1:52318/customers/me/cart/items/9780441569595
200

PATCH: JSON Merge Patch or JSON Patch?

PATCH itself (RFC 5789) only says "apply this change document"; the media type decides the document's format. There are two standard candidates. JSON Merge Patch (RFC 7396, application/merge-patch+json) sends an object shaped like the target document: a field you send changes, a field sent as null is removed, objects merge recursively and arrays are replaced as a whole. JSON Patch (RFC 6902, application/json-patch+json) is a list of operations: add, remove, replace, move, copy and test; it can target a single array element by index, and with test it can say "apply only if this field still has this value".

Merge Patch has two limits: you cannot write null as a value (because null means delete), and you cannot change a single element of an array. Bookstore's writable fields are plain values and small objects, so these limits do not affect us; Merge Patch is the easiest format to read and to produce from Angular. The Azure guidelines also require Merge Patch for PATCH. The decision is BS-42: PATCH accepts only Merge Patch; any other media type gets a 415 and, as RFC 5789 recommends, an Accept-Patch header that names the accepted format. That is the answer to question 2:

$ curl -i -X PATCH -H "Authorization: Bearer ayse" -H "Content-Type: application/json" -d '{"price":{"amount":379.00}}' http://127.0.0.1:52318/books/9780140449266
HTTP/1.1 415 Unsupported Media Type
Content-Type: application/problem+json
Date: Sat, 14 Nov 2026 22:37:01 GMT
Server: Kestrel
Transfer-Encoding: chunked
Accept-Patch: application/merge-patch+json

{"type":"https://tools.ietf.org/html/rfc9110#section-15.5.16","title":"Unsupported patch format","status":415,"detail":"PATCH /books/{isbn} accepts application/merge-patch+json only.","traceId":"00-1c554c2cc776d431261b9eb6f96546d3-cb391e893c01126e-00"}

$ curl -i -X PATCH -H "Authorization: Bearer ayse" -H "Content-Type: application/merge-patch+json" -d '{"price":{"amount":379.00},"description":"Unabridged, translated by Robin Buss."}' http://127.0.0.1:52318/books/9780140449266
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
Date: Sat, 14 Nov 2026 22:37:01 GMT
Server: Kestrel
ETag: "2"
Transfer-Encoding: chunked

{"isbn":"9780140449266","title":"The Count of Monte Cristo","author":"Alexandre Dumas","category":"classics","price":{"amount":379.00,"currency":"TRY"},"inStock":false,"description":"Unabridged, translated by Robin Buss."}

$ curl -s -X PATCH -H "Authorization: Bearer ayse" -H "Content-Type: application/merge-patch+json" -d '{"description":null}' http://127.0.0.1:52318/books/9780140449266
{"isbn":"9780140449266","title":"The Count of Monte Cristo","author":"Alexandre Dumas","category":"classics","price":{"amount":379.00,"currency":"TRY"},"inStock":false}

$ curl -s -X PATCH -H "Authorization: Bearer ayse" -H "Content-Type: application/merge-patch+json" -d '{"inStock":true}' http://127.0.0.1:52318/books/9780140449266 | jq -c '{status, title, detail}'
{"status":400,"title":"Field cannot be changed","detail":"'inStock' is read-only or unknown; writable fields: title, author, category, price, description."}

$ curl -s -X PATCH -H "Authorization: Bearer ayse" -H 'If-Match: "1"' -H "Content-Type: application/merge-patch+json" -d '{"price":{"amount":389.00}}' http://127.0.0.1:52318/books/9780140449266 | jq -c '{status, title, detail}'
{"status":412,"title":"Precondition failed","detail":"If-Match \"1\" does not match the current ETag \"3\"."}

In the Merge Patch only amount of the price object was sent, and currency was kept. description: null removed the field; it is no longer in the response. The attempt to change the read-only inStock is not silently ignored but gets a 400; silently ignoring it would make the client believe it had changed. The last request came with an old ETag and got a 412: the book in Ayşe's hands was at version 1, the one on the server at version 3. If-Match is optional for now; making it required (428) and the whole optimistic concurrency contract are in post 07.

Status Code Decisions: 400, 422 or 409?

The answer to question 4 is the most debated one. Asking for 5 books when 2 are in stock is not a typo; the request matches the schema, but a business rule rejects it. Bookstore's split is BS-44: 400 for a malformed or schema-invalid request, 422 for a valid request that a business rule rejects, 409 for a conflict with the target resource's current state, and 412 for an If-Match mismatch. 422 used to belong to WebDAV (RFC 4918); RFC 9110 moved it into general HTTP under the name "Unprocessable Content".

$ curl -s -X PUT -H "Authorization: Bearer ada" -H "Content-Type: application/json" -d '{"quantity":5}' http://127.0.0.1:52318/customers/me/cart/items/9780441569595
{"type":"https://tools.ietf.org/html/rfc4918#section-11.2","title":"Insufficient stock","status":422,"detail":"Only 2 copies of Neuromancer are available.","availableQuantity":2,"traceId":"00-6c214af662521017f73bf1b7e6511595-d9eb85d677f44017-00"}

$ curl -s -X PUT -H "Authorization: Bearer ada" -H "Content-Type: application/json" -d '{"quantity":0}' http://127.0.0.1:52318/customers/me/cart/items/9780441569595 | jq -c '{status, title}'
{"status":400,"title":"One or more validation errors occurred."}

A quantity of 0 violates the schema (at least 1), so it is a 400; the way to remove the line is DELETE. Some APIs return 409 for insufficient stock, and that is a defensible choice too. What matters is that the team picks one rule and gives the same answer on every endpoint; when the client sees a 422 it can tell the user "reduce the quantity", when it sees a 400 it asks them to fix the form. The table below summarises the codes we use at Bookstore:

CodeWhenBookstore example
200A read, a change or a custom method succeededGET a book, PATCH a price, :cancel
201A new resource was created; with LocationPOST a book, PUT a new ISBN, a return request
202The work was accepted but is not finishedInvoice PDF generation (post 07)
204Success, no body to returnDELETE a book, DELETE a cart line
400Malformed JSON, missing or schema-invalid field, read-only fieldQuantity 0, missing author in a PUT
401No credentials, or invalid onesDELETE without a token
403Known caller without permission who already knows the resource existsA customer deleting a book, a buyer listing another campus
404Does not exist, or the caller must not see itAnother institution's order, a second DELETE
405The resource exists, the method is not supported; with AllowDELETE an order
409Conflict with the target resource's stateSame ISBN, cancelling a cancelled order
412If-Match does not matchPATCH with an old ETag
415Unsupported body formatPlain JSON in a PATCH
422Valid request, a business rule rejects itMore than the stock, a currency other than TRY
428A conditional request is requiredIn post 07
429Rate limit exceededIn post 08

401, 403, 404 and 405

These four are confused most often. 401 means "I do not know who you are"; 403 means "I know who you are, and you are not allowed to do this". The customer Ada's attempt to delete a book gets a 403, an attempt without a token gets a 401. As we saw in the previous post, for resources whose existence the caller must not learn, we return 404 instead of 403 (BS-45). A 405 says that the resource exists but does not support the method, and according to RFC 9110 the Allow header is mandatory:

$ curl -s -o /dev/null -w '%{http_code}\n' -X DELETE -H "Authorization: Bearer ada" http://127.0.0.1:52318/books/9780141439587
403

$ curl -s -o /dev/null -w '%{http_code}\n' -X DELETE http://127.0.0.1:52318/books/9780141439587
401

$ curl -i -X DELETE -H "Authorization: Bearer nevin" http://127.0.0.1:52318/orders/01a1c112-d7c0-706c-8cb9-c18a8fadc1a6
HTTP/1.1 405 Method Not Allowed
Content-Type: application/problem+json
Date: Sat, 14 Nov 2026 22:37:01 GMT
Server: Kestrel
Allow: GET, POST
Transfer-Encoding: chunked

{"type":"https://tools.ietf.org/html/rfc9110#section-15.5.6","title":"Method Not Allowed","status":405,"traceId":"00-c00de597c908d4b1bf399ac6cbc02319-0e1eedcb59a5e449-00"}

$ curl -s -o /dev/null -w '%{http_code}\n' -X POST -H "Authorization: Bearer nevin" http://127.0.0.1:52318/orders/01a1c112-d7c0-706c-8cb9-c18a8fadc1a6
404

There is a small inconsistency in the last two outputs. The Allow header lists POST, but a POST to the same address gets a 404. The POST really belongs to the /orders/{orderId}:cancel custom method; ASP.NET Core routing appears to run its method check with this complex-segment endpoint still among the candidates. It is a detail that does not break clients but does not match what the contract says; it has to be documented, or the 405 response produced by hand. Seeing differences like this is exactly the value of comparing the framework's behaviour with the contract.

In ASP.NET Core

MapGet answers only GET; send a HEAD to the same route and you get a 405. That is why the book endpoint uses MapMethods("/{isbn}", ["GET", "HEAD"], GetBook); Kestrel does not send the body in a HEAD response, and the headers stay the same. TypedResults.Created(location, body) is used for 201, TypedResults.NoContent() for 204, and .NET 10's AddValidation() with Data Annotations for schema errors. ASP.NET Core has no ready binder for Merge Patch: we read the body as a JsonNode and apply the RFC 7396 algorithm with a twenty-line helper; the Accept-Patch and If-Match checks are written by hand as well. If you prefer JSON Patch, .NET 10's System.Text.Json-based JSON Patch support is covered in the ".NET 10" and "JSON, Content Negotiation and Formatters" posts of the ASP.NET Core Web API series.

Common Pitfalls

Returning 400 or 500 for every error. A client reads 400 as "fix your request" and 500 as "try again a bit later". An API that returns 500 for insufficient stock fires the client's retry policy for nothing; an API that returns 400 for a conflict shows the user a form error they cannot fix.

"success": false inside a 200. The /legacy/rpc in the first post was an example: monitoring, caches and retry mechanisms cannot see the error. If there is an error, the status code must say so.

Changing state with GET. A link like GET /cart/add?isbn=… can be triggered by the browser's prefetch, a link preview bot or a crawler, and a reverse proxy may resend it assuming it is safe. BS-46: GET and HEAD change nothing.

Using PUT for partial updates. A PUT that reads a missing field as "do not change" cannot tell which fields the client left empty on purpose. Partial updates are PATCH's job.

Silently ignoring read-only fields. If sending inStock in a PATCH gets a 200, the client believes it changed the stock. Either return an error or state clearly in the contract that it is ignored.

A Small Experiment

★☆☆ Request a book with curl -I, put the ETag you get into an If-Match header and PATCH its price. Then send the same request again with the same ETag. Explain why the second response is a 412.

★★☆ Write the same price change as a JSON Patch (RFC 6902) document. How do you express the condition "if the price is still 379.00, make it 389.00" with the test operation? How does that differ from If-Match?

★★★ Institutional orders have a minimum of 20 copies. Design a POST /institutions/{institutionId}/orders endpoint in the contract: which status codes do you return for a request with 15 copies, an unknown ISBN, an institution whose limit is exceeded and malformed JSON? Justify your decisions against BS-44 and run the contract through the linter.

When the status code is chosen well, the client roughly knows what went wrong, but what says exactly what went wrong is the error body. The outputs in this post showed three separate inconsistencies: a 422 type pointing to RFC 4918, error keys written differently from the JSON, and every error with its own sentence. In the next post we design the error contract from scratch with RFC 9457 Problem Details.