Skip to main content
PProfileMatch

REST API Basics: Resources, HTTP Methods and Status Codes

Web DevelopmentLesson 1 of 47 min read

What you'll learn

  • Model a problem as collections and items addressed by URLs
  • Choose between GET, POST, PUT, PATCH and DELETE and explain safety and idempotency
  • Return accurate status codes, including 201 with Location, 204, 404, 405, 415 and 400
  • Read raw HTTP responses with curl -i to verify your API's behaviour

On this page

  1. Resources are nouns, URLs are their addresses
  2. Methods: what each one promises
  3. A station API you can run
  4. Watching the status codes
  5. Choosing a status code
  6. Common mistakes and the problems they cause
  7. How this shows up in real projects and interviews

A support ticket arrives at a bike-share company: "The mobile app says the dock was updated, but nothing changed." The engineers look at the logs. The server answered every request with 200 OK, and the failures were reported inside the body as {"success": false}. The app only looked at the status line, so as far as it knew everything worked. This is the most common REST design bug, and it comes from treating HTTP as a pipe instead of as part of the contract. This lesson fixes that, using a small API for bike-share docking stations.

Resources are nouns, URLs are their addresses

REST (Representational State Transfer) is a set of architectural constraints for networked systems: a client-server split, stateless requests (each one carries everything the server needs), cacheable responses, and a uniform interface. In everyday usage, "REST API" means an HTTP API that exposes resources at URLs and lets clients act on them with the standard HTTP methods. Strictly following every REST constraint (including hypermedia links) is rare; what matters in practice is that the API is predictable.

A resource is a thing your system knows about: a station, a bike, a rental. Collections get a plural noun (/stations) and individual members are addressed by id (/stations/3). The URL names the thing; the HTTP method says what to do with it. That is why /getStation?id=3 and /deleteStation feel wrong: the verb is already in the request line.

Methods: what each one promises

Two properties decide how clients, proxies and retry logic treat a method. A safe method does not change server state, so it can be called freely. An idempotent method has the same effect whether you send it once or five times, which matters because networks drop responses and clients retry.

MethodTypical useSafeIdempotentUsual success status
GETRead a resource or a collectionYesYes200
POSTCreate a member in a collection, or trigger an actionNoNo201 with Location, or 200/202
PUTReplace the whole resourceNoYes200 or 204
PATCHChange only some fieldsNoNot guaranteed200
DELETERemove the resourceNoYes204

"Idempotent" refers to the effect on the server, not the response. Deleting station 2 twice leaves the same state both times, even though the second call may answer 404. PATCH is not guaranteed idempotent: {"op": "add bike"} applied twice would add two bikes, whereas {"bikes": 5} applied twice is harmless.

A station API you can run

Install Flask (pip install flask), save this as stations.py and run python stations.py. It keeps stations in memory, accepts JSON only, and answers errors with JSON as well, so clients never have to parse an HTML error page. I ran every request in this lesson against this exact file.

stations.py
from flask import Flask, Response, jsonify, request, url_for
from werkzeug.exceptions import HTTPException, MethodNotAllowed

app = Flask(__name__)
app.json.sort_keys = False

stations = {
    1: {"id": 1, "name": "Riverside Dock", "capacity": 20, "bikes": 12},
    2: {"id": 2, "name": "Central Library", "capacity": 15, "bikes": 0},
}
next_id = 3


@app.errorhandler(HTTPException)
def as_json(err):
    headers = {}
    if isinstance(err, MethodNotAllowed):
        headers["Allow"] = ", ".join(sorted(err.valid_methods))
    return jsonify(error=err.name, detail=err.description), err.code, headers


def require_json():
    if not request.is_json:
        return None, (jsonify(error="Unsupported Media Type", detail="use Content-Type: application/json"), 415)
    body = request.get_json(silent=True)
    if not isinstance(body, dict):
        return None, (jsonify(error="Bad Request", detail="send a JSON object"), 400)
    return body, None


@app.get("/stations")
def list_stations():
    return jsonify(items=list(stations.values()))


@app.post("/stations")
def create_station():
    global next_id
    body, err = require_json()
    if err:
        return err
    if not {"name", "capacity"} <= body.keys():
        return jsonify(error="Bad Request", detail="name and capacity are required"), 400
    station = {"id": next_id, "name": body["name"], "capacity": body["capacity"], "bikes": 0}
    stations[next_id] = station
    next_id += 1
    resp = jsonify(station)
    resp.status_code = 201
    resp.headers["Location"] = url_for("get_station", station_id=station["id"])
    return resp


@app.get("/stations/<int:station_id>")
def get_station(station_id):
    station = stations.get(station_id)
    if station is None:
        return jsonify(error="Not Found", detail=f"no station {station_id}"), 404
    return jsonify(station)


@app.put("/stations/<int:station_id>")
def replace_station(station_id):
    if station_id not in stations:
        return jsonify(error="Not Found", detail=f"no station {station_id}"), 404
    body, err = require_json()
    if err:
        return err
    if body.keys() != {"name", "capacity", "bikes"}:
        return jsonify(error="Bad Request", detail="PUT needs name, capacity and bikes"), 400
    stations[station_id] = {"id": station_id, **body}
    return jsonify(stations[station_id])


@app.patch("/stations/<int:station_id>")
def update_station(station_id):
    station = stations.get(station_id)
    if station is None:
        return jsonify(error="Not Found", detail=f"no station {station_id}"), 404
    body, err = require_json()
    if err:
        return err
    station.update({k: v for k, v in body.items() if k in {"name", "capacity", "bikes"}})
    return jsonify(station)


@app.delete("/stations/<int:station_id>")
def delete_station(station_id):
    if stations.pop(station_id, None) is None:
        return jsonify(error="Not Found", detail=f"no station {station_id}"), 404
    resp = Response(status=204)
    del resp.headers["Content-Type"]  # a 204 has no body, so no content type
    return resp


if __name__ == "__main__":
    app.run(port=5101)

Watching the status codes

Use curl -i to print the status line and headers along with the body. The commands below run in order against a fresh server; I removed the Server, Date and Connection headers from the output to keep it readable.

session.sh
curl -s -i http://localhost:5101/stations/1

curl -s -i -X POST http://localhost:5101/stations \
  -H "Content-Type: application/json" \
  -d '{"name": "Tech Park Gate", "capacity": 30}'

curl -s -i -X POST http://localhost:5101/stations -d 'name=Tech Park Gate'

curl -s -X PUT http://localhost:5101/stations/2 -H "Content-Type: application/json" \
  -d '{"name": "Central Library", "capacity": 18, "bikes": 4}'
curl -s -X PUT http://localhost:5101/stations/2 -H "Content-Type: application/json" \
  -d '{"name": "Central Library", "capacity": 18, "bikes": 4}'

curl -s -X PATCH http://localhost:5101/stations/2 -H "Content-Type: application/json" \
  -d '{"bikes": 5}'

curl -s -i -X DELETE http://localhost:5101/stations/2
curl -s -i -X DELETE http://localhost:5101/stations/2
curl -s -i -X DELETE http://localhost:5101/stations

curl -s -i -X POST http://localhost:5101/stations \
  -H "Content-Type: application/json" -d '{"name": "Tech Park Gate"}'

Output

HTTP/1.1 200 OK
Content-Type: application/json
Content-Length: 58

{"id":1,"name":"Riverside Dock","capacity":20,"bikes":12}
HTTP/1.1 201 CREATED
Content-Type: application/json
Content-Length: 57
Location: /stations/3

{"id":3,"name":"Tech Park Gate","capacity":30,"bikes":0}
HTTP/1.1 415 UNSUPPORTED MEDIA TYPE
Content-Type: application/json
Content-Length: 81

{"error":"Unsupported Media Type","detail":"use Content-Type: application/json"}
{"id":2,"name":"Central Library","capacity":18,"bikes":4}
{"id":2,"name":"Central Library","capacity":18,"bikes":4}
{"id":2,"name":"Central Library","capacity":18,"bikes":5}
HTTP/1.1 204 NO CONTENT

HTTP/1.1 404 NOT FOUND
Content-Type: application/json
Content-Length: 46

{"error":"Not Found","detail":"no station 2"}
HTTP/1.1 405 METHOD NOT ALLOWED
Content-Type: application/json
Content-Length: 91
Allow: GET, HEAD, OPTIONS, POST

{"error":"Method Not Allowed","detail":"The method is not allowed for the requested URL."}
HTTP/1.1 400 BAD REQUEST
Content-Type: application/json
Content-Length: 66

{"error":"Bad Request","detail":"name and capacity are required"}

Walk through what happened. Creating a station returned 201 Created plus a Location header telling the client where the new resource lives. Sending form data instead of JSON produced 415 Unsupported Media Type (the server understands the request but not that format), while a JSON body with a missing field gave 400 Bad Request. The two PUT calls returned identical results, which is idempotency in action, and the PATCH changed one field and left the rest alone. The first DELETE returned 204 No Content, which by definition has no body, and the repeat returned 404. Finally, DELETE /stations hit a path that exists but does not allow that method, so the server answered 405 with an Allow header listing what it does accept.

Choosing a status code

CodeMeaningUse it when
200 OKSuccess with a bodyReads, updates that return the resource
201 CreatedA new resource existsAfter POST; include Location
204 No ContentSuccess, nothing to returnDELETE, or an update where the client needs no body
400 Bad RequestMalformed requestInvalid JSON, missing required field
401 UnauthorizedNot authenticatedNo or invalid credentials
403 ForbiddenAuthenticated but not allowedUser lacks permission for this resource
404 Not FoundNo such resourceUnknown id or route
405 Method Not AllowedPath exists, method does notSend an Allow header
409 ConflictClashes with current stateDuplicate name, version clash, slot already taken
415 Unsupported Media TypeBody format not acceptedWrong or missing Content-Type
422 Unprocessable ContentWell-formed but semantically invalidField-level validation failures
500 / 503Server fault / temporarily unavailableNever blame the client; 503 can carry Retry-After

Common mistakes and the problems they cause

  • Always returning 200 and putting the error in the body. Symptom: the mobile app, monitoring dashboards and HTTP client libraries all report success while users see failures, and error-rate alerts never fire.
  • Verbs in URLs (`/createStation`, `/station/delete/3`). Symptom: every endpoint is a one-off, caches and proxies cannot reason about them, and a crawler or prefetcher that follows a GET link can trigger a deletion.
  • Using GET for something that changes state. Symptom: browsers, link previews and retries repeat the action without anyone clicking.
  • PUT used as a partial update. Symptom: a client sends only {"bikes": 5} and the name and capacity are silently wiped. PUT replaces; use PATCH for partial changes (the server above rejects an incomplete PUT with 400).
  • 201 without a `Location` header, or 200 for a created resource. Symptom: clients have to guess the new URL and cannot tell a create from an update.
  • Returning an HTML error page from a JSON API. Symptom: the client crashes with a JSON parse error instead of showing the message. Register a handler so framework errors such as 404 and 405 come back as JSON too.

How this shows up in real projects and interviews

In code review, most REST feedback is about exactly these basics: wrong status codes, inconsistent naming, a POST that should have been a PUT. In interviews, expect "what is the difference between PUT and PATCH?" (full replacement versus partial change), "which methods are idempotent, and why does it matter?" (safe retries), and "401 versus 403". A solid answer is concrete: name the retry scenario, or describe what the client does differently on each code. If you have built a service with the built-in Node module, compare it with the Node.js course: the status codes there are the same ones used here. Next you will design the URLs and JSON for a bigger domain: REST API Design: Endpoints and Payloads.

Try it yourself

Add a bikes sub-collection to the station API. GET /stations/1/bikes returns the bikes docked there, POST /stations/1/bikes docks a bike (201 with a Location, 409 if the station is at capacity), and DELETE /stations/1/bikes/<bike_id> undocks it (204, then 404 on the repeat). Test every branch with curl -i and write down which of your endpoints are safe and which are idempotent.

Show a hint

Keep a bikes list per station and compare its length with capacity before appending. Remember that 404 should be returned both for an unknown station and for an unknown bike id.

Key takeaways

  • Model things as resources: plural-noun collections (/stations) and members (/stations/3), with the HTTP method as the verb.
  • GET is safe and idempotent, PUT and DELETE are idempotent, POST is neither, and PATCH is not guaranteed to be.
  • Use real status codes: 201 plus Location on create, 204 for empty success, 400/415/422 for bad input, 404 for missing, 405 with Allow, 409 for conflicts.
  • Return JSON errors for every failure, including framework-level 404 and 405 responses.
  • Verify behaviour with curl -i, which shows the status line and headers that clients actually act on.

Test yourself

A trick from this lesson's ride: The status line is the headline, the body is the small print; tools read headlines. Four more tricks like it wait along the route, each followed by a question.

Ride this lesson

Frequently asked questions

What is the difference between PUT and PATCH?

PUT replaces the entire resource with the representation you send, so omitted fields are removed or reset, and repeating the same PUT gives the same result. PATCH applies a partial change to specific fields. Use PUT when the client holds the full object and PATCH when it only wants to change a few properties.

When should a REST API return 201 vs 200?

Return 201 Created when a request has created a new resource, usually from POST, and include a Location header with its URL. Return 200 OK when the request succeeded and you are returning a body for an existing resource, such as a GET or an update. If there is nothing to return, use 204 No Content.

Is DELETE idempotent in REST?

Yes. Idempotency is about the final server state, and after one DELETE or five, the resource is gone. The response code can still differ: many APIs return 204 the first time and 404 afterwards, and some return 204 both times to make retries easier for clients.