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}
POST/api/tests/{id}/insightgenerate an AI analysis
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
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.

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.