← Over the wire · all materialsReference · v1.0
API reference

Harbour Market API

A JSON API for the Harbour Night Market: stalls, reviews, saved stalls and orders. It runs on the course server, and everyone has their own market on it, named after their GitHub username:

https://harbour-api.lopin.me/<your-github-username>/api

That's the base URL; every path on this page (/api/stalls, /api/reviews/:id…) is relative to your market, so GET /api/stalls means GET https://harbour-api.lopin.me/<you>/api/stalls. In code, put the base URL in one constant and keep paths relative to it: API = "https://harbour-api.lopin.me/<you>/api", then getJSON("/stalls"), not "/api/stalls" (that would request …/api/api/stalls, a 404). The control page harbour-api.lopin.me checks your URL, sets chaos and resets your market. The same implementation also runs in the exercise editors and the request lab.

Conventions

Endpoints

  1. GET /api/stalls
  2. GET /api/stalls/:id
  3. POST /api/stalls 🔑
  4. PATCH /api/stalls/:id 🔑
  5. DELETE /api/stalls/:id 🔑
  6. GET /api/stalls/:id/reviews
  7. POST /api/stalls/:id/reviews
  8. GET · DELETE /api/reviews/:id
  9. GET /api/saved · PUT · DELETE /api/saved/:id
  10. POST · GET /api/orders
  11. Headers this API uses

The Stall object

{
  "id": "mezcal",              // a slug, never changes
  "name": "Mezcal Moon",
  "tag": "drinks",             // "food" | "music" | "crafts" | "drinks"
  "price": 9,                  // whole euros, 0 = free
  "soldOut": true,
  "featured": true,            // only on some stalls
  "blurb": "Small-batch mezcal and …",
  "rating": 3.7,               // average of its reviews, one decimal, null if none
  "reviewCount": 3
}

GET/api/stalls

Every stall. Filtering, searching, sorting and pagination happen on the server through query parameters, which can be combined.

ParameterExampleEffect
tag?tag=foodonly that tag; an unknown tag is 400
q?q=tacocase-insensitive search in names and blurbs. Short queries are answered more slowly (up to ~1 s for one letter)
sort?sort=price ?sort=-ratingname, price or rating; a leading - sorts descending; anything else is 400
limit, page?limit=4&page=2pagination (limit 1–50, default 4 when either is present)

200 · an array of Stall objects. Headers: X-Total-Count (matches before pagination), and with pagination Link: </api/stalls?…page=3>; rel="next", <…>; rel="prev".

GET/api/stalls/:id

200 · one Stall. 404 if there's no stall with that id.

POST/api/stalls 🔑 admin

Body: { "name", "tag", "price", "blurb" }, all required; soldOut optional. The id is made from the name ("Crêpe Escape" → "crepe-escape").

201 · the Stall, with Location: /api/stalls/:id. 401 no or invalid token · 403 a vendor token. 409 a stall with that id exists. 422 fields: name 1–40 characters, tag one of the four, price integer 0–100, blurb up to 200 characters, soldOut boolean.

PATCH/api/stalls/:id 🔑 admin or that stall's vendor

Body: only the fields to change, same rules as POST. Admin token, or the stall's own vendor token. 200 · the updated Stall. 401 no or invalid token · 403 another vendor's token · 404 · 422.

fetch("/api/stalls/mezcal", {
  method: "PATCH",
  headers: { "Content-Type": "application/json", Authorization: "Bearer harbour-admin-2026" },
  body: JSON.stringify({ soldOut: false }),
});

DELETE/api/stalls/:id 🔑 admin

204 · no body; the stall's reviews and its saved entry go too. 401 · 403 a vendor token · 404.

GET/api/stalls/:id/reviews

200 · an array of reviews, newest first, with X-Total-Count. 404 unknown stall.

{ "id": "r12", "stallId": "mezcal", "author": "Rosa", "rating": 5, "text": "Ask for the espadín.", "createdAt": "2026-10-16T16:22:00.000Z" }

POST/api/stalls/:id/reviews

Body: { "author", "rating", "text" }. Other fields are ignored.

FieldRuleMessage on a 422
authorstring, 1–40 characters after trimmingRequired, up to 40 characters.
ratinginteger 1–5, a numberA whole number from 1 to 5. / A number, not a string ("5").
textstring, 10–280 characters after trimming10–280 characters.

201 · the review as stored (with id and createdAt), and Location: /api/reviews/:id. 400 invalid JSON · 404 unknown stall · 415 not JSON · 422 { "fields": { … } }.

GETDELETE/api/reviews/:id

GET: 200 · the review. DELETE: 204. Both 404 for an unknown id. (No ownership check: a course simplification. A real API would only let the author delete.)

GETPUTDELETE/api/saved · /api/saved/:id

One list per server: everyone using the same server shares it.

POSTGET/api/orders

{ "id": "o1", "stallId": "taco-bike", "stallName": "Taco Bike", "name": "Ana", "placedAt": "2026-10-16T19:50:11.020Z", "status": "preparing" }

Headers this API uses

HeaderDirectionWhat it says
Content-Typeboththe body's format; requests with a body must send application/json
Acceptrequestthe formats the client can read (this API always answers JSON)
AuthorizationrequestBearer <token> for the routes that change stalls
WWW-Authenticateresponsesent with a 401: which scheme to use
Locationresponsethe URL of a created resource (201)
Allowresponsethe methods a path supports (405)
X-Total-Count · Linkresponsepagination: the total, the next and previous pages
Retry-Afterresponseseconds to wait after a 503
Cache-Controlresponseno-store: API responses are never cached
Origin · Access-Control-*bothCORS: Access-Control-Allow-Origin: * on every response; Access-Control-Expose-Headers lets your page read Location, X-Total-Count, Link, Retry-After, Allow

GET/api

200 · the API's name, version and resources. A good first request to check that the server is up.