<!--
  Published from backend/lambda-mcp/REST_API.md by backend/lambda-mcp/publish-dev-docs.mjs.
  Do not edit this copy — edit the source and re-run the publisher.
  `node publish-dev-docs.mjs --check` fails when this file drifts.
-->

# Scolavo REST API — Integration Guide

A plain authenticated **REST/JSON** front door to the same licensed curriculum the MCP
server serves. Same corpus, same entitlements, same metering, same licence — a different
door, built for **code that already knows what it wants**.

> This guide is the source of truth for the REST surface. The MCP surface has its own docs
> (`INTEGRATION_CUSTOM_APP.md` for custom apps, `/mcp/docs` for the hosted clients). The
> route contract below is locked by `test/rest.test.mjs`; the machine-readable version is
> `GET /v1/openapi.json`, and a test asserts the router cannot answer a route the schema
> does not document.

---

## 0. Which door should you use?

**MCP is for a model that is figuring out what it needs. REST is for code that already
knows.** That is the whole distinction, and it decides the bill.

| | MCP (`POST /mcp`) | REST (`GET /v1/…`) |
|---|---|---|
| Caller | a language model, choosing its next step | your program, running a known plan |
| Shape | JSON-RPC tool calls, a text copy *and* a structured copy of every response, token-budgeted | one JSON body, HTTP verbs, ETags, cursors |
| Errors | business denials arrive at **HTTP 200** as `isError` results (a host reads 4xx as a broken server and disables the connector) | **real status codes** — 403 entitlement, 429 quota with `Retry-After` |
| Batching | one asset per `get_download_url` call | **100 assets per `POST /v1/media/urls`** |
| Caching | none | `ETag` + `If-None-Match` → `304` |
| Good at | exploration, ad-hoc questions, "find me something about X" | ETL, syncs, search indexes, LMS imports, batch media pulls |

### Putting curriculum inside a chatbot?

There is a **third** door, and it is not this one. Wiring a chatbot to all 18 routes below
recreates the exact cost problem §0 is about: tool definitions are re-sent on every turn, and this
document's schema is ~41,000 tokens of them. **[`CHATBOT_KIT.md`](./CHATBOT_KIT.md)** is a curated
4-tool surface with a teaching system prompt — 2,039 tokens per turn, 20× smaller, generated from
the same schema so it cannot drift. Use it for conversational teaching; use this document for
pipeline work.

**If you are running a pipeline, use REST.** A B2B partner ran an ETL through the MCP
server and paid conversation prices for warehouse work; a third of their traffic was a
single-asset mint loop that this API answers in one call per hundred. That is why this
document exists. §3 is the recipe, §5 is the arithmetic.

### The one base URL

| | |
|---|---|
| Base URL | `https://mcp.scolavo.com/v1` |
| Schema | `GET https://mcp.scolavo.com/v1/openapi.json` (OpenAPI 3.1, **no auth**) |
| Token endpoint | `https://scolavo-auth.auth.us-east-1.amazoncognito.com/oauth2/token` (Cognito, direct) |
| Auth | `Authorization: Bearer <access_token>` on every route except `openapi.json` |
| Scopes | `scolavo-mcp/content.read` `…/content.export` `…/media.download` `…/testprep.read` `…/progress.read` `…/progress.write` |
| Bodies | JSON in, JSON out; every response except the `401` carries `x-request-id` (and every such error repeats it as `error.requestId` — quote it to support) |

### ⚠️ Gotchas that break most first integrations

1. **A valid token is not access.** Your org must exist, have a signed (or waived) **ECLA**
   and carry **content grants**. Without them every route returns `403 NO_ORGANIZATION` /
   `LICENSE_REQUIRED` / `TIER_NOT_LICENSED`. Ask Scolavo to provision the org and grants
   first — `INTEGRATION_CUSTOM_APP.md` §3.
2. **`403` is not always about you.** `insufficient_scope` means your *token* is missing a
   scope — re-request it at the token endpoint, it is free. `CONTENT_NOT_GRANTED` means your
   *licence* does not cover that subject, which is a commercial conversation. Branch on
   `error.code`, never on the status alone; the list is published as an enum in
   `openapi.json` (`components.schemas.Error`) — with one exception, the `401`'s
   `UNAUTHENTICATED`, which the schema does not yet carry. §6 is the authoritative table.
3. **Your licence shapes the corpus.** `/v1/subjects`, `/v1/tiers` and `/v1/search` return
   only what you are granted, and `total` is the licensed total. Do not diff it against a
   public marketing number and file a bug.
4. **Every collection in this API is called `items`.** Not `subjects`, not `hits`, not
   `classes`, not `urls` — `items`, on every route, beside `total / pageNum / totalPages /
   hasMore / nextCursor`. That is the whole reason you write one pager and reuse it. Reading
   for a route-specific name is the single most likely way to build a client that reports
   success while syncing nothing, because a missing key reads as an empty page, not as an
   error. `openapi.json` is authoritative and says the same thing on every response schema.
5. **`limit` is 1–100 and lists default to 25.** A 31-asset lesson's media listing is two
   pages at the default — pass `limit=100` or you will mint half a lesson and not notice.
   An out-of-range `limit` is **rejected with `400 BAD_REQUEST`, not clamped**: a client that
   believed it asked for 1,000 rows and silently got 100 would paginate wrong forever.
   `openapi.json` says the same, and a differential test now executes both the router and the
   schema's `validateParams()` helper on the same inputs so they cannot drift apart again.
6. **Cursors expire when the corpus rebuilds.** A cursor carries the `contentVersion` it was
   minted under; after a rebuild it returns `400 CURSOR_EXPIRED`. Restart the walk — do not
   "resume", or you will silently skip or duplicate rows.
7. **Slugs are matched EXACTLY.** No fuzzy matching (the MCP tools tolerate "chemistry" for
   "hs-science-chemistry" because a model guesses; code does not). A slug that exists in two
   tiers returns a `400` that names both — disambiguate with `?tier=`.
8. **Tokens live 900 s.** Cache and refresh on 401. Refreshing per request is itself a
   rate-limited call against Cognito.

---

## 1. Get a token (M2M / `client_credentials`)

Scolavo issues your org a dedicated **client_id + secret** (the secret is shown once). No
browser, no redirect URI.

```bash
# --data-urlencode, not -d: the scope list contains spaces, and a raw space in an
# x-www-form-urlencoded body is not something to leave to the server's goodwill.
TOKEN=$(curl -s "https://scolavo-auth.auth.us-east-1.amazoncognito.com/oauth2/token" \
  -u "$CLIENT_ID:$CLIENT_SECRET" \
  --data-urlencode "grant_type=client_credentials" \
  --data-urlencode "scope=scolavo-mcp/content.read scolavo-mcp/content.export scolavo-mcp/media.download" \
  | jq -r .access_token)

curl -s "https://mcp.scolavo.com/v1/license" -H "Authorization: Bearer $TOKEN" | jq
```

```json
{
  "orgId": "org-453b2b2bdf",
  "orgName": "Example Partner",
  "tier": "commercial",
  "status": "active",
  "contentGrants": ["college", "topics/ai-and-llms"],
  "quota": {
    "calls":         { "used": 1204, "limit": 500000 },
    "downloads":     { "used": 8810, "limit": 50000 },
    "exports":       { "used": 12,   "limit": 200 },
    "testprepitems": { "used": 0,    "limit": null }
  },
  "rateLimits": { "callsPerHour": null, "lessonReadsPerHour": 500, "urlMintsPerHour": 200 },
  "resetsAt": "2026-09-01T00:00:00.000Z",
  "eclaVersion": 1,
  "requiredEclaVersion": 1,
  "renewsAt": "2027-01-01T00:00:00Z",
  "contentVersion": "2026-08-04+4fd77ddd",
  "licenseUrl": "https://www.scolavo.com/legal/ecla"
}
```

A `null` limit means "no cap configured for your plan", not "zero". `requiredEclaVersion`
above `eclaVersion` is the one condition that turns every content route into
`403 LICENSE_REQUIRED`, so watch the pair rather than just the status.

- Access tokens live **900 s**. Cache them; refresh on 401.
- Ask only for the scopes you use. `content.export` and `media.download` are the two that
  spend real quota.
- M2M tokens carry **no learner identity** (`sub`), so `/v1/progress` returns
  `403 LEARNER_REQUIRED`. Progress is per-learner and needs an interactive
  (authorization_code) token — `INTEGRATION_CUSTOM_APP.md` Path B.
- `GET /v1/license` is **unmetered**, is not behind the entitlement gate, and answers even
  when you are out of quota — which is exactly when you need it. It is also the cheapest
  sync primitive you have (§3.4).

---

## 2. Routes

Sizes are **medians measured against the shipped corpus** — a 30-lesson sample spanning all
five tiers, where a class is 13 cards and 31 media assets and a subject is 30 classes.
Individual responses range roughly ±60% around these; they are typical, not guaranteed.

| Method + path | Scope | Typical response |
|---|---|---|
| `GET /v1/openapi.json` | *(public)* | ≈122 KB — the schema. Fetch once, revalidate with `If-None-Match` |
| `GET /v1/tiers` | `content.read` | ≈415 B — `items[] = {tier, label, count}` |
| `GET /v1/subjects?tier=&detail=&cursor=&limit=` | `content.read` | ≈2.4 KB brief · ≈5.4 KB standard · ≈24 KB full (25 rows) |
| `GET /v1/subjects/{subjectSlug}?tier=&detail=` | `content.read` | ≈1.1 KB — subject graph + `classCount`. The class LIST is not inlined: follow `classesUrl` |
| `GET /v1/subjects/{subjectSlug}/classes` | `content.read` | ≈4 KB per 25 classes, each with a ready `lessonId` |
| `GET /v1/lessons/{tier}/{subjectSlug}/{classNum}?detail=&include=` | `content.read` | ≈2.5 KB brief · ≈14 KB standard · ≈30 KB full |
| `GET …/{classNum}/transcript?include=markdown` | `content.read` | ≈15 KB — `items[]`, the narration split per card. Add `include=markdown` for the same words ALSO as one joined `transcript` string (≈31 KB total). They are two renderings of one thing, so the joined copy is opt-in rather than default |
| `GET …/{classNum}/quiz` | `content.read` | ≈2.7 KB (`items` = the quiz, plus `inlineChecks`; answer keys included) |
| `GET …/{classNum}/media?type=&limit=` | `content.read` | ≈3.5 KB (31 assets, `url: null`) — **paginated, default 25: pass `limit=100`** |
| `POST /v1/media/urls` | `media.download` | **≈1.25 KB per minted URL** (≈120 KB for a full batch of 100) — a SigV4 presigned URL is ~1,100 characters, so this is the one route where the response is mostly signature |
| `GET /v1/search?q=&tier=&subject=&cursor=&limit=` | `content.read` | ≈6.9 KB per 25 hits |
| `GET /v1/exports/subjects/{subjectSlug}?format=zip_url\|inline` | `content.export` | ≈1.3 KB (a signed URL); the zip itself is 49 KB–893 KB, median ≈500 KB. `format=inline` returns the bundle in the body instead (median ≈361 KB, max ≈656 KB, measured over all 125 subjects) and is the variant that carries an **ETag** — see §3.4 |
| `GET /v1/exports/classes/{tier}/{subjectSlug}/{classNum}` | `content.export` | ≈1.4 KB (the zip is ≈17 KB); `format=inline` returns it in the body and carries an **ETag** |
| `GET /v1/testprep` | `content.read` | ≈1 KB |
| `GET /v1/testprep/{test}/items?section=&cursor=&limit=` | `testprep.read` | ≈780 B per item (≈19 KB per 25; CAASPP items run ≈1.2 KB) |
| `GET /v1/license` | `content.read` | ≈600 B — **unmetered** |
| `GET /v1/progress?learnerId=&subjectSlug=` | `progress.read` | ≈260 B + ≈150 B per recorded class |
| `PUT /v1/progress/{tier}/{subjectSlug}/{classNum}` | `progress.write` | ≈225 B — body `{score?: 0-100, learnerId?}` |

### Common parameters

- **`detail`** = `brief` \| `standard` (default) \| `full`. It decides **how much**, never
  **which fields exist** — widening from `brief` to `full` never changes the shape, so you
  can start cheap and deepen later without a rewrite. It moves the price by roughly 10–12×:
  a lesson runs ≈2.5 KB / ≈14 KB / ≈30 KB, a 25-row subject page ≈2.4 KB / ≈5.4 KB / ≈24 KB.
  Those medians come from `measure-sizes.mjs`, sampling 200 lessons **in proportion to the
  corpus** — which is 41% College, and a College lesson (≈22 KB standard) is roughly three
  times a Primary one (≈7 KB). Sample evenly across tiers instead and the same corpus reports
  ≈9.9 KB, which is how four different "size of a lesson" figures ended up in circulation.
  If you are sizing a full sync, use the proportional figure; if you licence one tier, use
  that tier's.
  `full` on a subject row repeats the subject graph (tags, prerequisites, related subjects)
  identically on every row of a walk — fetch that once from `/v1/subjects/{subjectSlug}`
  instead. An unrecognised value is a `400` naming the parameter and listing the three legal
  ones — it never silently falls back to `standard`. On `/v1/subjects/{subjectSlug}` itself
  `detail` has **no effect at all**: the subject graph is returned at every level, the three
  responses are byte-identical (measured across all 125 subjects) and share ONE ETag, so
  widening it never costs a re-download. It is accepted for consistency, not for savings.
- **`include`** (lesson route, comma-separated): `quiz`, `inlineChecks`, `interactives`,
  `narrationText`, `videoTranscript`, `media`, `workedExample`; anything else is a `400` that
  lists the seven. Adding the one section you need on top of `standard` is almost always
  cheaper than jumping to `full`: `media` adds ≈2.7 KB of ids, `narrationText` more than
  multiplies the lesson by about 1.8×. `include=markdown` belongs to the *transcript*
  route only and is a `400` on the lesson route.
- **`limit`** 1–100, default 25 — out of range is a `400`, **not** a clamp; **`cursor`**
  opaque — pass `nextCursor` back verbatim and stop when `hasMore` is `false`. A cursor this
  API never minted is `400 BAD_REQUEST`; one minted under an older corpus is
  `400 CURSOR_EXPIRED`. Compare what you received against `total`, never against `limit`: a
  page can come back short (the testprep route shortens a page to your remaining item
  allowance rather than refusing it).
- **`tier`**, **`type`** (`video|audio|image`) and **`format`** (`zip_url|inline`) are strict
  enums: a wrong value is a `400` carrying `error.allowed`. `q` is required on `/v1/search`
  and capped at 200 characters.
- **`If-None-Match`** on a cacheable GET → `304` with an empty body (§3.4).

Every content response carries `contentVersion` and a lean licence envelope
`_license: {lic, tag}`. `tag` is a forensic marker identifying org + scope: keep it if you
persist our content, and expect it to be how a leak is traced back. Responses are
`Cache-Control: private` with `Vary: Authorization, Accept-Encoding` — they are per-licence
and must never land in a shared cache.

---

## 3. Recipe: sync the whole corpus, cheaply

The licensed corpus is **125 subjects / 3,680 classes / 113,844 media assets**. There are
two ways to pull it and they differ by a factor of ~96 in call volume.

### 3.1 The naive loop (do not build this)

```
for each subject:                       125 calls   (outline)
  for each class:                     3,680 calls   (lesson)
    GET …/media?limit=100             3,680 calls   (asset list — at the default limit=25
                                                       a 31-asset class costs two calls, not one)
      for each asset: mint one URL  113,844 calls   ← the N+1
                                    ─────────────
                                    121,329 calls, ~198 MB
```

It also burns **7,360 lesson-read units**. At a typical `lessonReadsPerHour` of 500 that is
a 15-hour floor before quota even enters the picture — and a full-corpus lesson walk trips
the enumeration alarm (§4), which auto-suspends the org and needs a human to reinstate it.

### 3.2 The cheap loop

```
GET /v1/license                              1 call  (unmetered) — read contentVersion
for each subject:
  GET /v1/exports/subjects/{slug}          125 calls — a zip: every class, every transcript,
                                                        the manifest and the media manifest
POST /v1/media/urls  (100 ids per call)  1,139 calls — only for assets you will actually fetch
                                        ────────────
                                          1,264 metered calls
```

**121,329 → 1,264 calls: 99.0% fewer requests, and zero lesson-read pressure** — an export
is not a lesson read, so the hourly velocity cap and the enumeration alarm never enter it.
The trade is honest and small: an export bills one download per class, so the bulk path
spends 3,680 extra download units — **3.2% more content quota to remove 99% of the calls.**

```bash
# One subject, everything in it, one call.
URL=$(curl -s "https://mcp.scolavo.com/v1/exports/subjects/ai-and-llms" \
  -H "Authorization: Bearer $TOKEN" | tee /dev/stderr | jq -r .url)
# → {"format":"zip_url","url":"https://…","expiresAt":"…","classCount":41,
#    "subjectSlug":"ai-and-llms","tier":"topics","contentVersion":"…","_license":{…}}
curl -sL "$URL" -o ai-and-llms.zip          # the URL is signed and lives ≤15 min
```

The bundle holds `manifest.json` (class list, per-class sha256, your org stamp),
`class-N.json` for every class, `class-N.transcript.md` where the class is narrated,
`media-manifest.json` (**every asset's id and location — so you need no media-listing
calls**) and `LICENSE.txt`. All 125 zips together are ≈60 MB. Use `?format=inline` for
gzipped base64 in the response body if your runner cannot follow a signed URL: measured over
all 125 subjects the median is ≈361 KB and the largest is ≈656 KB, comfortably inside the
gateway limit. `format=inline` is also the variant that carries an `ETag`, so a re-export of an
unchanged subject can cost one call and nothing else — see §3.4.

### 3.3 Batch-mint media — and only what you will fetch

```bash
curl -s -X POST "https://mcp.scolavo.com/v1/media/urls" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"mediaIds":["topics/ai-and-llms/1#definition#video","topics/ai-and-llms/1#definition#audio"],"expiresIn":900}'
```

```json
{
  "items": [
    {"mediaId":"topics/ai-and-llms/1#definition#video","type":"video","lessonId":"topics/ai-and-llms/1","url":"https://…","expiresAt":"2026-08-20T12:15:00.000Z"},
    {"mediaId":"topics/ai-and-llms/1#definition#audio","type":"audio","lessonId":"topics/ai-and-llms/1","url":"https://…","expiresAt":"2026-08-20T12:15:00.000Z"}
  ],
  "count": 2,
  "expiresAt": "2026-08-20T12:15:00.000Z",
  "contentVersion": "2026-08-04+4fd77ddd",
  "_license": {"lic":"ECLA-1-org-453b2b2bdf","tag":"K4M2XQ7ZB9"}
}
```

- **Cap: 100 ids per call.** 101 → `400 BATCH_TOO_LARGE`. Chunk client-side. Duplicate ids are
  **not** collapsed: each occurrence is minted and metered as its own download, so dedupe
  before you send.
- `items` comes back **in request order**, so you can zip it positionally against your input.
- **There is no partial success.** The batch is all-or-nothing by design — an id you are not
  licensed for fails the whole request with `403 TIER_NOT_LICENSED` / `CONTENT_NOT_GRANTED`,
  and an id that does not exist fails it with `404 UNKNOWN_MEDIA` naming the id. A per-item
  `errors[]` would be a licence warning a pipeline could ignore its way into a breach, so
  **branch on the status code**, fix the id, and resend. Ids may span lessons and subjects
  freely; every subject they touch is checked before anything is minted.
- `expiresIn` is 60–900 s (up to 3600 with the `longUrls` override). Above your cap it is
  **clamped**; below 60, or not an integer, it is **rejected with `400`**. URLs are
  deliberately short-lived — one that outlived a suspension would outlive the licence — and
  the response is `no-store`: never cache it, never expect a `304`, re-mint instead.
- **Batching is a discount on calls, never on content.** One download unit and one hourly mint
  unit are metered per URL **actually minted**, and a refused batch never bills a download:
  an unlicensed id costs nothing at all (it is denied before any meter moves), a bad id or an
  over-the-hourly-cap batch costs the `calls` unit only, and a batch stopped by the *monthly
  download quota* costs the `calls` unit plus the hourly mint units it had already reserved
  (those refill at the top of the hour; no download quota is lost). Each minted URL is written
  to the durable export ledger. **Mint at fetch time, not at plan time** — a pipeline that
  mints URLs for assets it never downloads pays full download quota for nothing, and that
  single mistake was a third of one partner's traffic.

### 3.4 Re-sync: the cheapest call is the one you skip

The corpus carries **one `contentVersion` for the whole catalogue** (e.g.
`2026-08-04+4fd77ddd`). It moves only when the corpus is rebuilt — a few times a year.

```bash
# The entire incremental sync, most of the time — and it needs no token at all:
curl -s -o /dev/null -w '%{http_code}\n' \
  "https://mcp.scolavo.com/v1/openapi.json" \
  -H 'If-None-Match: "<the etag you stored last time>"'
# 304  →  nothing changed. Stop.
```

`GET /v1/openapi.json` is the cheapest gate in the API and the only one that is
**unauthenticated and unmetered**: its ETag tracks `contentVersion`, so a `304` means the corpus
has not moved. Measured: **0 body bytes on the 304, no database reads, no `calls` unit.** The
`X-Scolavo-Content-Version` header on that same 304 tells you which build you are being told
about, so you can log it without parsing anything.

`GET /v1/license` answers the same question and is also unmetered, but it costs a token and
≈600 bytes because it reports live quota counters. Use it when you want the quota — before a
bulk job, after an unexpected 429 — and use the schema route when all you want to know is
whether to start.

```bash
V=$(curl -s "https://mcp.scolavo.com/v1/license" -H "Authorization: Bearer $TOKEN" | jq -r .contentVersion)
[ "$V" = "$LAST_SEEN" ] && exit 0     # nothing changed — 0 metered calls, ~600 bytes
```

Either way that is **1 call instead of 121,329.** Nothing else in this document matters as much.

Between rebuilds, `ETag` / `If-None-Match` buys you the same saving per resource:

```bash
curl -s -D - -o /dev/null "https://mcp.scolavo.com/v1/subjects?detail=brief" \
  -H "Authorization: Bearer $TOKEN" | grep -i ^etag
# etag: "8f2c…"
curl -s -o /dev/null -w '%{http_code}\n' \
  "https://mcp.scolavo.com/v1/subjects?detail=brief" \
  -H "Authorization: Bearer $TOKEN" -H 'If-None-Match: "8f2c…"'
# 304
```

A `304` costs **one call unit and nothing else**: no lesson read, no download, no
enumeration credit, no body. On a lesson-heavy incremental sync that is the difference
between paying your hourly read cap and not touching it.

**On an export it is worth far more than bytes.** `?format=inline` carries an ETag, so a
re-export of an unchanged subject costs one call instead of one download unit PER CLASS plus an
export unit. Measured over the whole corpus, unchanged:

| Re-export all 125 subjects, `format=inline` | requests | bytes | `downloads` | `exports` |
|---|---:|---:|---:|---:|
| without `If-None-Match` | 125 | 44,442,539 | **3,680** | **125** |
| with `If-None-Match` | 125 | **0** | **0** | **0** |

A plan with 5,000 monthly downloads and 50 monthly exports cannot afford the first row even
once. Store the ETag beside each bundle you keep and send it back.

⚠ **Five things about ETags that surprise people.**
- They are **derived, not hashed**: from `contentVersion` + route + params + your org + your
  grants, never from the response bytes. The single exception is `/v1/openapi.json`, which has
  no organization to derive from and is hashed from its own bytes.
- They are therefore **corpus-versioned, not per-subject** — when `contentVersion` moves,
  *every* ETag moves, even for subjects that did not change. Revalidation saves you nothing
  across a rebuild and everything between rebuilds.
- **Not every route has one, and on the export routes it depends on `format`.**
  `?format=inline` is conditionally cacheable; `?format=zip_url` (the default) is not, and
  neither are `POST /v1/media/urls` and `/v1/license`. Those bodies hold an expiring signed URL
  or live quota counters, so a `304` there would hand you something worse than bytes.
- **`If-None-Match: *` matches whatever is current**, so on a GET it answers `304` rather than
  serving the body. That is RFC-correct and catches people out — send the tag you stored, not
  a wildcard, unless a `304` is genuinely what you want.
- **What a 304 saves is not the same on every route.** On the lesson, subject, search and
  test-prep routes it is decided before any work happens, so it skips the read as well as the
  bytes. On `/v1/progress` the validator can only be computed after the learner record has been
  read, so the `304` saves the body and nothing else. `HEAD` is not supported anywhere (it
  answers `405` with `Allow: GET`) — the conditional GET is the cheap probe.

### 3.5 Ask for compressed bytes — it is free and it is the biggest single win

Send `Accept-Encoding` and the JSON comes back compressed. **Measured across every route
against the shipped corpus: 75.9% of the bytes, for nothing.** Most clients send the header
and decode the response without being told to — `curl --compressed`, Python `requests` and
`httpx`, Go's `net/http`, Node's `fetch` — so for many integrations this is already on and
you will never see the header. Check yours; a client that does not send it is paying 4× for
the same bytes.

```bash
curl -s --compressed -o /dev/null -w 'wire bytes: %{size_download}\n' \
  "https://mcp.scolavo.com/v1/subjects?detail=full&limit=100" -H "Authorization: Bearer $TOKEN"
# wire bytes: 14856      (101,426 uncompressed)
```

| Route | uncompressed | `gzip` | saved |
|---|---:|---:|---:|
| `POST /v1/media/urls` (100 ids) | 133,190 | 6,351 | **95.2%** |
| `GET /v1/subjects?detail=full&limit=100` | 101,426 | 14,856 | **85.4%** |
| `GET /v1/lessons/{…}/media?limit=100` | 3,661 | 577 | 84.2% |
| `GET /v1/progress` (60 classes) | 3,316 | 664 | 80.0% |
| `GET /v1/lessons/{…}/transcript?include=markdown` | 35,436 | 7,867 | 77.8% |
| `GET /v1/openapi.json` | 122,234 | 30,043 | **75.4%** |
| `GET /v1/search?q=energy&limit=100` | 25,455 | 5,753 | 77.4% |
| `GET /v1/testprep/{test}/items?limit=100` | 128,085 | 37,027 | 71.1% |
| `GET /v1/lessons/college/law-legal-writing/18` (`detail=standard`) | 13,355 | 4,417 | 66.9% |

`br` is offered too and is 1–3 points better on this corpus; it is used only if you name it,
because `Accept-Encoding: *` does not tell us you can decode brotli. `identity` and
`gzip;q=0` are honoured as the refusals they are.

**Four things worth knowing before you build around it.**
- **The `ETag` does not change.** It is derived from `contentVersion` + route + params +
  your org, never from the response bytes, so a validator you obtained uncompressed
  revalidates fine compressed and vice versa. That is also why every response carries
  `Vary: Accept-Encoding` — do not strip it in a proxy.
- **Small responses are not compressed.** Below ~384 bytes the codec frame costs more than it
  saves, so errors, `PUT /v1/progress` acknowledgements and `304`s come back as-is. There is
  no `Content-Encoding` header on those; do not assume one.
- **`format=inline` exports are not compressed either.** Their `data` field is *already*
  `gzip+base64` — that is what `encoding` says — so a second pass would cost CPU and add
  bytes. Un-base64 and gunzip `data` exactly as documented; nothing changed for that route.
- **Metering is unaffected.** Compression is a transport concern: `calls`, `downloads`,
  `exports` and `testprepitems` count the same units either way. This saves your bandwidth
  and your parse time, not your quota.

---

## 4. Metering

Counters are per org. Monthly meters reset at the top of the UTC month; velocity caps reset
at the top of the UTC hour. Read them any time at `GET /v1/license`.

| Meter | Period | What increments it |
|---|---|---|
| `calls` | month | **every `/v1` request that gets past the licence gate**, including a `304` and including a request that then 404s on a valid, licensed lesson id. Not `openapi.json`, not `/v1/license`, and **not a refusal raised before the gate** — a `401`, a `403` (scope or entitlement), a `400` on a parameter and a `404` on an unknown route are all free. |
| `downloads` | month | 1 per URL **actually minted** (a clean 100-id batch = 100; a refused batch = 0), plus 1 per class in an export — and none at all when an inline export answers `304` |
| `exports` | month | 1 per export call, whatever its class count. A `304` on `format=inline` bills none |
| `testprepitems` | month | 1 per item actually returned (a shortened page bills short). A `304` on a page you already hold bills none — the ETag accounts for the shortened size, so revalidation works under an allowance too |
| `lessonreads` | **hour** | 1 per lesson / transcript / quiz / media-list read, and per testprep page. Exports, batch mints and `304`s do not count. A `404` on a lesson route does. |
| `mints` | **hour** | 1 per URL minted — a 100-id batch consumes 100 of `urlMintsPerHour`, not one. Reserved before the monthly download meter runs, so a batch refused for monthly quota still spends them. |

Two more controls, because they look like outages:

- **Enumeration alarm.** Reading more than 40% of your *granted* lessons in one UTC day
  looks like corpus cloning: the org is auto-suspended and a human must reinstate it.
  Exports are the sanctioned bulk path and are exempt — if you need the whole corpus, §3.2
  is how you take it.
- **Suspension is immediate.** Entitlements are re-read from the database on every request,
  so a suspend or revoke lands on your very next call, not at your next token refresh.

### 429s and backing off

| `error.code` | Means | Do |
|---|---|---|
| `QUOTA_EXCEEDED` | a **monthly** meter is spent | stop. `Retry-After` is seconds until the month rolls and `error.quotaResetsAt` is the instant. Retrying in a loop cannot succeed — alert a human. |
| `RATE_LIMITED` | an **hourly** velocity cap (lesson reads or mints) | sleep `Retry-After` (≤ 3600 s) and resume. This is the one worth retrying. |
| `EXPORT_LIMIT` | the monthly export allowance is spent | stop; `error.exportsUsed` / `exportsLimit` say where you are. |

Every 429 carries `Retry-After` in seconds — honour the header rather than inventing a
backoff curve (`QUOTA_EXCEEDED` and `EXPORT_LIMIT` return the seconds to the month boundary,
`RATE_LIMITED` at most 3600). A refused call never bills **content**: a meter that refuses
does not increment, and a batch mint refused for a bad id, an unlicensed id or the hourly cap
consumes no download or mint units at all. Where it is refused decides what the attempt cost:
nothing at all when the licence denies it, the `calls` unit alone for a bad id or the hourly
cap, and the `calls` unit plus the hourly mint reservation when the monthly download quota is
what stopped it. `/v1/license` (unmetered) always shows you exactly where you stand.

---

## 5. What things cost

Read this before you write the loop, not after.

| Access pattern | calls | downloads | exports | lesson reads |
|---|---:|---:|---:|---:|
| Check whether anything changed (`/v1/license`) | **0** | 0 | 0 | 0 |
| Full catalogue listing (`detail=brief`, 125 subjects, `limit=100`) | 2 | 0 | 0 | 0 |
| One class, read | 1 | 0 | 0 | 1 |
| One class + transcript + quiz + media list | 4 | 0 | 0 | 4 |
| One 30-class subject, walked lesson by lesson | 61 | 0 | 0 | 60 |
| One 30-class subject, exported | **1** | 30 | 1 | **0** |
| 930 media assets (a 30-class subject), one at a time (MCP-style) | 930 | 930 | 0 | 0 |
| 930 media assets, batched at 100 | **10** | 930 | 0 | 0 |
| **Whole corpus, naive (§3.1)** | **121,329** | 113,844 | 0 | 7,360 |
| **Whole corpus, cheap (§3.2)** | **1,264** | 117,524 | 125 | **0** |
| Whole corpus, re-synced unchanged (gated on `contentVersion`) | **0** | 0 | 0 | 0 |
| Whole corpus re-exported unchanged, `format=inline`, no `If-None-Match` | 125 | **3,680** | **125** | 0 |
| Whole corpus re-exported unchanged, `format=inline` + `If-None-Match` | 125 | **0** | **0** | 0 |

Every operation in `openapi.json` also carries an `x-scolavo-cost` extension — typical
response bytes and the meters it moves — so a generated client can surface the price at the
call site. The shape of the bill: **calls are what a bad access pattern inflates by two
orders of magnitude; downloads track the content you actually take and barely move.** Batching and
exporting do not buy cheaper content — they buy back the 99% of requests that were never
about content at all.

Three rules that keep a pipeline cheap:

1. **Gate the whole run on `contentVersion`**, read from the unmetered `/v1/license`.
2. **Take bulk in bulk** — `/v1/exports/subjects/{slug}` instead of a lesson walk: fewer
   calls, no lesson-read velocity, no enumeration risk.
3. **Mint at fetch time, 100 at a time** — never speculatively.

And one that is orthogonal to all three: **send `Accept-Encoding` (§3.5).** It does not move a
single meter — quota counts units of work, not bytes — but it removes about three quarters of
the bytes those units carry. The two levers multiply.

---

## 6. Errors

Every error on every route has the same body:

```json
{"error":{"code":"CONTENT_NOT_GRANTED","message":"…","grant":"topics/ai-and-llms","requestId":"…"}}
```

Branch on `error.code`. Extra keys (`retryAfterSeconds`, `quotaResetsAt`, `grant`,
`parameter`, `allowed`, `hint`) are per-code and additive. `requestId` is also returned as
the `x-request-id` header on every response — quote it to support. The one exception is the
`401`: it is answered before a request id is minted, so it carries neither the header nor
`error.requestId`, and its code is `UNAUTHENTICATED` rather than an OAuth error name. Treat
"401 → get a new token" as the whole branch and you will not miss anything.

| Status | `error.code` | Meaning / fix |
|---|---|---|
| 400 | `BAD_REQUEST` | a bad parameter — `error.parameter` names it and `error.allowed` lists the valid values |
| 400 | `BAD_LESSON_ID` | the path is not `{tier}/{subjectSlug}/{classNum}`; take ids from `/classes` or `/search`, never build them |
| 400 | `BAD_LESSON_ID` (on a `mediaId`) | a `mediaId` is `{lessonId}#{cardId}#{type}`; a malformed one fails on the lessonId in front of the `#`. Take them from the media route or the export bundle's `media-manifest.json`, never by hand |
| 400 | `CURSOR_EXPIRED` | the corpus moved under your walk — restart it |
| 400 | `BATCH_TOO_LARGE` | more than 100 `mediaIds` — chunk |
| 400 | `AMBIGUOUS_SUBJECT` | the slug exists in more than one tier; `error.tiers` names them — retry with `?tier=` |
| 401 | `UNAUTHENTICATED` | missing, expired or badly signed token; `WWW-Authenticate` points at discovery and names the underlying OAuth reason |
| 403 | `insufficient_scope` | your **token** lacks a scope; `WWW-Authenticate` names it — re-request it at the token endpoint |
| 403 | `NO_ORGANIZATION` | valid token, bound to no licensed org |
| 403 | `LICENSE_REQUIRED` / `LICENSE_EXPIRED` | no accepted ECLA / lapsed renewal |
| 403 | `TIER_NOT_LICENSED` | your grants do not mention that tier at all |
| 403 | `CONTENT_NOT_GRANTED` | the tier is licensed, that subject is not |
| 403 | `ORG_SUSPENDED` | suspended — including by the enumeration alarm. Contact licensing |
| 403 | `LEARNER_REQUIRED` | progress needs an interactive (learner) token; M2M has no learner |
| 404 | `NOT_FOUND` · `UNKNOWN_SUBJECT` · `LESSON_NOT_FOUND` · `UNKNOWN_MEDIA` · `UNKNOWN_TEST` · `UNKNOWN_SECTION` | no such route / subject / class / asset / test / section |
| 409 | `WRITE_CONFLICT` | a concurrent progress write; retry after ~1 s (`Retry-After` says so) |
| 429 | `QUOTA_EXCEEDED` · `RATE_LIMITED` · `EXPORT_LIMIT` | §4 |
| 500 | `INTERNAL` | ours. Retry once, then send support the `requestId` |

Two edge conditions share the same body but are best handled by status: **405**
(`METHOD_NOT_ALLOWED` — right path, wrong verb; the `Allow` header lists the right ones) and
**413** (`PAYLOAD_TOO_LARGE` — the response would exceed API Gateway's 6 MB ceiling; lower
`limit`, use `detail=brief`, or take the whole subject through `/v1/exports`). 413 is a
guard rail rather than a thing you will meet: the largest response this corpus can produce
today is an inline subject export at ≈0.4 MB.

---

## 7. Status

The route contract above is locked by `test/rest.test.mjs` and `test/caching.test.mjs`
(`npm test`), written as the specification rather than as a description: auth and scope
refusals, entitlement leak tests across `/v1/subjects`, `/v1/tiers` and `/v1/search`, quota and
velocity 429s, ETag/304 semantics including which routes deliberately have no ETag and which
answer one only for `format=inline`, that a revalidated export bills no download and no export
unit and still denies a suspended licence, cursor walks, the 100-id batch mint with its
per-asset ledger rows, and `detail=brief` being strictly smaller than `detail=full`. Three drift guards keep this document and the schema honest: every route the router answers
must be documented in `GET /v1/openapi.json`, every response must carry the collections the
schema declares required, and every error code the router puts on the wire must appear in the
schema's `Error.code` enum — so a client generated from the schema cannot silently read an
empty page or meet a code it has no branch for.

Licensing, org provisioning and M2M credentials: `licensing@scolavo.com`, or
`INTEGRATION_CUSTOM_APP.md` §3 for the internal runbook.
