services/api/product_app.py. That module — not services/api/main.py — is the process the deployed container actually runs (Dockerfile starts uvicorn services.api.product_app:app). If you came here looking for the older research/clustering surface, it exists but is a different app entirely; see Research API.
Base URLs
- Local product stack
- Production
http://127.0.0.1:18000This is the port the product Compose stack publishes for the API container (infra/docker-compose.product.yml, overridable with CAUSELOOP_API_PORT). The browser never talks to it directly — the Next.js frontend’s route handler at src/app/api/backend/[...path]/route.ts proxies every call from http://127.0.0.1:13000. See Local product stack to bring the stack up.Whichever base URL you’re pointed at, every path in this reference is relative to it with no version prefix — e.g.
POST /login, not POST /v1/login.The allowlist philosophy
product_app.py does not simply include_router() the auth/onboarding/insights/members/sources/remediation/metrics routers and expose whatever routes they happen to define. Each router is passed through _selected_router() alongside an explicit frozenset of (METHOD, path) tuples — AUTH_OPERATIONS, ONBOARDING_OPERATIONS, INSIGHT_OPERATIONS, MEMBER_OPERATIONS, SOURCE_OPERATIONS, REMEDIATION_OPERATIONS, OBSERVABILITY_OPERATIONS — and only routes whose method+path appear in that set are copied onto the app that actually serves traffic. Everything else defined in the same router module is simply never registered on product_app.
This is enforced at import time, not by convention: _selected_router() raises RuntimeError at startup if an allowlisted operation is missing from the router, or if a route mixes an allowlisted method with a non-allowlisted one on the same path. A route that exists in auth_api.py, onboarding_api.py, sources_api.py, or remediation_api.py but isn’t in one of those frozensets is not a 403 or a hidden route — it is a plain 404, because FastAPI’s routing table never learned about it in the first place.
Concretely, the following all exist as real, working code in their router modules but are not part of the deployed product surface:
GET /sources/connectors,POST /sources/{source_id}/test-connection,POST /sources/{source_id}/sync,GET /sources/{source_id}/history,GET /sources/jobs/{job_id}/events(services/api/sources_api.py)GET /admin/sessions,DELETE /admin/sessions/{session_id},POST /admin/password-reset/request,POST /admin/password-reset/confirm,POST /password-reset/request,POST /password-reset/confirm,GET /dev/mailbox(services/api/auth_api.py)POST /remediation/reviews,POST /remediation/caps/{cap_id}/steps/{step},DELETE /remediation/caps/{cap_id}(services/api/remediation_api.py)POST /admin/tenants/{tenant_id}/materialize-now,POST /admin/tenants/{tenant_id}/run-migrations,POST /admin/tenants/{tenant_id}/import-checkpoint,POST /admin/tenants/{tenant_id}/rename(services/api/onboarding_api.py— each markedTEMPORARYin an adjacent comment)
/health* routes registered directly on product_app alongside the seven allowlisted route groups gives exactly 44 operations — the entire published surface. If you’re extending the product, adding a route to a router is necessary but not sufficient: it also has to be added to the relevant *_OPERATIONS frozenset in services/api/product_app.py, or it will 404 in every environment including local Compose.
OpenAPI: source of truth
Two OpenAPI documents matter, and they should agree:- Live schema —
GET /openapi.jsonon a running product API (http://127.0.0.1:18000/openapi.jsonlocally,https://api.causeloop.ai/openapi.jsonin production). FastAPI generates this from the routes actually registered onproduct_app, so it can never drift ahead of the allowlist — a route that isn’t allowlisted cannot appear here either. Interactive docs are at/docs(Swagger UI) and/redocon the same base URL; neither is disabled in this app. - Committed snapshot —
docs-site/api-reference/openapi.json. This is what Mintlify reads to auto-generate the per-endpoint pages under the Endpoints group in the sidebar (configured indocs-site/docs.json). It is a point-in-time copy, not a live proxy — if you change a route’s request/response shape, regenerate it by starting the product API locally and overwriting this file with the contents of its/openapi.json, then re-check the auto-generated pages render as expected.
/openapi.json over the committed file, and the file over anything written in prose (including this reference).
Endpoint groups
Authentication
Staff session (
/admin/login, /admin/me, /admin/logout), tenant session (/login, /me, /logout), /workspaces, /accept-invite, impersonation, cookies, and CSRF.Onboarding (staff)
Tenant lifecycle under
/admin/tenants/*: create, provision, ingest, training config, data policy, source seeding, train, checkpoints, activation, invitations, go-live. services/api/onboarding_api.py.Insights (tenant)
GET /insights/snapshot — one coherent (dataset_version, model_version) pair’s worth of materialized insight collections. services/api/insights_api.py.Members (tenant)
GET /members — read-only tenant user directory with role labels. services/api/members_api.py.Sources (tenant)
GET /sources, POST /sources, POST /sources/{source_id}/upload — connector registration and the file-upload ingest path. services/api/sources_api.py.Remediation (tenant)
CAP (corrective action plan) lifecycle, review decisions, tenant audit log, branded Excel export.
services/api/remediation_api.py.GET /metrics (Prometheus text format, employee-authenticated) rounds out the allowlist as the observability operation; see Observability for how it’s scraped.
Content types
Nearly every request and response body on this API isapplication/json. The exceptions:
- File uploads (
POST /admin/tenants/{tenant_id}/ingest/upload,POST /sources/{source_id}/upload) acceptmultipart/form-datawith a singlefilefield. GET /remediation/export.xlsxreturnsapplication/vnd.openxmlformats-officedocument.spreadsheetml.sheetbytes as a streamed attachment, not JSON.GET /metricsreturns Prometheus text exposition format, not JSON (see above).
422 before any handler code runs. See Errors and conventions for the full error contract.
The /health family
Three routes are registered directly on product_app (not through the allowlist mechanism, since they exist purely for orchestration and carry no tenant or auth semantics):
/health and /health/live are also two of the handful of paths the frontend proxy treats as public — reachable without a session cookie, so an external uptime check only needs the frontend’s URL. See Authentication for the full public-path list.