Escena Viva is fast and measured. What it has left is not a performance problem but a design problem. Its API grew by accumulation, module after module: first a hand-made server in M4, then Express routes in M6, then authentication in M8. Along the way, verbs appeared in URLs, status codes were chosen by eye, listings shipped without pagination, there was no versioning policy, and one doubt we explicitly left open in Module 4: what happens when Marc sends the same POST /api/orders twice because he lost coverage on the Festival de Jazz opening night. This lesson turns that API into a well-designed API, with justified criteria and not out of aesthetic taste: an API is a contract that other people program against and that you cannot break whenever you feel like it.

Contents

  1. What REST really is
  2. The Richardson maturity model and HATEOAS
  3. Designing resources and non-CRUD actions
  4. HTTP methods and their guarantees
  5. The idempotency key
  6. Status codes used with judgment
  7. Response and error format
  8. Pagination, filtering, sorting and field selection
  9. Versioning and deprecation policy
  10. HTTP caching and conditional requests
  11. Documentation with OpenAPI
  12. Redesigning the Escena Viva API

  1. What REST really is

REST (Representational State Transfer) is an architectural style described by Roy Fielding in his year-2000 doctoral thesis. It is not a format, it is not JSON and it is not "using HTTP verbs": it is six constraints.

Constraint What it requires How Escena Viva meets it
Client-server Separation of responsibilities and interfaces The API knows nothing about the web interface
Stateless Every request carries everything it needs JWT access tokens (M8); the Redis session is a conscious violation
Cacheable Responses are labeled as cacheable or not Cache-Control and ETag on reads
Uniform interface URIs, representations, self-descriptive messages, hypermedia The constraint we fall shortest on
Layered system The client cannot tell whether it is talking to the origin server or a proxy A reverse proxy and a cache in between (M11)
Code on demand (optional) The server may send executable code Not used, and almost nobody uses it

The stateless constraint is the one with the most practical consequences and the one most often violated. It is literally what makes the whole of Module 10 possible: if every request stands on its own, any of the seven workers from lesson 10-01 can serve it, and that is why SCHED_RR distribution works. When in 10-03 we moved sessions to Redis, what we did was externalize the state so the server would remain interchangeable. A server with state in memory does not scale horizontally: it is the same lesson seen from the other side.

  1. The Richardson maturity model and HATEOAS

Leonard Richardson proposed a four-level scale to place yourself honestly:

Level What characterizes it Example
0 / 1 A single endpoint as a tunnel; or resources with URIs but everything over POST POST /api with { "action": ... }; POST /events/evt-003/buy
2 HTTP methods and status codes with their semantics GET /events/evt-003, DELETE /orders/ord-77 → 204
3 HATEOAS: the response includes links to the possible actions The order response carries the cancel and tickets links

Almost no API called REST gets past level 2, and it is worth saying so without drama: level 2, done well, is an excellent design and it is the realistic goal. HATEOAS (Hypermedia as the Engine of Application State) means the client discovers what it can do from the response itself, instead of having URLs hard-coded:

{
  "id": "ord-77", "status": "paid", "totalCents": 9000,
  "_links": {
    "self":   { "href": "/api/v1/orders/ord-77" },
    "cancel": { "href": "/api/v1/orders/ord-77/cancellation", "method": "POST" }
  }
}

Its real value is that the links express the state: if the order is already cancelled, the cancel link does not appear, and the client does not have to replicate the business rules to know which button to show. The reason almost nobody reaches level 3 is that real clients (a React application, a mobile app) do not navigate hypermedia: they have the routes written in their code and gain nothing. A practical recommendation for Escena Viva: a solid level 2, with links where they pay off (state-conditioned actions and page navigation), without faking level 3 with self links nobody uses and without giving up links when they remove duplicated logic from the client.

  1. Designing resources and non-CRUD actions

Rule Yes No
Plural nouns, not verbs /events/evt-003 /getEvent/evt-003
Lower case and hyphens /sold-out-sessions /soldOutSessions
No format extension, no trailing slash /events + Accept /events.json, /events/
Hierarchy only where there is real ownership /events/evt-003/sessions /venues/ribera/events/evt-003/sessions/ses-003-1

On hierarchy, the criterion is existence dependency. A session does not exist without its event, so /events/evt-003/sessions is correct for listing them; but a specific session has an identity of its own (ses-003-1) and must also be reachable at /sessions/ses-003-1. The usual rule: nest at most one level for the listing inside the parent, and expose the child resource at its own root for direct access, because hierarchies of three or more levels force the client to know the whole chain of identifiers for nothing. And now the hard question: how do you model cancelling an order or publishing an event? They are neither creations nor deletions, they are state transitions, and there are three legitimate options plus one that is not:

Option Example When
A sub-resource representing the action POST /orders/ord-77/cancellation The action has data of its own or leaves a record you can query
PATCH on the status field PATCH /events/evt-003 → { "status": "published" } The transition is simple and has no side effects
A collection of transitions POST /orders/ord-77/transitions → { "type": "cancel" } Many transitions on the same entity
~~A verb in the URL~~ ~~POST /orders/ord-77/cancel~~ Never: it breaks the uniform interface

In Escena Viva we choose the first one for cancellation, and the reason is concrete: a cancellation is a business entity, with a reason, a date, a refunded amount and who requested it, which the back office will want to consult. GET /api/v1/orders/ord-77/cancellation returns that record, and that would not be possible with a PATCH. To publish an event, by contrast, we use PATCH, because it is a simple field change with no associated data.

  1. HTTP methods and their guarantees

Every method has three properties that the standard defines and that internet infrastructure takes at face value: proxies, browsers and client libraries retry idempotent methods automatically.

Method Safe Idempotent Cacheable Use in Escena Viva
GET / HEAD Yes Yes Yes Read the catalog, an event, an order; check the ETag
OPTIONS Yes Yes No CORS (M6)
POST No No Rarely Create an order, cancel, authenticate
PUT / DELETE No Yes No Replace an event; delete a draft
PATCH No No by default No Partial modification

The exact definitions matter. Safe means it does not modify server state: a GET that deletes something is a serious bug, because any crawler or browser prefetcher will trigger it. Idempotent means running it N times leaves the system as it would be after running it once: DELETE /events/evt-009 twice leaves the event deleted and the second one returns 404, but the state is the same; idempotent does not mean "returns the same thing". And cacheable means the response may be stored and reused. As for PUT versus PATCH, PUT replaces the whole resource (whatever you do not send is deleted) and PATCH modifies partially; the problem is that PATCH does not define its own format, so you have to say which one you use:

PATCH /api/v1/events/evt-003 HTTP/1.1
Content-Type: application/merge-patch+json

{ "title": "Festival de Jazz de Primavera 2026", "shortDescription": null }

application/merge-patch+json (RFC 7386) is the reasonable format: the fields present are assigned, and null means "delete this field". Its limit is that it cannot genuinely set a field to null nor modify a specific element of an array; that is what application/json-patch+json (RFC 6902) is for, with explicit operations, far more powerful and far more awkward. Recommendation: merge-patch unless you need the other one. And a note on PATCH and idempotency: { "title": "X" } is idempotent in practice; what is not are relative operations such as { "increaseCapacity": 10 }, which are best avoided.

  1. The idempotency key

Here we resolve the Module 4 doubt. A real scenario: on the Festival de Jazz opening night, Marc taps "Buy 2 tickets for ses-003-1". The request reaches the server, is processed, order ord-77 is created... and the response is lost because his phone switched cell towers. The client retries. Two orders are created and Marc pays twice. Since POST is not idempotent by definition, the infrastructure cannot help us, and the industry-standard solution (Stripe, PayPal, and now an IETF draft) is a header:

POST /api/v1/orders HTTP/1.1
Content-Type: application/json
Idempotency-Key: 8f14e45f-ea0c-4f2e-9b3d-2c1a7e5d0b91

{ "sessionId": "ses-003-1", "quantity": 2 }

The client generates the key (a UUID) before the first attempt and reuses it on every retry of that same operation. The server uses it like this:

'use strict';

const crypto = require('node:crypto');
const { getRedisClient } = require('../db/redis.js');
const { ApiError } = require('../errors.js');

const TTL = 24 * 3600;

// Idempotency middleware for non-idempotent POST operations.
const createIdempotency = ({ redis = getRedisClient() } = {}) =>
  async function idempotency(req, res, next) {
    const key = req.get('Idempotency-Key');
    if (!key) return next(); // optional; it can be required on purchases

    // The body fingerprint stops the same key being reused for a different
    // request, which would be a client bug.
    const fingerprint = crypto.createHash('sha256')
      .update(JSON.stringify(req.body || {})).digest('hex');
    const redisKey = `idem:${req.user?.id || 'anonymous'}:${key}`;
    const inProgress = JSON.stringify({ status: 'in-progress', fingerprint });

    // Atomic SET NX: only the first one to arrive reserves the operation.
    if (!(await redis.set(redisKey, inProgress, 'EX', TTL, 'NX'))) {
      const stored = JSON.parse(await redis.get(redisKey));
      if (stored.fingerprint !== fingerprint) {
        return next(new ApiError('IDEMPOTENCY_KEY_REUSED', 422,
          [{ field: 'Idempotency-Key', detail: 'The key was already used with another body.' }]));
      }
      if (stored.status === 'in-progress') {
        // The first attempt is still running: 409, and let it retry.
        res.set('Retry-After', '2');
        return next(new ApiError('OPERATION_IN_PROGRESS', 409, []));
      }
      // We replay the original response as it was, with no second charge.
      res.set('Idempotent-Replay', 'true');
      return res.status(stored.status).json(stored.body);
    }

    // We are the first one. We intercept json() to store the response.
    // 5xx are not memoized: the retry must be able to run.
    const originalJson = res.json.bind(res);
    res.json = (body) => {
      const record = JSON.stringify({ status: res.statusCode, body, fingerprint });
      (res.statusCode < 500
        ? redis.set(redisKey, record, 'EX', TTL)
        : redis.del(redisKey)).catch(() => {});
      return originalJson(body);
    };
    return next();
  };

module.exports = { createIdempotency };

Four decisions worth justifying. The body fingerprint prevents a client with a programming bug from reusing the key for another purchase. The in-progress state covers the race between two simultaneous requests. 5xx errors are not memoized, because a transient failure must be retryable. And the 24-hour TTL bounds the growth without shutting out reasonable retries. All of this combines with the consumer idempotency from lesson 10-03: the key protects the entry to the API, and jobId protects the execution of the job.

  1. Status codes used with judgment

Code When In Escena Viva
200 OK A read, or a modification with a body GET /events
201 Created A resource was created; the Location header is mandatory POST /orders
202 Accepted Accepted for later processing POST /orders/ord-77/tickets (queue, 10-03)
204 No Content Success with no body DELETE /events/evt-009
207 Multi-Status A batch operation with mixed results POST /orders/batch
304 Not Modified If-None-Match matches An unchanged catalog (M4)
400 / 401 / 403 Malformed / no credentials / no permission Broken JSON; expired token; Bóveda touching evt-003
404 Not Found It does not exist, or it must not be known to exist Another user's order
409 Conflict A conflict with the current state Insufficient capacity; cancelling an already cancelled order
412 Precondition Failed If-Match does not match A lost update avoided
422 Unprocessable Content Valid syntax, invalid semantics quantity: 0, a date in the past
429 / 500 / 503 Limit exceeded; internal error; unavailable purchaseLimit; an unexpected failure; the database is down

The antipattern to eradicate is answering 200 OK with { "error": ... } inside. It breaks everything between the client and you: proxies cache an error as though it were a valid response, client libraries do not throw, monitoring counts 0% errors while the system burns, and automatic retries never trigger. The status code is part of the message, not decoration. The 400 / 422 distinction is the one that raises the most doubts: 400 if I could not understand the request (broken JSON, a missing header); 422 if I understood it perfectly but cannot accept it (quantity: -3 is a valid number and an impossible quantity), so almost every zod validation error (M6) is a 422. The 409, in turn, has a very specific use: trying to buy 5 tickets when 3 are left is not a validation error —5 is a perfectly valid quantity— but a conflict with the resource's current state, and moreover an error that can disappear if somebody cancels their order, which the client needs to know.

  1. Response and error format

Envelope or not? Returning the resource directly ({ "id": "evt-003", ... }) is clean and it is what most people do; an envelope ({ "data": ..., "meta": ... }) lets you add metadata without touching the resource. Inconsistency is the only unacceptable option. Escena Viva's decision: the bare resource on detail endpoints, an envelope on collections, because a collection unavoidably needs pagination metadata.

{
  "data": [{ "id": "evt-003", "title": "Festival de Jazz de Primavera" }],
  "meta": { "total": 3, "limit": 20, "nextCursor": "ZXZ0LTAwMw==" },
  "links": { "next": "/api/v1/events?cursor=ZXZ0LTAwMw==&limit=20" }
}

Errors. RFC 9457 (Problem Details for HTTP APIs, which supersedes 7807) defines a standard format, served with Content-Type: application/problem+json:

{
  "type": "https://escenaviva.test/errors/insufficient-capacity",
  "title": "Insufficient capacity",
  "status": 409,
  "detail": "You requested 5 tickets and 3 are left for session ses-003-1.",
  "instance": "/api/v1/orders",
  "available": 3
}

Compared with Escena Viva's own format:

Aspect Escena Viva's format RFC 9457
Shape { error: { code, message, status, details } } A flat object with type, title, status, detail
Stable identifier code (INSUFFICIENT_CAPACITY) type (a URI)
Field errors details: [{ field, detail }] A custom extension (errors)
Tooling None Recognized by libraries and gateways
Documentation Separate The type is a URL with the explanation

The honest decision: we keep our format, because there are clients that already depend on it and breaking it would be a major contract break, but we add Content-Type: application/problem+json with the type, title and status aliases as additional fields, so standard tooling understands the response and our clients keep working. It is a progressive convergence, not a big-bang migration; if you were starting from scratch today, use RFC 9457 directly. And a security rule inherited from M8: the error never leaks internal details —no stack traces, no table names, no SQL— but it does include the requestId (from the request-id.js middleware) so the user can quote it to support and you can correlate it with the log.

  1. Pagination, filtering, sorting and field selection

For pagination there are two schools:

Aspect Offset (?page=3&limit=20) Cursor (?cursor=ZXZ0...&limit=20)
Jump to page 47 / total Yes / easy with COUNT No, sequential only / expensive or nonexistent
Cost in the database OFFSET 10000 reads and discards 10,000 rows An index + WHERE id > cursor: constant
Changing data Items are skipped and repeated Stable
Client comprehension Immediate Requires explanation

The decisive argument is the changing-data one: if Lucía looks at page 1 of the catalog sorted by sales, tickets get sold and then she asks for page 2, with offset she will see repeated items and skip others, because the order changed under her feet. With a cursor that does not happen, because it encodes a stable position in the ordering.

'use strict';

// The cursor encodes the sort fields, not a row number.
const encodeCursor = ({ startsAt, id }) =>
  Buffer.from(JSON.stringify({ startsAt, id })).toString('base64url');

// An invalid cursor is answered with a 400 (it returns undefined).
const decodeCursor = (cursor) => {
  if (!cursor) return null;
  try { return JSON.parse(Buffer.from(cursor, 'base64url').toString('utf8')); }
  catch { return undefined; }
};

// The id tie-breaker is mandatory: without it, two events with the same
// date can appear twice or vanish between pages.
const buildClause = (cursor) => (!cursor ? {} : {
  where: { [Op.or]: [
    { startsAt: { [Op.gt]: cursor.startsAt } },
    { startsAt: cursor.startsAt, id: { [Op.gt]: cursor.id } },
  ] },
});

module.exports = { encodeCursor, decodeCursor, buildClause };

A practical recommendation: cursor for the public catalog (large, changing, sequential navigation) and offset for the admin panel (where the Sala Bóveda organizer does want to jump to page 7 and see the total). And in both cases, a maximum limit enforced by the server: limit=100000 cannot be a way of taking the API down. With the same consistency you define the query grammar, a single set of conventions for the whole API:

Need Convention Example
Equality filter field=value ?venueId=org-ribera
Multiple filter (OR) A comma-separated list ?status=published,soldOut
Range _from / _to suffixes ?startsAt_from=2026-04-01
Text search q ?q=jazz
Sorting sort, with - for descending ?sort=-startsAt,title
Fields and relations fields, include ?fields=id,title&include=sessions

All of this is validated with zod (M6), with an allowlist of sortable and expandable fields: without it, ?sort=secretColumn is an information leak and ?include=all is a denial of service. Field selection is also a real optimization, because it shrinks the serialized JSON that lesson 10-04 identified as a significant CPU consumer.

  1. Versioning and deprecation policy

Three approaches, with their trade-offs:

Strategy Example For Against
In the path /api/v1/events Visible, trivial to route and cache "Not very RESTful": the resource does not change identity when the version changes
In a header X-API-Version: 2 Stable URLs Invisible; requires Vary; forgotten while debugging
By media type Accept: application/vnd.escenaviva.v2+json The most faithful to REST Verbose; poorly supported by tooling and caches

Recommendation: in the path. It is what Stripe, GitHub and practically everyone else use, for a pragmatic reason: it is the only one a developer understands without reading documentation, and the only one that works well with proxies and caches. And a warning: versioning is expensive, because every live version is code you must maintain and test. Change inside v1 whenever the change is additive (new optional fields, new endpoints) and reserve v2 for real breakage: removing a field, changing a type, changing the meaning of something. When it is time to deprecate, there are standard headers:

HTTP/1.1 200 OK
Deprecation: Sun, 01 Nov 2026 00:00:00 GMT
Sunset: Sun, 01 May 2027 00:00:00 GMT
Link: <https://escenaviva.test/docs/v2-migration>; rel="deprecation"
Warning: 299 - "GET /api/v1/events is deprecated. Migrate to /api/v2/events before 2027-05-01."

Deprecation states since when it has been obsolete; Sunset (RFC 8594) states when it will stop working. A reasonable calendar: the announcement, six months of coexistence with warnings, a trial "blackout day" (a few hours of 410 responses so laggard clients find out), and removal. And something that gets forgotten: measure the usage of each version per client, because without that data switching v1 off is a leap into the void.

  1. HTTP caching and conditional requests

In Module 4 we implemented ETag and 304 by hand on top of node:http; now we apply them with judgment and combine them with the Redis cache from lesson 10-03.

'use strict';

const crypto = require('node:crypto');

// The catalog is public and the same for everyone: it can be cached in
// intermediate proxies as well as in the browser.
const createCatalogController = ({ catalog }) =>
  async function listEvents(req, res, next) {
    try {
      const { data } = await catalog.getCatalog(req.validatedData.query);
      const body = JSON.stringify(data);
      const etag = `"${crypto.createHash('sha1').update(body).digest('base64url')}"`;
      res.set('ETag', etag);
      // max-age: freshness in the client. s-maxage: in proxies.
      // stale-while-revalidate: serves a stale copy while refreshing.
      res.set('Cache-Control', 'public, max-age=30, s-maxage=60, stale-while-revalidate=120');
      // No body: zero bytes transferred.
      if (req.get('If-None-Match') === etag) return res.status(304).end();
      return res.type('application/json').send(body);
    } catch (error) {
      return next(error);
    }
  };

module.exports = { createCatalogController };

We now have three chained cache layers: Cache-Control in the browser (the request never even leaves), ETag/304 in the server (the request leaves but no body is transferred) and Redis (10-03), which avoids recomputing and querying PostgreSQL. A critical security rule: private data carries Cache-Control: private, no-store. A public on the response to GET /api/v1/orders/ord-77 would let a shared proxy store Lucía's orders and serve them to another user. And always Vary: Authorization on responses that depend on the user. Conditional requests to avoid the lost update. The scenario: two Auditorio Ribera organizers edit evt-003 at the same time. Both read the current version, both write; the second one stomps on the first one's changes and nobody notices. That is the lost update, and HTTP solves it with optimistic concurrency control:

GET /api/v1/events/evt-003        → 200 OK, ETag: "v7-a3f2c1"

PATCH /api/v1/events/evt-003
If-Match: "v7-a3f2c1"
Content-Type: application/merge-patch+json
{ "totalCapacity": 520 }
                                  → 412 if somebody already changed it to "v8-..."

A createRequireIfMatch({ getEtag }) middleware implements it in three checks: if the If-Match header is missing, it answers 428 (Precondition Required), which tells the client its request is well formed but that the API demands a condition; if the resource does not exist, 404; and if the tag received does not match the current one (and is not *), it throws ApiError('VERSION_CONFLICT', 412).

  1. Documentation with OpenAPI

An API with no documentation cannot be used; an API with hand-written documentation lies, because nobody updates it when the code changes. The solution is for the contract and the code to share a single source, and in Escena Viva we already have that source: the zod schemas in src/schemas/ that validate every request. With zod-to-openapi they become the OpenAPI document, with the guarantee that what is documented is exactly what is validated.

'use strict';

const { OpenApiGeneratorV31, extendZodWithOpenApi } = require('@asteasolutions/zod-to-openapi');
const { z } = require('zod');
const swaggerUi = require('swagger-ui-express');
const { schemaRegistry } = require('../schemas/registry.js');

extendZodWithOpenApi(z);

const generateDocument = () =>
  new OpenApiGeneratorV31(schemaRegistry.definitions).generateDocument({
    openapi: '3.1.0',
    info: { title: 'Escena Viva API', version: '1.4.0' },
    servers: [{ url: 'https://api.escenaviva.test/api/v1' }],
  });

// Served alongside the API: the documentation travels with the code.
function mountDocumentation(app) {
  const document = generateDocument();
  app.get('/api/v1/openapi.json', (req, res) => res.json(document));
  app.use('/api/v1/docs', swaggerUi.serve, swaggerUi.setup(document));
}

module.exports = { generateDocument, mountDocumentation };

This way the contract becomes verifiable instead of a promise: in the Module 9 integration tests you can validate every response against the OpenAPI schema, so a response that drifts from the contract breaks the build. That is the difference between documentation and a contract.

  1. Redesigning the Escena Viva API

Before After Decision
GET /api/events GET /api/v1/events?cursor=&limit=20 Version in the path; cursor pagination mandatory
GET /api/event/:id GET /api/v1/events/:id Consistent plural across the whole API
GET /api/events/:id/getSessions GET /api/v1/events/:id/sessions No verbs; hierarchy by real ownership
— GET /api/v1/sessions/:id The session has an identity of its own: direct access
POST /api/buy with 200 POST /api/v1/orders + Idempotency-Key → 201 + Location A resource, not an action; the code expresses the creation; no duplicate charges
POST /api/orders/:id/cancel POST /api/v1/orders/:id/cancellation A cancellation is a queryable entity
POST /api/orders/:id/generatePdf POST /api/v1/orders/:id/tickets → 202, and GET /api/v1/jobs/:id A queued job (10-03) with a status resource
POST /api/events/:id/publish PATCH /api/v1/events/:id (merge-patch) A simple transition with no data of its own
GET /api/events?all=true GET /api/v1/events?status=draft,published A consistent filter grammar
200 with { error: ... } Real 4xx/5xx + application/problem+json The status is part of the message
No ETag on the detail ETag + If-None-Match + If-Match Bandwidth savings and optimistic concurrency control
No documentation GET /api/v1/openapi.json and /api/v1/docs A contract generated from the zod schemas

Common Mistakes and Tips

  • Verbs in URLs (/getEvents, /buyTickets): the verb goes in the HTTP method.
  • 200 with an error inside. It breaks proxies, clients, retries and monitoring.
  • A GET that modifies, or listings with no maximum limit: in the first case a prefetcher can delete data for you, in the second ?limit=1000000 is a denial of service with parameters.
  • Confusing 401 with 403. 401 = I do not know who you are. 403 = I know who you are and you cannot.
  • Cache-Control: public on private data. A shared proxy can serve Lucía's orders to another user.
  • Versioning out of habit, or writing the documentation by hand: the first is avoidable maintenance, the second goes out of sync within weeks.
  • Tip: on POST requests that charge money, require Idempotency-Key and answer 400 if it is missing. It is safer than making it optional.
  • Tip: add one integration test per documented status code. If you document a 409, prove it.

Exercises

Exercise 1: idempotency under retry

Apply the idempotency middleware to POST /api/v1/orders. Write an integration test (M9) that sends the same request twice with the same Idempotency-Key and verifies: a single order created, the same response and Idempotent-Replay: true on the second one. Add a case with the same key and a different body.

Exercise 2: stable cursor pagination

Implement GET /api/v1/events with a cursor over (startsAt, id). Test it: ask for page 1 with limit=2, insert a new event with an earlier date, ask for page 2 and check that no item is repeated or lost. Repeat with offset and compare.

Exercise 3: avoid the lost update

Add an ETag to GET /api/v1/events/:id and require If-Match on PATCH. Simulate two organizers editing evt-003: both read, the first writes successfully, the second writes with the old tag. Verify the 412 and design the error response so the client knows what to do.

Solutions

Exercise 1. The first request returns 201 with Location: /api/v1/orders/ord-77. The second returns exactly the same body and the same 201, with Idempotent-Replay: true, and Order.count() is still 1. The subtle point is that the replayed response is a 201, not a 200: the original response is replayed as it was, because the client must not be able to tell a retry from a first attempt. With the same Idempotency-Key and a different body, the fingerprint does not match and a 422 with IDEMPOTENCY_KEY_REUSED is returned: it is a client bug, not a new purchase. For the race between simultaneous requests, SET NX guarantees only one wins and the other gets a 409 with Retry-After: 2.

Exercise 2. With a cursor, page 2 continues exactly where page 1 ended: the new event with the earlier date does not appear (it falls "behind" the cursor) and no item is duplicated. With offset, the new event shifts everything forward and the last item of page 1 reappears as the first of page 2. It is a silent failure that in the Festival de Jazz catalog would mean showing the same session twice and hiding another one. An indispensable detail: the cursor must include the id tie-breaker; with startsAt alone, two events on the same date cause exactly the problem you were trying to avoid.

Exercise 3. The first PATCH with If-Match: "v7-a3f2c1" matches and returns 200 with a new ETag. The second, with the old tag, gets a 412. The error must be actionable:

{
  "error": {
    "code": "VERSION_CONFLICT",
    "message": "The event was modified by another user since your last read.",
    "status": 412,
    "details": [{ "field": "If-Match", "detail": "Read it again and retry." }]
  }
}

Without If-Match, the second organizer would have stomped on the first one's change: evt-003's capacity would be left at the wrong value and nobody would know until opening night. An implementation note: the tag must derive from the resource's version (a version field incremented on every write, or updatedAt), not from the serialized body, because the body can vary with field selection or formatting.

Conclusion

The Escena Viva API has moved from "it works" to "it is well designed", and every decision has its reason. We know what REST really requires —and that the stateless constraint is exactly what makes the scaling from lesson 10-01 possible—, where Richardson's model places us and why a solid level 2 with selective links is the realistic goal. We model resources with plural nouns and justified hierarchies, and non-CRUD actions as sub-resources with an entity of their own. We know each HTTP method's guarantees, the difference between PUT and PATCH with merge-patch, and we have finally closed the Module 4 doubt with the idempotency key, which stops Marc paying twice when he loses coverage. We use status codes with judgment (201 with Location, 202 for the queued job, 409 for capacity, 422 for validation) and we have banished the 200 with an error inside. We converge towards problem+json without breaking our clients. We paginate by cursor where the data changes, with a consistent query grammar and server-enforced limits. We version in the path with a deprecation policy with dates. We chain three cache layers and avoid the lost update with If-Match and 412. And we generate the documentation from the zod schemas, so the contract cannot lie. With all of that, one question remains that none of these improvements answers: an event's screen in the Escena Viva app still needs three calls (the event, its sessions, the venue), or a single response with many fields that client does not use. REST design does not remove that dilemma, it only manages it. In the next lesson, GraphQL with Node.js, we will see an alternative where the client asks for exactly what it needs, what price is paid for it —caching, complexity, query limits— and why the right answer is almost never to replace REST, but to let the two coexist.

Node.js Course: From Beginner to Advanced

Module 1: Introduction to Node.js

Module 2: Core Concepts

Module 3: File System and I/O

Module 4: HTTP and Web Servers

Module 5: NPM and Package Management

Module 6: The Express.js Framework

Module 7: Databases and ORMs

Module 8: Authentication and Authorization

Module 9: Testing and Debugging

Module 10: Advanced Topics

Module 11: Deployment and DevOps

Module 12: Real-World Projects

© Copyright 2026. All rights reserved