Skip to main content
PProfileMatch

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

  1. Naming endpoints so people can guess them
  2. Payload conventions that age well
  3. The booking service
  4. 400, 409 or 422?
  5. Common mistakes and the symptoms they produce
  6. How this shows up in real projects and interviews

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.

AvoidPreferWhy
GET /getAppointmentsGET /appointmentsThe method already says "get"
POST /appointment/createPOST /appointmentsPlural collection, no verb
GET /doctors/12/patients/9/appointments/4GET /appointments/4Flat ids stay stable when relationships change
GET /appointments?doctorID=1&Status=OpenGET /appointments?doctor_id=1&status=openOne naming style for every parameter
POST /cancelAppointmentPOST /appointments/4/cancellationAn 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 Z suffix (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 add next_cursor or total later without breaking clients that expected an array.
  • Pick one key style (snake_case or camelCase) for the whole API, and treat null and "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.

clinic.py
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.

book.sh
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.

conflict.sh
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
}

-> 409
invalid.sh
curl -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"
    }
  ]
}

-> 422
cancel.sh
curl -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/cancellation

Output

{
  "appointment_id": "apt_1001",
  "status": "cancelled"
}

-> 201
{
  "title": "Appointment is already cancelled",
  "status": 409
}

-> 409

400, 409 or 422?

SituationStatusExample from the clinic API
The request cannot be parsed400Body is not JSON at all
The request parses but violates field rules422starts_at is "next tuesday" and patient.name is missing
The request is valid but clashes with current state409The 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.30000000000000004 and 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 (doctorId in one place, doctor_id in another). Symptom: client code full of special cases and bugs where a field is read as undefined.
  • 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.

Ride this lesson

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.