services/api/product_app.py), so you don’t have to re-derive them per endpoint. Individual request/response schemas live on the auto-generated endpoint pages under Endpoints in the sidebar.
The error shape
Every error response on this API is FastAPI’s default:detail is usually a plain string suitable for showing directly to a user ("Invalid workspace, email, or password.", "CAP not found."), but a handful of routes return a structured object instead — for example the legacy connector-activation path returns {"detail": {"status": "not_enabled", "message": "..."}} (see not-yet-enabled connectors below). There is no separate error_code field, no envelope wrapper, and no errors array on the product API — just detail, whose shape varies only in that one documented case.
422 — validation errors
A request body that fails Pydantic validation (wrong type, missing required field, value outside an enum/Literal) never reaches your route handler. FastAPI returns 422 with the standard HTTPValidationError shape, visible in every generated endpoint page:
loc is a path into the request (["body", "<field>"] for a JSON field, ["path", "<param>"] for a path parameter, ["query", "<param>"] for a query parameter). A handful of routes also raise 422 deliberately from handler code for domain-level shape problems that Pydantic can’t express as a type — e.g. POST /admin/tenants re-validates the slug with validate_tenant_slug() and raises 422 with a plain-string detail (not the list shape) if it fails a rule Pydantic’s Field(min_length=3, max_length=63) doesn’t cover (the slug’s character-set regex — lowercase alphanumeric and hyphens only, no leading/trailing hyphen). Don’t assume every 422 has the list-shaped detail — check whether it came from Pydantic (list) or a handler’s own HTTPException(422, ...) (string) before parsing it programmatically.
409 — meaningful conflicts, not generic failures
A 409 on this API always means “the request is well-formed and you’re allowed to make it, but the resource’s current state makes it invalid right now” — never a catch-all for unexpected errors. Three recurring shapes:
Duplicate identity
onboarding_api.py’s create_tenant() before the insert, against the control.tenants.slug uniqueness the database also enforces — the API-level check exists to return this message instead of surfacing a raw database constraint violation as a 500.
Lifecycle-state conflicts
provision() only accepts tenants whose lifecycle_state is draft, provisioning, or onboarding. The same pattern recurs elsewhere on the onboarding lifecycle:
POST /admin/tenants/{tenant_id}/go-live—409listing exactly which prerequisites are missing ("Cannot go live yet: historical data ingested, a model checkpoint activated, at least one user invited."), checked against real state (aDatasetrow exists, aModelCheckpointis active, a pending/acceptedTenantInvitationexists) rather than a stored flag.POST /admin/tenants/{tenant_id}/train—409 {"detail": "No ingested dataset to train on -- complete the ingest step first."}if noDatasetexists yet.POST /admin/tenants/{tenant_id}/impersonate—409if the tenant isn’tlive, or has no active user to impersonate.POST /sources/{source_id}/upload—409 {"detail": "Source is kind 'postgres', not file_upload."}if you point the file-upload endpoint at a source registered as a different connector kind.
Optimistic-concurrency conflicts
CAP and review mutations require the caller to prove they saw the current state before writing:update_cap() in remediation_api.py conditions its UPDATE on both Cap.id and Cap.version == expected_version; if another writer already bumped the version, the conditional update matches zero rows and the route returns 409 rather than silently overwriting the other writer’s change. PATCH /remediation/reviews/{review_id} uses the same idea without an explicit version number — the UPDATE is conditioned on Review.decision IS NULL, so a second decision on an already-decided review is a 409 {"detail": "This review has already been decided."}, by design: “two reviewers cannot silently replace each other’s audit decision” (from the route’s own docstring).
Not-yet-enabled connectors (a real 409 example)
The legacy /clients/* onboarding API (services/api/clients_api.py, part of the research surface — see Research API) registers four connector kinds but only actually implements one: file_upload. Attempting to activate postgres, s3_dump, or kafka_pubsub raises ConnectorNotEnabledError (packages/causegraph/src/causegraph/onboarding/connectors.py), caught and surfaced as:
enabled (or expect this 409) first.
404 — resource doesn’t exist, route doesn’t exist, or you’re not allowed to know which
Two distinct causes produce an identical-looking 404, and the API deliberately does not distinguish them in the response:
- Genuine resource-not-found:
GET /admin/tenants/{tenant_id}for a UUID with no matching row →404 {"detail": "Tenant not found."}. Every_tenant_record_or_404()-style lookup acrossonboarding_api.py,remediation_api.py, andsources_api.pyfollows this pattern with a resource-specific message ("CAP not found.","Review not found.","Unknown source.","Unknown upload: ...".). - Route not on the allowlist at all: any path/method not in
product_app.py’s*_OPERATIONSfrozensets (see API reference overview) never gets a FastAPI route registered, so it falls through to Starlette’s default404 {"detail": "Not Found"}.
detail string), which is intentional: a route the product intentionally doesn’t expose should be indistinguishable from a resource that doesn’t exist, not leak “this endpoint exists but isn’t for you.”
401 vs 403
These are not interchangeable, and the API is consistent about which one it uses:
401— authentication itself failed or is absent: no session cookie, an expired/revoked session, or (at login) a wrong email/password/workspace combination. Always thrown byget_current_employee()orget_current_tenant_user(), or by the login handlers themselves.403— you are authenticated, but this specific action isn’t allowed: a CSRF token missing or mismatched (require_csrf), an employee’scontrol_rolenot in the route’s allowed set (require_employee_role), or a tenant user’s permission set missing the route’s required permission (require_permission). The message names what was required:"Requires one of ('onboarding_admin',), has 'support_readonly'."/"Missing required permission 'caps.write'."
403 on a route you can normally hit usually means a role/permission gap or a missing/stale CSRF header, not a broken session.
Job-status polling
Long-running operations are modeled as durable rows incontrol.platform_jobs rather than synchronous responses: provision_tenant, train_tenant_model, materialize_insights, and send_email — the last two are triggered internally and aren’t polled directly. The two the API exposes for direct polling share one status vocabulary:
not_started is synthetic — it’s what the status route returns when no PlatformJob row exists yet for that tenant+job-type pair, not a real stored value. Every other value (queued, running, succeeded, failed, cancelled) is the literal contents of PlatformJob.status.
*/status route. error_detail is null until a job fails, at which point it holds a human-readable failure reason.
Idempotency
The API’s idempotency guarantee is built on one mechanism used consistently everywhere durable work is enqueued:enqueue_platform_job() (services/pipeline/jobs.py) inserts with ON CONFLICT (idempotency_key) DO NOTHING and, on conflict, looks up and returns the existing job’s id instead of erroring or duplicating work. Callers construct keys that encode exactly what makes a request a duplicate:
POST /admin/tenants/{tenant_id}/provision→ keyprovision:{tenant_id}— calling it twice while a provision job is already queued/running returns the samejob_id, not a second job.POST /admin/tenants/{tenant_id}/train→ keytrain:{tenant_id}:{dataset_id}:{config_hash}— retraining the same dataset with the same training config resolves to the same job; changing either produces a new one. Passingretry_terminal=True(used here) additionally re-queues an existing job that had reachedfailed/cancelled, rather than treating it as permanently resolved.POST /admin/tenants/{tenant_id}/ingest/commit→ the underlyingSourceUploadrow’sstatusis checked first: committing an already-committedupload returns the previously-created dataset’s summary verbatim instead of re-processing it; an upload that previouslyfailedvalidation returns409rather than silently retrying.
POST /remediation/caps has its own idempotent-adopt behavior: creating a CAP against a target that already has a non-terminal CAP with the same problem statement (or title, if no problem statement was given) returns the existing CAP verbatim — no duplicate row, no audit log entry, since nothing changed. This exists specifically because a UI “Adopt as plan” action gives no inline feedback on success, so a double-click used to create duplicate CAPs before this check existed.
There is no generic Idempotency-Key request header on this API — idempotency is handled per-route, using domain-meaningful keys derived from the request’s own content, not a client-supplied token.
Pagination
Most list endpoints on this API return their full result set in one response, unpaginated:GET /admin/tenants, GET /sources, GET /members, GET /remediation/caps, GET /remediation/reviews, GET /admin/tenants/{tenant_id}/checkpoints, GET /admin/tenants/{tenant_id}/invitations. This is a deliberate simplicity trade-off appropriate to per-tenant result sizes (a tenant’s member list, source list, or CAP backlog), not an oversight — there is no page/cursor/next convention anywhere on this API to be consistent with.
The one exception is GET /remediation/audit-log, which accepts a limit query parameter (default 50, clamped server-side to the range 1–200) but still has no cursor or offset — it’s a “most recent N” cap, not true pagination. If you need the full history beyond 200 rows, use GET /remediation/export.xlsx instead, which is unbounded.
GET /remediation/caps and GET /remediation/reviews both accept optional target_type/target_id query parameters to filter to one entity’s records rather than paginating the whole tenant’s list.