Early access

A key is not a password. It is a principal — and in a school it is a principal that can reach a child’s record.

APIkey.software is the credential plane for a school platform’s public API. It is built around four refusals: a key reaches only routes somebody deliberately opened, it is confined to one school by the database itself rather than by the application in front of it, it can never mint another key, and the call that reaches its ceiling is refused before it is ever counted.

Book a conversation See what is built

Early access. The key plane is built and wired; the public route allowlist is deliberately two roster reads, and it grows one vetted route at a time.

What it is

The boundary, not the gateway

Most API-key products are gateways. They sit in front of an application, hold a policy about what each key may call, and pass the survivors through to a service that trusts whatever arrives. That design has one structural weakness, and in most industries you can live with it: everything below the gateway believes the gateway did its job.

Schools are the case where you cannot live with it. The record behind the endpoint belongs to a child, the integration asking for it was written by a vendor you did not hire, and the blast radius of a policy mistake is not a rate-limit overage — it is another school’s roster. So the wall has to sit lower than the application, and it has to still be there when the layers above it are wrong.

It is a principal model

A presented key resolves to a request context with a role, a school, a scope set and a first-class key marker. Everything downstream — tenancy, scope, rate limiting, metering — keys off that one object.

It is a deny-by-default wall

A route that nobody explicitly published is not reachable with a key, whatever the key holds. The whole first-party surface is closed to keys until a route is deliberately added.

It is an engine-level tenancy pin

The confinement to one school is expressed as row-level policy state in Postgres, not as an if in a handler.

It is not a gateway, a CDN or a WAF

It does not terminate TLS for you, cache your responses, or inspect payloads for attacks. It decides who a caller is, what it may reach, and how much of it.

It is not a billing system

The meter counts calls. It does not price them, invoice them or move money. Nothing on this surface touches a payment rail.

It is not a broad public API yet

Two roster read routes are exposed today. The wall is the product; the breadth is the roadmap, and this page will not pretend otherwise.

The credential

Two halves, one of which we never keep

A credential is <keyId>.<secret>. The first half is public, stored in plain text and carried in the token; the second half is shown to you exactly once and never stored anywhere in any form we can reverse.

  1. The public half is hrk_id_ followed by twelve random bytes in base62. It is a unique index, which is the entire reason verification is a single lookup rather than a scan of candidate rows with a hash comparison against each.
  2. The secret half is hrk_sk_ followed by twenty-four random bytes in base62. It is returned once, at mint, in the response body. It is never written to a log, never returned by a list call, and never recoverable — a lost secret is re-minted, not looked up.
  3. What persists is the argon2id hash of the secret, the public id, and the last four characters for display. The hashing scheme is the same one every other bearer secret in the codebase uses, deliberately, so a single verify path covers all of them rather than each surface inventing its own.
  4. Presentation is either an X-Api-Key header or an ordinary Authorization: Bearer header. Resolution is the first branch tried, ahead of session JWTs and cookies.
  5. Verification parses on the first separator, rejects anything without both expected prefixes before touching the database at all, does one indexed lookup, and then performs exactly one constant-time argon2id verify.

There is a smaller decision in here worth naming, because it is the kind of thing that silently rots. Liveness — is this key revoked, has it expired — is decided by one function used by all three faces that can present a key: the raw header, the token exchange, and the re-check performed on an already-exchanged token. A key whose expiry timestamp is exactly now is treated as already dead. If those three faces each wrote their own comparison, one of them would eventually use < where the others used <=, and a key would outlive its own expiry on exactly one path. That is not a hypothetical class of bug; it is why the shared comparator exists.

The wall

Closed by default, and it was not always

Every route a key may reach is named in an explicit registry that maps the route template to the scope it requires. The check runs centrally, before the handler, for every key principal:

Route absent from the registry
403 route_not_public_api. Not “not found” and not “forbidden” in the abstract — a specific, named refusal that says the route is not part of the public API at all. The entire first-party surface sits behind this by default.
Route present, scope not held
403 insufficient_scope. A distinct refusal, because “this door does not exist for keys” and “this door exists and your key lacks the grant” are different facts and an integrator debugging at 2am deserves to be told which one they hit.
Human sessions
Never consulted against the registry at all. A signed-in administrator carries full role authority through the ordinary permission system; this wall governs key principals only.

We publish the defect this replaced, because a page that only lists the wall gives no reason to believe the wall is load-bearing. The scope check used to exist as a helper that no resource route ever called. It was importable, it was tested, and it was dead. The practical effect was that a key reached every route its school allowed and its granted scopes were decorative — an export-only integration minted with a read scope could call anything its tenant could.

The fix was not to add the missing call to each route, because that design is only ever as good as the number of routes somebody remembered. It was to invert the default: enforce centrally, refuse anything unlisted, and add routes to the list deliberately. The seeded allowlist is two roster reads. That number is small on purpose and it is the honest measure of how much public API exists today.

One school

The empty set that is the whole wall

A key belongs to exactly one school. The interesting part is not the rule, it is where the rule lives.

When a credential resolves, the synthesized context carries the key’s school — read from the stored row, never from the request — and a district list that is deliberately empty. Those two values become row-level security state in Postgres. With no district set, the helper that expands a district into its member schools returns nothing, so every leaf policy falls back to the pinned school alone. There is no union, no widening path, and nothing an application-layer bug can do to broaden it, because the application is not the thing enforcing it.

This is a correction, and the module says so rather than quietly presenting the current state as the original design. The district list previously held the key’s district. The effect was that at the database engine, a key could reach every school in its district; only an allowlist check in the application pinned it to one. Nothing was known to have been exploited — but the wall was in the wrong layer, and a wall in the wrong layer is one refactor from being no wall.

The school comes from the row

Never from the request. A tampered credential cannot name a different tenant, and a wrong secret fails verification and becomes a 401 before tenancy is ever considered.

The key is never a system principal

The lookup that resolves the credential runs under system scope because authentication necessarily precedes tenancy, and it returns only the single key row. The request that follows runs under the key’s real, pinned scope.

The audit principal is synthetic

A key is not a person and must not inherit a human’s session or MFA lifecycle, so its principal id is derived deterministically from the key row and is never a reused human account id.

Scopes

The whole vocabulary, including what is missing from it

These are all thirteen scopes a key may be granted. The read and write split is enforced at the vocabulary level, which is why an export-only integration cannot be mis-configured into a mutating one — the grant simply does not exist to give it.

ScopeKindWhat it admits
roster:readreadRead the roster. The canonical public-API read surface, and the only scope the seeded allowlist actually maps a route to today.
roster:writewriteWrite roster records. Mintable, and deliberately not mapped to any seeded route -- a scope can exist in the vocabulary without a door being open to it.
books:readreadRead publication and book records.
orders:readreadRead order records. Read-only: there is no orders:write in the vocabulary at all, so no key can be minted that places an order.
events:readreadRead calendar and event records.
events:writewriteCreate and amend events.
webhooks:managemanageManage webhook subscriptions. The one management verb in the vocabulary, and it manages webhooks -- never keys.
export:readreadBulk export reads for a data-warehouse or SIS integration.
fundraising:compliancereadThe fundraising compliance surface.
directory:readreadThe National School Directory licence gate for GET /v1/directory/export. READ-only by construction, so a directory licence can never mutate the global dataset. The exported FIELD BAND is set by the key's tier, not by this scope.
roster-core.readonlyaliasOneRoster 1.2 spec vocabulary. An alias: a key minted with it also holds roster:read after expansion, so the unchanged deny-by-default wall admits it.
roster.readonlyaliasOneRoster 1.2 spec vocabulary, aliased to roster:read at mint.
gradebook.readonlyaliasOneRoster 1.2 gradebook read, spec vocabulary.

Two absences matter more than any entry above. There is no orders:write, so no key can be minted that places an order. And there is no key-management scope of any kind, which is what makes “a key cannot mint a key” a property of the vocabulary rather than a rule somebody has to remember to check.

The three OneRoster entries are aliases rather than parallel permissions. A key minted with a specification-vocabulary scope also holds the canonical scope after expansion, which happens once at mint. The wall itself never learns about aliases, so there is exactly one place where alias logic can be wrong.

Who may mint

Humans, holding the school

Minting, listing and revoking live under a school and are reachable only by a human administrator who holds that school: a school administrator, a district administrator, a partner administrator whose studio owns the school, or platform support. There is no new machine role and no programmatic management grant.

One denial is written out explicitly rather than left to fall off the end of the allowlist. An account manager is refused by name. The reasoning is worth stating because it is the sort of thing that looks like over-engineering until you trace it: a rep holds a cookie session and is governed by a wall on personal data. A key principal is governed by the scope registry instead. So a rep who could mint a key would have had a lane out of their own restriction — trade the session for a credential and the wall no longer applies. Refusing incidentally would have worked until someone reordered the checks; refusing by name says what is intended.

The same logic drives the role a key assumes internally. It is a plain tenant role, chosen because of three things it is not: it is not a district role, so it never triggers the pre-expansion that widens a principal to a district’s schools; it is not a support role, so it never inherits the studio-scope bypass; and it is not an administrative role, so the key-management routes reject it. A key cannot mint, list or revoke a key — including its own.

The exchange

Standard client credentials, and a throttle with a memory

If you would rather hold a short-lived token than send a long-lived secret on every call, exchange the key’s two halves for one. The grant is client_credentials, shaped to the ordinary specification: the public id is the client id, the secret half is the client secret, and what comes back is a bearer token with an explicit lifetime.

The exchanged token carries the same school pin and the same granted scopes as the key it came from, so it travels the identical context and tenancy path. That is the point of doing it this way rather than inventing a second session type: there is one tenancy mechanism, and the token is not a way around it. A key behaves identically whichever way it authenticates, and the marker that makes a principal scope-gated and rate-limited is carried through the token rather than being attached to the object that happened to be created at the header.

Repeated failures against a single client id are counted on their own throttle, separate from the general per-address limiting. The reason is specific: a secret guarded only by a per-address limit can be ground down by an attacker spread across many addresses, or by one sitting behind the same shared address as legitimate traffic. Ten failures inside a minute against one client id is enough to start refusing. A successful exchange clears the counter, so an integration that polls normally is never punished for a colleague’s typo an hour earlier.

Two ceilings

A burst boundary and a monthly counter are not the same thing

These get conflated constantly, and conflating them produces integrations that fail in ways their authors cannot explain. They are separate mechanisms, stored differently, with different failure modes.

TierBurst capacitySustained refillDurable monthly cap
free60 tokens1/s · ~60/min10,000 calls
standard300 tokens10/s · ~600/min200,000 calls
partner1200 tokens50/s · ~3,000/min5,000,000 calls

The bucket

A token bucket in ephemeral storage, measured in tokens per second. It absorbs a spike and refills continuously. When it is empty you get a standard rate-limit response with a computed retry hint. It protects the service; it forgets quickly.

The counter

A durable count per key, per billing period, per operation kind, held in the database and surviving restarts. It is the substrate a licence tier settles against. It does not refill; it resets with the calendar month in UTC.

Why you can hit either

You can sit far below your monthly cap and still be throttled for one second because you sent a burst. You can trickle calls all month and still reach your cap on the last day. Neither implies the other.

Both sets of figures are operator-retunable through environment settings without a deploy, which is the same convention the rest of the platform uses for ceilings. Reads and writes are counted as distinct operation kinds under the same key and period, derived from the HTTP verb, so an export-only integration visibly shows zero writes — a per-kind breakdown without recording the route path, which could otherwise leak a tenant-shaped access pattern.

The meter

A pure function, a chain, and an honest limit on what the chain proves

The metering brain does no input or output, touches no database, and reads no clock of its own — the caller passes the instant. That is what lets the same functions run in the enforcement path, in a usage view, in a forecast and in a test, with no divergence between what production does and what the tests check.

A refused call costs nothing
The gate reads the period total, decides, and records only on the allowed path. A call rejected for quota is never itself counted. The first denied call of a period is the one that arrives exactly at the cap.
Sandbox traffic is measured but never capped
An explicit opt-in header meters the call to a separate sandbox period. Exploration stays visible without consuming or being blocked by the production allotment.
Ordering is deliberate
The gate runs after the context and burst limiter and before the route’s tenant database transaction opens, so a quota rejection never costs a transaction.
It is honest-off
Enforcement requires a provisioned database. Without one the store resolves to a no-database outcome and the gate is skipped entirely — not partially, not in a degraded mode. Nothing is counted and nothing is refused on quota grounds.

What the hash chain actually proves

Each increment folds the previous hash, the counter’s identity and the new running total into a fresh digest. The recorded head therefore commits to the entire increment sequence, so a counter that is silently rolled back to a lower value cannot reproduce its own chain head without replaying every step.

That is a real property and it is a narrow one, so here is the boundary stated plainly rather than left for you to discover. It is an append-linked integrity signal over one number’s history. It is not an independent event log: it does not record which routes were called, by whom, or when. It does not prove a call happened — only that the count was not quietly rewritten. And it is tamper-evident, not tamper-proof: an actor who can rewrite the counter and replay the chain from genesis defeats it. We describe it this narrowly on purpose, because “tamper-proof audit log” is exactly the phrase this mechanism does not earn.

Where we win and where we lose

Three of these rows go against us

A comparison table with no losing rows is an advertisement, and a reader who finds the first omission stops believing the other nine. So the rows where a mature developer platform beats us are marked, in the same table, in the same type.

TopicThe usual approachHere
What bounds the keyA gateway policy in front of the application. The database sees a trusted service account and answers whatever it is asked.The Postgres engine. A key's context carries an EMPTY district set, so the row-level policies confine it to its one pinned school even if every layer above it were wrong.
Default route postureOpen by default: a valid key reaches the surface, and you subtract what it must not touch.Closed by default. A route absent from the registry answers 403 route_not_public_api. You add one vetted route at a time.
Can a key create a keyUsually yes, with a management scope.No. There is no keys:manage scope in the vocabulary, and the carrier role is not an admin role, so the key-management routes are unreachable from a key.
Secret at restOften a reversible token or a fast hash.argon2id, the same scheme as every other bearer secret in the codebase, so one verify path covers all of them. Only the hash and the last four characters persist.
Verify costSometimes a scan of candidate rows.O(1). The public id half is a unique index, so it is one lookup and exactly one argon2id verify.
A refused callFrequently still counted against your quota.Costs zero. The caps gate rejects BEFORE it records, so a blocked call never burns the counter.
Breadth of the public API we loseLarge, documented, general-availability surfaces with SDKs in a dozen languages.Two seeded roster routes. This is the row where we are behind, and it is behind on purpose -- but if you need a broad public API this month, we do not have one.
Client libraries we loseFirst-party SDKs, generated clients, a package for your language.None. The credential is a bearer string over HTTPS and the exchange is standard client_credentials, so any HTTP client works -- but we ship no SDK and will not pretend otherwise.
Usage dashboard we loseA live console with charts.The meter is a pure function with a durable counter behind it. The numbers are real when a database is provisioned; the console around them is not built yet.
SandboxA separate environment, separate keys, separate data.One header. x-homeroom-sandbox meters to a separate sandbox period that is measured but never capped, so exploration is free and still visible.

The board

Built, built but not switched on, and not built

Three states, named separately, because collapsing the middle one into either neighbour is how a page becomes a lie. “Built but not switched on” means the code exists, is tested and is correct, and something outside it — a database, a decision, a vetting pass — is what stands between it and effect.

CapabilityStateDetail
Credential model, mint, revokeBuiltGeneration, argon2id at rest, one-time secret display, last-four retention, O(1) verify and the shared liveness comparator are wired and covered by their own suites.
Deny-by-default route wallBuiltEnforced centrally in the context preHandler for every key principal. Absent route and insufficient scope are distinct, named refusals.
Single-school engine confinementBuiltThe empty district set is set at both key faces, so the RLS policies confine a key to its pinned school.
OAuth client_credentials exchangeBuiltShort-lived scoped token, per-client-id failure throttle, cleared on success.
Per-key burst limitingBuiltPer-tier token bucket with standard rate-limit response headers and a computed Retry-After.
Durable monthly meteringBuilt, not switched onThe arithmetic, the caps decision, the period keys and the integrity chain are pure, tested functions. Enforcement requires a provisioned database; without one the gate is skipped and nothing is counted.
Breadth of the public APIBuilt, not switched onTwo roster read routes are exposed. Everything else in the platform is closed to keys by default and joins the allowlist one vetted route at a time.
Self-serve developer consoleNot builtKeys are minted by a school or district admin through the management routes. There is no public sign-up, no key dashboard and no usage chart.
Client SDKsNot builtNone, in any language. The credential is a bearer string and the exchange is standard, so a plain HTTP client is sufficient -- but we ship no package.

Student data

What a key can reach, and what it cannot

The only student-facing routes on the public allowlist today are roster reads, and they are reachable only by a key that was granted a roster read scope by a human administrator of that one school. Records reached this way remain governed by the same consent state that governs every other path to them; a key is not a way around a family’s decision, and holding a scope is not the same as a record being available.

One school, at the engine

A key cannot read another school’s students even if every check above the database were wrong, because the row-level policies confine it and the district set is empty.

No blanket export

There is no scope that hands over a whole tenant. Bulk reads are their own grant and are still bounded by the same single-school pin.

Read and write are different grants

An integration built to export cannot mutate, because the vocabulary separates the two and the wall checks the specific one.

Keys are revocable immediately

Revocation is a stored state checked by the shared liveness comparator on every presentation, including on an already-exchanged token.

The meter records no route paths

Usage is counted by operation kind rather than by endpoint, so the counters cannot become a record of which student records were of interest.

Nothing here is sold

No usage data is shared, brokered or used for advertising. The counters exist to enforce a ceiling.

Questions

The ones an integrator actually asks

Is my usage metered today?

Only when a database is provisioned. The metering plugin resolves its store first, and with no DATABASE_URL that store returns no_db and the gate is skipped entirely -- the hook becomes a no-op. That is the honest answer and it is the same one the code gives: the first arm of the outcome type is literally {kind:"skipped", reason:"no_db"}. The pure caps arithmetic is deterministic and tested either way.

You call it a hash chain. Is that an audit log?

No, and the source file says so in its own header, which is why this page will not upgrade the wording. Each increment folds the previous hash, the counter identity and the NEW running count into a fresh sha256. That makes it an append-linked integrity signal over the counter's own history: silently rolling a counter back to a lower number cannot reproduce the recorded chain head without replaying every increment. It is not an independent record of events, it does not prove WHAT was called, and it is not tamper-proof. It is tamper-EVIDENT over one number.

What actually stops a key reaching another school?

The database. The synthesized context pins schoolIds to the one school on the stored key row and sets districtIds to the EMPTY set. With no district GUC, the RLS helper that expands a district to its schools returns nothing, so the leaf policies fall back to the pinned school alone. The school is read from the stored row and never from the request, so a tampered credential cannot name a different tenant -- and a wrong secret fails the argon2id verify and becomes a 401 long before any of this matters.

Was that always true?

No. It is a fix with a name -- COHORT-D1 -- and the module header records the previous behaviour rather than quietly deleting it: districtIds used to be the key's district, which widened its reach at the engine to every school in that district, with only an application-layer allowlist pinning the school. The wall was real but it was in the wrong layer. Now the engine is the wall. We would rather publish the old defect than let the page imply we got it right first time.

Can a key mint another key?

No, by two independent mechanisms. There is no keys:manage scope in the vocabulary to grant, and the carrier role a key assumes is a plain tenant role rather than an admin one, so the key-management routes reject it on the role check. Minting is human-admin-only: a school or district admin holding the school, a partner admin whose studio owns it, or platform support.

Why is an account manager singled out?

Because a rep with a cookie session could otherwise mint a key and step around the PII wall that governs reps -- a key principal is governed by the scope registry, not by the rep grant, so the key lane would have been an escape hatch. The mint route denies a rep role explicitly and by name rather than letting it fall off the end of an allowlist, because an incidental refusal is one refactor away from not being a refusal.

How many routes can a key actually reach?

Two, seeded: the roster collection and a single roster record, both requiring roster:read. That is not a placeholder we forgot to expand -- the registry's own header says the point is that the wall is enforced, not that the allowlist is broad. Routes join the public API deliberately, one vetted route at a time, and a route module can register its own at boot.

What is the difference between the rate limit and the cap?

They answer different questions. The token bucket is a burst boundary measured in tokens per second and held in ephemeral storage: it protects the service from a spike and refills continuously. The monthly cap is a durable counter per key, per billing period, per operation kind that survives restarts. You can be far under your monthly cap and still be throttled for one second, and you can be trickling calls and still reach your cap on the last day of the month.

Do I get charged for a call you refuse?

No. The gate reads the period total, decides, and only records when the call is allowed, so a call refused for quota is never itself counted. Nothing here moves money in any case: the meter counts calls, not dollars.

What happens the instant my key expires?

It is already dead. One shared comparator decides liveness for all three faces -- the raw header, the token exchange and the re-check on an exchanged JWT -- and it treats an expiry timestamp at or before now as expired. That is deliberate: two faces disagreeing on whether the boundary is < or <= is exactly how a key outlives its own expiry on one path and not another.

Do you support OAuth?

The client_credentials grant, shaped to RFC-6749 section 4.4. You exchange the key's public id and secret for a short-lived scoped access token that carries the same school pin and the same granted scopes, so it rides the identical context and tenancy path with no separate plumbing. Repeated failures against one client id are throttled on their own counter, and a successful exchange clears it so ordinary polling is never punished.

Is there a price?

Not on this site. There is no checkout here, no plan picker and no invoice. The tiers on this page are rate and volume ceilings, not price points.

Tell us what you are integrating

The public allowlist grows one vetted route at a time, and which route comes next is decided by what somebody is actually building. If you are writing an integration against a school platform and need a surface that is not open yet, that is the useful conversation to have.

Book a conversation

Honesty

What this page is careful about

Every capability described here was read out of a source file before it was written down, and the claims are deliberately bounded in four places where a looser sentence would have been easy to write. Metering enforces only where a database is provisioned. The integrity chain is tamper-evident over a counter, not an audit log. The public API is two routes, not a platform. And there are no client libraries at all.

There are no customer counts on this page, no adoption percentages, no benchmark figures and no named references, because none of those would be real. There is no pricing and no checkout. If something here later stops being true, the correction belongs on the page and not in a footnote.