A plain authenticated REST/JSON front door to the same licensed curriculum — one base URL, standard OAuth, HTTP verbs, ETags and cursors. This is the reference: every route, every parameter, every error code, and what each call costs.
There are three ways into the Scolavo corpus, and the difference is not taste — it decides the bill. REST is the recommended default for most integrations.
ETL, nightly syncs, search indexes, LMS imports, batch media pulls. One JSON body per request, real status codes, 100 assets per mint call, and ETag revalidation so an unchanged resource costs nothing but a round trip. Everything on this page.
Wiring all 18 routes below into a chatbot recreates the exact cost problem REST exists to solve: tool definitions are re-sent on every turn, and this API’s full schema is roughly 41,000 tokens of them. The kit is a curated 4-tool surface with a teaching system prompt — generated from the same schema, so it cannot drift. Use it for teaching; use this page for pipelines. Chatbot kit →
Exploration and ad-hoc questions inside Claude, ChatGPT or Cursor. Business denials arrive at HTTP 200 as isError results, because a host reads a 4xx as a broken server and disables the connector. Not what you want under a cron job. Connect your AI →
If you are running a pipeline, use REST. A partner once 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 door exists.
https://mcp.scolavo.com/v1JSON in, JSON out. Authorization: Bearer <access_token> on every route except /v1/openapi.json. Every response — the 401 challenge included — carries x-request-id, and every error body repeats it as error.requestId. Quote it to support.
| 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 |
| Scopes | scolavo-mcp/content.read …/content.export …/media.download …/testprep.read …/progress.read …/progress.write |
| Collections | Every collection on every route is called items — never subjects, hits, classes or urls — beside total / pageNum / totalPages / hasMore / nextCursor |
| Caching | Cache-Control: private with Vary: Authorization, Accept-Encoding. Responses are per-licence and must never land in a shared cache |
Reading for a route-specific collection name is the single most likely way to build a client that reports success while syncing nothing: a missing key reads as an empty page, not as an error. Write one pager and reuse it everywhere.
Scolavo issues your organization a dedicated confidential client — a client_id and secret, shown once at issuance. No browser, no redirect URI, no SDK. Standard OAuth 2.0 client_credentials against the Cognito token endpoint.
# --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 --compressed "https://mcp.scolavo.com/v1/license" -H "Authorization: Bearer $TOKEN" | jq
{
"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"
}Your org must exist, hold a signed (or waived) ECLA, and carry content grants. Without them every content route answers 403 NO_ORGANIZATION / LICENSE_REQUIRED / TIER_NOT_LICENSED. A requiredEclaVersion above eclaVersion is the one condition that turns every content route into a 403 while status still reads active — watch the pair, not the status.
Cache them and refresh on 401. Requesting one per request is itself a rate-limited call against Cognito. Ask only for the scopes you use: content.export and media.download are the two that spend real quota.
A client_credentials token has no sub, so /v1/progress answers 403 LEARNER_REQUIRED. Progress is per-learner and needs an interactive (authorization_code) token — or keep progress on your side, keyed to our stable lessonId.
In the quota block above, limit: null means “no cap configured for your plan”. GET /v1/license is unmetered, sits outside the entitlement gate, and answers even when you are out of quota — which is exactly when you need it.
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. Scopes below are the second half of the string — the full scope is scolavo-mcp/<scope>.
| Method | Path | Scope | What it returns |
|---|---|---|---|
GET | /v1/openapi.json | public | The OpenAPI 3.1 contract for everything below. ≈122 KB, no token required, unmetered. Fetch once and revalidate with If-None-Match. |
GET | /v1/tiers | content.read | items[] = {tier, label, count} — the curriculum tiers your licence covers. ≈415 B. |
GET | /v1/subjects | content.read | A page of licensed subjects. ≈2.4 KB brief · ≈5.4 KB standard · ≈24 KB full, per 25 rows. Metadata only — not a lesson read. |
GET | /v1/subjects/{subjectSlug} | content.read | One subject and its place in the curriculum — tags, prerequisites, related subjects, classCount. The class list is NOT inlined: follow classesUrl. ≈1.1 KB. |
GET | /v1/subjects/{subjectSlug}/classes | content.read | The subject’s classes, each carrying a ready-made lessonId. ≈4 KB per 25 classes. |
GET | /v1/lessons/{tier}/{subjectSlug}/{classNum} | content.read | One class lesson. ≈2.4 KB brief · ≈14 KB standard · ≈30 KB full. Costs one lesson read. |
GET | /v1/lessons/{tier}/{subjectSlug}/{classNum}/transcript | content.read | items[] = the narration split per card (≈15 KB). include=markdown adds the same words ALSO as one joined transcript string (≈31 KB total). |
GET | /v1/lessons/{tier}/{subjectSlug}/{classNum}/quiz | content.read | items = the end-of-class quiz, plus inlineChecks. Answer keys included. ≈2.7 KB. |
GET | /v1/lessons/{tier}/{subjectSlug}/{classNum}/media | content.read | The class’s asset inventory — ids and types with url:null. ≈3.5 KB for 31 assets. Paginated, default 25: pass limit=100. |
POST | /v1/media/urls | media.download | Up to 100 signed asset URLs in one call, returned in request order. ≈1.25 KB per URL (≈120 KB for a full batch of 100). |
GET | /v1/search | content.read | Hits over titles, subtitles, card labels, key points and quiz questions — not full body text. ≈6.9 KB per 25 hits. |
GET | /v1/exports/subjects/{subjectSlug} | content.export | A whole subject as one stamped bundle. format=zip_url (default) returns a signed URL (≈1.3 KB); the zip is 49 KB–893 KB, median ≈500 KB. format=inline returns the bundle in the body and is the variant that carries an ETag. |
GET | /v1/exports/classes/{tier}/{subjectSlug}/{classNum} | content.export | One class as a bundle. ≈1.4 KB for the signed URL; the zip is ≈17 KB. format=inline carries an ETag. |
GET | /v1/testprep | content.read | The four practice-test banks and the sections inside each. ≈1 KB. |
GET | /v1/testprep/{test}/items | testprep.read | A page of practice items. ≈780 B per item (≈19 KB per 25; CAASPP items run ≈1.2 KB). Metered per item actually returned. |
GET | /v1/license | content.read | Your org, content grants, live quota counters, rate limits and contentVersion. ≈600 B, unmetered, and it answers even when you are out of quota. |
GET | /v1/progress | progress.read | Recorded classes for one learner. ≈260 B + ≈150 B per class. Needs an interactive learner token. |
PUT | /v1/progress/{tier}/{subjectSlug}/{classNum} | progress.write | Record a class completion. Body {score?: 0-100, learnerId?}. ≈225 B. |
Every content response also carries contentVersion and a lean licence envelope _license: {lic, tag}. tag is a forensic marker identifying org and scope: keep it if you persist our content, and expect it to be how a leak is traced back.
tierA strict enum. On the list routes it is the cheapest filter there is — it removes rows before pagination. On the single-subject and export routes it does a second job: a slug can exist in more than one tier, and tier is how you disambiguate. Without it, an ambiguous slug answers 400 AMBIGUOUS_SUBJECT naming both tiers. A wrong value is a 400 carrying error.allowed.
detail = brief | standard | fullDefault standard. 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.4 KB / ≈14 KB / ≈30 KB, a 25-row subject page ≈2.4 KB / ≈5.4 KB / ≈24 KB. An unrecognised value is a 400 naming the parameter and listing the three legal ones — it never silently falls back. Two traps: full on a subject row repeats the whole subject graph identically on every row of a walk (fetch it once from /v1/subjects/{subjectSlug} instead), and on that route detail has no effect at all — all three responses are byte-identical and share one ETag.
include (lesson route, comma-separated)Seven values: quiz, inlineChecks, interactives, narrationText, videoTranscript, media, workedExample. Anything else is a 400 that lists all 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, while narrationText multiplies the whole lesson by about 1.8× (measured across 60 lessons spanning all five tiers). include=markdown belongs to the transcript route only and is a 400 on the lesson route.
limit — 1 to 100, default 25Out of range 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, and quietly — so the API refuses instead. The error body carries parameter, received, min and max. And mind the default: a 31-asset lesson’s media listing is two pages at 25, so pass limit=100 or you will mint half a lesson and not notice. Only calls is metered per request, so a full walk at limit=100 costs a quarter of the calls of the default for the same bytes.
cursor — opaque, and corpus-boundPass nextCursor back verbatim and stop when hasMore is false. A cursor carries the contentVersion it was minted under; after a rebuild it answers 400 CURSOR_EXPIRED. Restart the walk — do not “resume”, or you will silently skip or duplicate rows. Compare what you received against total, never against limit: a page can come back short, because the test-prep route shortens a page to your remaining item allowance rather than refusing it.
Two more strict enums: type (video|audio|image) on the media route and format (zip_url|inline) on the export routes. q is required on /v1/search and capped at 200 characters. Slugs are matched exactly — there is no fuzzy matching here, because code does not guess.
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 about 96 in call volume.
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)
for each asset: mint one URL 113,844 calls ← the N+1
─────────────
121,329 calls, ~198 MBIt 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, which auto-suspends the org and needs a human to reinstate it.
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 calls121,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.
GET /v1/license is unmetered. Gate the entire run on contentVersion: if it matches what you stored, nothing has changed and the cheapest sync is the one you skip. The corpus carries one contentVersion for the whole catalogue and it moves only on a rebuild — a few times a year.
detail=briefGET /v1/subjects?detail=brief&limit=100 — the full licensed catalogue in two calls, metadata only. It is not a lesson read and the enumeration alarm never sees it. 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.
An export 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 at all — and LICENSE.txt. All 125 zips together are ≈60 MB.
101 ids is 400 BATCH_TOO_LARGE. 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. Never mint speculatively: 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.
Store the ETag beside each bundle you keep and send it back as If-None-Match. A 304 costs one call unit and nothing else — no lesson read, no download, no export unit, no enumeration credit, no body.
# One subject, everything in it, one call.
URL=$(curl -s --compressed "https://mcp.scolavo.com/v1/exports/subjects/ai-and-llms" \
-H "Authorization: Bearer $TOKEN" | jq -r .url)
curl -sL "$URL" -o ai-and-llms.zip # the URL is signed and lives <= 15 min
# Batch-mint, 100 ids per call, only for assets you will actually fetch.
curl -s --compressed -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}'
# Re-sync: send back the validator you stored.
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…"'
# 304There is no partial success on a batch mint. It 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. Branch on the status code, fix the id, resend. expiresIn is 60–900 s (up to 3600 with the longUrls override); above your cap it is clamped, below 60 or non-integer it is rejected with a 400. The mint response is no-store — never cache it, never expect a 304, re-mint instead.
Between rebuilds, If-None-Match is the cheapest thing in this API. On an export it is worth far more than bytes.
Re-export all 125 subjects, format=inline, unchanged | 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.
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.
When contentVersion moves, every ETag moves — even for subjects that did not change. Revalidation saves you nothing across a rebuild and everything between rebuilds.
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 or /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 currentSo 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.
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, so the conditional GET is your cheap probe.
Send Accept-Encoding and the JSON comes back compressed. Measured across every route against the shipped corpus: 75.9% of the bytes removed, 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 about 4× for the same bytes.
| 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 (standard) | 13,355 | 4,417 | 66.9% |
br is offered too and is 1–3 points better on this corpus, but it is used only if you name it: Accept-Encoding: * resolves to gzip, because “anything I did not list” is not the same sentence as “I can decode brotli”. identity and gzip;q=0 are honoured as the refusals they are, and q-values are read as a real negotiation rather than a substring match.
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.
Below 384 bytes the codec frame costs more than it saves once base64 is applied, so errors, PUT /v1/progress acknowledgements and 304s come back as-is. There is no Content-Encoding header on those; do not assume one.
format=inline exports are not compressed eitherTheir 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.
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 — which is why it multiplies with the two levers that do move meters.
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)Every error on every route has the same shape. Branch on error.code, never on the status alone — a 403 can mean “your token is missing a scope” (free to fix, re-request it) or “your licence does not cover that subject” (a commercial conversation), and only the code tells them apart.
{"error":{"code":"CONTENT_NOT_GRANTED","message":"…","grant":"topics/ai-and-llms","requestId":"…"}}Extra keys are per-code and additive: parameter, allowed, received, min/max, grant, tiers, allow, retryAfterSeconds, quotaResetsAt, hint. requestId is also the x-request-id header on every response — quote it to support. The complete list of codes is published as an enum in the schema (components.schemas.Error), and a drift test asserts that every code the router can put on the wire appears there, so a generated client can never meet one it has no branch for.
| error.code | Means | What a client should do |
|---|---|---|
BAD_REQUEST | A parameter is missing, malformed, or not one of the legal values. error.parameter names it; error.allowed lists the values when it is an enum. | Fix the request. Never retry it unchanged. |
INVALID_PARAMETER | Accepted synonym of BAD_REQUEST. Published so a generated client has a branch for it. | Same branch as BAD_REQUEST. |
BAD_LESSON_ID | The path did not resolve to {tier}/{subjectSlug}/{classNum}. A malformed mediaId lands here too — it fails on the lessonId in front of the first #. | Take ids from /classes, /search or the bundle’s media-manifest.json. Never build one by hand. |
BAD_MEDIA_ID | Reserved. In practice a bad mediaId comes back as BAD_LESSON_ID. | Handle it with the BAD_LESSON_ID branch. |
CURSOR_EXPIRED | The cursor was minted under an older contentVersion — the corpus was rebuilt under your walk. | Restart the walk from page 1. Do not "resume", or you silently skip or duplicate rows. |
BATCH_TOO_LARGE | More than 100 mediaIds in one POST /v1/media/urls. | Chunk client-side at 100. |
AMBIGUOUS_SUBJECT | The slug exists in more than one tier; error.tiers names them. | Retry with ?tier=. |
| error.code | Means | What a client should do |
|---|---|---|
invalid_token | Missing, malformed, expired or unverifiable bearer token. | Get a new token and retry once. Tokens live 900 s — cache and refresh on 401, never per request. |
invalid_request | Reserved. The OAuth code for a malformed request — but this door never emits it: a missing, malformed or non-Bearer header all come back as invalid_token. | Handle it with the invalid_token branch. |
| error.code | Means | What a client should do |
|---|---|---|
insufficient_scope | Your TOKEN lacks the scope this route requires. The body and the WWW-Authenticate challenge both name it. | Re-request the token with that scope. This is free and takes seconds — it is not a licensing problem. |
NO_ORGANIZATION | A valid token that is bound to no licensed organization. | Ask Scolavo to provision the org. Retrying cannot help. |
LICENSE_REQUIRED | A newer ECLA version must be accepted — watch requiredEclaVersion against eclaVersion on GET /v1/license. | Your admin signs once; access opens on the very next call. |
LICENSE_EXPIRED | The plan’s renewsAt is in the past. There is no grace period. | Renewal lands on the next call. Alert a human; do not spin. |
TIER_NOT_LICENSED | No grant covers that content tier at all. | Commercial conversation. Drop the tier from the run. |
CONTENT_NOT_GRANTED | The tier is licensed but that specific subject is not. error.grant names it. | Commercial conversation. GET /v1/license lists exactly what you hold. |
ORG_SUSPENDED | The plan is not active — including an enumeration-alarm auto-suspend. | Stop the pipeline and contact licensing. A human must reinstate. |
LEARNER_REQUIRED | The progress routes need an interactive learner token; a client_credentials token carries no learner. | Use an authorization_code token, or keep progress on your side. |
| error.code | Means | What a client should do |
|---|---|---|
UNKNOWN_SUBJECT | No such subjectSlug. Slugs are tier-specific and matched EXACTLY — display names and partial slugs are not accepted. | Take the slug from GET /v1/subjects. |
LESSON_NOT_FOUND | classNum is out of range for the subject, or the lesson object is absent. The body reports the real range. | Re-read the range from /classes. Note this one DOES bill a call and a lesson read. |
UNKNOWN_MEDIA | No such mediaId on that lesson. In a batch mint this fails the WHOLE batch and names the id. | Fix the id and resend the batch. Nothing was minted, so no download was billed. |
UNKNOWN_TEST | No such test id. | Take it from GET /v1/testprep. |
UNKNOWN_SECTION | That test has no such section; the body lists the ones it does have. | Sections are per test — "rw" is a Digital SAT / PSAT section, not an ACT one. |
NOT_FOUND | No route matches this method and path. | Check the path against GET /v1/openapi.json. Free — it never reaches the licence gate. |
| error.code | Means | What a client should do |
|---|---|---|
METHOD_NOT_ALLOWED | Right path, wrong verb. The Allow header lists the methods it does serve. HEAD is not supported anywhere and answers 405 with Allow: GET. | Use a conditional GET as your cheap probe, not HEAD. |
WRITE_CONFLICT | Another writer updated the learner record; nothing was saved. | Retry after ~1 s — Retry-After says so. |
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. |
| error.code | Means | What a client should do |
|---|---|---|
RATE_LIMITED | An hourly velocity cap — lesson reads or URL mints. | Sleep Retry-After (at most 3600 s) and resume. This is the one worth retrying. |
QUOTA_EXCEEDED | A MONTHLY meter is spent. Retry-After is the seconds to the month boundary and error.quotaResetsAt is the instant. | Stop and alert a human. Retrying in a loop cannot succeed. |
EXPORT_LIMIT | The monthly export allowance is spent; error.exportsUsed / exportsLimit say where you are. | Stop. Same branch as QUOTA_EXCEEDED. |
| error.code | Means | What a client should do |
|---|---|---|
INTERNAL | Ours. | Retry once, then send support the requestId. |
The 401 is the one response that is different. It is answered before any licence work happens and carries a WWW-Authenticate challenge pointing at OAuth discovery and naming the underlying reason. Treat “401 → get a new token, retry once” as the whole branch and you will not miss anything. Every 429 carries Retry-After in seconds — honour the header rather than inventing a backoff curve. RATE_LIMITED returns at most 3600 s and is worth retrying; QUOTA_EXCEEDED and EXPORT_LIMIT return the seconds to the month boundary, and retrying those in a loop cannot succeed.
Counters are per organization. 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, which is itself unmetered.
| Meter | Period | What increments it |
|---|---|---|
calls | month | Every /v1 request that gets past the licence gate — including a 304, and including a 404 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 | One per URL actually minted (a clean 100-id batch = 100; a refused batch = 0), plus one per class in an export — and none at all when an inline export answers 304. |
exports | month | One per export call, whatever its class count. A 304 on format=inline bills none. |
testprepitems | month | One per item actually returned — a shortened page bills short. A 304 on a page you already hold bills none. |
lessonreads | hour | One per lesson / transcript / quiz / media-list read, and per testprep page. Exports, batch mints and 304s do not count. A 404 on a lesson route does. |
mints | hour | One per URL minted — a 100-id batch consumes 100 of urlMintsPerHour, not one. Reserved before the monthly download meter runs. |
A 304 costs one calls unit and nothing else. No lesson read, no download, no export unit, no enumeration credit, and zero body bytes. On a lesson-heavy incremental sync that is the difference between paying your hourly read cap and not touching it. A refused call never bills content: 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.
Read this before you write the loop, not after.
| Access pattern | calls | downloads | exports | lesson reads |
|---|---|---|---|---|
| Check whether anything changed (GET /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, one at a time | 930 | 930 | 0 | 0 |
| 930 media assets, batched at 100 | 10 | 930 | 0 | 0 |
| Whole corpus, naive walk | 121,329 | 113,844 | 0 | 7,360 |
| Whole corpus, the cheap loop | 1,264 | 117,524 | 125 | 0 |
| Whole corpus, re-synced unchanged (gated on contentVersion) | 0 | 0 | 0 | 0 |
| All 125 subjects re-exported unchanged, format=inline, no If-None-Match | 125 | 3,680 | 125 | 0 |
| All 125 subjects re-exported unchanged, format=inline + If-None-Match | 125 | 0 | 0 | 0 |
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.
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, take it through /v1/exports.
Entitlements are re-read from the database on every request, so a suspend or a revoke lands on your very next call — not at your next token refresh.
GET https://mcp.scolavo.com/v1/openapi.jsonOpenAPI 3.1, ≈122 KB, unauthenticated and unmetered — point openapi-generator, oazapfts or your language’s equivalent straight at it. Every route, parameter, response schema and error code on this page is generated from the same table the router uses, and three drift guards keep them honest: every route the router answers must be documented in the schema, every response must carry the collections the schema declares required, and every error code the router emits must appear in Error.code.
Every operation 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. And because the document’s ETag tracks contentVersion, this is also the cheapest gate in the API:
# 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 → the corpus has not moved. Stop.Zero body bytes on that 304, no database reads, and no calls unit. The X-Scolavo-Content-Version header on the same 304 tells you which build you are being told about, so you can log it without parsing anything.
Machine-to-machine clients are issued per organization, alongside the content grants that decide what your token can see. Institutional licensing is annual and PO-friendly, with single-subject evaluation licenses to start.