Documentation
The API
Read this first
There are two ways to call Kestrel. The screens use a browser session; a program uses a scoped token against /api/v1. Both are checked on the server, by the same code, on each request.
The reference is published in full, because you should be able to decide whether to integrate with a vendor before you sign anything. A closed API disqualifies a vendor in this sector, and a reference kept behind a partner conversation amounts to a closed API.
The reference is generated
The machine-readable reference is an OpenAPI document the server produces from its own route table. It is not maintained by hand, so it cannot drift from the routes it describes.
curl https://app.kestrel.hoverpoint.io/_openapi.json
Point your generator at it. It is built from the schemas each endpoint validates with, so each request body, query parameter and response it describes is the one the server accepts. It lists the endpoints and nothing else.
What a token reads
The versioned surface serves the person record: who a school has, one person by reference, and who is a student, staff member, applicant, volunteer, contractor or board member there, with the span each of those has run for. It also serves who pays a student's fees, behind its own scope, for a finance platform to sync from. The one write is enquiry intake. It takes a token like the reads, so a school's own website can post an enquiry and a stranger cannot fill the register through it.
A nightly sync is two calls and a page loop: the people, then the affiliations. Both accept limit and a cursor. The affiliations read takes state=open for the roll as it stands today. By default it returns closed spans as well, so a sync can see who has left. The generated document lists each route with its parameters and its response.
Two calls that need no account
Both are useful for checking connectivity.
curl https://app.kestrel.hoverpoint.io/health
{ "status": "healthy", "uptime": 1284, "version": "2026-08-29T04:11:00Z" }version is the build timestamp. It tells one deployment from the next without naming the source revision the build was cut from. The route answers while database migrations are still running, so a health check can tell "starting" from "broken".
curl https://app.kestrel.hoverpoint.io/api/hello
Authentication
For a person, sign-in is passwordless: a passkey, or a one-time code sent by email. It produces a session cookie, and that cookie is what the /api/app endpoints check. There is no password to send, and therefore no basic auth.
For a program, /api/v1 takes a bearer token. An administrator at the school issues it under Settings, and no-one has to ask us. The school names what the token is for and ticks its scopes, and the secret is shown once and stored nowhere. We keep a hash, so we cannot resend it and neither can anyone who reaches our database.
curl -H "Authorization: Bearer kst_…" https://app.kestrel.hoverpoint.io/api/v1/people
A token names one school and has scopes, which are the same capability names a plugin is granted: person.read, affiliation.read, restricted.read. A token cannot be pointed at another school, and it cannot read a field its scopes do not reach. Those fields are absent from the response. Ask for the narrowest set that does your job.
Each token has an expiry, chosen by the administrator who issues it: a year by default, and up to two years. Plan for the renewal. An administrator can revoke a token at any time, and the next request presenting it is refused.
Each route that serves student data checks its caller itself. The browser-side route guard exists to make navigation behave, and the access control is on the server.
Lists, and retrying a write
Each collection answers with items and a nextCursor. Pass the cursor back to get the next page, and stop once it comes back null. The cursor is opaque: do not parse it or do arithmetic on it, because what is inside it is ours to change.
GET /api/v1/people?limit=100
{ "items": [ … ], "nextCursor": "eyJvIjoxMDB9" } Each write accepts an Idempotency-Key header, and you should send one. If your request times out, you have no way to know whether it happened. Retrying with the same key returns the first attempt's answer without doing the work again. Reusing a key for a different request is refused with conflict, because completing it under the first one's key would be worse than failing. Keys are honoured for 24 hours.
Requests are limited per caller and per endpoint. A refusal says how long to wait in Retry-After, and each response includes X-RateLimit-Remaining so you can pace yourself before being refused.
Errors
Each failure is JSON with the same shape, from all endpoints on both surfaces. statusMessage is written for a person; data is for your code. Branch on data.code. One status can have several meanings, and each code has one.
{
"statusCode": 422,
"statusMessage": "This palette was refused.",
"data": {
"code": "unprocessable",
"detail": ["The accent on the page in light mode is 2.4:1, and needs 3:1."],
"traceId": "0af7651916cd43dd8448eb211c80319c"
}
}- bad_request
The request was malformed, or it belongs to no school. Sending it again unchanged fails the same way.
HTTP 400
- validation_failed
A field was missing or in the wrong shape. `fields` names all of them, each by its path in your request.
HTTP 400
- unauthenticated
The request had no credential, or one that is no longer valid. Sign in again, or check the token. Do not retry unchanged.
HTTP 401
- forbidden
The caller was identified and is not permitted. The message names the missing capability. Retrying does not help.
HTTP 403
- step_up_required
The endpoint asks for the person's passkey again, and the session has not proved it in the last five minutes. Complete a passkey sign-in, then resend.
HTTP 403
- not_found
The record or the route does not exist. A record another school has gets this answer too.
HTTP 404
- conflict
The record changed since you read it, or an Idempotency-Key was reused for a different request. Re-read, reapply, resend.
HTTP 409
- payload_too_large
The body is larger than the endpoint accepts.
HTTP 413
- unprocessable
The request was understood and refused. `detail` lists all the reasons, one sentence each.
HTTP 422
- audience_unknown
A message named a group, such as `activity:rowing`, that no part of this deployment answers. `detail` names each one. Nothing was sent.
HTTP 422
- audience_empty
A message's groups contained no-one who may be told, so no message was written. Change the groups or the purpose.
HTTP 422
- no_consent_standing
A consent named a giver who does not hold parental responsibility for the child on the day it was given. The answer is the same whether the person never had it, had it and it closed, or an order or a protected record keeps them out, so the code says nothing about which. Nothing was recorded.
HTTP 422
- subject_required
A consent was recorded for a purpose that is about a named thing, an excursion say, and named none. Name the thing it is about. Nothing was recorded.
HTTP 422
- ask_closed
A parent answered a consent ask the school has closed, or one about a thing that has since left, been cancelled or stopped taking answers. Nothing was recorded.
HTTP 422
- schedule_published
A change named a line, a billing period or a draft of a fee schedule that has since been published. A published schedule is never changed: start a correction, which is a new version, and make the change there. Nothing was recorded.
HTTP 422
- no_portal_identity
The account is signed in and the school has not given it family access, so the portal's endpoints answer it nothing.
HTTP 403
- standing_withheld
A family reader asked about a child for a purpose their standing on that child does not grant, or about a child they hold no standing on. The answer is the same for both.
HTTP 403
- section_off
The school has switched this portal section off for families. The same endpoint still answers staff holding its capability.
HTTP 403
- rate_limited
The caller has made too many requests. `Retry-After` gives the number of seconds to wait.
HTTP 429
- internal
The fault is ours. It is safe to retry once, after a delay. If it keeps happening, quote the `traceId`.
HTTP 500
- not_implemented
The request was understood, and this build has nowhere to put it. The message names what is missing, and retrying fails the same way.
HTTP 501
detail contains sentences a person can act on, and fields contains the parts of your request that were wrong, each with the path you sent it under. Both list all the failures, so a form does not have to be submitted six times to find six problems. Neither contains a stack trace or an internal message.
The distinction between conflict and unprocessable matters most. A conflict means the record moved on and your change is probably still valid: re-read, reapply, resend. Unprocessable means the content itself was refused and resending it unchanged will be refused again.
Versioning and deprecation
The version is in the path. A breaking change to /api/v1 becomes /api/v2 and leaves what v1 answers unchanged. An endpoint can change shape between releases, so three commitments apply:
- Adding a field to a response is not a breaking change. Write your client to ignore fields it does not know.
- Removing or renaming a field, or changing its type, means a new major version. Both versions run side by side for at least twelve months.
- A deprecation is announced on this page before it takes effect, and the announcement gives the removal date.
Getting in touch
Write to api@seraco.io. If you are building something and an endpoint you need is missing, tell us before you work around it.