How to buildOctober 11, 202617 min read
Spring BootStep 5 of 7

API design: resources, versioning, errors, pagination

In short

A usable API is built around noun resources, uses HTTP methods with the meaning RFC 9110 gives them, answers with precise status codes and Problem Details errors (RFC 9457), pages through large lists with a cursor and evolves without breaking clients. Teams write these conventions into an OpenAPI 3.1 document before coding, check it with a linter and test the implementation against that contract.

How-toLevel: IntermediateAPI DesignPractice: ~1–2 h

After this you will be able to

  • design resource URLs and pick the method and status code for each operation according to RFC 9110
  • define a single application/problem+json error format (RFC 9457) with an errors extension member
  • implement cursor pagination, protect POST from duplicates with Idempotency-Key and updates from being lost with ETag and If-Match
  • capture the contract in OpenAPI 3.1, check it with redocly lint and with contract tests against a stub

Verified with

OpenAPI 3.1.2Node.js 24 LTS (24.18)Express 5.3.0Ajv 8.20 (JSON Schema 2020-12)@redocly/cli 2.62.1supertest 7.3.1node:test

Checked against docs: October 11, 2026

Contents
  1. 01Resources and URLs: nouns, collections, nesting
  2. 02Methods, idempotency and status codes
  3. 03Error format: Problem Details (RFC 9457)
  4. 04Pagination, filtering and sorting
  5. 05Retrying POST without duplicates: the Idempotency-Key header
  6. 06Versioning and backward compatibility
  7. 07The contract in OpenAPI 3.1 and linting it
  8. 08A stub that follows the contract, tests and a review checklist

Resources and URLs: nouns, collections, nesting

The running example is a salon booking API: clients book appointments (bookings) with masters. We design it step by step, then capture the contract in OpenAPI, lint it and write a stub with tests. This guide is for developers who have already built a REST API (for instance, in the Express lesson) and now want one that is pleasant to use and can evolve without breakage.

A resource is a domain entity with its own address: a booking, a master, a service. A REST API (a style where data is exposed as addressable resources and actions are standard HTTP methods) is organised around resources, not actions. Microsoft's web API design guide puts it this way: base URIs on nouns, use plural nouns for collections, and let the HTTP method carry the verb.

POST /createBooking

Prefer
POST /bookings
Why
The method expresses the action; the path names only the resource

GET /getBooking?id=42

Prefer
GET /bookings/{bookingId}
Why
Every booking has its own stable address

POST /bookings/42/cancel

Prefer
PATCH /bookings/42 with {"status":"cancelled"}
Why
Cancelling changes the resource state. An action path is justified only when the operation is not a field change

GET /salons/1/masters/7/bookings/42

Prefer
GET /bookings/42, GET /bookings?master_id=7
Why
Microsoft advises against URIs more complex than collection/item/collection

/Bookings, /booking_list

Prefer
/bookings
Why
One style: lower case, plural
  • IDs in URLs are opaque: a UUID does not reveal how many records exist and cannot be walked like 1, 2, 3
  • One field naming style across the API: ours is snake_case (master_id, starts_at); never mix it with camelCase
  • Dates are RFC 3339 strings with an offset (2026-11-02T10:00:00+05:00); the server stores and returns UTC
  • The major version lives in the base URL (/v1 in servers), not in individual resources

Methods, idempotency and status codes

RFC 9110 gives methods two properties. A safe method is essentially read-only: the client does not request any state change on the server. An idempotent method is one where several identical requests have the same intended effect as a single one. Of the methods in RFC 9110, GET, HEAD, OPTIONS and TRACE are safe; those plus PUT and DELETE are idempotent. POST is neither, and RFC 5789 says PATCH is neither safe nor idempotent either.

GET

Safe
yes
Idempotent
yes
In our API
List bookings and read one

POST

Safe
no
Idempotent
no
In our API
Create a booking

PUT

Safe
no
Idempotent
yes
In our API
Replace the whole resource — not used here

PATCH

Safe
no
Idempotent
no
In our API
Partial change: reschedule, cancel, add a note

DELETE

Safe
no
Idempotent
yes
In our API
Delete — we cancel through PATCH instead to keep history

These properties matter for retries. RFC 9110 allows an idempotent request to be repeated automatically if the connection fails before the response arrives, and says a client SHOULD NOT automatically retry a non-idempotent request unless it has some means to know the retry is safe. For POST, that means is an idempotency key, covered below.

Pick status codes by their RFC 9110 meaning, not "everything is 200, the error is in the body". The table is our API's agreement; we will write the same agreement into OpenAPI.

200 OK

When
Reads and successful PATCH
Response contains
The resource and ETag

201 Created

When
Booking created
Response contains
The resource, Location with its URL, ETag

204 No Content

When
Success without a body, such as DELETE
Response contains
Nothing

400 Bad Request

When
Malformed JSON, invalid fields or parameters, missing Idempotency-Key
Response contains
Problem Details and errors

401 Unauthorized

When
Missing or invalid token
Response contains
Problem Details and WWW-Authenticate

403 Forbidden

When
The user is known but lacks permission
Response contains
Problem Details

404 Not Found

When
No such booking or route
Response contains
Problem Details

409 Conflict

When
Conflicts with current state: the slot is taken
Response contains
Problem Details with its own type

412 Precondition Failed

When
If-Match did not match: the booking changed since it was read
Response contains
Problem Details

415 Unsupported Media Type

When
PATCH not sent as application/merge-patch+json
Response contains
Problem Details

422 Unprocessable Content

When
Idempotency key already used with a different body
Response contains
Problem Details

428 Precondition Required

When
PATCH without If-Match (RFC 6585)
Response contains
Problem Details

429 Too Many Requests

When
Rate limit exceeded (RFC 6585)
Response contains
Problem Details and Retry-After

The stub does not implement 403 and 429: permissions and rate limits have their own lessons. Some APIs report validation errors with 400, others with 422: RFC 9110 describes 422 as "the syntax is correct, but the instructions cannot be processed", and the RFC 9457 example uses it. We use 400, as in the Express lesson, and keep 422 for a reused idempotency key, as the IETF draft suggests. Both conventions exist; pick one and write it into the contract.

Error format: Problem Details (RFC 9457)

Clients need one predictable error format so the frontend and integrations can parse every error with one function. The standard is Problem Details for HTTP APIs, RFC 9457, which obsoletes RFC 7807. The response has the application/problem+json media type and these members:

type

Meaning
URI of the problem type — the primary identifier for programs
RFC 9457 rule
Defaults to about:blank when absent; absolute URIs are recommended

title

Meaning
Short summary of the problem type
RFC 9457 rule
Does not change between occurrences; for about:blank it matches the status phrase, such as "Not Found"

status

Meaning
The status code
RFC 9457 rule
Advisory only: the server MUST use the same code in the actual HTTP response

detail

Meaning
Explanation of this occurrence
RFC 9457 rule
Helps the client fix the request; consumers SHOULD NOT parse it

instance

Meaning
URI of this occurrence
RFC 9457 rule
For example, an event ID in the error log

Extensions

Meaning
Your own members, such as errors
RFC 9457 rule
Consumers MUST ignore extensions they do not recognise

Generic errors — missing booking, missing token — use type: about:blank, because the status code says it all. Domain errors get their own type: …/problems/slot-taken, …/problems/idempotency-key-reused. Clients branch on that URI, and developers find its documentation. Here is the stub's actual response to a request without service and with tomorrow as the date (formatted for reading):

JSON
{
  "type": "about:blank",
  "title": "Bad Request",
  "status": 400,
  "detail": "Request validation failed",
  "errors": [
    {
      "pointer": "#/service",
      "detail": "must have required property 'service'"
    },
    {
      "pointer": "#/starts_at",
      "detail": "must match format \"date-time\""
    }
  ]
}
The stub's 400 response: errors is an extension member, pointer is a JSON Pointer to the field, as in the RFC 9457 example
  • detail never contains stack traces, SQL or database messages: they help attackers. Details go to the log; the client gets a generic sentence
  • The code in status and in the status line is always the same: proxies, caches and HTTP libraries only look at the status line
  • The UI takes user-facing text from its own translations keyed by type and shows detail in debug mode at most

Pagination, filtering and sorting

A list that returns 20 bookings today will return 20,000 in a year. Build pagination into the first version: adding it later is a breaking change, because clients expect the whole list.

Offset: `?limit=20&offset=40`

  • Easy to implement: LIMIT … OFFSET … in SQL
  • You can jump straight to page seven
  • If rows are inserted or deleted between requests, items shift: duplicates and gaps appear
  • A large OFFSET makes the database read and discard every preceding row

Cursor: `?limit=20&cursor=…`

  • The cursor points at the last item of the page; the next page is everything after it
  • Inserts and deletes cause no duplicates or gaps
  • With an index on the sort key, speed does not depend on depth
  • No jumping to an arbitrary page — forward only (backward if you return a second cursor)

Microsoft recommends meaningful defaults (its example is limit=25 and offset=0) and an upper bound on limit so one request cannot dump the whole database. Our default limit is 20, the maximum is 100, and anything larger gets a 400.

  1. Sort with a unique tiebreaker

    Bookings are ordered by starts_at, then by id for equal times. Without the second key, two bookings at the same time can swap places between requests and one of them gets lost.

  2. The cursor is opaque

    The cursor encodes the sort order plus starts_at and id of the last item in base64url. Clients do not parse it; they pass next_cursor along, so the format can change without a new API version.

  3. The cursor is bound to the sort

    A cursor from sort=-starts_at is rejected together with sort=starts_at: the stub answers 400 and names the cursor parameter.

  4. The end is explicit

    On the last page next_cursor is null. The field is required in the schema, so the client never has to guess whether the list is over.

Filters are plain query parameters on resource fields: ?status=confirmed&master_id=m-1. Sorting works well as a single signed parameter: sort=starts_at ascending, sort=-starts_at descending. List the allowed values in the contract with enum so clients cannot sort by an unindexed field. Microsoft describes partial responses (?fields=id,starts_at) as a way to reduce payload size; we left them out — for small resources they rarely pay off.

The next-page link can also be sent in a Link header with rel="next" per RFC 8288. A next_cursor field in the body is easier to read from JavaScript, the header suits generic clients; nothing stops you from sending both.

Retrying POST without duplicates: the Idempotency-Key header

A client sent POST /bookings and got no response: the network dropped, the mobile connection timed out. Was the booking created? Retrying is risky — a second booking may appear; not retrying is bad too — the client may end up with none. The fix is an idempotency key: the client generates a unique key per operation and sends it with every retry, and the server recognises the retry by the key and returns the stored result.

What the draft suggests and what the stub does:

Key format

Draft -07
Structured Field String in quotes; a UUID is recommended
Stub
Accepts only "…" with 16–64 Latin letters, digits and dashes, otherwise 400

Key missing where required

Draft -07
400 with a link to documentation
Stub
400, type …/idempotency-key-missing

Key reused with a different body

Draft -07
422
Stub
422, type …/idempotency-key-reused

First request with this key still running

Draft -07
409
Stub
Not implemented: the handler is synchronous, so there is no race

Retry after completion

Draft -07
Return the earlier result
Stub
The same 201, Location, ETag and body

Key retention

Draft -07
The service publishes its expiry policy
Stub
Keys live in memory until restart

The server decides whether the body is the same by a fingerprint — SHA-256 of the JSON body in the stub. Keys are stored together with the client account: the draft's security section warns that otherwise a guessed key would return someone else's result. This is how it looks from outside — a curl script against the running stub:

demo.sh
API=http://localhost:3917/v1
AUTH="Authorization: Bearer demo-token"
JSON="Content-Type: application/json"
KEY='Idempotency-Key: "8e03978e-40d5-43e8-bc93-6894a57f9324"'
BODY='{"master_id":"m-1","service":"haircut","starts_at":"2026-11-02T10:00:00+05:00"}'

# 1. Create, then retry with the same key: the same booking comes back
curl -si -w '\n' -X POST $API/bookings -H "$AUTH" -H "$KEY" -H "$JSON" -d "$BODY"
curl -s -w '\n' -X POST $API/bookings -H "$AUTH" -H "$KEY" -H "$JSON" -d "$BODY"

# 2. Same key, different body: 422
curl -s -w '\n' -X POST $API/bookings -H "$AUTH" -H "$KEY" -H "$JSON" \
  -d '{"master_id":"m-1","service":"coloring","starts_at":"2026-11-02T10:00:00+05:00"}'

# 3. Invalid body: 400 with a pointer per field
curl -s -w '\n' -X POST $API/bookings -H "$AUTH" -H "$JSON" \
  -H 'Idempotency-Key: "0b5f1c2e-7d1a-4c11-9e55-3f0c2a9d7b10"' \
  -d '{"master_id":"m-1","starts_at":"tomorrow"}'
terminal
$ bash demo.sh
HTTP/1.1 201 Created
Location: /v1/bookings/9ff0c277-6654-4fd6-8282-75f1156b6564
ETag: "v1"
Content-Type: application/json; charset=utf-8
Content-Length: 183

{"id":"9ff0c277-6654-4fd6-8282-75f1156b6564","master_id":"m-1","service":"haircut","starts_at":"2026-11-02T05:00:00.000Z","status":"confirmed","created_at":"2026-10-12T02:25:52.357Z"}
{"id":"9ff0c277-6654-4fd6-8282-75f1156b6564","master_id":"m-1","service":"haircut","starts_at":"2026-11-02T05:00:00.000Z","status":"confirmed","created_at":"2026-10-12T02:25:52.357Z"}
{"type":"https://api.salon.test/problems/idempotency-key-reused","title":"Idempotency-Key is already used","status":422,"detail":"This key was already used with a different request body"}
{"type":"about:blank","title":"Bad Request","status":400,"detail":"Request validation failed","errors":[{"pointer":"#/service","detail":"must have required property 'service'"},{"pointer":"#/starts_at","detail":"must match format \"date-time\""}]}
Actual output with the Date, Connection and Keep-Alive headers removed. The retry returned the same booking, the key with another body got 422, the invalid body got 400 with pointers
  • In production, keys live in the database with a unique constraint on account plus key, and the result is written in the same transaction as the booking — otherwise a crash in between leaves the key and the data out of sync
  • Mark the key as in progress before processing so a concurrent retry gets 409 instead of creating a duplicate
  • Publish the key format and retention period in the API documentation
  • Decide whether to store error responses. The stub stores only successful creation, so after a 409 "slot taken" a retry with the same key runs again

Versioning and backward compatibility

The best versioning strategy is to release new versions as rarely as possible. Agree up front which changes do not break clients, and write clients that ignore unknown fields — just as RFC 9457 requires consumers to ignore unknown Problem Details extensions.

Non-breaking

  • A new optional response field
  • A new resource or operation
  • A new optional parameter whose default keeps the old behaviour
  • A new error type using a status code clients already handle

Breaking

  • Removing or renaming a field, changing its type or format
  • A new required request field
  • A new enum value in a response if clients parse it with a strict switch
  • Changing a status code, a URL or the meaning of a field — say, an amount in tenge instead of tiyn

In the path

Example
/v1/bookings
Pros
Visible, easy to route, cache-friendly
Cons
Many versions mean many routes; the resource URL changes with the version

Query parameter

Example
/bookings?api-version=2
Pros
The resource URL stays the same and caches well
Cons
Every handler must parse the parameter

Header

Example
Api-Version: 2
Pros
Clean URLs
Cons
Invisible in links; the server and caches need extra logic

Media type

Example
Accept: application/vnd.salon.v2+json
Pros
The version belongs to the representation, not the URL
Cons
Harder for clients and tooling

This summarises the trade-offs in Microsoft's guide: path and query versions are the easiest to cache, header-based versions need more server logic. In our experience, for APIs consumed by mobile apps and partner integrations, a major version in the path (/v1) with only backward-compatible changes inside it is the most convenient. The contract revision lives in the document's info.version, not in the URL.

An old version is not switched off by surprise. RFC 9745 (Standards Track, March 2025) defines the Deprecation header with the date the resource was or will be deprecated, and a rel="deprecation" link to an explanation. The shutdown date comes from the Sunset header of RFC 8594. Headers from the RFC 9745 examples:

HTTP
Deprecation: @1688169599
Link: <https://developer.example.com/deprecation>;
      rel="deprecation"; type="text/html"
Sunset: Sun, 30 Jun 2024 23:59:59 UTC
The Deprecation value is a Structured Fields date in Unix seconds: @1688169599 is 30 June 2023, 23:59:59 UTC

The contract in OpenAPI 3.1 and linting it

OpenAPI is a machine-readable description of an HTTP API in YAML or JSON: resources, parameters, request bodies, status codes, data schemas and authentication. Contract-first means the document is written and reviewed before the code: frontend, mobile and integration teams see the API early, and documentation, clients and checks are produced from the file.

We use OpenAPI 3.1.2, the latest patch of the 3.1 line. The specification says patch versions fix the text, not the feature set, and tooling should not distinguish between 3.1.0 and 3.1.1. The 3.2 line is out (the latest on spec.openapis.org is 3.2.1), but check that your generators and validators support it before switching. The key change from 3.0 to 3.1: the Schema Object is a superset of JSON Schema 2020-12. So instead of nullable: true you write type: [string, 'null'], and a regular JSON Schema validator can check the schemas — which the stub relies on.

The document, the stub and the tests live in one folder. Dependencies:

package.json
{
  "name": "bookings-api",
  "version": "1.0.0",
  "private": true,
  "type": "module",
  "scripts": {
    "start": "node src/server.js",
    "test": "node --test",
    "lint:api": "redocly lint openapi.yaml"
  },
  "dependencies": {
    "ajv": "^8.20.0",
    "ajv-formats": "^3.0.1",
    "express": "^5.3.0",
    "yaml": "^2.9.1"
  },
  "devDependencies": {
    "@redocly/cli": "^2.62.1",
    "supertest": "^7.3.1"
  }
}

The document itself. Schemas, error responses and the ETag header are defined once in components and referenced with $ref. Booking does not forbid extra properties, so new fields can be added to responses; the incoming BookingCreate and BookingPatch are closed with additionalProperties: false, so a typo in a field name does not pass silently.

openapi.yaml
openapi: 3.1.2
info:
  title: Bookings API
  version: 1.0.0
  summary: Client bookings for a salon.
  description: |
    Errors use Problem Details (RFC 9457, application/problem+json).
    Clients must ignore unknown response fields: new optional fields
    are added without a new major version.
  license:
    name: MIT
    identifier: MIT
servers:
  - url: https://api.salon.test/v1
security:
  - bearerAuth: []
tags:
  - name: bookings
    description: Client bookings
paths:
  /bookings:
    get:
      tags: [bookings]
      operationId: listBookings
      summary: List bookings (cursor pagination)
      parameters:
        - name: limit
          in: query
          schema: { type: integer, minimum: 1, maximum: 100, default: 20 }
        - name: cursor
          in: query
          description: Opaque value of next_cursor from the previous page.
          schema: { type: string }
        - name: status
          in: query
          schema: { $ref: '#/components/schemas/BookingStatus' }
        - name: master_id
          in: query
          schema: { type: string }
        - name: sort
          in: query
          description: Sort field; a leading minus means descending.
          schema: { type: string, enum: [starts_at, -starts_at], default: starts_at }
      responses:
        '200':
          description: One page of bookings.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/BookingPage' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
    post:
      tags: [bookings]
      operationId: createBooking
      summary: Create a booking
      parameters:
        - name: Idempotency-Key
          in: header
          required: true
          description: |
            Quoted unique string, e.g. "8e03978e-40d5-43e8-bc93-6894a57f9324".
            A retry with the same key and body returns the stored result.
          schema: { type: string, pattern: '^"[A-Za-z0-9-]{16,64}"$' }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/BookingCreate' }
      responses:
        '201':
          description: Booking created.
          headers:
            Location:
              description: URL of the new booking.
              schema: { type: string, format: uri-reference }
            ETag: { $ref: '#/components/headers/ETag' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Booking' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '409': { $ref: '#/components/responses/Conflict' }
        '422': { $ref: '#/components/responses/UnprocessableContent' }
  /bookings/{bookingId}:
    parameters:
      - name: bookingId
        in: path
        required: true
        schema: { type: string, format: uuid }
    get:
      tags: [bookings]
      operationId: getBooking
      summary: Get a booking
      responses:
        '200':
          description: The booking.
          headers:
            ETag: { $ref: '#/components/headers/ETag' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Booking' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
    patch:
      tags: [bookings]
      operationId: updateBooking
      summary: Reschedule, cancel or annotate a booking (JSON Merge Patch)
      parameters:
        - name: If-Match
          in: header
          required: true
          description: ETag from the last GET; protects against lost updates.
          schema: { type: string }
      requestBody:
        required: true
        content:
          application/merge-patch+json:
            schema: { $ref: '#/components/schemas/BookingPatch' }
      responses:
        '200':
          description: Updated booking.
          headers:
            ETag: { $ref: '#/components/headers/ETag' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Booking' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }
        '412': { $ref: '#/components/responses/PreconditionFailed' }
        '415': { $ref: '#/components/responses/UnsupportedMediaType' }
        '428': { $ref: '#/components/responses/PreconditionRequired' }
components:
  securitySchemes:
    bearerAuth: { type: http, scheme: bearer }
  headers:
    ETag:
      description: Version of the booking; send it back in If-Match.
      schema: { type: string }
  schemas:
    BookingStatus:
      type: string
      enum: [confirmed, cancelled]
    Booking:
      type: object
      required: [id, master_id, service, starts_at, status, created_at]
      properties:
        id: { type: string, format: uuid }
        master_id: { type: string }
        service: { type: string }
        starts_at: { type: string, format: date-time }
        status: { $ref: '#/components/schemas/BookingStatus' }
        note: { type: string, maxLength: 500 }
        created_at: { type: string, format: date-time }
    BookingCreate:
      type: object
      additionalProperties: false
      required: [master_id, service, starts_at]
      properties:
        master_id: { type: string, minLength: 1 }
        service: { type: string, minLength: 1 }
        starts_at: { type: string, format: date-time }
        note: { type: string, maxLength: 500 }
    BookingPatch:
      type: object
      additionalProperties: false
      minProperties: 1
      properties:
        starts_at: { type: string, format: date-time }
        status: { const: cancelled }
        note:
          type: [string, 'null']
          maxLength: 500
          description: null removes the note (RFC 7396).
    BookingPage:
      type: object
      required: [data, next_cursor]
      properties:
        data:
          type: array
          items: { $ref: '#/components/schemas/Booking' }
        next_cursor:
          type: [string, 'null']
          description: Pass as ?cursor= to get the next page; null on the last page.
    Problem:
      type: object
      required: [type, title, status]
      properties:
        type: { type: string, format: uri-reference }
        title: { type: string }
        status: { type: integer, minimum: 400, maximum: 599 }
        detail: { type: string }
        instance: { type: string, format: uri-reference }
        errors:
          type: array
          description: Extension member — one item per invalid field or parameter.
          items:
            type: object
            required: [detail]
            properties:
              detail: { type: string }
              pointer: { type: string, description: JSON Pointer into the request body. }
              parameter: { type: string, description: Name of the invalid query parameter. }
  responses:
    BadRequest:
      description: Malformed request or failed validation.
      content:
        application/problem+json:
          schema: { $ref: '#/components/schemas/Problem' }
    Unauthorized:
      description: Missing or invalid bearer token.
      headers:
        WWW-Authenticate:
          description: Authentication scheme the client must use.
          schema: { type: string }
      content:
        application/problem+json:
          schema: { $ref: '#/components/schemas/Problem' }
    NotFound:
      description: No booking with this id.
      content:
        application/problem+json:
          schema: { $ref: '#/components/schemas/Problem' }
    Conflict:
      description: The slot is already taken.
      content:
        application/problem+json:
          schema: { $ref: '#/components/schemas/Problem' }
    PreconditionFailed:
      description: If-Match does not match the current ETag; re-read the booking.
      content:
        application/problem+json:
          schema: { $ref: '#/components/schemas/Problem' }
    PreconditionRequired:
      description: The If-Match header is required.
      content:
        application/problem+json:
          schema: { $ref: '#/components/schemas/Problem' }
    UnprocessableContent:
      description: Idempotency-Key was already used with a different body.
      content:
        application/problem+json:
          schema: { $ref: '#/components/schemas/Problem' }
    UnsupportedMediaType:
      description: PATCH body must be application/merge-patch+json.
      content:
        application/problem+json:
          schema: { $ref: '#/components/schemas/Problem' }
The booking API contract: cursor pagination, required Idempotency-Key on POST, If-Match on PATCH, every error as application/problem+json

Linting is done with Redocly CLI. Without a config file, redocly lint applies the built-in recommended ruleset: structural validity, unresolved $refs, unused components, 4xx responses on operations and more. recommended-strict is the stricter set, and custom rules go into redocly.yaml.

terminal
$ npm run lint:api
> bookings-api@1.0.0 lint:api
> redocly lint openapi.yaml

No configurations were provided -- using built in recommended configuration by default.

validating openapi.yaml...
openapi.yaml: validated in 23ms

Woohoo! Your API description is valid. 🎉

To make sure the linter catches mistakes, we introduced a typo in one reference: BookingsPage instead of BookingPage. The linter reported both the unresolved reference and the now unused schema, and the command exited with a non-zero code — so it can run in CI and keep a broken contract out of the main branch:

terminal
$ npx redocly lint openapi.yaml --format=stylish
No configurations were provided -- using built in recommended configuration by default.

validating openapi.yaml...
openapi.yaml:
  49:23  error    no-unresolved-refs    Can't resolve $ref
  176:5  warning  no-unused-components  Component: "BookingPage" is never used.

openapi.yaml: validated in 24ms

❌ Validation failed with 1 error and 1 warning.
run `redocly lint --generate-ignore-file` to add all problems to the ignore file.
$ echo $?
1

A stub that follows the contract, tests and a review checklist

A stub is a minimal implementation that behaves according to the contract but keeps data in memory. It lets the frontend start before the backend is ready and proves the contract can be implemented at all. The key move: schemas from openapi.yaml are loaded into the Ajv validator in JSON Schema 2020-12 mode, so the same contract checks incoming requests on the server and responses in the tests.

src/contract.js
import { readFileSync } from "node:fs";
import { Ajv2020 } from "ajv/dist/2020.js";
import addFormats from "ajv-formats";
import { parse } from "yaml";

const doc = parse(readFileSync(new URL("../openapi.yaml", import.meta.url), "utf8"));

// OpenAPI 3.1 schemas are JSON Schema 2020-12, so one validator serves
// both the server (requests) and the tests (responses).
const ajv = new Ajv2020({ allErrors: true, strict: false });
addFormats(ajv);
ajv.addSchema({ $id: "openapi", components: doc.components });

export function schemaErrors(name, data) {
  const validate = ajv.getSchema(`openapi#/components/schemas/${name}`);
  if (validate(data)) return [];
  return validate.errors.map((e) => {
    const missing = e.params.missingProperty ? `/${e.params.missingProperty}` : "";
    const extra = e.params.additionalProperty ? `/${e.params.additionalProperty}` : "";
    return { pointer: `#${e.instancePath}${missing}${extra}`, detail: e.message };
  });
}

The server runs on Express 5. Errors are a Problem class that the central handler turns into application/problem+json. Authentication is a stub: any bearer token counts as an account; real verification is covered in the authentication and authorization lessons.

src/app.js
import express from "express";
import { createHash, randomUUID } from "node:crypto";
import { STATUS_CODES } from "node:http";
import { schemaErrors } from "./contract.js";

const TYPES = "https://api.salon.test/problems/";

class Problem extends Error {
  constructor(status, detail, { type, title, headers, ...extra } = {}) {
    super(detail);
    this.status = status;
    this.headers = headers ?? {};
    this.body = {
      type: type ? TYPES + type : "about:blank",
      title: title ?? STATUS_CODES[status],
      status,
      detail,
      ...extra,
    };
  }
}

const etag = (b) => `"v${b.version}"`;
const view = ({ version, ...booking }) => booking;
const encodeCursor = (b, sort) =>
  Buffer.from(JSON.stringify([sort, b.starts_at, b.id])).toString("base64url");

function decodeCursor(cursor, sort) {
  try {
    const [s, startsAt, id] = JSON.parse(Buffer.from(cursor, "base64url").toString());
    if (s === sort && typeof startsAt === "string" && typeof id === "string") return { startsAt, id };
  } catch {}
  throw new Problem(400, "Cursor is invalid or belongs to another sort order", {
    errors: [{ parameter: "cursor", detail: "use next_cursor from the previous page" }],
  });
}

export function createApp() {
  const app = express();
  app.disable("x-powered-by");
  app.set("etag", false); // ETag is the booking version, set explicitly below
  const bookings = new Map();
  const idempotency = new Map();

  const slotTaken = (masterId, startsAt, exceptId) =>
    [...bookings.values()].some(
      (b) => b.id !== exceptId && b.status === "confirmed" &&
        b.master_id === masterId && b.starts_at === startsAt,
    );

  app.use(express.json({ type: ["application/json", "application/merge-patch+json"] }));

  // Stub authentication: a real API verifies the token (see the auth lessons).
  app.use((req, res, next) => {
    const token = /^Bearer (\S+)$/.exec(req.get("authorization") ?? "")?.[1];
    if (!token) {
      throw new Problem(401, "Send a bearer token", { headers: { "WWW-Authenticate": "Bearer" } });
    }
    req.account = token;
    next();
  });

  const v1 = express.Router();

  v1.get("/bookings", (req, res) => {
    const { limit = "20", cursor, status, master_id, sort = "starts_at" } = req.query;
    const size = Number(limit);
    const errors = [];
    if (!Number.isInteger(size) || size < 1 || size > 100) {
      errors.push({ parameter: "limit", detail: "must be an integer from 1 to 100" });
    }
    if (!["starts_at", "-starts_at"].includes(sort)) {
      errors.push({ parameter: "sort", detail: "must be starts_at or -starts_at" });
    }
    if (status !== undefined && !["confirmed", "cancelled"].includes(status)) {
      errors.push({ parameter: "status", detail: "must be confirmed or cancelled" });
    }
    if (errors.length) throw new Problem(400, "Query validation failed", { errors });

    const dir = sort === "-starts_at" ? -1 : 1;
    const cmp = (a, b) => dir * (a.starts_at.localeCompare(b.starts_at) || a.id.localeCompare(b.id));
    let rows = [...bookings.values()]
      .filter((b) => (!status || b.status === status) && (!master_id || b.master_id === master_id))
      .sort(cmp);
    if (cursor) {
      const after = decodeCursor(cursor, sort);
      rows = rows.filter((b) => cmp(b, { starts_at: after.startsAt, id: after.id }) > 0);
    }
    const page = rows.slice(0, size);
    const hasMore = rows.length > size;
    res.json({
      data: page.map(view),
      next_cursor: hasMore ? encodeCursor(page.at(-1), sort) : null,
    });
  });

  v1.post("/bookings", (req, res) => {
    const key = req.get("idempotency-key");
    if (!key) {
      throw new Problem(400, "This operation requires an Idempotency-Key header", {
        type: "idempotency-key-missing", title: "Idempotency-Key is missing",
      });
    }
    if (!/^"[A-Za-z0-9-]{16,64}"$/.test(key)) {
      throw new Problem(400, "Idempotency-Key must be a quoted string of 16-64 letters, digits or dashes", {
        type: "idempotency-key-invalid", title: "Idempotency-Key is invalid",
      });
    }
    const errors = schemaErrors("BookingCreate", req.body);
    if (errors.length) throw new Problem(400, "Request validation failed", { errors });

    // The key is scoped to the account: one client cannot replay another's result.
    const scope = `${req.account}:${key}`;
    const fingerprint = createHash("sha256").update(JSON.stringify(req.body)).digest("hex");
    const saved = idempotency.get(scope);
    if (saved) {
      if (saved.fingerprint !== fingerprint) {
        throw new Problem(422, "This key was already used with a different request body", {
          type: "idempotency-key-reused", title: "Idempotency-Key is already used",
        });
      }
      return res.status(201).set(saved.headers).json(saved.body);
    }

    const startsAt = new Date(req.body.starts_at).toISOString();
    if (slotTaken(req.body.master_id, startsAt)) {
      throw new Problem(409, "The master already has a booking at this time", {
        type: "slot-taken", title: "Slot is already taken",
      });
    }
    const booking = {
      id: randomUUID(),
      master_id: req.body.master_id,
      service: req.body.service,
      starts_at: startsAt,
      status: "confirmed",
      ...(req.body.note !== undefined && { note: req.body.note }),
      created_at: new Date().toISOString(),
      version: 1,
    };
    bookings.set(booking.id, booking);
    const headers = { Location: `/v1/bookings/${booking.id}`, ETag: etag(booking) };
    idempotency.set(scope, { fingerprint, headers, body: view(booking) });
    res.status(201).set(headers).json(view(booking));
  });

  const find = (id) => {
    const booking = bookings.get(id);
    if (!booking) throw new Problem(404, `Booking ${id} not found`);
    return booking;
  };

  v1.get("/bookings/:id", (req, res) => {
    const booking = find(req.params.id);
    res.set("ETag", etag(booking)).json(view(booking));
  });

  v1.patch("/bookings/:id", (req, res) => {
    if (!req.is("application/merge-patch+json")) {
      throw new Problem(415, "Use Content-Type: application/merge-patch+json");
    }
    const booking = find(req.params.id);
    const ifMatch = req.get("if-match");
    if (!ifMatch) throw new Problem(428, "Send If-Match with the ETag from your last GET");
    if (ifMatch !== etag(booking)) {
      throw new Problem(412, "The booking has changed since you read it; GET it again");
    }
    const errors = schemaErrors("BookingPatch", req.body);
    if (errors.length) throw new Problem(400, "Request validation failed", { errors });

    const next = { ...booking };
    if ("starts_at" in req.body) next.starts_at = new Date(req.body.starts_at).toISOString();
    if ("status" in req.body) next.status = req.body.status;
    if (req.body.note === null) delete next.note; // RFC 7396: null removes the member
    else if ("note" in req.body) next.note = req.body.note;
    if (next.status === "confirmed" && slotTaken(next.master_id, next.starts_at, next.id)) {
      throw new Problem(409, "The master already has a booking at this time", {
        type: "slot-taken", title: "Slot is already taken",
      });
    }
    next.version += 1;
    bookings.set(next.id, next);
    res.set("ETag", etag(next)).json(view(next));
  });

  app.use("/v1", v1);

  app.use((req, res, next) => {
    next(new Problem(404, `Route ${req.method} ${req.path} not found`));
  });

  app.use((err, req, res, next) => {
    if (res.headersSent) return next(err);
    const problem =
      err instanceof Problem ? err
      : err.type === "entity.parse.failed" ? new Problem(400, "Request body is not valid JSON")
      : null;
    if (!problem) {
      console.error(err);
      return res.status(500).type("application/problem+json")
        .json(new Problem(500, "Unexpected server error").body);
    }
    res.status(problem.status).set(problem.headers)
      .type("application/problem+json").json(problem.body);
  });

  return app;
}
A stub for development and tests, not production code: data and idempotency keys live in memory, the token is not verified
src/server.js
import { createApp } from "./app.js";

const port = Number(process.env.PORT ?? 3000);
createApp().listen(port, () => console.log(`Bookings API stub on http://localhost:${port}/v1`));

The tests on node:test and supertest check not only status codes but also that bodies match the Booking, BookingPage and Problem schemas — these are contract tests.

test/api.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { randomUUID } from "node:crypto";
import request from "supertest";
import { createApp } from "../src/app.js";
import { schemaErrors } from "../src/contract.js";

const AUTH = { Authorization: "Bearer test-account" };
const key = () => `"${randomUUID()}"`;
const slot = (h) => `2026-11-02T${String(h).padStart(2, "0")}:00:00.000Z`;
const draft = (h = 10) => ({ master_id: "m-1", service: "haircut", starts_at: slot(h) });

// Contract check: the body matches the schema from openapi.yaml.
function matches(name, body) {
  assert.deepEqual(schemaErrors(name, body), [], `${name}: ${JSON.stringify(body)}`);
}

function isProblem(res, status) {
  assert.equal(res.status, status);
  assert.match(res.headers["content-type"], /^application\/problem\+json/);
  assert.equal(res.body.status, status);
  matches("Problem", res.body);
}

test("POST creates a booking: 201, Location, ETag, body matches the contract", async () => {
  const res = await request(createApp()).post("/v1/bookings")
    .set(AUTH).set("Idempotency-Key", key()).send(draft());
  assert.equal(res.status, 201);
  assert.equal(res.headers.location, `/v1/bookings/${res.body.id}`);
  assert.equal(res.headers.etag, '"v1"');
  matches("Booking", res.body);
});

test("retry with the same key and body returns the same booking, no duplicate", async () => {
  const app = createApp();
  const k = key();
  const first = await request(app).post("/v1/bookings").set(AUTH).set("Idempotency-Key", k).send(draft());
  const retry = await request(app).post("/v1/bookings").set(AUTH).set("Idempotency-Key", k).send(draft());
  assert.equal(retry.status, 201);
  assert.equal(retry.body.id, first.body.id);
  const list = await request(app).get("/v1/bookings").set(AUTH);
  assert.equal(list.body.data.length, 1);
});

test("same key with another body is 422, missing key is 400", async () => {
  const app = createApp();
  const k = key();
  await request(app).post("/v1/bookings").set(AUTH).set("Idempotency-Key", k).send(draft(10));
  isProblem(await request(app).post("/v1/bookings").set(AUTH).set("Idempotency-Key", k).send(draft(11)), 422);
  isProblem(await request(app).post("/v1/bookings").set(AUTH).send(draft(12)), 400);
});

test("invalid body is 400 with pointers; taken slot is 409", async () => {
  const app = createApp();
  const bad = await request(app).post("/v1/bookings").set(AUTH).set("Idempotency-Key", key())
    .send({ master_id: "m-1", starts_at: "tomorrow", price: 0 });
  isProblem(bad, 400);
  const pointers = bad.body.errors.map((e) => e.pointer).sort();
  assert.deepEqual(pointers, ["#/price", "#/service", "#/starts_at"]);

  await request(app).post("/v1/bookings").set(AUTH).set("Idempotency-Key", key()).send(draft(10));
  isProblem(await request(app).post("/v1/bookings").set(AUTH).set("Idempotency-Key", key()).send(draft(10)), 409);
});

test("cursor pagination walks every booking exactly once", async () => {
  const app = createApp();
  for (const h of [13, 9, 11, 10, 12]) {
    await request(app).post("/v1/bookings").set(AUTH).set("Idempotency-Key", key()).send(draft(h));
  }
  const seen = [];
  let cursor = null;
  do {
    const res = await request(app).get("/v1/bookings")
      .query({ limit: 2, sort: "-starts_at", ...(cursor && { cursor }) }).set(AUTH);
    assert.equal(res.status, 200);
    matches("BookingPage", res.body);
    seen.push(...res.body.data.map((b) => b.starts_at));
    cursor = res.body.next_cursor;
  } while (cursor);
  assert.deepEqual(seen, [13, 12, 11, 10, 9].map(slot));

  isProblem(await request(app).get("/v1/bookings").query({ limit: 500 }).set(AUTH), 400);
  isProblem(await request(app).get("/v1/bookings").query({ cursor: "garbage" }).set(AUTH), 400);
});

test("PATCH needs If-Match: 428 without it, 412 when stale, 200 when current", async () => {
  const app = createApp();
  const created = await request(app).post("/v1/bookings").set(AUTH).set("Idempotency-Key", key()).send(draft());
  const url = created.headers.location;
  const patch = (body, ifMatch) => {
    const req = request(app).patch(url).set(AUTH).set("Content-Type", "application/merge-patch+json");
    return (ifMatch ? req.set("If-Match", ifMatch) : req).send(JSON.stringify(body));
  };

  isProblem(await patch({ note: "window seat" }), 428);
  const ok = await patch({ note: "window seat" }, created.headers.etag);
  assert.equal(ok.status, 200);
  assert.equal(ok.body.note, "window seat");
  assert.equal(ok.headers.etag, '"v2"');
  isProblem(await patch({ status: "cancelled" }, created.headers.etag), 412);

  const cleared = await patch({ note: null }, ok.headers.etag);
  assert.equal("note" in cleared.body, false);
  matches("Booking", cleared.body);
});

test("unknown booking is 404, request without a token is 401", async () => {
  const app = createApp();
  isProblem(await request(app).get(`/v1/bookings/${randomUUID()}`).set(AUTH), 404);
  const anon = await request(app).get("/v1/bookings");
  isProblem(anon, 401);
  assert.equal(anon.headers["www-authenticate"], "Bearer");
});
terminal
$ npm test
> bookings-api@1.0.0 test
> node --test

✔ POST creates a booking: 201, Location, ETag, body matches the contract (31.7978ms)
✔ retry with the same key and body returns the same booking, no duplicate (7.2981ms)
✔ same key with another body is 422, missing key is 400 (9.4061ms)
✔ invalid body is 400 with pointers; taken slot is 409 (5.7477ms)
✔ cursor pagination walks every booking exactly once (21.0143ms)
✔ PATCH needs If-Match: 428 without it, 412 when stale, 200 when current (7.9507ms)
✔ unknown booking is 404, request without a token is 401 (2.9521ms)
ℹ tests 7
ℹ suites 0
ℹ pass 7
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 374.3534

Conditional requests from outside: without If-Match — 428, with the current ETag — 200 and the new version "v2", the stale ETag again — 412. The note was appended at the end of the object; clients must not depend on JSON field order.

demo-patch.sh
API=http://localhost:3917/v1
AUTH="Authorization: Bearer demo-token"
PATCH="Content-Type: application/merge-patch+json"
URL=$API/bookings/$1

# Without If-Match: 428
curl -s -w '\n' -X PATCH $URL -H "$AUTH" -H "$PATCH" -d '{"note":"first visit"}'
# With the current ETag: 200 and a new ETag
curl -si -w '\n' -X PATCH $URL -H "$AUTH" -H "$PATCH" -H 'If-Match: "v1"' -d '{"note":"first visit"}'
# The same stale ETag again: 412
curl -s -w '\n' -X PATCH $URL -H "$AUTH" -H "$PATCH" -H 'If-Match: "v1"' -d '{"status":"cancelled"}'
terminal
$ bash demo-patch.sh 9ff0c277-6654-4fd6-8282-75f1156b6564
{"type":"about:blank","title":"Precondition Required","status":428,"detail":"Send If-Match with the ETag from your last GET"}
HTTP/1.1 200 OK
ETag: "v2"
Content-Type: application/json; charset=utf-8
Content-Length: 204

{"id":"9ff0c277-6654-4fd6-8282-75f1156b6564","master_id":"m-1","service":"haircut","starts_at":"2026-11-02T05:00:00.000Z","status":"confirmed","created_at":"2026-10-12T02:25:52.357Z","note":"first visit"}
{"type":"about:blank","title":"Precondition Failed","status":412,"detail":"The booking has changed since you read it; GET it again"}
Actual output with transport headers removed

We checked the tests with mutations: disabling the fingerprint comparison, the If-Match check or key storage, or making the cursor comparison non-strict, makes the matching test fail. One mutation survived: removing id from the sort keeps the tests green, because none of them has two bookings at the same time. Closing that gap is the first exercise.

  • URLs are plural nouns, nested no deeper than collection/item/collection
  • Methods match their meaning: GET changes nothing, repeating PUT and DELETE is safe
  • Every operation documents its success response and 4xx errors; 201 returns Location
  • Every error is application/problem+json; domain errors have their own type
  • Lists are paginated from the first version, limit has an upper bound, ordering is deterministic
  • A POST that must not be duplicated (a payment, a booking) accepts Idempotency-Key
  • Updates are protected from lost updates with ETag and If-Match
  • A change either does not break clients or ships as a new major version, with Deprecation and Sunset on the old one
  • redocly lint passes in CI, and tests check responses against the contract schemas

Next: the same conventions in Spring Boot — in the Spring Boot REST API lesson — and the REST versus GraphQL choice in a separate comparison.

How this was verified5 · passed2 · not runchecked 11 October 2026

“Run” means the code was executed in a scratch project or container and the output in the article comes from that run. “Partial” means it was checked against stubs instead of external services. “Not run” means it needs a device, an account or a key; the steps were checked against the official documentation. The verification project files and run logs are kept in the InterLAB archive.

Tell us about your task — we'll review your request and get back to you

Got a project?
We'll scope and estimate it — for free.

Tell us about the product or the problem. After reviewing the task we'll come back with an architecture, a timeline and a price for the first version.

  • Scope and risks estimated before we start
  • NDA — yours or ours
  • Work starts within a week

Please do not send passwords, access keys or source code through this form — we will agree a safe way to share access separately.

By clicking the button you agree to the processing of your personal data.