Skip to content

TransitPin - API Reference

Source of truth: the live FastAPI backend on app3 (/opt/transitpin-api/), read from main.py, billing_routes.py, ops_routes.py, and maps_routes.py on 2026-08-15. There are 40 endpoints total (21 in main.py, 10 in billing_routes.py, 6 in ops_routes.py, 3 in maps_routes.py).

1. Conventions

  • Base URL: same-origin /api/... (nginx proxies /api/ to uvicorn on 127.0.0.1:8900). The API is not otherwise exposed publicly.
  • All request and response bodies are JSON.
  • Request bodies are dict (untyped) on most endpoints; the ops endpoints use Pydantic models (AssumeIdentityRequest, ExitRequest, LogActionRequest).
  • IDs are TEXT: UUID hex strings (uuid.uuid4().hex), slugs (tenant ids, route ids like r1), or opaque tokens.
  • Errors are standard FastAPI HTTPException JSON: {"detail": "<message>"}.
  • Version reported by /api/health is 1.0.0.

2. Auth schemes

Scheme Header Where used
none - public endpoints (health, theme, public schools, auth, registration, billing rates, provider status, tomtom health, weather)
tenant JWT Authorization: Bearer <token> product endpoints (routes, buses, schools, children, registrations, stats, billing). Validated by get_current_user.
impersonation token Authorization: Bearer <token> ops impersonated endpoints (get_impersonated_user)
platform admin tenant JWT whose user role is platform_admin or admin /api/tenants, /api/ops/assume-identity, /api/ops/audit-log, and cross-tenant /api/tenants/{tenant}/logs

2.1 get_current_user behavior (tenant JWT)

  1. Requires an Authorization: Bearer <token> header (else 401).
  2. Calls auth2 GET http://127.0.0.1:8102/api/latest/users/me with the token in an x-hexclave-access-token header (plus x-hexclave-access-type: client and x-hexclave-project-id: internal; 5s timeout). On HTTP 200 returns a normalized user {id, email, name, role, tenant_id} where role and tenant_id are read from auth2 client_metadata (with client_read_only_metadata as fallback).
  3. If auth2 does not return 200, and the token starts with demo-, returns a hardcoded user {"id":"demo-user","email":"demo@transitpin.com","name":"Demo User","role":"admin"} (role admin is a legacy alias for platform_admin).
  4. Otherwise 401 Invalid token.

auth2 session tokens are opaque (not JWTs), so there is no tenant_id JWT claim. Role and tenant identity live in auth2 client_metadata, set at registration time by the registering service.

2.2 Impersonation token (ops)

A secrets.token_urlsafe(32) value stored in impersonation_sessions with a 15-minute expiry. get_impersonated_user validates it (existence + expiry) and returns the impersonated identity, never consulting auth2.

2.3 Tenant resolution

get_tenant_id (auth-gated endpoints), priority order: (1) tenant_id claim on the authenticated user, (2) first URL path segment when not api, (3) X-Tenant-Id request header, (4) default demo.

resolve_public_tenant (no-auth endpoints in main.py), priority order: (1) tenant or slug query parameter, (2) X-Tenant-Id header, (3) first Host subdomain label (skipping www, my, app, ops, webmail, and numeric labels), (4) first path segment when not api, (5) default demo.

POST /api/registrations/full resolves tenant from the payload tenant field, then the X-Tenant-Id header, then demo (it does NOT read the Host header).

3. Endpoint reference

3.1 Health and tenants

GET /api/health

Auth: none. Response: {"status":"ok"|"degraded","db":"ok"|"error","version":"1.0.0","time":"<iso>"}.

GET /api/tenants

Auth: platform admin. Response: array of tenants, each with id, name, tier, theme (parsed object), domain, created_at.

GET /api/theme/{slug}

Auth: none. Response: {"tenant_id","name","tier","theme":{...},"domain"}. 404 Tenant not found for an unknown slug.

3.2 Auth

POST /api/auth/login

Auth: none. Body: {"email","password"} (both required, else 400). Behavior: calls auth2 POST /api/latest/auth/password/sign-in with the publishable client key and client headers, then resolves the user via GET /users/me. Returns {"access_token","token_type":"Bearer","user":{...}} where user carries role and tenant_id from client_metadata. Wrong credentials return 4xx (no demo fallback); the demo fallback fires only if auth2 is unreachable.

POST /api/auth/register

Auth: none. Body: {"email","password","display_name"?,"tenant"?} (email and password required, else 400). Behavior: creates a real auth2 parent account via POST /api/latest/auth/password/sign-up (Stack Auth accepts only email+password), then applies display_name and client_metadata (role=parent, tenant_id=<tenant>) via a best-effort PATCH /users/me. Returns {"access_token","token_type":"Bearer","user":{...},"metadata_applied":bool}. Parent enrollment (household, children, invoices) is handled separately by /api/registrations/full.

3.3 Registrations (legacy list)

GET /api/registrations

Auth: tenant JWT. Query: status (optional, all or a status), search (optional, matches name/school/route). Response: array of registration objects ordered by created_at DESC.

PUT /api/registrations/{reg_id}/approve

Auth: tenant JWT. Behavior: marks the registration approved, promotes the household's pending registration invoice to sent, and emails the parent the $50 registration fee payment option (best-effort; a payment link is included when REGISTRATION_PAY_URL is set). 404 if not found in the tenant. Response: {"status":"ok","id":"<reg_id>","action":"approved","email_sent":bool}.

PUT /api/registrations/{reg_id}/reject

Auth: tenant JWT. Response: {"status":"ok","id":"<reg_id>","action":"rejected"}. 404 if not found in the tenant.

POST /api/registrations/full

Auth: none (public; the signup form runs before the parent has a token). Body:

{
  "tenant": "<slug>",
  "parent": {"name","email","phone","relationship","address"},
  "parent2": {"name","relationship","phone","email"},
  "children": [{"name","transport_need":"morning|afternoon|both","school","school_id","grade","route"}],
  "emergency_contacts": [],
  "first_pickup_date": "YYYY-MM-DD",
  "policy_acknowledged": ["transportation-agreement", "..."]
}
Behavior: writes household + primary payer + optional second payer + one child row per named child (with trip_type mapped from transport_need and rate_weekly set) + a registration invoice of $50.00 (status pending). Also creates a registrations row (id = household id) so the signup surfaces in the admin approval queue. Sets first_pickup_date and policy_acknowledged on the household. The registration-fee email is deferred to admin approval; nothing is emailed at signup. Response:
{
  "household_id": "...",
  "children_count": 2,
  "weekly_total": 240.0,
  "registration_fee": 50.0,
  "registration_fee_invoice_id": "...",
  "email_sent": true,
  "first_pickup_date": "2026-09-01",
  "policy_acknowledged": 5,
  "parent2_added": true
}

3.4 Routes

GET /api/routes

Auth: tenant JWT. Response: array of route objects (no stops array; the stops field is the denormalized integer count).

POST /api/routes

Auth: tenant JWT. Body: {"name" (required), "bus_id", "driver", "mode", "stops":[{"name","address","lat","lng","time","riders":[]}]}. Response: the created route merged with its ordered stops (each stop's riders JSON-decoded).

GET /api/routes/{route_id}

Auth: tenant JWT. Response: route merged with stops[]. 404 Route not found.

PUT /api/routes/{route_id}

Auth: tenant JWT. Body: any of name, driver, bus_id, mode, status, miles, and optionally stops[] (which deletes and re-inserts all stops and re-syncs the stops count). Response: route merged with stops[]. 404 Route not found.

DELETE /api/routes/{route_id}

Auth: tenant JWT. Response: {"ok":true}. 404 Route not found. Also deletes the route's route_stops.

3.5 Buses

GET /api/buses

Auth: tenant JWT. Response: array of bus objects.

GET /api/buses/{bus_id}

Auth: tenant JWT. Response: one bus object. 404 Bus not found.

PUT /api/buses/{bus_id}/location

Auth: tenant JWT. Body: {"lat","lng","heading","speed"}. Response: {"status":"ok"}. Sets last_update to now.

3.6 Schools

GET /api/schools

Auth: tenant JWT. Response: array of school objects (all columns).

GET /api/public/schools

Auth: none (tenant resolved via resolve_public_tenant). Response: {"tenant_id":"<slug>","schools":[{"id","name","address","start_time","end_time"}]}.

3.7 Children and stats

GET /api/children

Auth: tenant JWT. Response: array of active children (all columns, including the billing extension columns trip_type, household_id, rate_weekly, school_id).

GET /api/dashboard/stats

Auth: tenant JWT. Response: {"pending_registrations","active_buses","active_children","total_routes"} (integers).

3.8 Audit (main.py)

GET /api/tenants/{tenant}/logs

Auth: platform admin OR a user whose tenant_id equals tenant. Response: array of audit_log rows (newest first), with details JSON-decoded.

3.9 Billing

GET /api/billing/households

Auth: tenant JWT. Response: array of households, each with children[] and a computed weekly_total.

GET /api/billing/households/{household_id}

Auth: tenant JWT. Response: household plus children[], payers[], allocations[], weekly_total, and payer_splits[] (each {"payer_id","amount"}). 404 Household not found.

POST /api/billing/households/{household_id}/payers

Auth: tenant JWT. Body: {"name","email","phone","allocation":{"child_id","split_type":"percentage|fixed|per_child","value"}}. Behavior: inserts a non-primary payer and, if allocation is present, a billing_allocations row. Response: {"payer":{...},"allocation":{...}|null}.

POST /api/billing/generate

Auth: tenant JWT. Body: none. Behavior: for the current Monday-Sunday cycle, computes each household total and splits it across payers, creating one draft invoice per payer (provider square if Square is connected, else none). Response: {"cycle_start","cycle_end","invoices":[{...}]}.

POST /api/billing/generate-due

Auth: tenant JWT. Body: none. Behavior: generates and emails the FIRST weekly transportation invoice for every household whose first_pickup_date is within 5 days (or past). Idempotent: households that already have a transportation invoice are skipped. Response: {"date","generated":[{...}],"emailed":[{...}],"skipped":["<household_id>",...]}.

POST /api/billing/households/{household_id}/first-pickup

Auth: tenant JWT. Body: {"first_pickup_date":"YYYY-MM-DD"} (required). Response: {"household_id","first_pickup_date"}. 404 Household not found.

GET /api/billing/outstanding

Auth: tenant JWT. Response: array grouped by household, each {"household_id","household_name","total_outstanding","payers":[{...}]} (invoices with status unpaid, partial, or sent).

GET /api/billing/rates

Auth: none. Response: {"one_way":120.0,"two_way":175.0,"cycle":"weekly","registration_fee":50.0}.

GET /api/billing/provider-status

Auth: none. Response: {"square_connected":false,"stripe_connected":false}.

3.10 Ops (assume identity)

POST /api/ops/assume-identity

Auth: platform admin. Body: {"tenant_id" (required), "user_email", "target_role":"operator"}. Response: {"token":"<opaque>","expires_at":"<iso>","banner":"Acting as <tenant_id>"}. Also writes assume_identity_start to audit_log.

POST /api/ops/exit-impersonation

Auth: impersonation token via body {"token"} or Bearer header. Response: {"status":"ok","exited":true,"tenant_id":"<id>"}, or {"status":"ok","exited":false,"detail":"session not found"}.

GET /api/ops/audit-log

Auth: platform admin. Query: tenant_id (optional), limit (default 100, clamped 1..1000). Response: array of audit_log rows (newest first), details JSON-decoded.

POST /api/ops/log-action

Auth: impersonation token (Bearer header). Body: {"action" (required), "resource", "details":{}}. Response: {"status":"ok","id":"<audit id>","logged":true}.

GET /api/ops/impersonated/me

Auth: impersonation token. Response: {"impersonated_user":{...}}.

GET /api/ops/impersonated/registrations

Auth: impersonation token. Response: array of registrations for the impersonated tenant, and auto-writes a list_registrations audit row.

3.11 Maps, traffic, weather

GET /api/health/tomtom

Auth: none. Server-side ping to TomTom flowSegmentData. Response: {"ok":true,"status":200,"detail":"TomTom flowSegmentData reachable"}.

GET /api/weather

Auth: none. Query: lat (default 32.0158), lon (default -81.0595). Response: raw Open-Meteo forecast JSON (current, daily, ...). 502 on upstream failure.

GET /api/routes/{route_id}/conditions

Auth: tenant JWT. Response: {"route_id","route_name","summary":"clear|moderate|heavy|unavailable","incident_count","incidents":[{...}],"schools":[{...}],"bbox":"31.95,-81.20,32.15,-80.95"}. 404 Route not found. Degrades to summary:"unavailable" on TomTom failure.