Skip to Content

API contract

How it works, in a paragraph. The checker talks to two endpoints: one that takes two photos and returns a conclusion plus a token, and one that returns a stored conclusion for a token. The front end and the API are in separate repositories, so the contract cannot be a shared type — it is a published schema snapshot that the front end pins, and a diff in that snapshot is the signal that the two have drifted apart.

Two endpoints

TakesReturns
Submit a checktwo image files, plus a required idempotency key headera token and an outcome
Fetch a resulta token in the paththe stored outcome

The idempotency key is required rather than generated server-side, and that is the only way it can do its job: the point is that retrying the same submission does not spend a second check, and a key minted per request would be new on every attempt. The page generates one per submission and reuses it across retries.

An outcome is a tagged union

| { kind: 'unreadable'; reason: PhotoProblem | null } | { kind: 'excluded' } | { kind: 'stage'; stage: NorwoodStage; confidence: 'medium' | 'high' }

Only stage carries a stage. confidence has no low, because low confidence produces an abstention rather than a hedged verdict — see the tool state machine for why the absence rather than a rule.

Two fields repay a closer look:

  • unreadable.reason is a closed set of things the visitor can act on — too dark, too blurry, too far, obstructed, not a scalp. It exists because an abstention with no hint is the one outcome that spends a check for nothing.
  • It is null on a stored result. The hint was about the photos in front of the visitor at the time; on a link opened days later there is nothing for “the shot was too dark” to refer to. Confidence is dropped on storage for the same reason.

What the model is asked is wider than what is returned. It names the condition it suspected when it declined, and that field is dropped before the response is built — it is never sent to the browser and never stored. Naming a possible diagnosis to a stranger is exactly the claim nobody is yet accountable for, and a field that is not returned cannot leak.

Every refusal, by name

The body carries a reason from a closed set, so the page renders the right panel without matching on prose:

ReasonStatusMeans
missing_photos, bad_idempotency_key400The request was malformed
type, size, dimensions, heic400The photo was rejected — see photo handling
quota429This visitor is done for today
capacity429The tool is done for today
classifier_disabled503The estimate is off. No check spent
provider502The provider failed. No check spent
expired404Returned for an expired result and an unknown token alike

That last row is deliberate. An expired result and a guessed token return the same answer, because any difference between them tells someone probing for tokens that they found a real one.

quota and capacity share a status code and mean different things to the visitor, which is exactly why the reason is in the body rather than inferred from the status.

How the contract is pinned across two repositories

The API lives in a different repository from this app, and its shared types are private to it — so the contract cannot travel as an imported schema. Instead the API publishes an OpenAPI snapshot that is committed, and regenerating it produces a diff. The diff is the drift signal.

The response shape is declared once on the API side and used three times: as its own type, as the HTTP response schema, and as the structured-output schema handed to the model. Tightening the union tightens all three at once, which is why a change to what the model may answer cannot quietly disagree with what the endpoint documents.

The seam this app is built against

Every state the page can show has a mocked response behind it — each outcome, each refusal, and the expired result — and each is reachable by URL. That is not scaffolding to be deleted: it is how all sixteen states stay exercisable without a backend, including the ones that are hard to provoke on demand, like the global capacity ceiling or a provider outage.

A consumer-side contract test belongs with the client that parses these responses, which is where the pinned snapshot can actually be contradicted. Regenerating the snapshot on the API side is the other half of the same check.

Last updated on