REST API Design: Endpoint Naming and JSON Payload Best Practices
Web DevelopmentLesson 2 of 46 min read
What you'll learn
- Name collections, nested resources and actions consistently
- Choose payload conventions for ids, timestamps, money, enums and links
- Decide between 400, 409 and 422 for different kinds of invalid requests
- Build and test a booking endpoint that returns field-level validation errors
On this page
A mobile team integrates with a clinic's booking API and ships a build that shows every consultation fee as "NaN". The server returned "fee": "₹450.00", and the app tried to do arithmetic on that string. A week later a second client misreads "starts_at": "02/11/2026 09:30" as the 11th of February. None of these are coding errors in the usual sense. They are design errors in the payload, and they are expensive because once clients depend on a shape you cannot change it casually. This lesson is about getting those decisions right up front, using a clinic appointment API.
Naming endpoints so people can guess them
A good API lets a developer guess the next URL. The rules are small and mostly about consistency: plural nouns for collections, lowercase with hyphens or underscores (pick one), ids in the path, and filters in the query string. Nest a resource under its parent only when it cannot exist without it and the parent id is needed to address it (/doctors/dr_nair/slots). Beyond one or two levels, flatten: /appointments/apt_1001 is better than /doctors/dr_nair/patients/p77/appointments/apt_1001.
| Avoid | Prefer | Why |
|---|---|---|
GET /getAppointments | GET /appointments | The method already says "get" |
POST /appointment/create | POST /appointments | Plural collection, no verb |
GET /doctors/12/patients/9/appointments/4 | GET /appointments/4 | Flat ids stay stable when relationships change |
GET /appointments?doctorID=1&Status=Open | GET /appointments?doctor_id=1&status=open | One naming style for every parameter |
POST /cancelAppointment | POST /appointments/4/cancellation | An action modelled as a resource |
That last row deserves a note, because real domains are full of actions that are not CRUD: cancel, approve, refund, publish. You have two reasonable options. Model the change as a state field and PATCH it ({"status": "cancelled"}), which is simple but lets a client write any status. Or model the action as a sub-resource you create (POST /appointments/4/cancellation), which lets the server enforce the rules, attach data such as a reason, and later expose GET .../cancellation as a record of what happened. This lesson uses the second form.
Payload conventions that age well
- Ids are opaque strings such as
"apt_1001", even when the database uses integers. You can change the storage later without a breaking change, and a prefix tells you what an id refers to when it shows up in a log. - Timestamps are ISO 8601 in UTC with a
Zsuffix (2026-11-02T09:30:00Z). Never send locale-formatted dates or epoch values without documenting the unit. - Money is an object with an integer in minor units and a currency code (
{"amount": 45000, "currency": "INR"}), never a float and never a formatted string. - Enums are lowercase strings with a documented, closed set (
booked,cancelled). - Collections are wrapped in an object (
{"slots": [...]}) so you can addnext_cursorortotallater without breaking clients that expected an array. - Pick one key style (snake_case or camelCase) for the whole API, and treat
nulland "field omitted" as different statements, documented consistently. - Include links when they save the client work, such as the URL to cancel an appointment, so clients do not have to build URLs by string concatenation.
The booking service
The server below exposes a doctor's slots, books an appointment, and lets a client cancel it. Read the book function closely: it checks every field and reports all the problems at once, with the field name, instead of failing on the first one. It uses the status codes deliberately, which the table after the examples explains.
from datetime import datetime
from flask import Flask, jsonify, request, url_for
app = Flask(__name__)
app.json.sort_keys = False
app.json.compact = False
DOCTORS = {
"dr_nair": {
"name": "Dr. Nair",
"fee": {"amount": 45000, "currency": "INR"}, # minor units (paise)
"slots": ["2026-11-02T09:00:00Z", "2026-11-02T09:30:00Z", "2026-11-02T10:00:00Z"],
}
}
appointments = {}
def problem(status, title, **extra):
return jsonify(title=title, status=status, **extra), status
@app.get("/doctors/<doctor_id>/slots")
def list_slots(doctor_id):
doctor = DOCTORS.get(doctor_id)
if doctor is None:
return problem(404, "Doctor not found")
date = request.args.get("date")
booked = {a["starts_at"] for a in appointments.values() if a["status"] == "booked"}
slots = [
{"starts_at": s, "available": s not in booked}
for s in doctor["slots"]
if date is None or s.startswith(date)
]
return jsonify(doctor_id=doctor_id, slots=slots)
@app.post("/appointments")
def book():
body = request.get_json(silent=True) or {}
errors = []
doctor = DOCTORS.get(body.get("doctor_id"))
if doctor is None:
errors.append({"field": "doctor_id", "message": "unknown doctor"})
patient = body.get("patient") or {}
if not str(patient.get("name", "")).strip():
errors.append({"field": "patient.name", "message": "required"})
try:
datetime.fromisoformat(body.get("starts_at", ""))
except (TypeError, ValueError):
errors.append({"field": "starts_at", "message": "must be an ISO 8601 timestamp"})
if errors:
return problem(422, "Validation failed", errors=errors)
taken = any(a["starts_at"] == body["starts_at"] and a["status"] == "booked"
for a in appointments.values())
if body["starts_at"] not in doctor["slots"] or taken:
return problem(409, "That slot is not available")
apt_id = f"apt_{1001 + len(appointments)}"
appointments[apt_id] = {
"id": apt_id,
"status": "booked",
"doctor_id": body["doctor_id"],
"starts_at": body["starts_at"],
"patient": {"name": patient["name"].strip()},
"fee": doctor["fee"],
"links": {"self": f"/appointments/{apt_id}",
"cancellation": f"/appointments/{apt_id}/cancellation"},
}
resp = jsonify(appointments[apt_id])
resp.status_code = 201
resp.headers["Location"] = url_for("get_appointment", apt_id=apt_id)
return resp
@app.get("/appointments/<apt_id>")
def get_appointment(apt_id):
apt = appointments.get(apt_id)
return jsonify(apt) if apt else problem(404, "Appointment not found")
@app.post("/appointments/<apt_id>/cancellation")
def cancel(apt_id):
apt = appointments.get(apt_id)
if apt is None:
return problem(404, "Appointment not found")
if apt["status"] == "cancelled":
return problem(409, "Appointment is already cancelled")
apt["status"] = "cancelled"
return jsonify(appointment_id=apt_id, status="cancelled"), 201
if __name__ == "__main__":
app.run(port=5102)Here is a successful booking. Note the 201, the Location header, the money object, the ISO timestamp and the links block.
curl -s -i -X POST http://localhost:5102/appointments \
-H "Content-Type: application/json" \
-d '{"doctor_id": "dr_nair", "starts_at": "2026-11-02T09:30:00Z", "patient": {"name": "Asha Verma"}}'Output
HTTP/1.1 201 CREATED
Content-Type: application/json
Content-Length: 328
Location: /appointments/apt_1001
{
"id": "apt_1001",
"status": "booked",
"doctor_id": "dr_nair",
"starts_at": "2026-11-02T09:30:00Z",
"patient": {
"name": "Asha Verma"
},
"fee": {
"amount": 45000,
"currency": "INR"
},
"links": {
"self": "/appointments/apt_1001",
"cancellation": "/appointments/apt_1001/cancellation"
}
}Now three failures. The first is a second patient trying to take the same half-hour; the second is a request that is wrong in three ways at once; the third cancels an appointment, then tries to cancel it again.
curl -s -w "\n-> %{http_code}\n" -X POST http://localhost:5102/appointments \
-H "Content-Type: application/json" \
-d '{"doctor_id": "dr_nair", "starts_at": "2026-11-02T09:30:00Z", "patient": {"name": "Rohan Das"}}'Output
{
"title": "That slot is not available",
"status": 409
}
-> 409curl -s -w "\n-> %{http_code}\n" -X POST http://localhost:5102/appointments \
-H "Content-Type: application/json" \
-d '{"doctor_id": "dr_who", "starts_at": "next tuesday", "patient": {}}'Output
{
"title": "Validation failed",
"status": 422,
"errors": [
{
"field": "doctor_id",
"message": "unknown doctor"
},
{
"field": "patient.name",
"message": "required"
},
{
"field": "starts_at",
"message": "must be an ISO 8601 timestamp"
}
]
}
-> 422curl -s -w "\n-> %{http_code}\n" -X POST http://localhost:5102/appointments/apt_1001/cancellation
curl -s -w "\n-> %{http_code}\n" -X POST http://localhost:5102/appointments/apt_1001/cancellationOutput
{
"appointment_id": "apt_1001",
"status": "cancelled"
}
-> 201
{
"title": "Appointment is already cancelled",
"status": 409
}
-> 409400, 409 or 422?
| Situation | Status | Example from the clinic API |
|---|---|---|
| The request cannot be parsed | 400 | Body is not JSON at all |
| The request parses but violates field rules | 422 | starts_at is "next tuesday" and patient.name is missing |
| The request is valid but clashes with current state | 409 | The 09:30 slot is already booked; the appointment is already cancelled |
Teams argue about 400 versus 422, and either is defensible if you are consistent and document it. The important difference is between the first two (the client must change the request) and 409 (the same request might succeed later, or after the client fetches fresh state). Notice also that the cancellation returned 201: it created a cancellation record. Whether to answer 201, 200 or 204 for an action is a judgement call; what matters is that clients can rely on it.
Common mistakes and the symptoms they produce
- Floats for money. Symptom: totals like
0.30000000000000004and invoices that are a paisa off. Use integer minor units. - Returning a bare JSON array from a list endpoint. Symptom: you later need pagination metadata and cannot add it without breaking every client.
- Failing on the first validation error. Symptom: a form with five mistakes needs five round trips. Return every field error in one response.
- Leaking your database shape. Symptom: a column rename becomes an API break. Map storage rows to a response model on purpose.
- Mixing naming styles (
doctorIdin one place,doctor_idin another). Symptom: client code full of special cases and bugs where a field is read asundefined. - Deep nesting. Symptom: URLs with four ids, handlers that must verify every parent relationship, and clients unable to fetch a resource without knowing its ancestry.
How this shows up in real projects and interviews
API design reviews are where senior engineers spend their influence: nobody remembers a clever internal function, but everyone suffers a badly shaped field for years. In design-focused interviews you may be asked to design the endpoints for a booking, library or ride-hailing system; the strong move is to list the resources first, name the relationships, decide where actions fit, and say out loud which fields are money, time and ids and why. The same habits carry into system design work such as the URL shortener walkthrough. Next: what happens when a collection grows too large to return in one response, in REST API Pagination, Filtering, Sorting and Versioning.
Try it yourself
Design (on paper, then in a Flask file) a small API for a community library: books, members and loans. List the resources and URLs first, then implement POST /loans (member id, book id, due date) and POST /loans/<id>/return. Decide your status codes for: unknown book, book already on loan, due date in the past, returning a loan twice. Write the JSON for a loan, including a due date, using the conventions from this lesson.
Show a hint
Treat "returning" as a sub-resource like the clinic's cancellation. A loan already on loan is a state conflict (409); a due date in the past is a field-rule violation (422).
Key takeaways
- Use plural nouns, consistent casing, flat URLs where possible, and filters in the query string.
- Model non-CRUD actions either as a status change or, more explicitly, as a sub-resource you create such as
/appointments/4/cancellation. - Send ids as opaque strings, timestamps as ISO 8601 UTC, and money as integer minor units with a currency code.
- Wrap collections in an object so metadata can be added later without breaking clients.
- Report every validation error at once with field names; use 400 for unparseable input, 422 for field rule violations and 409 for state conflicts.
Test yourself
A trick from this lesson's ride: Money in whole paise as integers, plus a currency code. Floats make pennies wander. Four more tricks like it wait along the route, each followed by a question.
Frequently asked questions
How should I name REST API endpoints?
Use plural nouns for collections (/appointments), put the id in the path for a single item (/appointments/4), keep names lowercase and consistent, and avoid verbs because the HTTP method already expresses the action. Use query parameters for filtering and sorting, and avoid nesting more than one or two levels deep.
How do I represent money in a JSON API?
Send an integer in the smallest currency unit together with a currency code, for example {"amount": 45000, "currency": "INR"}. This avoids floating-point rounding errors and ambiguity about formatting or decimal separators, and it works for currencies with different numbers of decimal places.
Should validation errors be 400 or 422?
Both are used. A common convention is 400 when the request cannot be parsed (invalid JSON, wrong types at the top level) and 422 when it parses but fails validation rules, with a list of field-level problems. The key is to choose one convention, document it, and apply it consistently across the API.