Documentation

The API

Version 0.4 ·Updated 2026-09-12

Next reviewed by 2027-02-28.

Written for integrators building against Kestrel without asking permission first.

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.