Pool
Open dashboard

API

Everything the interface can do is an API call. Create a key under Settings → API keys, then send it as a bearer token.

curl https://pool.biswas.me/api/pools \
  -H "Authorization: Bearer pool_sk_..."

Want to try the API without signing up? Open the live demo, then create a key under Settings — the demo account works exactly like a real one.

An X-API-Key header works too. Session cookies authenticate the same endpoints from the browser, so there is no separate API surface to keep in sync.

A key carries a read or read,write scope, enforced on the HTTP method: a read key cannot POST, PATCH or DELETE, and gets a 403 if it tries. A key cannot issue another key.

Machine-readable

The full OpenAPI 3.1 document lives at /api/openapi.yaml. It is public and needs no credential — requiring one to discover how to present one is a loop.

curl -s https://pool.biswas.me/api/openapi.yaml -o pool.yaml

Hand it and a key to an LLM or a client generator and it can work out the calls unaided. Creating a key returns the document's location and a curl that works as printed, alongside the secret.

Quick start

Create a pool, record a test, and log what you spent — the three calls that matter.

KEY="pool_sk_..."
BASE="https://pool.biswas.me/api"

# 1. Create a pool. Volume drives every dose calculation.
curl -sX POST $BASE/pools -H "Authorization: Bearer $KEY" \
  -H 'Content-Type: application/json' -d '{
    "name": "Backyard pool",
    "volume_l": 58000,
    "sanitizer": "salt",
    "surface": "vinyl",
    "location": "Outdoor"
  }'

# 2. Record a water test. Every reading is optional.
curl -sX POST $BASE/tests -H "Authorization: Bearer $KEY" \
  -H 'Content-Type: application/json' -d '{
    "pool_id": 1,
    "tested_at": "2026-07-29",
    "company_name": "Jameson Pool & Spa",
    "free_chlorine": 0.23,
    "total_chlorine": 0.49,
    "total_salt": 2161,
    "ph": 7.30,
    "total_alkalinity": 106,
    "calcium_hardness": 159,
    "cyanuric_acid": 5,
    "temperature": 21,
    "total_copper": 0.30,
    "iron": 0.10
  }'

# 3. Log what went in, and what it cost. Backdating is normal.
curl -sX POST $BASE/log -H "Authorization: Bearer $KEY" \
  -H 'Content-Type: application/json' -d '{
    "pool_id": 1,
    "occurred_on": "2026-06-10",
    "category": "chemical",
    "item": "Chlorine",
    "quantity": 10, "unit": "L",
    "cost": 42.50,
    "vendor": "Jameson Pool & Spa"
  }'

The test response comes back with the readings scored against the right ranges for your pool, the saturation index, derived warnings, and a dosing plan.

Endpoints

Pools

GET/api/poolslist your pools
POST/api/poolscreate — name, volume_l required
GET/api/pools/{id}
PATCH/api/pools/{id}
DELETE/api/pools/{id}

Water tests

GET/api/tests?pool_id=&from=&to=&limit=
POST/api/testsreturns readings, alerts and doses
POST/api/tests/from-photomultipart; transcribes a photographed sheet, then analyses it
GET/api/tests/{id}full detail with treatments and notes
PATCH/api/tests/{id}
DELETE/api/tests/{id}
GET/api/tests/{id}/contextthe brief an analysis is written from
POST/api/tests/{id}/insightgenerate an analysis, or file one you wrote
POST/api/treatments/{id}/applied{"applied": true}

Logbook and costs

GET/api/log?pool_id=&season_id=&category=&from=&to=
POST/api/logaccepts cost (dollars) or cost_cents
PATCH/api/log/{id}
DELETE/api/log/{id}
GET/api/seasons?pool_id=
POST/api/seasonsentries re-file into the matching season automatically

Attachments

POST/api/attachmentsmultipart; images recompressed, 25 MB limit
GET/api/attachments?pool_id=&kind=&test_id=
GET/api/attachments/{id}/file
POST/api/attachments/{id}/linkattach to a log entry
POST/api/attachments/{id}/parseread a stored sheet into a test
DELETE/api/attachments/{id}

Analytics

GET/api/analytics/summary?pool_id=dashboard headline
GET/api/analytics/costs?pool_id=&season_id=by category, month, item, vendor, cumulative
GET/api/analytics/trends?pool_id=series with ideal bands, plus logbook events

Account

GET/api/me
GET/api/keys
POST/api/keysplaintext key returned once
DELETE/api/keys/{id}
PUT/api/me/aiyour own LLM key, base URL and model

A test from a photograph

Photograph the printout the pool store hands over and the whole of it — twenty readings, the date, who tested it — becomes a scored test with a dosing plan, without anyone typing a number.

curl -sX POST $BASE/tests/from-photo \
  -H "Authorization: Bearer $KEY" \
  -F "[email protected]" \
  -F "pool_id=1" \
  -F "hint=the salt row is smudged"

The response is the usual test detail — readings, warnings, doses — plus parsed, which reports what was read off the paper, and insight, the analysis, which runs in the same request unless you send analyse=false.

Nothing is written until the photo has been read, so an unreadable picture leaves no empty test behind. A date you supply beats the one printed on the sheet. The photo is filed against the test it produced, so the readings can always be checked against the paper they came from.

Readings that are physically impossible for pool water — a pH of 73, a negative hardness — are discarded rather than corrected, and listed in parsed.rejected: there is no way to know which digit was misread, and a wrong number here becomes a dose recommendation. Whatever the model could not read is filed as a note on the test, so a blank row still has an explanation months later.

Requires an AI provider — the server's, or your own key from PUT /api/me/ai.

Or read a sheet you already uploaded

Upload through /api/attachments — where the image is sniffed, downscaled and re-encoded — and then point /parse at it. The picture never has to be sent twice, and a sheet filed weeks ago can still become a scored test.

ID=$(curl -sX POST $BASE/attachments \
  -H "Authorization: Bearer $KEY" \
  -F "[email protected]" -F "pool_id=1" -F "kind=test_sheet" | jq .id)

curl -sX POST $BASE/attachments/$ID/parse -H "Authorization: Bearer $KEY"

The attachment is filed against the test it produces. Both paths return the same document: the test detail, parsed, and insight.

Doing the reading yourself

The photo endpoints call whatever model this server is configured with. If you have a better one — an agent with a frontier vision model, say — do the reading yourself and post the result. Nothing about that path is second-class: the chemistry, the dosing plan and the season rollups are computed by the server either way.

# 1. Post the readings you transcribed. Say what did the reading.
TEST=$(curl -sX POST $BASE/tests -H "Authorization: Bearer $KEY" \
  -H 'Content-Type: application/json' -d '{
    "pool_id": 1, "tested_at": "2026-08-22", "source": "claude-code",
    "operator": "Jet", "test_count": 3, "company_name": "Jameson Pool & Spa",
    "ph": 7.30, "total_alkalinity": 109, "calcium_hardness": 244,
    "cyanuric_acid": 23, "free_chlorine": 4.17, "total_copper": 0.50
  }' | jq -r .test.id)

# 2. Keep the photo with the test it came from.
curl -sX POST $BASE/attachments -H "Authorization: Bearer $KEY" \
  -F "[email protected]" -F "pool_id=1" -F "kind=test_sheet" -F "test_id=$TEST"

# 3. Fetch the brief — the same context the server would give its own model.
curl -s $BASE/tests/$TEST/context -H "Authorization: Bearer $KEY" | jq -r .brief

# 4. File the analysis you wrote against those instructions.
curl -sX POST $BASE/tests/$TEST/insight -H "Authorization: Bearer $KEY" \
  -H 'Content-Type: application/json' -d '{
    "headline": "Copper at 0.50 ppm — sequester before any oxidiser",
    "summary": "...",
    "findings": [{"title": "...", "detail": "...", "severity": "serious"}],
    "actions": ["..."], "watch": ["..."],
    "author": "claude-opus-4.6 via Claude Code"
  }'

The context response also carries instructions — the prompt the local model is held to. Follow it. It contains the dosing-order rule, which is a safety constraint rather than a stylistic one: metal sequestrant always goes in before any oxidiser.

A supplied analysis is validated, bounded and severity-normalised exactly like a locally generated one, and recorded with provider: external so it stays obvious months later which is which. Posting /insight with no body still asks the server to generate one.

Uploading a receipt

curl -sX POST $BASE/attachments \
  -H "Authorization: Bearer $KEY" \
  -F "[email protected]" \
  -F "pool_id=1" \
  -F "total=86.40" \
  -F "purchased_on=2026-06-10" \
  -F "vendor=Jameson Pool & Spa"

Images are downscaled to 1600px on the long edge and re-encoded, so a 12 MB phone photo typically lands under 300 KB. The response reports both sizes. PDFs are stored as uploaded.

Reference

Log entry categories

chemical · equipment · service · maintenance · utility · opening · closing · other

Test reading fields

free_chlorine total_chlorine combined_chlorine total_salt bromine ph total_alkalinity calcium_hardness cyanuric_acid phosphate borate tds temperature total_copper free_copper combined_copper iron

Combined chlorine is derived from total minus free when you don't supply it. Adjusted alkalinity, the saturation index and the 0–100 score are always computed server-side.

Errors

Failures return the matching HTTP status with {"error": "..."}. A revoked or expired key returns 401; a key whose scope does not cover the request returns 403. An upload over 25 MB returns 413, and a photo nothing could be read from returns 422.