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.
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.
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.
name, volume_l required{"applied": true}cost (dollars) or cost_centsPhotograph 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.
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.
chemical · equipment · service · maintenance ·
utility · opening · closing · other
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.
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.