Article 3 ended with a HelloController answering curl, and every article since has been about what happens inside the application: beans, injection, configuration, logging. Chapter 3 turns outward and builds a REST API. Before the first annotation, it is worth seeing what actually crosses the socket, because every design decision in an API — which method, which status code, which URL — is a decision about that text.
This article is about HTTP itself, captured on the wire against a small Spring Boot application: the structure of a request and a response, what each method promises according to RFC 9110, why idempotency decides whether a retry is safe, the status codes and headers an API really uses, what REST means once the buzzword is set aside, and the rules for designing URLs. It ends with the endpoint table that the rest of the chapter implements.
![]()
Every exchange below was captured from one run of a Spring Boot 4.1.1 application (Spring Framework 7.0.9, embedded Tomcat 11.0.24) on OpenJDK 21.0.6, using curl 8.7.1 and the nc that ships with macOS. Quotations from the standards come from RFC 9110 (HTTP Semantics), RFC 9112 (HTTP/1.1) and RFC 5789 (PATCH).
A small product API to test against
Generate the project the same way as in article 3:
curl -s "https://start.spring.io/starter.zip?type=gradle-project&language=java&bootVersion=4.1.1&javaVersion=21&groupId=com.example&artifactId=demo&name=demo&packageName=com.example.demo&dependencies=web" -o demo.zip
unzip demo.zip -d demoThen add one class. It keeps products in a ConcurrentHashMap with an AtomicLong for the ids, because databases arrive in Chapter 4:
package com.example.demo.product;
import java.math.BigDecimal;
import java.net.URI;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicLong;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PatchMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;
@RestController
@RequestMapping("/api/products")
public class ProductController {
public record Product(Long id, String name, BigDecimal price, String category) {}
private final Map<Long, Product> store = new ConcurrentHashMap<>();
private final AtomicLong nextId = new AtomicLong();
public ProductController() {
insert(new Product(null, "Clean Code", new BigDecimal("32.50"), "books"));
insert(new Product(null, "Mechanical Keyboard", new BigDecimal("89.00"), "electronics"));
}
@GetMapping
public List<Product> list(@RequestParam(required = false) String category) {
return store.values().stream()
.filter(p -> category == null || category.equals(p.category()))
.toList();
}
@GetMapping("/{id}")
public Product get(@PathVariable Long id) {
return find(id);
}
@PostMapping
public ResponseEntity<Product> create(@RequestBody Product body) {
Product created = insert(body);
return ResponseEntity.created(URI.create("/api/products/" + created.id())).body(created);
}
@PutMapping("/{id}")
public Product replace(@PathVariable Long id, @RequestBody Product body) {
find(id);
Product replaced = new Product(id, body.name(), body.price(), body.category());
store.put(id, replaced);
return replaced;
}
@PatchMapping("/{id}")
public Product update(@PathVariable Long id, @RequestBody Product body) {
Product current = find(id);
Product updated = new Product(id,
body.name() != null ? body.name() : current.name(),
body.price() != null ? body.price() : current.price(),
body.category() != null ? body.category() : current.category());
store.put(id, updated);
return updated;
}
@DeleteMapping("/{id}")
public ResponseEntity<Void> delete(@PathVariable Long id) {
if (store.remove(id) == null) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND);
}
return ResponseEntity.noContent().build();
}
private Product find(Long id) {
Product product = store.get(id);
if (product == null) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND);
}
return product;
}
private Product insert(Product body) {
long id = nextId.incrementAndGet();
Product product = new Product(id, body.name(), body.price(), body.category());
store.put(id, product);
return product;
}
}The annotations in this class are the subject of articles 16 and 17. Here it is only something to send requests to. The store starts with two products, Clean Code with id 1 and Mechanical Keyboard with id 2. Build the jar and start it:
./gradlew bootJar
java -jar build/libs/demo-0.0.1-SNAPSHOT.jar --server.port=8115The runs below use port 8115; any free port works. All the exchanges in this article come from a single run of the application, in the order they appear, so a product created in one section is still there in the next.
HTTP is text: requests and responses on the wire
HTTP/1.1 is a text protocol. Speaking it needs no client library, only a way to write bytes to a TCP connection. printf writes the request, and nc sends it to port 8115 and prints whatever comes back:
printf 'GET /api/products/1 HTTP/1.1\r\nHost: localhost\r\n\r\n' | nc localhost 8115HTTP/1.1 200
Content-Type: application/json
Content-Length: 61
Date: Sat, 12 Sep 2026 07:03:15 GMT
{"id":1,"name":"Clean Code","price":32.50,"category":"books"}A request line and one header went out; a status line, three headers and a JSON body came back. That is the complete exchange, and everything a web framework does with HTTP is built on text like this. The \r\n in the printf string is not decoration: RFC 9112 ends every line of an HTTP/1.1 message with CRLF, the two bytes carriage return and line feed, and marks the end of the headers with a line that contains nothing else.
On macOS, nc exited as soon as the response arrived. If yours sits waiting instead, press Ctrl+C. RFC 9112 makes persistent connections the default in HTTP/1.1, so the server keeps the connection open for another request, and not every nc closes its side when its input ends; the OpenBSD variant has the -N flag for exactly that.
Anatomy of an HTTP request
A GET carries no body, so a POST shows every part. The body is 55 bytes of JSON, and the request says so:
printf 'POST /api/products HTTP/1.1\r\nHost: localhost\r\nContent-Type: application/json\r\nContent-Length: 55\r\n\r\n{"name":"Refactoring","price":47.00,"category":"books"}' | nc localhost 8115RFC 9112 gives every HTTP/1.1 message, request or response, the same shape:
HTTP-message = start-line CRLF
*( field-line CRLF )
CRLF
[ message-body ]In the request above, those parts are:
- Request line —
POST /api/products HTTP/1.1: the method, the request target and the protocol version, separated by single spaces. RFC 9110 makes the method case-sensitive, sopostis notPOST. - Header fields — one
Name: valueper line. Field names are case-insensitive, socontent-typeandContent-Typeare the same header. HTTP/1.1 requiresHost, which lets one server on one address serve several host names. - Empty line — a CRLF on its own. It is the only signal that the headers are over.
- Body — the content. Nothing marks where it ends: the server reads exactly as many bytes as
Content-Lengthannounced, which is why theprintfstring has no CRLF after the JSON.
Anatomy of an HTTP response
Tomcat answered the POST with:
HTTP/1.1 201
Location: /api/products/3
Content-Type: application/json
Transfer-Encoding: chunked
Date: Sat, 12 Sep 2026 07:03:16 GMT
3e
{"id":3,"name":"Refactoring","price":47.00,"category":"books"}
0- Status line —
HTTP/1.1 201: the version and a three-digit status code. The grammar allows a reason phrase after the code,status-line = HTTP-version SP status-code SP [ reason-phrase ], and Tomcat 11 sends none: the line isHTTP/1.1 201, one space, then CRLF. RFC 9112 tells clients to ignore the phrase anyway, because it "is not a reliable channel for information". - Header fields —
Locationis the URL of the product that was just created,Content-Typesays the body is JSON, andDateis when the response was generated. - Empty line — exactly as in the request.
- Body — chunked this time.
Transfer-Encoding: chunkedis the alternative toContent-Lengthfor content whose size is not announced up front: each chunk is its size in hexadecimal, CRLF, the data, CRLF, and a chunk of size0followed by one more CRLF ends the body.3eis 62, the length of the JSON. The GET earlier usedContent-Length: 61instead. curl and every HTTP library reassemble chunks for you, so this framing rarely shows up outside a raw capture.

What Tomcat does with a request that has no Host header
Leave Host out:
printf 'GET /api/products/1 HTTP/1.1\r\n\r\n' | nc localhost 8115HTTP/1.1 400
Content-Type: text/html;charset=utf-8
Content-Language: en
Content-Length: 435
Date: Sat, 12 Sep 2026 07:03:15 GMT
Connection: close
<!doctype html><html lang="en"><head><title>HTTP Status 400 – Bad Request</title><style type="text/css">body {font-family:Tahoma,Arial,sans-serif;} h1, h2, h3, b {color:white;background-color:#525D76;} h1 {font-size:22px;} h2 {font-size:16px;} h3 {font-size:14px;} p {font-size:12px;} a {color:black;} .line {height:1px;background-color:#525D76;border:none;}</style></head><body><h1>HTTP Status 400 – Bad Request</h1></body></html>RFC 9112 leaves the server no choice: "A server MUST respond with a 400 (Bad Request) status code to any HTTP/1.1 request message that lacks a Host header field". The HTML page is Tomcat's own error report, not the JSON that Spring Boot produces, because the request never reached Spring. In the captured run DispatcherServlet was logging at DEBUG and printed a line for every other request in this article, but nothing for this one. Connection: close tells the client that Tomcat closes the connection after this response.
The same exchange through curl -v
curl -v http://localhost:8115/api/products/1* Host localhost:8115 was resolved.
* IPv6: ::1
* IPv4: 127.0.0.1
* Trying [::1]:8115...
* Connected to localhost (::1) port 8115
> GET /api/products/1 HTTP/1.1
> Host: localhost:8115
> User-Agent: curl/8.7.1
> Accept: */*
>
* Request completely sent off
< HTTP/1.1 200
< Content-Type: application/json
< Content-Length: 61
< Date: Sat, 12 Sep 2026 07:03:16 GMT
<
* Connection #0 to host localhost left intact
{"id":1,"name":"Clean Code","price":32.50,"category":"books"}Lines starting with > are the request curl sent, < the response it received, and * curl's own notes about the connection. curl wrote the request line and three headers for you: Host including the port, since 8115 is not the default port 80; a User-Agent; and Accept: */*, meaning any media type will do. curl -i prints the status line and headers without the connection notes, and it is what the rest of this article uses.
Everything in this article is HTTP/1.1. HTTP/2 and HTTP/3 carry the same methods, status codes and headers in binary frames instead of text lines, which is why the semantics live in RFC 9110 and each version's wire format in its own RFC — 9112, 9113 and 9114.
HTTP methods: GET, POST, PUT, PATCH, DELETE, HEAD and OPTIONS
The method is the first word of the request line and says what the client wants done with the target resource. RFC 9110 gives methods three properties that matter far more than their names:
- Safe — "the client does not request, and does not expect, any state change on the origin server". A server may still write an access log line for a GET; what counts is that the client asked for no change.
- Idempotent — "the intended effect on the server of multiple identical requests with that method is the same as the effect for a single such request."
- Cacheable — a response may be stored and reused to answer a later request.
| Method | Asks the server to | Safe | Idempotent | Cacheable | Request body |
|---|---|---|---|---|---|
GET | send a representation of the resource | yes | yes | yes | not normally; it "has no generally defined semantics" |
HEAD | do what GET does, without sending the content | yes | yes | yes | no |
POST | process the enclosed representation by the resource's own rules | no | no | only with explicit freshness information and a matching Content-Location | yes |
PUT | create or replace the resource's state with the enclosed one | no | yes | no | yes, the complete new state |
PATCH | apply a set of changes to the resource | no | no | only with explicit freshness information and a matching Content-Location | yes, the changes |
DELETE | remove the resource | no | yes | no | not normally; no generally defined semantics |
OPTIONS | describe the communication options for the resource | yes | yes | no | not normally |
Three details the table leaves out. PATCH is not part of RFC 9110 at all: RFC 5789 added it in 2010, calling it "neither safe nor idempotent" while noting that a PATCH "can be issued in such a way as to be idempotent". RFC 9110 defines caching for POST, yet adds that "the overwhelming majority of cache implementations only support GET and HEAD". And the specification also defines CONNECT and TRACE, which APIs do not use.
PUT vs PATCH vs POST
The three methods that send a body are the ones most often mixed up, and the RFCs are precise about each:
- PUT replaces. It "requests that the state of the target resource be created or replaced with the state defined by the representation enclosed in the request message content." The body is the complete new state: whatever it leaves out is not kept.
- PATCH modifies. Its body "contains a set of instructions describing how a resource currently residing on the origin server should be modified to produce a new version." Only what the instructions touch changes.
- POST processes. It asks the target resource to "process the representation enclosed in the request according to the resource's own specific semantics." In an API that means creating a new member of a collection whose URL the server chooses, or running an operation no other method describes.
The difference between the first two shows on product 2. A PATCH that sends only a price:
curl -i -X PATCH -H 'Content-Type: application/json' -d '{"price":79.00}' http://localhost:8115/api/products/2HTTP/1.1 200
Content-Type: application/json
Content-Length: 76
Date: Sat, 12 Sep 2026 07:03:16 GMT
{"id":2,"name":"Mechanical Keyboard","price":79.00,"category":"electronics"}The name and the category survived. Now a PUT with the name and the same price, but no category:
curl -i -X PUT -H 'Content-Type: application/json' -d '{"name":"Mechanical Keyboard","price":79.00}' http://localhost:8115/api/products/2HTTP/1.1 200
Content-Type: application/json
Content-Length: 67
Date: Sat, 12 Sep 2026 07:03:16 GMT
{"id":2,"name":"Mechanical Keyboard","price":79.00,"category":null}category is now null. The PUT body was taken as the whole product, and a product without a category is what it described.
⚠️ A client that means "change one field" and sends PUT with a partial body erases every field it did not send. If clients update a few fields at a time, give them PATCH.
PUT can also create. RFC 9110 requires 201 (Created) when a PUT creates the resource, and 200 or 204 when it replaces an existing one. That only makes sense when the client chooses the URL, as with a file uploaded to a path of its choosing. In this API the server assigns ids, so a PUT to an id that does not exist is an error rather than a create:
curl -i -X PUT -H 'Content-Type: application/json' -d '{"name":"Mechanical Keyboard","price":79.00}' http://localhost:8115/api/products/77HTTP/1.1 404
Content-Type: application/json
Transfer-Encoding: chunked
Date: Sat, 12 Sep 2026 07:03:16 GMT
{"timestamp":"2026-09-12T07:03:16.110Z","status":404,"error":"Not Found","path":"/api/products/77"}The test target accepts a PATCH body as plain application/json and changes the fields that are present, which is the common simplification. The two standard patch formats are JSON Merge Patch (RFC 7396, application/merge-patch+json), where null removes a field, and JSON Patch (RFC 6902, application/json-patch+json), a list of operations such as add, replace and remove.
HEAD and OPTIONS on the wire
curl -I sends HEAD:
curl -I http://localhost:8115/api/products/1HTTP/1.1 200
Content-Type: application/json
Content-Length: 61
Date: Sat, 12 Sep 2026 07:03:16 GMTThe same Content-Type and the same Content-Length: 61 as the GET, and no body. RFC 9110 says the server "MUST NOT send content in the response" to HEAD and "SHOULD send the same header fields" it would send for GET, which makes HEAD the cheap way to ask whether something exists and how large it is.
OPTIONS asks which methods a URL supports:
curl -i -X OPTIONS http://localhost:8115/api/products/1
curl -i -X OPTIONS http://localhost:8115/api/productsHTTP/1.1 200
Allow: PATCH,DELETE,PUT,GET,HEAD,OPTIONS
Accept-Patch:
Content-Length: 0
Date: Sat, 12 Sep 2026 07:03:16 GMT
HTTP/1.1 200
Allow: POST,GET,HEAD,OPTIONS
Accept-Patch:
Content-Length: 0
Date: Sat, 12 Sep 2026 07:03:16 GMTAllow lists the methods: a single product can be read, replaced, patched and deleted; the collection can be read and posted to. Three things in these responses are worth knowing:
- The controller declares no HEAD or OPTIONS method, yet both are answered and listed. Spring does that on its own, and article 16 shows how.
- The order inside
Allowcarries no meaning and is not even stable. An earlier run of the same jar printedDELETE,PATCH,GET,HEAD,PUT,OPTIONSfor the same URL. Accept-Patchis the header RFC 5789 defines for listing the patch document formats a resource accepts. It is empty because the controller declares none.
Browsers also send OPTIONS on their own, as the CORS preflight before certain cross-origin requests. That belongs to Chapter 5.
Idempotency and retries: why sending a request twice matters
Networks fail in the least convenient place: after the server has done the work and before the client has read the answer. The client's timeout fires, and it faces a question HTTP cannot answer for it — did the request happen? All it can do is send the request again, and whether that is harmless depends on the method.
RFC 9110 states the rule. Idempotent methods are singled out "because the request can be repeated automatically if a communication failure occurs before the client is able to read the server's response". For the others, "a client SHOULD NOT automatically retry a request with a non-idempotent method unless it has some means to know that the request semantics are actually idempotent", and "a proxy MUST NOT automatically retry non-idempotent requests".
Sending the same request twice is exactly what a retry does. The POST first:
curl -i -X POST -H 'Content-Type: application/json' -d '{"name":"Effective Java","price":45.00,"category":"books"}' http://localhost:8115/api/productsThe first time:
HTTP/1.1 201
Location: /api/products/4
Content-Type: application/json
Transfer-Encoding: chunked
Date: Sat, 12 Sep 2026 07:03:16 GMT
{"id":4,"name":"Effective Java","price":45.00,"category":"books"}The second time, with the identical command:
HTTP/1.1 201
Location: /api/products/5
Content-Type: application/json
Transfer-Encoding: chunked
Date: Sat, 12 Sep 2026 07:03:16 GMT
{"id":5,"name":"Effective Java","price":45.00,"category":"books"}Two successes, two products: 4 and 5. A client that retried an order or a payment after a timeout would have created a duplicate, and received a perfectly normal 201 for it.
Now the PUT, twice:
curl -i -X PUT -H 'Content-Type: application/json' -d '{"name":"Clean Code","price":29.90,"category":"books"}' http://localhost:8115/api/products/1Both runs printed exactly the same response:
HTTP/1.1 200
Content-Type: application/json
Content-Length: 61
Date: Sat, 12 Sep 2026 07:03:16 GMT
{"id":1,"name":"Clean Code","price":29.90,"category":"books"}The whole collection afterwards:
curl -s http://localhost:8115/api/products[{"id":1,"name":"Clean Code","price":29.90,"category":"books"},{"id":2,"name":"Mechanical Keyboard","price":79.00,"category":null},{"id":3,"name":"Refactoring","price":47.00,"category":"books"},{"id":4,"name":"Effective Java","price":45.00,"category":"books"},{"id":5,"name":"Effective Java","price":45.00,"category":"books"}]Product 1 exists once, at 29.90, exactly as it would after a single PUT. Products 4 and 5 are duplicates.
Idempotency is a statement about the effect on the server, not about the response. DELETE is idempotent, and deleting the duplicate twice shows the difference:
curl -i -X DELETE http://localhost:8115/api/products/5
curl -i -X DELETE http://localhost:8115/api/products/5HTTP/1.1 204
Date: Sat, 12 Sep 2026 07:03:16 GMT
HTTP/1.1 404
Content-Type: application/json
Transfer-Encoding: chunked
Date: Sat, 12 Sep 2026 07:03:16 GMT
{"timestamp":"2026-09-12T07:03:16.155Z","status":404,"error":"Not Found","path":"/api/products/5"}204, then 404. After either request product 5 is gone, which is the effect the client asked for. RFC 9110 anticipates exactly this: repeating an idempotent request has the same intended effect "even if the original request succeeded, though the response might differ."

The Idempotency-Key header
Retrying a POST safely needs the server's cooperation. The usual pattern: the client generates a unique key for each logical operation, typically a UUID, and sends it as a header such as Idempotency-Key: 8e03978e-40d5-43e8-bc93-6894a57f9324. The server stores the key together with the outcome of the first request, and when the same key arrives again it returns that stored outcome instead of processing the request a second time. The retry creates nothing new.
It is a convention, not a standard. The IETF HTTPAPI working group drafted it as draft-ietf-httpapi-idempotency-key-header, and the draft lists Stripe and Adyen among existing implementations, but its last revision, -07 of 15 October 2025, expired on 18 April 2026 without being published as an RFC. Implementing it needs somewhere durable to keep keys and outcomes, so it is not part of this chapter.
HTTP status codes for REST APIs
A status code is three digits, and only the first carries structure. In RFC 9110's words, "the first digit of the status code defines the class of response. The last two digits do not have any categorization role."
| Class | RFC 9110 description | For an API |
|---|---|---|
1xx | Informational: the request was received, continuing process | handled by servers and HTTP libraries; you do not return these |
2xx | Successful: the request was successfully received, understood, and accepted | the operation worked |
3xx | Redirection: further action needs to be taken in order to complete the request | rare in JSON APIs |
4xx | Client Error: the request contains bad syntax or cannot be fulfilled | the client must change something, or for 429 wait, before trying again |
5xx | Server Error: the server failed to fulfill an apparently valid request | the fault is on the server side; a later retry may succeed |
A client that does not recognise a code must treat it as the x00 code of its class, one more reason to stick to registered codes rather than inventing a 299. The codes an API actually uses:
| Code | Name | Return it when |
|---|---|---|
200 | OK | a GET succeeded, or a PUT, PATCH or POST succeeded and returns a body |
201 | Created | a new resource now exists; send its URL in Location |
202 | Accepted | the request was queued for later processing and is not done yet |
204 | No Content | the operation succeeded and there is nothing to send back, typically DELETE |
304 | Not Modified | a conditional GET found that the client's cached copy is still current |
400 | Bad Request | the request cannot be read: malformed JSON, a value of the wrong type, a broken query parameter |
401 | Unauthorized | credentials are missing or invalid; the response must carry WWW-Authenticate |
403 | Forbidden | the caller is known but not allowed to do this |
404 | Not Found | nothing exists at this URL, or the server chooses not to reveal that it does |
405 | Method Not Allowed | the URL exists but does not support this method; the response must carry Allow |
406 | Not Acceptable | the server cannot produce any media type the Accept header allows |
409 | Conflict | the request conflicts with the current state: already shipped, already registered, edited by someone else |
415 | Unsupported Media Type | the endpoint does not read the request body's Content-Type |
422 | Unprocessable Content | the body is well-formed but its content breaks the rules |
429 | Too Many Requests | a rate limit was hit; Retry-After can say when to try again (RFC 6585) |
500 | Internal Server Error | an unexpected failure on the server, usually a bug |
502 | Bad Gateway | a gateway or proxy received an invalid response from the service behind it |
503 | Service Unavailable | the service is temporarily overloaded or down for maintenance; Retry-After can say for how long |
504 | Gateway Timeout | a gateway or proxy did not get a response in time from the service behind it |
Two of those headers are requirements rather than advice: a 401 "MUST send a WWW-Authenticate header field", and a 405 "MUST generate an Allow header field". 422 has a history too. WebDAV introduced it as Unprocessable Entity, and RFC 9110 brought it into core HTTP as Unprocessable Content, which is why Spring's HttpStatus has both UNPROCESSABLE_ENTITY and UNPROCESSABLE_CONTENT.
Status codes from the test target
The test target has already produced 201 with Location and 204. A product that does not exist:
curl -i http://localhost:8115/api/products/99HTTP/1.1 404
Content-Type: application/json
Transfer-Encoding: chunked
Date: Sat, 12 Sep 2026 07:03:16 GMT
{"timestamp":"2026-09-12T07:03:16.162Z","status":404,"error":"Not Found","path":"/api/products/99"}A method the URL does not support — deleting the entire collection:
curl -i -X DELETE http://localhost:8115/api/productsHTTP/1.1 405
Allow: POST, GET
Content-Type: application/json
Transfer-Encoding: chunked
Date: Sat, 12 Sep 2026 07:03:16 GMT
{"timestamp":"2026-09-12T07:03:16.168Z","status":405,"error":"Method Not Allowed","path":"/api/products"}The 405 carries the Allow header the RFC requires. It lists only POST, GET, while the OPTIONS response for the same URL also listed HEAD and OPTIONS, the two methods Spring answers by itself; article 16 returns to that. These JSON bodies are Spring Boot's default error response, which article 20 replaces.
Common status code mistakes
200 with the error in the body. A 200 carrying {"success": false, "error": "not found"} tells every piece of software between the server and the caller — HTTP libraries, proxies, caches, monitoring, retry logic — that the request succeeded. Only code that parses that particular body knows otherwise. Put the outcome in the status code and the details in the body.
500 for a client mistake. A NumberFormatException from a bad parameter or a NullPointerException from a missing field, left unhandled, reaches the client as 500. That says the server is broken, invites a retry for input that will never become valid, and wakes whoever is on call. Check the input and answer with a 4xx.
400 versus 422. RFC 9110 separates them. 400 means the server "cannot or will not process the request due to something that is perceived to be a client error (e.g., malformed request syntax, invalid request message framing, or deceptive request routing)". 422 means the server "understands the content type of the request content (hence a 415 (Unsupported Media Type) status code is inappropriate), and the syntax of the request content is correct, but it was unable to process the contained instructions." Malformed JSON is a 400, and Spring already agrees:
curl -i -X POST -H 'Content-Type: application/json' -d '{"name":"Effective Java","price":}' http://localhost:8115/api/productsHTTP/1.1 400
Content-Type: application/json
Transfer-Encoding: chunked
Date: Sat, 12 Sep 2026 07:03:16 GMT
Connection: close
{"timestamp":"2026-09-12T07:03:16.198Z","status":400,"error":"Bad Request","path":"/api/products"}The application log names the cause: HttpMessageNotReadableException: JSON parse error: Unexpected character ('}' (code 125)): expected a value. A well-formed product with a negative price is the kind of request 422 describes. Many APIs answer 400 for both kinds of problem, which is defensible as long as it is consistent; the design at the end of this article keeps them apart.
401 versus 403. 401 means the request "lacks valid authentication credentials": the server does not know who is calling, and it must say how to authenticate in WWW-Authenticate. 403 means the server "understood the request but refuses to fulfill it": it may know exactly who is calling, and the answer is still no. After a 403 the client "SHOULD NOT automatically repeat the request with the same credentials". Chapter 5 produces both.
404 to hide existence, applied inconsistently. Answering 404 instead of 403 for something the caller may not see is explicitly allowed: "An origin server that wishes to 'hide' the current existence of a forbidden target resource MAY instead respond with a status code of 404 (Not Found)." It stops a customer from discovering which order ids exist by trying them. It only works if every such resource gets the same treatment. An API that answers 403 for other customers' orders that exist and 404 for ids that do not has told the caller exactly what it meant to hide.
Headers that matter for an API
| Header | Sent in | Says |
|---|---|---|
Content-Type | requests and responses with a body | the media type of this message's body |
Accept | requests, and 406 or 415 responses | the media types the client will take back, or the server will take in |
Location | 201 and 3xx responses | the URL of the created resource, or of the redirect target |
Allow | 405 and OPTIONS responses | the methods the resource supports |
Cache-Control | mostly responses | whether a response may be stored, and for how long |
ETag / If-None-Match | response / request | a version tag for a representation, and "only send it if it changed" |
Authorization / WWW-Authenticate | request / 401 response | the caller's credentials, and the challenge that asks for them |
Retry-After | 429 and 503 responses | how long to wait before trying again |
Content-Type and Accept are the pair that gets confused. Content-Type describes the body of the message it appears in. Accept lists the media types the client is willing to receive. When the server cannot read the type of the request body, it answers 415; when it cannot produce any type that Accept allows, it answers 406. That choice of representation is content negotiation, and the easiest way to trigger it is to forget the Content-Type header in curl:
curl -i -X POST -d '{"name":"Effective Java","price":45.00,"category":"books"}' http://localhost:8115/api/productsHTTP/1.1 415
Accept: application/json, application/*+json
Content-Type: application/json
Transfer-Encoding: chunked
Date: Sat, 12 Sep 2026 07:03:16 GMT
{"timestamp":"2026-09-12T07:03:16.175Z","status":415,"error":"Unsupported Media Type","path":"/api/products"}The request half of the same command's -v output shows why:
> POST /api/products HTTP/1.1
> Host: localhost:8115
> User-Agent: curl/8.7.1
> Accept: */*
> Content-Length: 58
> Content-Type: application/x-www-form-urlencoded
> With -d and no Content-Type of your own, curl sends application/x-www-form-urlencoded, the format of an HTML form. The endpoint reads only JSON, so it answers 415, and the Accept header in that response lists what it would have taken. RFC 9110 gives Accept exactly that meaning in a response: it "provides information about which content types are preferred in the content of a subsequent request to the same resource."
Location holds a URI reference. The test target sends a relative one, /api/products/4, and RFC 9110 resolves a relative value against the request's target URI, which gives http://localhost:8115/api/products/4.
Cache-Control, ETag and If-None-Match work together. Cache-Control tells browsers and proxies whether a response may be stored and for how long, with directives such as no-store or max-age=60. ETag is an opaque version tag for a representation. A client that already holds a copy sends that tag back in If-None-Match, and if the representation has not changed the server answers 304 Not Modified with no body, so the client keeps using its copy. None of the responses in this article carry these headers, because the test target sends no caching information; HTTP caching is a topic for the Advanced course.
Authorization carries credentials on every request, for example Authorization: Bearer followed by a token, and it is the counterpart of WWW-Authenticate on a 401. Both arrive in Chapter 5.
What REST actually means
REST, Representational State Transfer, is an architectural style that Roy Fielding defined in his 2000 doctoral dissertation. It is not a protocol, a data format or a specification. It is a set of constraints, and HTTP was shaped by them: Fielding is also one of the editors of RFC 9110.
Resources and representations. A resource is anything that has an identifier: product 1, the collection of products, the orders of customer 42. A representation is a snapshot of a resource's state in a transferable format, such as the 61 bytes of JSON the first request in this article received. RFC 9110 uses the same words: a representation "is information that is intended to reflect a past, current, or desired state of a given resource". A client never receives the resource itself, only representations of it, and one product could have a JSON and an XML representation at the same URL.
The constraints, briefly:
- Client-server — the user interface and the data storage are separate, so each side can change independently.
- Stateless — each request carries everything the server needs to understand it. There is no server-side session that one request sets up and the next relies on, which is why an API sends credentials in
Authorizationon every call. - Cacheable — responses state whether they may be reused, so the work of answering the same request twice can be skipped.
- Uniform interface — every resource is handled the same way: identified by a URL, manipulated through representations, with self-descriptive messages (method, status code,
Content-Type), and with hypermedia in the responses telling the client what it can do next. - Layered system — a client cannot tell whether it is talking to the application or to a gateway, load balancer or cache in front of it. Status codes 502 and 504 exist because of those layers.
- Code on demand — optional: the server may send executable code, as it sends JavaScript to a browser.
The Richardson Maturity Model, presented by Leonard Richardson at QCon and written up by Martin Fowler in 2010, grades how much of HTTP an API actually uses. Level 0 sends everything through one URL, usually as POST, with the operation named in the body. Level 1 gives each resource its own URL. Level 2 uses the HTTP methods and status codes for their defined meaning, so a PUT is idempotent and a 404 means not found; this chapter builds a Level 2 API. Level 3 adds hypermedia controls, known as HATEOAS (Hypermedia as the Engine of Application State): each response carries links to the actions available next, so a client can follow the API instead of hard-coding its URLs. HATEOAS is covered in the Advanced course.
REST is not "JSON over HTTP". JSON is a format and HTTP is a protocol; neither makes an API RESTful. POST /api/getProducts answering with JSON is JSON over HTTP at Level 0. Fielding goes further: in his 2008 post "REST APIs must be hypertext-driven" he wrote that an API whose application state is not driven by hypertext "cannot be RESTful and cannot be a REST API. Period." In everyday use, "REST API" means Level 2 — resources, methods and status codes used correctly — and that is the meaning this series uses.
RESTful URL design rules
A URL identifies a resource, and the method says what to do with it. Most of the rules below follow from keeping those two jobs apart.
Plural nouns, with the identifier in the path
| Do | Don't |
|---|---|
GET /api/products | GET /api/product, GET /api/productList |
GET /api/products/42 | GET /api/product/42, GET /api/products?id=42 |
Name a collection with a plural noun and address one member by appending its id. The collection and its members then share a prefix, and /api/products/42 reads as product 42 of the products. The id belongs in the path because it identifies the resource; the query string is for narrowing a collection.
Sub-resources for ownership, and how deep to nest
| Do | Don't |
|---|---|
GET /api/customers/42/orders | GET /api/customerOrders/42 |
GET /api/orders/1001 | GET /api/customers/42/orders/1001/items/3 |
When a resource belongs to another, nest it: /api/customers/42/orders is the orders of customer 42, and a 404 for it can mean that customer does not exist. Filtering a top-level collection with GET /api/orders?customerId=42 is an equally valid choice when orders are often searched across customers; pick one per relationship.
Keep nesting to one level: collection, id, sub-collection. Once a child has an id of its own, give it a top-level URL. An order placed with POST /api/customers/42/orders is answered with Location: /api/orders/1001, and from then on /api/orders/1001 is its address. Deeper paths make every URL depend on the whole chain of parents, even when the client knows only the order id.
Query strings for filtering, sorting, pagination and search
| Do | Don't |
|---|---|
GET /api/products?category=books | GET /api/products/category/books, GET /api/books |
GET /api/products?sort=price,desc&page=0&size=20 | GET /api/products/sorted-by-price/page/1 |
GET /api/products?q=keyboard | GET /api/products/search/keyboard |
The query string narrows or orders a collection without naming a new resource: the result is still products, only fewer of them or in a different order. The sort=price,desc&page=0&size=20 form, with a zero-based page, is the one Spring Data reads, and Chapter 4 implements it with the database. The test target already filters by category:
curl -i "http://localhost:8115/api/products?category=books"HTTP/1.1 200
Content-Type: application/json
Content-Length: 192
Date: Sat, 12 Sep 2026 07:03:16 GMT
[{"id":1,"name":"Clean Code","price":29.90,"category":"books"},{"id":3,"name":"Refactoring","price":47.00,"category":"books"},{"id":4,"name":"Effective Java","price":45.00,"category":"books"}]Products 1, 3 and 4. Product 2 lost its category to the PUT earlier, so it no longer matches. The quotes around the URL keep the shell from interpreting ? and &.
No verbs in URLs, and what to do with actions
| Do | Don't |
|---|---|
GET /api/products | GET /api/getProducts |
POST /api/products | POST /api/createProduct |
DELETE /api/products/42 | POST /api/products/42/delete, GET /api/deleteProduct?id=42 |
The method is the verb. A verb in the path repeats it at best and contradicts it at worst: GET /api/deleteProduct?id=42 is a safe method that deletes. RFC 9110 distinguishes safe methods precisely so that "automated retrieval processes (spiders) and cache performance optimization (pre-fetching)" can call them "without fear of causing harm", and such a URL gives them exactly that opportunity.
Some operations are not create, read, update or delete on any resource: cancelling an order, approving an invoice, resending an email. Three designs are common:
| Design | Example | Trade-off |
|---|---|---|
| action sub-path | POST /api/orders/1001/cancel | explicit, and easy to document, authorise and log as one operation; the path contains a verb and the result is not a resource you can GET |
| sub-resource | POST /api/orders/1001/cancellation | stays with nouns and gives the cancellation its own URL, so its reason and time can be read back; one more resource to model for a single state change |
| state field | PATCH /api/orders/1001 with {"status":"CANCELLED"} | no new URL; the business rules — which transitions are allowed, the refund and the stock that go with them — hide behind a generic field update |
The worked design below uses POST /api/orders/{id}/cancel: cancelling is one operation with side effects, and nothing about the cancellation needs to be read back. If it did — a reason, a timestamp, who cancelled — the sub-resource would be the better fit. Either way the method is POST, never GET, because the operation changes state.
Casing, trailing slashes, file extensions and versions
| Do | Don't |
|---|---|
/api/product-categories | /api/productCategories, /api/Product_Categories |
/api/products | /api/products/ as a second spelling of the same URL |
/api/products/42 with Accept: application/json | /api/products/42.json |
Lowercase, with hyphens. RFC 3986 makes the scheme and host of a URI case-insensitive, but "the other generic syntax components are assumed to be case-sensitive". Spring follows that for the path:
curl -i http://localhost:8115/API/ProductsHTTP/1.1 404
Content-Type: application/json
Transfer-Encoding: chunked
Date: Sat, 12 Sep 2026 07:03:16 GMT
{"timestamp":"2026-09-12T07:03:16.212Z","status":404,"error":"Not Found","path":"/API/Products"}The same request to /api/products returned 200. A URL that works in only one spelling is the reason mixed case is a bad idea: someone will type it the other way. Hyphens keep multi-word segments readable, where camelCase depends on capitals that are easy to get wrong and underscores disappear under a link underline.
One form of each URL, without a trailing slash. To HTTP, /api/products and /api/products/ are two different URLs. Pick the form without the slash and use it everywhere: in controllers, in Location headers, in documentation. What Spring does when a request arrives with the slash anyway is shown in article 16.
No file extensions. The format of a response is negotiated with Accept, not encoded in the path; /api/products/42 is one resource whatever format it is sent in.
Versioning, as /api/v1/products in the path or as a request header or media type parameter, is a decision to make before the first client exists. Spring Framework 7 supports these strategies natively through ApiVersionConfigurer; choosing between them belongs to the Advanced course.
Worked design: the product catalogue endpoints
Articles 16 to 20 implement this API. It applies every rule above: plural nouns, ids in the path, one level of nesting for a customer's orders, query strings for filters, one action URL, and status codes taken from the table.
| Method | URL | Meaning | Success | Typical errors |
|---|---|---|---|---|
GET | /api/products | list products; ?category=, ?q=, ?sort=, ?page=, ?size= | 200 | 400 |
POST | /api/products | create a product | 201 + Location | 400, 415, 422 |
GET | /api/products/{id} | read one product | 200 | 404 |
PUT | /api/products/{id} | replace a product | 200 | 400, 404, 415, 422 |
PATCH | /api/products/{id} | change some fields of a product | 200 | 400, 404, 415, 422 |
DELETE | /api/products/{id} | delete a product | 204 | 404 |
GET | /api/customers | list customers; ?page=, ?size= | 200 | 400 |
POST | /api/customers | register a customer | 201 + Location | 400, 409 email already registered, 415, 422 |
GET | /api/customers/{id} | read one customer | 200 | 404 |
GET | /api/customers/{id}/orders | that customer's orders; ?status= | 200 | 400, 404 no such customer |
POST | /api/customers/{id}/orders | place an order for that customer | 201 + Location: /api/orders/{orderId} | 400, 404 no such customer, 409 not enough stock, 415, 422 |
GET | /api/orders | all orders; ?status=, ?page=, ?size= | 200 | 400 |
GET | /api/orders/{id} | read one order | 200 | 404 |
POST | /api/orders/{id}/cancel | cancel an order | 200 | 404, 409 already shipped |
A few decisions in it deserve a sentence each:
- Ids are assigned by the server. POST creates; PUT and PATCH only change what exists, so a PUT to an unknown id is 404, as the test target showed.
- An order is created under its customer and lives on its own.
POST /api/customers/{id}/ordersanswers 201 withLocation: /api/orders/{orderId}. The nested URL means this customer's orders, the top-level one means this order. - Orders are cancelled, not deleted. An order is a business record, so there is no
DELETE /api/orders/{id}. - 400 and 422 are kept apart. 400 for a body or parameter that cannot be read; 422 for one that reads correctly but breaks a rule, such as a negative price.
- 409 is about current state: an email that is already registered, stock that has run out, an order that has already shipped.
- Only the errors specific to an endpoint are listed. Any endpoint can also answer 500 when something breaks, and 401 or 403 once security is added in Chapter 5.
- Pagination parameters are part of the contract from the start.
page,sizeandsortare implemented together with the database in Chapter 4.

FAQ
What is the difference between PUT and PATCH?
PUT sends the complete new state and replaces the resource, so anything missing from the body is gone, as "category":null showed above. PATCH sends only the changes. PUT is idempotent by definition; PATCH is not, although a patch that sets fields to fixed values happens to be. Use PATCH when clients update a few fields, and PUT when they own the whole representation.
Should a POST that creates a resource return 200 or 201?
201, with a Location header holding the new resource's URL. RFC 9110 identifies the created resource by that header "or, if no Location header field is received, by the target URI", and for POST /api/products the target URI is the collection, not the new product. Returning the created representation in the body as well saves the client a second request.
Can a GET request have a body?
The message format does not stop it, but RFC 9110 says such content "has no generally defined semantics, cannot alter the meaning or target of the request, and might lead some implementations to reject the request and close the connection". Put the parameters of a GET in the path and the query string.
Why does Spring Boot answer HTTP/1.1 200 without OK?
Because embedded Tomcat 11 sends no reason phrase, and HTTP/1.1 allows that: the phrase is optional in the status line, and RFC 9112 tells clients to ignore it. Clients act on the number.
Should an API return 403 or 404 when the caller may not see a resource?
RFC 9110 allows either. 403 is fine when the existence of the resource is not a secret. 404 hides whether it exists, which matters for ids a caller could enumerate, such as other customers' orders. Whichever you choose, apply it to every such resource, or the difference between the two answers reveals what you tried to hide.
Is every API that returns JSON a REST API?
No. JSON is a format, and REST is a set of architectural constraints. An API that funnels everything through POST /api with the operation named in the body is JSON over HTTP. In everyday use, "REST API" means resources with their own URLs and methods and status codes used for their defined meaning, Level 2 of the Richardson Maturity Model, while Roy Fielding reserves the term for APIs that are also driven by hypermedia.
Conclusion
HTTP is lines of text: a start line, header fields, an empty line and a body, and every part of an API maps onto one of them. Methods carry promises — GET, HEAD and OPTIONS are safe, PUT and DELETE are idempotent, POST and PATCH are neither — and the same POST sent twice made two products while the same PUT sent twice left one state. Status codes put the outcome where every client and intermediary can read it, with Location, Allow, WWW-Authenticate and Retry-After next to the codes that need them. REST is a set of constraints rather than a data format, and the URL rules follow from letting URLs name resources while methods say what to do with them. The worked design turns all of that into fourteen endpoints.
The next article starts implementing them: @RestController and request mapping with @GetMapping, @PostMapping, @PutMapping and @DeleteMapping.