API Reference
Base URL (local): http://127.0.0.1:8000/api/v1 — OpenAPI at /openapi.json and Swagger at /docs. All JSON bodies use strict lowerCamelCase. Single pagination-free list responses in v1; order is explicit.
Envelope
Every JSON response except raw attachment bytes, text/event-stream, and 204 reset uses:
{"data": <T>, "meta": {"schemaVersion":"1","requestId":"<uuid>","serverTime":"...Z","stateEpoch":"<uuid>","storeRevision":42}}
{"error":{"code":"<dot.cases>","message":"...","details":{...}},"meta":{...}}
requestId is per-request UUID from IdGenerator. storeRevision monotonic, stateEpoch changes only on reset/reseed. GET /sync-snapshot adds streamEpoch/streamCursor.
Health
GET /api/v1/health
{"data":{"status":"ok","schemaVersion":"1","contractVersion":"1","seedVersion":"1","serverTime":"2026-08-01T12:00:00Z","devRoutesEnabled":false},"meta":{...}}
Bookings & Hub
GET /api/v1/bookings
GET /api/v1/bookings/{booking_id}/hub
hub returns {booking, inventoryReports[], inspections[], tasks[], charges[], notifications[], generatedAt} filtered to one booking — the aggregate the Maintenance Hub renders without N round-trips. Charges and tasks are not paginated; v1 order is snapshot order.
curl
curl -s http://127.0.0.1:8000/api/v1/bookings/BKG-001/hub | python -m json.tool
Golden: docs/contracts/examples/get_booking_hub_200.json, get_booking_hub_empty_200.json.
Sync snapshot
GET /api/v1/sync-snapshot
Returns {data:{bookings,inventoryReports,inspections,tasks,charges,notifications}, meta:{..., streamEpoch, streamCursor}}. Frontend's SnapshotCoordinator seeds Drift from this plus fixture bundle.
Inventory & Inspections
GET /api/v1/inventory-reports/{report_id}
GET /api/v1/inspections/{inspection_id}
Inspections include generalNotes[], itemActions[] (chargeId?→charge), itemUpdates[]. Each itemAction carries thumbnailUrl for the horizontal photo strip.
Charges
GET /api/v1/charges?bookingId=BKG-001&status=outstanding&status=accepted
GET /api/v1/charges/{charge_id}
status is repeatable; unknown value ⇒ 422 validation_failed with allowed list. Before any read, ChargeService reconciles deadlines under the store lock so callers never see stale outstanding past deadlineAt.
Commands — Accept / Pay (JSON)
Required header: Idempotency-Key: <uuid>. Body:
{"expectedStateEpoch":"<uuid from meta.stateEpoch>","expectedVersion":1}
POST /api/v1/charges/{charge_id}/accept
POST /api/v1/charges/{charge_id}/pay
POST .../accept—outstanding → accepted, writesacceptedAt=<now>, acceptanceOrigin=student, version+1.POST .../pay—accepted → paid, writespaidAt=<now>.
curl (full journey)
EPOCH=$(curl -s http://127.0.0.1:8000/api/v1/charges/CHG-001 | python -c 'import json,sys;print(json.load(sys.stdin)["meta"]["stateEpoch"])')
curl -s -X POST http://127.0.0.1:8000/api/v1/charges/CHG-001/accept \
-H 'Content-Type: application/json' -H "Idempotency-Key: $(uuidgen)" \
-d "{\"expectedStateEpoch\":\"$EPOCH\",\"expectedVersion\":1}" | python -m json.tool
# same key+body replay is idempotent; same key+different body => 409 idempotency.payload_mismatch
Goldens: post_charge_accept_200.json, post_charge_pay_200.json, error_charge_version_conflict_409.json.
Contest (multipart)
POST /api/v1/charges/{charge_id}/contest
Content-Type: multipart/form-data; boundary=...
Idempotency-Key: <uuid>
Parts:
metadata(Content-Type: application/json):{"reason":"<10–2000 graphemes>","expectedStateEpoch":"<uuid>","expectedVersion":1}attachments(0–5 files):image/jpeg, image/png, image/webp, video/mp4, application/pdf; per-file ≤10 MiB, total ≤25 MiB. Server stages to.staging/<requestId>/, hashes SHA-256, sanitizesdisplayName, then finalizes only insidecommit().
curl
EPOCH=$(curl -s http://127.0.0.1:8000/api/v1/charges/CHG-001 | python -c 'import json,sys;print(json.load(sys.stdin)["meta"]["stateEpoch"])')
curl -s -X POST http://127.0.0.1:8000/api/v1/charges/CHG-001/contest \
-H "Idempotency-Key: $(uuidgen)" \
-F 'metadata={"reason":"Window already damaged on check-in — see photos","expectedStateEpoch":"'"$EPOCH"'","expectedVersion":1};type=application/json' \
-F 'attachments=@/tmp/photo.jpg;type=image/jpeg' | python -m json.tool
Golden: post_charge_contest_200.json.
Tasks
POST /api/v1/tasks
Idempotency-Key: <uuid>
Content-Type: application/json
{"id":"<client uuid>","bookingId":"BKG-001","category":"electrical","notes":"Replace bulb","location":"Kitchen","date":"2026-08-02T09:00:00Z","expectedStateEpoch":"<uuid>"}
id is client UUID and authoritative. Notes are .strip()'d server-side. 201 + envelope on create; task status new now inProgress/outstanding/... transitions are future.
Golden: post_task_201.json.
Notifications
GET /api/v1/notifications?after=2026-08-01T12:00:00Z&unreadOnly=false
POST /api/v1/notifications/{notification_id}/read
Content-Type: application/json
{"expectedStateEpoch":"<uuid>"}
GET filters by after (exclusive ISO-8601 UTC) and unreadOnly; POST .../read is naturally idempotent (second call same stateEpoch is success, no idempotency key, no ledger).
Golden: get_notifications_200.json, post_notification_read_200.json.
Attachments (bytes)
GET /api/v1/attachments/{attachment_id}
GET /api/v1/attachments/{attachment_id}/thumbnail # when thumbnailUrl exists
Returns raw bytes with Content-Type + Content-Disposition; not envelope-wrapped. Seed media lives in app/static/photos/, uploads in runtime/uploads/<id>.
SSE Events
GET /api/v1/events
Accept: text/event-stream
Last-Event-ID: <cursor> # optional replay
Frames (from sse_frames.json):
id: 01H...
event: charge.updated
data: {"chargeId":"CHG-001","type":"charge.updated", ...}
: heartbeat
Server sends event ∈ {charge.created, charge.updated, task.created, notification.updated, sync.required}, id is streamCursor, data JSON. Heartbeat ": \n\n" every sse_heartbeat_seconds=15. Last-Event-ID replays from ring if still retained, else sync.required so client takes a fresh snapshot. Chaos injection can delay/429 this stream.
Dev routes
Enabled only when KX_ENABLE_DEV_ROUTES=true and KX_DEV_TOKEN non-empty — otherwise not even bound. Every call requires X-Dev-Token: <token> constant-time compare.
POST /api/v1/_dev/reset # 204, exclusive barrier, clears state+uploads, reseeds
POST /api/v1/_dev/charges # raise arbitrary charge for deadline tests
POST /api/v1/_dev/charges/{id}/operator/{uphold|dismiss} # contested → accepted|resolved
POST /api/v1/_dev/clock # bump demo clock
/_dev/reset takes barrier.exclusive(), so in-flight reads/uploads finish before deletion.
Filtering & validation summary
| Query | Allowed | On bad value |
|---|---|---|
status (repeatable) | frozen enum list | 422 with {allowed: [...]} |
bookingId | existing id (404 if not found on hub/task) | 404 |
after (notifications) | ISO-8601 UTC Z | 422 |
Idempotency-Key | UUID v4 | 400 request.idempotency_key_missing or 422 |
See Envelopes & Errors for the full {code, details} catalogue and status mapping.