Coverage note (2026-09-23): every route in
backend/app/api/routes/was compared both ways against the tables below (the 2026-09-23 audit,docs/audits/2026-09-23-recent-work-audit.md): nine that were missing are now listed, and no documented route is absent from the code. The route modules remain the tiebreaker (docs/DOCMAP.md); the OpenAPI docs at/docs(bottom of this page) are generated from them. The 2026-07-24 inventory (docs/qa-audit/full-audit-2026-07-24/02-api-contract.md) is history.
The backend exposes a REST API at http://localhost:8000/api. All endpoints
require authentication, except the public-by-design set:
/api/auth/* (register/login/google/verify-email/resend-verification/forgot-password/reset-password),
GET /api/health, GET /api/billing/plans, and the two signed webhooks
(POST /api/billing/webhook — Stripe signature; POST /api/repos/{project_id}/webhook — HMAC).
Note that GET /api/health/modules does require authentication.
Two mechanisms are supported (backend/app/api/deps.py::get_current_user):
-
Browsers authenticate with an
httpOnlysession cookie plus a CSRF double-submit token (theX-CSRF-Tokenheader must equal the CSRF cookie on any mutating request, or the call is rejected with403). The JWT is not exposed to JavaScript and is omitted from login response bodies whenauth_cookie_enabled. -
Non-browser API clients continue to pass a JWT in the
Authorizationheader (CSRF does not apply to bearer auth):Authorization: Bearer <jwt-token>
| Method | Endpoint | Description |
|---|---|---|
| POST | /api/auth/register |
Create account (email, password, display_name) |
| POST | /api/auth/login |
Login (email, password) → JWT token |
| POST | /api/auth/google |
Google OAuth login (credential token) |
| POST | /api/auth/change-password |
Change password (current + new) |
| POST | /api/auth/refresh |
Refresh JWT token |
| GET | /api/auth/me |
Get current user profile |
| POST | /api/auth/complete-onboarding |
Mark onboarding complete |
| DELETE | /api/auth/account |
Delete account and all data |
| POST | /api/auth/mcp-tokens |
Issue a per-user MCP API token (plaintext shown once) |
| GET | /api/auth/mcp-tokens |
List the caller's MCP tokens (hash + prefix only) |
| DELETE | /api/auth/mcp-tokens/{token_id} |
Revoke an MCP token (404 if missing/not owned) |
| POST | /api/auth/verify-email |
Confirm an email address; on success the now-verified address auto-accepts its pending email invites (F-PROJ-01) |
| POST | /api/auth/resend-verification |
Re-issue and re-send the verification link for the current user |
| POST | /api/auth/forgot-password |
Request a password-reset link. Public and rate-limited (SCN-013) |
| POST | /api/auth/reset-password |
Set a new password from a reset link. Public and rate-limited (SCN-013) |
Rate limits: Register: 5/min, Login: 10/min, Google: 10/min, MCP token create: 10/min, revoke: 30/min
See docs/MCP_SERVER.md for the full MCP integration guide.
| Method | Endpoint | Description |
|---|---|---|
| POST | /api/projects |
Create project |
| GET | /api/projects |
List user's projects |
| GET | /api/projects/{id} |
Get project details |
| PATCH | /api/projects/{id} |
Update project |
| DELETE | /api/projects/{id} |
Delete project |
| GET | /api/projects/{id}/readiness |
Check project setup readiness. Each step has three states — done, running (repo_indexing, db_indexing, code_db_syncing), neither — and a running step is never listed in missing_steps, because starting it again is refused by uq_indexing_runs_active_one. repo_indexing reads indexing_runs (B-27) |
| GET | /api/projects/{id}/pipeline-status |
Unified repo/DB index/code-DB sync running state |
| GET | /api/projects/{id}/knowledge-health |
Knowledge freshness panel data |
| GET | /api/projects/{id}/sync-history?limit=N |
Recent scheduled background runs (viewer) — the nightly knowledge sync (kind: "daily_sync") and each analytics connection's collection (kind: "analytics_collect", carrying connection_id), newest-first across both. Returns {"runs": [{id, kind, connection_id, status, outcome, trigger, created_at, started_at, finished_at, duration_seconds, error, progress_pct, steps}]}. status is the run lifecycle (completed, failed, running…); outcome is what the run reported about itself from meta_json (`success |
| POST | /api/projects/access-requests |
Request the privilege to create projects. The email to the operator IS the request, so a send that did not happen answers 503, never ok (SCN-004). 3/hour |
| POST | /api/projects/{project_id}/sync-now |
Trigger a daily-sync run on demand (editor) |
| GET | /api/projects/{project_id}/sync-schedule |
Effective schedule for a project — its override, else the global setting |
| PUT | /api/projects/{project_id}/sync-schedule |
Set a per-project daily-sync override (editor) |
| GET | /api/projects/{project_id}/runs |
Background-run history for a project (viewer) |
| GET | /api/projects/{project_id}/attention |
What needs the user in this project (viewer). Returns {"items": [{kind, subject, what, severity, route, at}], "more": N, "degraded": [source]}. severity is `critical |
| Method | Endpoint | Description |
|---|---|---|
| POST | /api/connections |
Create connection (database, MCP, or analytics source) |
| GET | /api/connections/project/{project_id} |
List connections for a project |
| GET | /api/connections/{id} |
Get connection |
| PATCH | /api/connections/{id} |
Update connection. Includes purpose — free text, max 2000 chars, what this source is FOR in the owner's words (SCN-134). It reaches the SQL agent's prompt as data under its own heading, attributed to the user and never framed as an instruction; the block is budgeted and drops whole descriptions, naming the ones it omitted. |
| DELETE | /api/connections/{id} |
Delete connection |
| POST | /api/connections/{id}/test |
Test connectivity (analytics sources are probed with the vendor adapter) |
| POST | /api/connections/{id}/refresh-schema |
Refresh schema cache |
| POST | /api/connections/{id}/index-db |
Index database schema |
| GET | /api/connections/{connection_id}/index-db |
The full stored database index for a connection. Viewer role |
| DELETE | /api/connections/{connection_id}/index-db |
Clear the database index. Owner role, 10/min |
| POST | /api/connections/{id}/sync |
Trigger code-DB sync (202) |
| GET | /api/connections/{connection_id}/sync |
The full code↔DB sync results. Viewer role |
| DELETE | /api/connections/{connection_id}/sync |
Clear the code↔DB sync data. Owner role, 10/min |
| POST | /api/connections/{connection_id}/reconnect |
Re-run the health probe and reopen the connector. Analytics sources have no database to reach, so they are answered without one (editor) |
| POST | /api/connections/{connection_id}/test-ssh |
Test SSH reachability on its own, separately from the database behind it |
| GET | /api/connections/exec-templates |
The commands the server itself runs in SSH-exec mode, read-only. The form shows these instead of shipping copies: its own carried {db_password} on the remote argv and piped the SQL to the client's stdin (C-02) |
| GET | /api/connections/health |
Health of every connection the caller can see |
| GET | /api/connections/{connection_id}/health |
Health of one connection |
| GET | /api/connections/{connection_id}/index-db/status |
Schema-index status for a connection |
| GET | /api/connections/{connection_id}/sync/status |
Code↔DB sync status for a connection |
ssh_command_template and ssh_pre_commands are returned to project owners only
(C-12). They are free-form shell run on the bastion; a non-owner sees a redaction marker
where one exists and null where none does, so a client can still tell that a custom
command is configured without being able to read it. A new template carrying
{db_password} is refused with 422 — the command line is visible in the process list on
the bastion — while a stored one keeps working and warns on every run. So is a new
template that uses $DBPASS anywhere but an environment assignment
(MYSQL_PWD="$DBPASS" mysql … is accepted; -p"$DBPASS" is not). A custom template
receives the query on stdin; the built-in ones pass it as an argument (T05b).
An SSH host needs an SSH user — POST and the merged row of PATCH answer 422
otherwise (C-15). The SSH key stays optional in the API (a self-hosted server may use its
own keys); the connection form requires one.
PATCH changes only the fields it names (exclude_unset), and an explicit null
clears a field — connection_string: null erases the stored DSN. The connection form
therefore sends only what the user changed since the edit began (T05b).
POST /api/connections and PATCH /api/connections/{id} accept five additional
fields used by analytics sources (Google Analytics 4 today — see
docs/ANALYTICS_SOURCES.md):
| Field | Type | Default | Description |
|---|---|---|---|
source_type |
string | "database" |
"database", "mcp", or an analytics vendor: "ga4", "appstore", "googleplay" |
vendor_credential_id |
string | null | null |
Id of an already-stored VendorCredential. Required when source_type is an analytics source; the secret itself is never sent here. |
source_config |
object | null | null |
Non-secret vendor knobs. For GA4: {"property_ids": ["294380179"], "property_timezone": "America/Los_Angeles", "backfill_days": 30, "event_names": [...], "currency_code": "USD"}. property_timezone (IANA) decides when a day ends (A-04) — without it the newest period is journalled partial and collected again; an unknown zone or a non-ISO-4217 currency is a 422, and backfill_days is clamped (A-08) |
collection_enabled |
boolean | true |
Whether the hourly wave dispatches this connection |
collection_hour |
integer (0–23) | 3 |
Hour the connection collects in, local to DAILY_KNOWLEDGE_SYNC_TIMEZONE |
source_config is returned on ConnectionResponse as a decoded object (a corrupt
blob degrades to null rather than failing the listing). db_type, db_port and
db_name are nullable in the response since an analytics source has no engine,
port or database — they are cleared on create rather than persisted as defaults.
Behaviour notes:
- Creating or updating an analytics connection without
vendor_credential_id→ 422. - A
vendor_credential_idthe caller does not own → 404 (the lookup is owner-strict, so "someone else's" and "does not exist" are answered identically). A credential whoseproviderdoes not matchsource_type→ 422. PATCHasserts credential ownership on the merged row, so a connection can never end a PATCH holding a credential the requester does not own.source_typeappstore/googleplay→ 422 in this release (no collector yet).index-db,refresh-schemaandsyncrefuse an analytics connection with 400 and point atPOST /api/connections/{id}/collect.
| Method | Endpoint | Description |
|---|---|---|
| POST | /api/connections/{id}/collect |
Enqueue a collection run now (202). Editor role. Rate limit 10/min. |
| GET | /api/connections/{id}/collection-status |
Per-report collection state. Viewer role. |
Both return 400 for a non-analytics connection and 404 when the connection does not exist.
POST /api/connections/{id}/collect enqueues the same job the hourly cron runs,
under its own task id (ANA-10): sharing the wave's day-scoped id made a manual
collect a silent duplicate for the rest of the day after any wave run. The two
cannot overlap because the collection service holds a per-connection Redis lock for
the length of a run (A-05); a second caller is told the connection is already being
collected. It deliberately ignores collection_enabled (that flag pauses the schedule; pulling
on demand is how a credential fix is verified). Response:
{"status": "queued", "connection_id": "…", "task_id": "analytics_collect:manual:<connection_id>:<YYYY-MM-DDTHH:MM:SS>"}GET /api/connections/{id}/collection-status returns:
{
"connection_id": "…",
"source_type": "ga4",
"collection_enabled": true,
"collection_hour": 3,
"next_scheduled_hour": 3,
"timezone": "Europe/Berlin",
"backfill_days": 30,
"status": "partial",
"last_run_at": "2026-08-01T03:04:11+00:00",
"last_error": null,
"caveat": "GA4 returned a truncated page for this period",
"pending_periods": 2,
"reports": [
{
"report": "overview",
"grain": "daily",
"latest_ok_period": "2026-07-31",
"latest_collected_period": "2026-07-31",
"ok_periods": 28,
"empty_periods": 0,
"failed_periods": 1,
"rows_written": 28,
"pending_periods": 2,
"pending_sample": ["2026-07-14", "2026-07-15"],
"last_run_at": "2026-08-01T03:04:11+00:00",
"last_error": null,
"caveat": "GA4 returned a truncated page for this period"
}
]
}status is one of:
| Value | Meaning |
|---|---|
never_collected |
The journal holds no row for this connection — nothing has run yet. |
ok |
Every period the backfill window expects has been collected. |
partial |
Some periods landed; some are still owed (failed, or not yet fetched). |
failed |
Rows exist but nothing succeeded — every period failed. |
Zero rows is only a failure when something actually failed: a window that came back
genuinely empty everywhere collected fine and reports ok.
caveat is not an error. last_error carries the newest journal row whose
status is failed; caveat carries the newest non-failed row that still has a
note attached — most often "the vendor truncated this page", i.e. the data is real
but a lower bound. An ok period can carry a caveat. Both fields exist per report
and at connection level; clients must not render a caveat as a failure.
Pending vs empty. pending_periods counts expected periods the journal has not
completed. A period recorded empty is complete (the vendor genuinely had no data)
and is not pending; a period recorded failed stays owed until it succeeds and is
refilled on the next run. The tail-refetch window (the most recent
ANALYTICS_REFETCH_TAIL_PERIODS periods, always re-fetched) is deliberately not
counted as pending. pending_sample is capped at 10 periods.
next_scheduled_hour is null when collection_enabled is false. A run that
failed before it could reach any report is journalled under the reserved report
name _connect, which currently appears as an entry in reports[] with
grain: null.
| Method | Endpoint | Description |
|---|---|---|
| POST | /api/chat/ask |
Send message (returns full response) |
| POST | /api/chat/ask/stream |
Send message (SSE streaming) |
| GET | /api/chat/sessions/{project_id} |
List chat sessions for a project |
| GET | /api/chat/sessions/{id}/messages |
Get session messages |
| DELETE | /api/chat/sessions/{id} |
Delete session |
| PATCH | /api/chat/sessions/{session_id} |
Rename a session. Body {title} (1–255 chars). Owner and current member of its project; 30/min |
| GET | /api/chat/estimate?project_id=&connection_id=&session_id= |
Estimate token cost and how full the conversation is. session_id is what makes the second half a measurement: without it the response carries history_measured: false and reports the static context only. context_utilization_pct is the session's stored history against the rotation threshold — the same quantity /ask/stream rotates on |
| GET | /api/chat/suggestions |
Get query suggestions |
| WS | /api/chat/ws/{project_id}/{connection_id} |
WebSocket chat (single-use ticket via Sec-WebSocket-Protocol; mint with POST /api/chat/ws-ticket) |
| POST | /api/chat/feedback |
Rate a message (body: {message_id, rating}) |
| GET | /api/chat/search?project_id=&q=&limit= |
Full-text search across the project's chat messages (viewer) |
| POST | /api/chat/explain-sql |
Plain-language explanation of a SQL statement, with a computed complexity score; cached by statement hash (viewer) |
| POST | /api/chat/summarize |
Summarise one chat message (viewer) |
| POST | /api/chat/sessions/ensure-welcome |
Idempotently create the project's welcome session; returns whether it was created (viewer) |
| POST | /api/chat/sessions/{session_id}/generate-title |
Derive a session title from its first user message |
| GET | /api/chat/analytics/feedback/{project_id} |
Aggregated message-feedback statistics for a project |
| POST | /api/chat/sessions |
Create a chat session |
Chat concurrency & limits: /api/chat/ask and /api/chat/ask/stream first check the user's token budget (429 when exhausted), then acquire an agent_limiter concurrency slot (429 when the per-user/global slot cap is hit) and hold a per-session lock (409 if a request for the same session is already running). The agent run is bounded by a wall-clock timeout (stream_timeout_seconds, default 360s): the non-streaming /ask returns 504 on timeout; /ask/stream reports the timeout in-band as an SSE error event (error_type: "timeout") since the HTTP status is already committed.
Chat response types (response_type field in the answer body):
text— plain conversational or knowledge answer (no SQL executed).sql_result— at least one SQL query ran and produced rows.clarification_request— agent needs more information before proceeding.step_limit_reached— the orchestrator (single-loop or pipeline path) exhaustedmax_orchestrator_iterationsor the wall-clock budget before completing normally; a partial/best-effort answer is still returned. Clients should surface this as a soft warning (not an error). As of W3 (ORCH-A02), pipeline answers can also return this type when the pipeline budget is exhausted.error— an unrecoverable failure;answercontains a user-friendly message.
| Method | Endpoint | Description |
|---|---|---|
| POST | /api/notes |
Save a query as note |
| GET | /api/notes?project_id= |
List notes |
| GET | /api/notes/{note_id} |
Get one note (same access rule as the list) |
| PATCH | /api/notes/{id} |
Update note |
| DELETE | /api/notes/{id} |
Delete note |
| POST | /api/notes/{note_id}/execute |
Run a saved note's query. Routed through SafetyGuard like every other raw-SQL entry point |
| Method | Endpoint | Description |
|---|---|---|
| POST | /api/batch/execute |
Create and run batch |
| GET | /api/batch?project_id= |
List batches |
| GET | /api/batch/{id} |
Get batch results |
| DELETE | /api/batch/{id} |
Delete batch |
| POST | /api/batch/{id}/export |
Export batch results |
Per-connection agent memory. Learnings are not shared across connections by default —
cross_connection_learnings_enabled is off, and vision.md §7 treats that as an invariant.
| Method | Endpoint | Description |
|---|---|---|
| GET | /api/connections/{connection_id}/learnings |
List active learnings for a connection |
| DELETE | /api/connections/{connection_id}/learnings |
Clear every learning for a connection |
| PATCH | /api/connections/{connection_id}/learnings/{learning_id} |
Edit one learning (a user override outranks a derived lesson) |
| DELETE | /api/connections/{connection_id}/learnings/{learning_id} |
Remove one learning |
| POST | /api/connections/{connection_id}/learnings/{learning_id}/contradict |
Downvote a learning: lowers confidence and may deactivate it |
| POST | /api/connections/{connection_id}/learnings/recompile |
Force the learnings prompt to be rebuilt |
| POST | /api/connections/{connection_id}/learnings/validate-schema |
Cross-check learnings against the connection's current schema |
| GET | /api/connections/{connection_id}/learnings/status |
Learning count and last-compiled time |
| GET | /api/connections/{connection_id}/learnings/summary |
The compiled learnings prompt as the agent receives it |
| POST | /api/connections/{connection_id}/learnings/{learning_id}/confirm |
Upvote a learning: raises confidence and times_confirmed. Deduplicated per user — a second identical vote is a no-op |
| Method | Endpoint | Description |
|---|---|---|
| POST | /api/repos/{project_id}/repositories |
Add repository |
| GET | /api/repos/{project_id}/repositories |
List repositories |
| PATCH | /api/repos/repositories/{id} |
Update repository |
| DELETE | /api/repos/repositories/{id} |
Delete repository |
| POST | /api/repos/repositories/{repo_id}/webhook-secret |
Mint or rotate this repository's own git-webhook secret. Owner only; the plaintext is returned once, because the stored copy is encrypted and the caller has to paste it into the provider. Rotating invalidates the previous one immediately. Until a repository has one, POST /api/repos/{project_id}/webhook refuses every push — there is deliberately no fallback to a deployment-wide secret (AUTH-05). |
| POST | /api/repos/{project_id}/index |
Index repository |
| GET | /api/repos/{project_id}/docs |
List indexed documents |
| POST | /api/repos/check-access |
Verify SSH/HTTPS reachability of a repository and list its branches |
| POST | /api/repos/{project_id}/check-updates |
Fetch the remote and compare HEAD against the last indexed SHA |
| GET | /api/repos/{project_id}/docs/{doc_id} |
Full content of one indexed document |
| GET | /api/repos/{project_id}/status |
Index state of the project's repository |
| Method | Endpoint | Description |
|---|---|---|
| POST | /api/rules |
Create custom rule |
| GET | /api/rules?project_id= |
List rules |
| GET | /api/rules/{rule_id} |
Get one rule. Viewer role on the rule's project |
| PATCH | /api/rules/{id} |
Update rule |
| DELETE | /api/rules/{id} |
Delete rule |
| Method | Endpoint | Description |
|---|---|---|
| POST | /api/dashboards |
Create dashboard |
| GET | /api/dashboards?project_id= |
List dashboards |
| GET | /api/dashboards/{id} |
Get dashboard |
| PATCH | /api/dashboards/{id} |
Update dashboard |
| DELETE | /api/dashboards/{id} |
Delete dashboard |
| Method | Endpoint | Description |
|---|---|---|
| GET | /api/logs/{project_id}/users |
List users with request counts |
| GET | /api/logs/{project_id}/requests |
Paginated request traces |
| GET | /api/logs/{project_id}/requests/{trace_id} |
Full trace detail with spans. Each span's span_type says what the time was spent on (PRJ-04, SPAN_TYPE_MAP in trace_persistence_service.py): db_query is the query itself, counted once; tool_call is an envelope around it; llm_call covers query repair, learning analysis and router retries too; plus sub_agent and viz. One trace row per workflow (unique workflow_id) |
| GET | /api/logs/{project_id}/summary |
Aggregated summary (totals, success rate, cost) |
| PATCH | /api/logs/{project_id}/errors/{error_id} |
Move an error through open → acknowledged → resolved. A resolved error that happens again is reopened by the next occurrence (F-E1); an acknowledged one stays acknowledged |
| GET | /api/logs/{project_id}/query-failures |
Paginated list of captured query failures |
| GET | /api/logs/{project_id}/query-failures/{failure_id} |
One query failure in full, including its parsed attempt history |
| GET | /api/logs/{project_id}/errors |
Filterable, deduplicated error catalog. A run killed by the stale-run reaper is catalogued here, its message naming the step that died |
| GET | /api/logs/{project_id}/runs |
Background-run history for the observability screen |
All logs endpoints require owner role. Query parameters: days, user_id, status, date_from, date_to, page, page_size. status is one of completed, failed, checkpoint (a pipeline paused for review) or provisional (the trace buffer was evicted before the run reported its end; replaced when the chat route finalizes it).
| Method | Endpoint | Description |
|---|---|---|
| POST | /api/ssh-keys |
Create SSH key for current user |
| GET | /api/ssh-keys |
List current user's SSH keys |
| GET | /api/ssh-keys/{key_id} |
Get SSH key by id |
| DELETE | /api/ssh-keys/{key_id} |
Delete SSH key (409 if in use by a connection) |
Owner-scoped, reusable secrets for external analytics vendors (Google Analytics 4 service-account JSON today; App Store Connect and Google Play secrets are accepted and stored but have no collector yet). Modelled on SSH keys: strictly owner-scoped lookups, Fernet-encrypted at rest, write-only over HTTP.
| Method | Endpoint | Description |
|---|---|---|
| POST | /api/vendor-credentials |
Store a credential (10/min) |
| GET | /api/vendor-credentials |
List the caller's credentials |
| POST | /api/vendor-credentials/{credential_id}/verify |
Ask the vendor whether it still accepts this key (10/min). One real token refresh — the only probe that tells a revoked key from a vendor hiccup. Returns {verified, error, credential}; a refused key is a 200 with verified: false (the request worked, the answer is bad news), a vendor that could not be reached is 503 and records nothing; a provider with no probe yet (appstore, googleplay) or a row the server cannot decrypt is 409 and records nothing either (T06b) — neither is evidence about the key. A network failure on the way to Google is a 503, not a refusal (F-G1). The verdict is stored on the credential as last_verified_at / last_verify_error, where error is null exactly when that attempt succeeded. |
| DELETE | /api/vendor-credentials/{credential_id} |
Delete (10/min); 409 if a connection still references it |
Create body:
{"name": "analytics-sa", "provider": "ga4", "secret": "{\"type\":\"service_account\", …}"}provider is one of ga4, appstore, googleplay. secret is capped at 32,000
characters and is stored verbatim (no stripping — a .p8 PEM's trailing newline is
part of the key material).
Response — the secret is never in it, on any route:
{
"id": "…",
"name": "analytics-sa",
"provider": "ga4",
"fingerprint": "3f2a1c9e7b4d5061",
"meta": {"client_email": "sa@project.iam.gserviceaccount.com", "project_id": "…"},
"last_verified_at": null,
"last_verify_error": null,
"created_at": "2026-08-01T10:00:00+00:00",
"updated_at": "2026-08-01T10:00:00+00:00"
}VendorCredentialResponse has no field for the plaintext or its ciphertext.
fingerprint is the first 16 hex characters of sha256(plaintext) — enough to
answer "is this the same key?" without handing anything back. meta carries only
non-secret fields lifted out of the credential (for GA4: client_email and
project_id, so the UI can show which service account to share a property with);
it is null for providers whose secret is an opaque blob. The plaintext leaves the
store only through the collection job's service-level lookup — never over HTTP.
Status codes:
- 422 — unsupported
provider, empty secret, or (forga4) a secret that is not valid JSON, is not a JSON object, or is missingclient_email/private_key. Nothing is persisted on a rejected create. - 404 — the credential is not visible to the caller. The lookup never unions NULL-owner rows, so another tenant's credential and a non-existent id are answered identically and neither confirms the id.
- 409 —
DELETEwhile a connection still references the credential (connections.vendor_credential_idisON DELETE RESTRICT, so the refusal comes from the database rather than a pre-check a concurrent create could slip past): "Cannot delete: this credential is in use by a connection. Delete or re-point the connection first."
| Method | Endpoint | Description |
|---|---|---|
| POST | /api/invites/{project_id}/invites |
Create invite (owner); sends email |
| GET | /api/invites/{project_id}/invites |
List project invites (owner) |
| DELETE | /api/invites/{project_id}/invites/{invite_id} |
Revoke invite (owner) |
| POST | /api/invites/{project_id}/invites/{invite_id}/resend |
Resend pending invite email (owner). Returns {ok, email_sent}; the UI reports email_sent: false as a failed send (SCN-021) |
| POST | /api/invites/accept/{invite_id} |
Accept invite for current user. Requires a verified email address (AUTH-01): the email match proves only the caller's stored string, which they chose at registration — and registration returns a live session with email_verified=false. Refused with 403 until the address is confirmed; a Google login is pre-verified, and verifying an address auto-accepts what was pending |
| GET | /api/invites/pending |
List pending invites for current user |
| PATCH | /api/invites/{project_id}/members/{member_user_id} |
Change a member's role to editor or viewer (owner; 20/min). Ownership moves only through transfer-ownership |
| DELETE | /api/invites/{project_id}/members/{member_user_id} |
Remove member (owner) |
| POST | /api/invites/{project_id}/transfer-ownership |
Transfer ownership (F-PROJ-10). Body {new_owner_user_id}. 204 on success, no body. The target must already be a member (else 400); the previous owner is demoted to editor, not removed; Project.owner_id and the member row move together. The receiving owner's plan project-quota is enforced before any write. Authorization is the current owner or an admin (ADMIN_EMAILS) — deliberately not require_role(…, "owner"), because owner_id is ondelete="SET NULL" and an orphaned project has no owner, so that check would 403 everyone on exactly the project this exists to rescue. A non-admin member claiming an orphaned project gets 403. Rate limit 5/minute. Note PATCH …/members/{id} accepts only editor/viewer — ownership has one way in, so the quota check cannot be bypassed. |
| DELETE | /api/invites/{project_id}/members/me |
Leave the project (F-PROJ-12). Removes the caller's own membership; 204 on success, 404 when not a member. An owner is refused with 400 — leaving would strand the workspace, which is what F-PROJ-10 exists to prevent — and the message names the transfer route. Ownership is read from the member row or Project.owner_id, so an owner whose row disagrees with the column is still refused. Path is /me, not /{id}: the request cannot express acting on anyone else. Rate limit 10/minute. |
| GET | /api/invites/{project_id}/members |
List members, bounded (F-PROJ-13): default 500, hard maximum 1000. X-Total-Count carries the real total and X-Result-Capped is true when the body is a partial page — a capped page with no marker reads as complete. Headers rather than a wrapper object so the response shape is unchanged for existing clients. Both are listed in CORS expose_headers — the SPA is another origin, and a browser hides an unexposed header from it (B-26); the UI reads them through api.invites.listMembersPage. |
| POST | /api/invites/decline/{invite_id} |
Decline an invitation addressed to the caller |
| Method | Endpoint | Description |
|---|---|---|
| POST | /api/schedules |
Create SQL schedule (cron expression, alert conditions) |
| GET | /api/schedules?project_id= |
List schedules |
| GET | /api/schedules/{id} |
Get schedule |
| PATCH | /api/schedules/{id} |
Update schedule |
| DELETE | /api/schedules/{id} |
Delete schedule |
| POST | /api/schedules/{id}/run-now |
Execute schedule SQL immediately; evaluate alerts |
| GET | /api/schedules/{id}/history |
Get run history |
| Method | Endpoint | Description |
|---|---|---|
| GET | /api/notifications |
List notifications (query: unread_only, limit) |
| GET | /api/notifications/count |
Unread notification count |
| PATCH | /api/notifications/{id}/read |
Mark notification as read |
| POST | /api/notifications/read-all |
Mark all notifications as read |
| Method | Endpoint | Description |
|---|---|---|
| POST | /api/visualizations/render |
Render visualization from columns/rows and config |
| POST | /api/visualizations/export |
Export query result as CSV, JSON, or XLSX |
The IndexingRun projection behind every background job — repo index, DB index, code↔DB
sync. A run reaped as stale carries error='stale run reaped'; RunCoordinator
reconciles it if the pipeline turns out to still be alive.
| Method | Endpoint | Description |
|---|---|---|
| GET | /api/runs/{run_id} |
One run: status, current step, progress, failure kind |
| GET | /api/runs/{run_id}/events |
The run's step journal |
| POST | /api/runs/{run_id}/cancel |
Request cooperative cancellation (editor) |
| POST | /api/runs/{run_id}/retry |
Start a fresh run from the same trigger (editor) |
| Method | Endpoint | Description |
|---|---|---|
| GET | /api/workflows/events |
SSE stream of workflow step events (query: workflow_id) |
| Method | Endpoint | Description |
|---|---|---|
| POST | /api/data-validation/validate-data |
Record validation verdict and run feedback pipeline |
| GET | /api/data-validation/validation-stats/{connection_id} |
Accuracy stats for connection |
| GET | /api/data-validation/benchmarks/{connection_id} |
Benchmarks for connection |
| GET | /api/data-validation/analytics/{project_id} |
Project-wide feedback analytics (owner) |
| GET | /api/data-validation/summary/{project_id} |
Lightweight analytics summary (owner) |
| POST | /api/data-validation/investigate |
Start async data investigation |
| GET | /api/data-validation/investigate/{investigation_id}?project_id= |
Get investigation detail. Viewer role; project_id is required (the thumbs-down modal passes it since the Track D1 follow-up) |
| POST | /api/data-validation/investigate/{investigation_id}/confirm-fix |
Accept or reject proposed fix |
| POST | /api/data-validation/anomaly-analysis |
Run anomaly intelligence on posted rows/columns |
| POST | /api/data-validation/anomaly-scan/{connection_id} |
Probe tables for anomalies |
| Method | Endpoint | Description |
|---|---|---|
| GET | /api/models |
List available LLM models (query: provider) |
| Method | Endpoint | Description |
|---|---|---|
| GET | /api/tasks/active |
List currently running background workflows |
| Method | Endpoint | Description |
|---|---|---|
| GET | /api/usage/stats |
Token usage comparison and daily breakdown (query: days, project_id) |
| Method | Endpoint | Description |
|---|---|---|
| GET | /api/metrics |
App metrics: active workflows, per-path request stats, uptime |
| GET | /api/metrics/prometheus |
Prometheus text-format exposition, summed across every process |
Both metrics endpoints require an admin user (ADMIN_EMAILS).
/api/metrics/prometheus reads a shared store; /api/metrics does not (OPS-03).
The worker runs arq rather than uvicorn and has no scrape endpoint at all, so every
counter an ARQ job emits — indexing_runs_total, daily_sync_budget_near_ceiling_total,
db_index_sample_budget_exhausted_total — used to be absent from this page rather
than zero. Each process now publishes its counter deltas into Redis on a 60-second loop
(app/ops/metrics_store.py), and the Prometheus endpoint flushes its own first so a
reader never sees a page missing what the process serving them just counted. With no
REDIS_URL it renders the local collector, which is what a self-hosted single-process
install has. The JSON endpoint's code_graph / runs / diagnostics blocks remain
this process's view.
The following counters are registered by MetricsCollector and exposed via /api/metrics/prometheus:
| Counter | Labels | Description |
|---|---|---|
datagate_block_total |
stage_id, check |
Incremented each time DataGate hard-fails a pipeline stage result (impossible value, impossible count). Useful for alerting on persistent data quality issues. |
filter_guard_degrade_total |
project_id |
Incremented when the required-filter guard degrades (satisfiable filter present but cannot be applied — query is answered with a warning rather than blocked). |
retrieval_degraded_total |
project_id |
Incremented when KnowledgeFreshnessService flags a retrieval result as degraded (stale index, missing BM25 snapshot, etc). |
| Method | Endpoint | Description |
|---|---|---|
| POST | /api/backup/trigger |
Run manual backup |
| GET | /api/backup/list |
List backup files on disk |
| GET | /api/backup/history |
Recent backup records from DB |
Present only when billing_enabled is on; every route below returns 404 when it is off.
| Method | Endpoint | Description |
|---|---|---|
| GET | /api/billing/plans |
Public plan catalogue for the pricing page |
| GET | /api/billing/subscription |
The caller's plan, subscription state, and usage against its limits — in both units (row 1b). usage.monthly_cost_used / monthly_cost_limit are the ceiling the tier is actually sold in; the token figures beside them are the backstop for when cost accounting itself breaks. *_cost_estimated says how much of the spend was priced by fallback rather than measured, because a row whose model is absent from the live price catalogue is charged conservatively rather than forgiven |
| POST | /api/billing/checkout |
Start a Stripe Checkout session for a plan |
| POST | /api/billing/portal |
Open the Stripe Customer Portal — invoices, cancellation, card update |
| POST | /api/billing/topup |
Start a one-time Checkout for LLM credit. mode=payment, and no amount is sent — the price carries custom_unit_amount, so Stripe's page asks the customer for the figure. Refuses with 400 until the account has a provisioned key, because crediting a balance with nothing to spend from takes the money and grants nothing |
| POST | /api/billing/verify |
Settle a checkout the caller just returned from, if its webhook has not landed yet. A safety net, never the primary path — a customer who closes the tab is served by the webhook alone. Checks that the session belongs to the caller, and answers 404 identically for a wrong-owner and a missing session so the response is not an oracle |
| POST | /api/billing/webhook |
Stripe webhook receiver. Signature-verified and idempotent (T-BILL-5) |
| Method | Endpoint | Description |
|---|---|---|
| POST | /api/demo/setup |
Create demo project with sample in-memory database |
| Method | Endpoint | Description |
|---|---|---|
| GET | /api/data-graph/{project_id}/summary |
Graph summary (metric and relationship counts) |
| GET | /api/data-graph/{project_id}/metrics |
List metrics (query: connection_id, category) |
| POST | /api/data-graph/{project_id}/metrics |
Upsert metric definition |
| GET | /api/data-graph/{project_id}/relationships |
List relationships (query: metric_id) |
| POST | /api/data-graph/{project_id}/relationships |
Add metric relationship |
| POST | /api/data-graph/{project_id}/discover/{connection_id} |
Auto-discover metrics from DB index |
| DELETE | /api/data-graph/{project_id}/metrics/{metric_id} |
Delete metric |
| Method | Endpoint | Description |
|---|---|---|
| GET | /api/insights/{project_id} |
List insights (filters: connection, type, severity, status, confidence) |
| GET | /api/insights/{project_id}/summary |
Counts by type and severity |
| POST | /api/insights/{project_id} |
Create insight (owner) |
| PATCH | /api/insights/{project_id}/{insight_id}/confirm |
Confirm insight |
| PATCH | /api/insights/{project_id}/{insight_id}/dismiss |
Dismiss insight |
| PATCH | /api/insights/{project_id}/{insight_id}/resolve |
Resolve insight |
| GET | /api/insights/{project_id}/actions |
Prioritized actions from active insights |
| Method | Endpoint | Description |
|---|---|---|
| POST | /api/feed/{project_id}/scan/{connection_id} |
Run autonomous insight scan for one connection |
| POST | /api/feed/{project_id}/scan |
Scan all connections in project |
| POST | /api/feed/{project_id}/opportunities/{connection_id} |
Scan for growth opportunities |
| POST | /api/feed/{project_id}/losses/{connection_id} |
Scan for revenue/conversion losses |
| Method | Endpoint | Description |
|---|---|---|
| POST | /api/reconciliation/{project_id}/row-counts |
Compare row counts between two connections |
| POST | /api/reconciliation/{project_id}/values |
Compare aggregate values |
| POST | /api/reconciliation/{project_id}/schemas |
Compare table/column schemas |
| POST | /api/reconciliation/{project_id}/full |
Full reconciliation with insight storage |
| Method | Endpoint | Description |
|---|---|---|
| POST | /api/semantic-layer/{project_id}/build/{connection_id} |
Build semantic catalog from DB index |
| POST | /api/semantic-layer/{project_id}/normalize |
Normalize metrics across connections |
| GET | /api/semantic-layer/{project_id}/catalog |
Browse metric catalog (query: connection_id, category) |
| Method | Endpoint | Description |
|---|---|---|
| POST | /api/explore/{project_id} |
Query-less investigation report (query: connection_id) |
| Method | Endpoint | Description |
|---|---|---|
| POST | /api/temporal/{project_id}/analyze |
Time-series analysis (trend, seasonality, anomalies) |
| POST | /api/temporal/{project_id}/lag |
Lag/lead detection between two series |
| Method | Endpoint | Description |
|---|---|---|
| GET | /api/health |
Basic health check (public) |
| GET | /api/health/modules |
Detailed module health (authentication required — 401 without a token) |
All errors follow this format:
{
"detail": "Human-readable error message"
}Common status codes:
400— Bad request (invalid input)401— Unauthorized (missing/invalid token)403— Forbidden (insufficient permissions)404— Not found409— Conflict (duplicate resource)422— Validation error (Pydantic)429— Rate limit exceeded or token budget spent — two different conditions on one status. The rate limiter's body carries"error_type": "rate_limit"and"is_retryable": true; a spent token budget carries{"detail": {"message": …, "error_type": "token_budget", "is_retryable": false}}and resets with the billing period rather than in a moment. A client that treats them alike tells the second kind of caller to wait for something that will not change today.500— Internal server error
Almost every mutating endpoint is rate-limited. Measured against the route tree on
2026-09-18: 129 mutating routes, 126 carrying @limiter.limit, 3 without —
| Unthrottled | Why |
|---|---|
POST /api/billing/webhook |
Stripe's own retry schedule drives it, and throttling a webhook provider makes it retry harder. Guarded by signature verification and an idempotency ledger instead. |
POST /api/auth/logout |
Refusing a logout leaves a session open, which is the wrong direction to fail in. |
POST /api/auth/complete-onboarding |
Idempotent, once per account, and reachable only with a valid session. |
The paragraph that used to stand here said "112 of 120 mutations; the 8 unthrottled
exceptions include…" and named five endpoints that do carry limiters, plus the billing
paths without the router's /billing prefix (API-13). Limits vary by endpoint
sensitivity.
What a limit counts against is the authenticated user where the request carries a
readable token, and otherwise the caller's address. It used to be
request.client.host verbatim, which behind a platform router is the router's
address for every caller — so each limit was one counter shared by the whole tenant
base, and the sixth registration attempt anywhere in the world in a minute was
refused (API-05).
The address is read from X-Forwarded-For by counting TRUSTED_PROXY_HOPS entries in
from the right. Each proxy appends what it saw, so the left-hand entries are
whatever the caller sent; taking the right-most is what stops one caller minting an
unlimited number of buckets. TRUSTED_PROXY_HOPS=0 ignores the header entirely.
The X-RateLimit-* headers indicate current usage — note that a browser on a
different origin cannot read them unless the API's CORS configuration exposes them
(API-10, open).
When running locally, interactive API docs are available at:
- Swagger UI:
http://localhost:8000/docs - ReDoc:
http://localhost:8000/redoc