schoolapi.software · the integration and developer API layer

The documentation is generated from the wall that refuses you

Most school integrations publish a document and enforce something else, and you find the gap in production. Here the reference and the runtime wall read the same registry: buildOpenApiSpec takes the live app.apiScopes Map as its argument, and that Map is what the deny-by-default request handler consults on every call a key makes. A route we forget to document is a route your key is refused on. A route we document that your key cannot reach cannot come out of that generator. Read the whole surface at GET /v1/openapi.json before you ask anyone for a credential.

1 · What this surface does today

8 registered doors, each named by the file that opens it

The public API is not the private API with the sharp parts removed. It is an explicit allowlist: a route is reachable by a key only because some module called app.registerApiScope(template, scope) when it mounted. Two templates are hard-coded into the seed; seven modules register the rest at boot. That is the whole public surface at this commit, and the list below is it — not a selection from it.

enforced/v1/schools/{schoolId}/roster

scope roster:read

The seed allowlist, and one of the only two templates hard-coded into it. A key without roster:read is refused with insufficient_scope before the handler runs.

registered in plugins/api-scope-registry.ts API_SCOPE_SEED
enforced/v1/schools/{schoolId}/roster/{studentId}

scope roster:read

One student, by id, under the same scope and the same row-level walls. An identifier is the only thing a webhook ever hands you; this is how you resolve it.

registered in plugins/api-scope-registry.ts API_SCOPE_SEED
servedOneRoster 1.2 provider, composites, identity, rostering collections

scope roster:read

Four modules each register their own route templates against roster:read at boot. A key minted with the spec-vocabulary scope roster-core.readonly, roster.readonly or gradebook.readonly also holds canonical roster:read, so a client written to the standard reaches them without learning our scope names.

registered in routes/oneroster-provider.ts:191, oneroster-composites.ts:116, oneroster-identity.ts:85, oneroster-rostering-collections.ts:62
servedEd-Fi provider route templates

scope roster:read

The second interop dialect, registered the same way against the same scope. Two standards, one enforced allowlist, one set of consent gates underneath.

registered in routes/edfi-provider.ts:105
servedthe National School Directory export route

scope directory:read

Read-only by construction: a directory license can never mutate the global dataset. The exported field band is a property of the key's tier, not of this scope.

registered in routes/directory-export.ts:153
servedthe fundraising-compliance route templates

scope fundraising:compliance

Its own scope, held by nothing else. A roster integration cannot read compliance records by accident, because the wall checks the scope mapped to the template it matched.

registered in routes/fundraising-compliance.ts:115
servedthe public-record API route templates

scope roster:read

Registered at mount, so it appears in the generated spec the moment it is exposed and disappears from it the moment it is not.

registered in routes/public-record-api.ts:95
served/v1/usage

scope * (any live key)

The one route on the allowlist that pins no specific scope: being an authenticated, live key is enough to read your own usage. You never have to ask us what your key has spent.

registered in plugins/api-key-metering.ts:200

2 · Deny by default

Two different refusals, because they answer two different questions

When a request arrives carrying a key, the handler takes the route template the router matched — the parameterised form, /v1/schools/:schoolId/roster, not the concrete URL — and looks it up in the registry.

If the template is absent, the answer is 403 route_not_public_api. That route is not part of the public API for any key, however privileged. Stop looking; there is nothing to ask for.

If the template is present but your key's scopes do not include the one it maps to, the answer is 403 insufficient_scope. The route is public and your key is simply not entitled to it. Now there is something to ask an administrator for, and the error names it.

Signed-in human sessions are never consulted against this registry at all. It exists only to narrow keys. That matters because of how the wall came to be: the scope check used to be a function nobody called, so a key reached every route its school allowed and its granted scopes did nothing whatsoever. The repair was not a longer allowlist. It was moving the check into the request handler that every key request passes through, and making the default answer no. The registry stays deliberately small for the same reason: the property that protects you is that the wall is enforced, not that the list is long.

3 · The reference, and why you can trust it

Two unauthenticated endpoints that expose the shape and never the data

GET /v1/openapi.json

OpenAPI 3.0.3, generated rather than written

The paths come from the live registry, the parameters are derived from the template (:schoolId becomes {schoolId}), the token endpoint and the webhook-management surface are described explicitly, and the event catalog rides along in an x-webhooks block. Import it into whatever you already use; generate a typed client from it.

routes/dev-portal.ts, services/openapi-spec.ts
GET /v1/docs

A reference page with nothing in it but the reference

Server-rendered, inline styles, no script tag, no external origin, served under a self-only content-security policy. It renders with JavaScript disabled and behind a district proxy that blocks third-party origins, and it adds no supply-chain surface to the system you are integrating with. The plainness is a security property, not a taste.

routes/dev-portal.ts

Both are unauthenticated on purpose: they expose only the API shape, never tenant data. And both are deliberately off the scope registry, which means a key is refused at them while a browser is not. The shape is for people. The data is for credentials. One honest limit: the paths and scopes in that spec are generated, but the descriptions and example payloads in it are written by us. They are documentation, not a runtime contract, and the source says so in the same words.

4 · The scope vocabulary

13 scopes, three of which are somebody else’s spelling

A key is minted with an explicit set of scopes and can never widen them. Ten are canonical. The other three are OneRoster 1.2 spec-vocabulary names, and they exist so a client written against the published standard does not have to learn our vocabulary to talk to us: a key granted roster.readonly also holds canonical roster:read after mint, through the scope-alias registry, and reaches the rostering surface unchanged. Openness here is a mechanism with a line number, not a posture.

ScopeKindWhat it opens
roster:readcanonicalRead the consent-gated roster surface.
roster:writecanonicalWrite back to the roster surface.
books:readcanonicalRead book records.
orders:readcanonicalRead order records.
events:readcanonicalRead event records.
events:writecanonicalWrite event records.
webhooks:managecanonicalManage outbound webhook endpoints.
export:readcanonicalRead export artifacts.
fundraising:compliancecanonicalReach the fundraising-compliance templates and nothing else.
directory:readcanonicalRead the National School Directory export. Read-only by construction.
roster-core.readonlyspec aliasOneRoster 1.2 spec vocabulary. Maps to canonical roster:read at mint.
roster.readonlyspec aliasOneRoster 1.2 spec vocabulary. Maps to canonical roster:read at mint.
gradebook.readonlyspec aliasOneRoster 1.2 spec vocabulary. Maps to canonical roster:read at mint.

5 · Outbound events

18 subscribable event types. 4 have a producer today.

The catalog is one list, and three things read it: the register and list routes validate a subscription against it, the generated spec enumerates it, and the emitting call-sites name events out of it. So the producer side and the subscriber side cannot silently drift apart.

Of the 18 types, 4 are wired to a producer call-site at this commit — they are highlighted below. Subscribing to one of the other types delivers nothing and returns no error. That is the honest behaviour and also the awkward one, which is why it is written here rather than discovered by you in week three. If your integration depends on a type that is not highlighted, tell us: wiring a producer is a small, named change, and we would rather make it than let you build against silence.

Every payload carries identifiers, integer cents and state. Never a student name, a photo, or an email address. To resolve an identifier you call back into the scoped API with your own key, which puts that read under the same row-level and consent walls as every other read. The consequence is deliberate: a webhook log that leaks is a list of opaque identifiers.

  • order.placed
  • order.paid
  • proof.approved
  • book.submitted
  • order.refunded
  • subscription.created
  • subscription.renewed
  • subscription.canceled
  • donation.received
  • donation.refunded
  • campaign.goal_reached
  • agreement.signed
  • agreement.voided
  • deal.created
  • deal.won
  • deal.lost
  • roster.imported
  • qt.response.submitted

Highlighted = a producer call-site emits it at this commit.

6 · Who this is for, and what your first week looks like

A district IT integrator, or a studio’s own developer, with a real deliverable by Friday

This page is written for the person who will actually make the calls: district or school IT wiring a student information system to a yearbook and photography platform, a partner studio's developer, or an internal engineer joining the estate. Not for the committee that signs. The committee's questions are answered on the school-facing product pages; yours are answered here, with route templates and error codes.

  1. Read the whole surface, with no credential. Fetch GET /v1/openapi.json and open GET /v1/docs. Both are unauthenticated. You can scope the work, size it, and decide against it without asking anyone for anything.
  2. Generate a client. The spec is OpenAPI 3.0.3 with concrete path parameters, so your usual generator produces a typed client. Nothing bespoke to install.
  3. Ask an administrator at the school for a key, and name the scopes. The error vocabulary tells you exactly which ones. Key lifecycle is a signed-in human action inside the application; there is no form on this site, by design.
  4. Make the first roster read. /v1/schools/{schoolId}/roster under roster:read, or point your existing OneRoster client at the provider routes with a spec-vocabulary scope. Same wall, same gates, no rewrite.
  5. Check your own consumption. GET /v1/usage needs no specific scope beyond a live key. You measure yourself; you do not file a ticket to find out what you spent.
  6. Subscribe an endpoint to the events that have producers. Take the identifier out of the payload and resolve it through the scoped API. That round trip is the integration pattern here, and it is what keeps children’s names out of your queues and logs.

7 · The objections we actually get

Four fair ones, answered with the mechanism rather than a reassurance

“Your allowlist is tiny. That is not much of an API.”

Correct, and it is the trade we chose. The seed is two templates; seven modules register the rest at boot. We would rather ship a short list that is genuinely enforced than a long one where the check is a function nobody calls — which is precisely the defect that produced this design, and it is recorded in the source rather than quietly repaired. Adding a route to the public API is one deliberate registration, and it appears in the generated spec the same moment.

“Documentation always drifts from behaviour.”

It cannot drift in the usual direction here, because the paths are not typed by a person: they are read out of the same in-memory registry the request handler consults. What the spec can get wrong is prose — descriptions and example payloads are written by us and are documentation, not a runtime contract. That limit is stated in the generator’s own header, and it is stated on this page two sections up.

“We cannot let a vendor key roam our district.”

It does not. A key’s synthesized context carries exactly one school and an empty district list, so the database’s own row-level policies confine it to that school — the pin is in the engine, not only in application code that a future refactor could route around. That is the argument apikey.software makes in full; this page depends on it rather than repeating it.

“What stops a compromised key from escalating?”

Its carrier role is deliberately not an administrative one, which is what puts every key-management route outside the set it can reach. A key cannot mint a key, cannot revoke one, and cannot revoke itself. The worst case is bounded by the scopes it was granted and the one school it is pinned to — and GET /v1/usage makes its consumption visible to you without asking us for a report.

8 · What this surface refuses, in the present tense

The list we would rather you read here than discover mid-integration

  • There is no signup on this site. Nothing here mints a credential. A key is issued by an administrator, signed in, inside the application; a key itself cannot mint or revoke a key, including itself.
  • No price is set and nothing on this page takes a payment. order.paid is an event type in a catalog, which is a documentation fact about a vocabulary, not a live charge rail on this surface.
  • 14 of the 18 catalogued event types have no producer call-site at this commit. A subscription to one of them delivers nothing and errors on nothing. The catalog is a vocabulary, and 4 of its words are currently spoken.
  • The write scopes exist in the mint vocabulary; the enforced allowlist at this commit is a read surface. roster:write and events:write can be granted to a key, and a route becomes reachable only when a module registers it. Ask before you plan a write path and we will answer with what is registered, not with what is spellable.
  • No model produces anything on this surface. The spec is emitted by a generator reading a Map. The prose is written by people. There is no inference anywhere in the path between the registry and the page you are reading.

Every one of those is a sentence we could have left off the page, and none of them would have been noticed until it cost somebody a sprint. That is exactly why they are on it.

9 · The data this touches, and how a child’s record is gated

The roster is the sharpest surface here, so it carries the most refusals

The read this API is most often used for is a roster read, and a roster is a list of children. Three separate mechanisms stand between a key and that list, and they are not alternatives to one another.

The allowlist decides whether the route is public at all. The scope decides whether this key may reach it. The database’s row-level policies decide which rows exist for this principal — and because a key’s context carries one school and an empty district list, that confinement is enforced by the engine rather than by a filter in application code that somebody could forget to apply. Underneath all three sit the consent gates on the record itself.

Outbound the rule is simpler and absolute: webhook payloads carry identifiers, integer cents and state. Never a name, never a photo, never an email address. Resolving an identifier costs you a call back through the scoped API, which is mildly inconvenient and precisely the point — it puts every read back under the same walls, and it keeps children’s names out of third-party queues, retry buffers and log aggregators you and we do not control.

Records belong to the school. They are not sold and they are not shared with outside companies. An integration you build reads them under the school’s authority, on the school’s behalf, for as long as the school says so.

10 · Where this sits

The credential is one product. The door list is another.

apikey.software — what a key is

A key as a principal rather than a password: pinned to one school by the database engine, unable to mint another key, refused before its ceiling is counted. That page makes the credential argument in full. This one takes it as given.

schoolapi.software — what a key reaches

Which route templates are exposed, under which scope, described by a spec generated from the registry that enforces them. If the other page is the badge, this is the list of doors the badge opens, published by the same building.

Both belong to the infrastructure side of the Homeroom estate — the connective tissue rather than a classroom-facing product. The family shares a posture: precise, calm, documented, and willing to write the awkward sentence. It does not share a palette with the school-facing products, and this page deliberately does not wear the institutional navy those pages use. You are not a procurement committee. You are the person who gets the call when a nightly sync stops at two in the morning, and the design owes you a route template rather than a reassurance.

11 · What this costs

No price is set, and this page does not take a payment

Access is arranged as part of a school or district relationship, and terms are a conversation with us. There is no checkout on this site, no plan table, and no figure we could print here that would still be honest tomorrow.

What does exist is the measurement. GET /v1/usage is on the allowlist with no specific scope required, so any live key can read its own consumption from the first call it makes. Whatever terms are eventually agreed, you will have been able to see the number they are computed from the whole time.

12 · The next concrete step

Read the spec first. Talk to us second.

The first step costs you nothing and requires nobody’s permission: fetch GET /v1/openapi.json from the API origin for the school you are integrating with, or open GET /v1/docs in a browser. That is the whole reachable surface, generated from the wall that enforces it. Decide from that.

When you want a key, the school’s administrator issues it, signed in, with the scopes named. If the route you need is not on the allowlist, write to us and say which template and which scope: exposing a route is one deliberate registration, and we would far rather have that conversation than have you build around a wall.

Write to [email protected]. Tell us the dialect you speak, the records you need, and the direction they move. We will answer with what is registered.

13 · Questions integrators ask

Answered with the mechanism

Can I read the spec before anyone gives me a key?
Yes. GET /v1/openapi.json and GET /v1/docs are unauthenticated and expose only the API shape, never tenant data. Read the whole surface, generate a client, write your integration, and only then ask an administrator for a key. Both endpoints are closed to keys and open to people: they are not on the scope registry, so a key is refused there and a browser is not.
How do I know the documentation matches what the server will actually let me do?
Because it is not written. buildOpenApiSpec takes the live app.apiScopes registry as an argument and emits the paths from it. That is the same Map the deny-by-default request handler reads on every key request. A route we forget to document is a route a key is refused on, and a route we document that a key cannot reach cannot be produced by that generator.
Our client is written against the OneRoster spec. Do we have to rewrite it?
No. Mint the key with roster-core.readonly, roster.readonly or gradebook.readonly. The scope-alias registry treats a key holding one of those as also holding canonical roster:read, so a spec-written client reaches the rostering surface without learning our scope names.
What happens if I call an endpoint my key was not granted?
Two different refusals, and the difference is deliberate. If the matched route template is not on the registry at all you get 403 route_not_public_api: that route is not part of the public API for anyone. If it is on the registry but your scopes do not include the one it maps to you get 403 insufficient_scope: the route is public, your key is not entitled to it. The first tells you to stop looking; the second tells you what to ask for.
Can a key create another key, or extend itself?
No. A key's carrier role is deliberately not an administrative one, which is what puts the key-management routes outside the set of routes it can reach. It cannot mint a key, cannot revoke a key, and cannot revoke itself. Key lifecycle is a human, signed-in action inside the application.
Do webhook payloads contain student information?
No. Payloads carry identifiers, integer cents and state. To turn an identifier into a record you call back into the scoped REST API with your own key, which puts that read under the same row-level and consent walls as every other read. The egress surface stays id-only on purpose: a webhook log that leaks is a list of opaque identifiers.
Is there a charge for this?
No price is set and nothing on this site takes a payment. GET /v1/usage lets a live key read its own consumption, so the measurement exists before any commercial conversation does. Terms are a conversation with us, not a checkbox.
Why is the reference page so plain?
It is server-rendered with inline styles under a self-only content-security policy: no scripts, no external origins, no CDN. It renders with JavaScript disabled, it renders behind a district proxy that blocks third-party origins, and it adds no supply-chain surface to the system you are integrating against.