{"openapi":"3.1.0","info":{"title":"Feedbot","version":"0.1.0"},"paths":{"/v1/feedbacks":{"get":{"tags":["v1"],"summary":"List ","operationId":"list__v1_feedbacks_get","parameters":[{"name":"status","in":"query","required":false,"schema":{"anyOf":[{"$ref":"#/components/schemas/FeedbackStatus"},{"type":"null"}],"title":"Status"}},{"name":"type","in":"query","required":false,"schema":{"anyOf":[{"$ref":"#/components/schemas/FeedbackType"},{"type":"null"}],"title":"Type"}},{"name":"severity","in":"query","required":false,"schema":{"anyOf":[{"$ref":"#/components/schemas/Severity"},{"type":"null"}],"title":"Severity"}},{"name":"limit","in":"query","required":false,"schema":{"type":"integer","maximum":200,"minimum":1,"default":50,"title":"Limit"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/FeedbackOut"},"title":"Response List  V1 Feedbacks Get"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"post":{"tags":["v1"],"summary":"Create ","operationId":"create__v1_feedbacks_post","parameters":[{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/FeedbackIn"}}}},"responses":{"201":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/FeedbackOut"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/v1/feedbacks/{public_id}":{"get":{"tags":["v1"],"summary":"Get ","operationId":"get__v1_feedbacks__public_id__get","parameters":[{"name":"public_id","in":"path","required":true,"schema":{"type":"string","title":"Public Id"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/FeedbackOut"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"patch":{"tags":["v1"],"summary":"Patch ","operationId":"patch__v1_feedbacks__public_id__patch","parameters":[{"name":"public_id","in":"path","required":true,"schema":{"type":"string","title":"Public Id"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/FeedbackPatch"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/FeedbackOut"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/v1/stats":{"get":{"tags":["v1"],"summary":"Stats ","operationId":"stats__v1_stats_get","parameters":[{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/StatsOut"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/v1/auth/login":{"post":{"tags":["v1.auth"],"summary":"Request a magic-link sign-in email","description":"Send a magic-link to ``body.email``.\n\nAlways responds 200 with ``{\"sent\": true}`` — whether the email is\nregistered or not is intentionally hidden to prevent enumeration.\n\nOn every call (registered or not) the response sets an ``mlnonce``\ncookie. The link the user clicks must be opened in the same browser\nor the magic-link verifier will mark the login as ``cross_device``\nand email a notice.","operationId":"login_v1_auth_login_post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/LoginIn"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/LoginOut"}}}},"503":{"description":"Email delivery is not configured on this deployment."},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/v1/signup":{"post":{"tags":["v1.auth"],"summary":"Create a new tenant + owner and email a magic-link","description":"Create a brand-new tenant + owner and send a magic-link to ``body.email``.\n\nBehaviour matrix:\n\n- ``FEEDBOT_ALLOW_SIGNUP`` unset/false → 404 (route appears not to\n  exist). Self-host stays invite-only by default.\n- SMTP not configured (console backend in production) → 503, same as\n  the regular login flow. Without email delivery, the magic-link\n  can't reach the user.\n- Email already owns/belongs to a tenant → response is identical to\n  the new-tenant path (``{sent: true}``) so an attacker can't probe\n  which addresses are registered.\n\nOn success, the response body is identical to the regular login —\nthe SPA shows the same \"check your email\" card.","operationId":"signup_v1_signup_post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SignupIn"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SignupOut"}}}},"404":{"description":"Signup is disabled on this deployment."},"503":{"description":"Email delivery is not configured on this deployment."},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/v1/auth/magic":{"get":{"tags":["v1.auth"],"summary":"Consume a magic-link token and start a session","description":"Consume a magic-link and issue a server-side session cookie.\n\nOn success, sets the ``fb_session`` cookie and clears ``mlnonce``.\nThe SPA should treat any 2xx response as \"logged in\" and refetch\n``/v1/me`` to load identity.","operationId":"magic_v1_auth_magic_get","parameters":[{"name":"email","in":"query","required":true,"schema":{"type":"string","title":"Email"}},{"name":"token","in":"query","required":true,"schema":{"type":"string","title":"Token"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"204":{"description":"Session created; cookie set."},"400":{"description":"Invalid or expired link."},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/v1/auth/logout":{"post":{"tags":["v1.auth"],"summary":"Revoke the current session","description":"Revoke just the session that authenticated this request and clear the cookie.\n\nIdempotent — returns 204 even if no cookie was sent.","operationId":"logout_v1_auth_logout_post","responses":{"204":{"description":"Successful Response"}}}},"/v1/auth/logout-all":{"post":{"tags":["v1.auth"],"summary":"Revoke every session of the current user","description":"Revoke every active session of ``me`` (including the one making the request).\n\nUseful for \"Sign out everywhere\" on the future Security page.","operationId":"logout_all_v1_auth_logout_all_post","responses":{"204":{"description":"Successful Response"}}}},"/v1/auth/sessions":{"get":{"tags":["v1.auth"],"summary":"List active sessions for the current user","operationId":"list_sessions_v1_auth_sessions_get","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"items":{"$ref":"#/components/schemas/SessionOut"},"type":"array","title":"Response List Sessions V1 Auth Sessions Get"}}}}}}},"/v1/me":{"get":{"tags":["v1.auth"],"summary":"Identity + visible projects for the current user","description":"Everything the SPA needs at boot: identity, role, tenant, projects.\n\nA 401 here tells the SPA to redirect the user to the login screen.","operationId":"me_v1_me_get","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/MeOut"}}}},"401":{"description":"No active session."}}}},"/v1/setup-status":{"get":{"tags":["v1.auth"],"summary":"Whether the deployment still needs first-run bootstrap","description":"Cheap check the SPA does at boot to decide whether to route the user\nto ``/setup`` or to ``/login``.\n\nNo auth — there's no user yet when this matters, and post-bootstrap the\nanswer is a stable ``False``.","operationId":"setup_status_v1_setup_status_get","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SetupStatusOut"}}}}}}},"/v1/setup":{"post":{"tags":["v1.auth"],"summary":"Bootstrap the first owner — only valid while the users table is empty","description":"Create the first tenant + owner user, then email them a magic link.\n\nOn deployments without working SMTP we fall back to returning the link\ninline — the SPA renders it as a one-time button so the new owner can\nfinish onboarding without having to dig through container logs.","operationId":"setup_bootstrap_v1_setup_post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SetupIn"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SetupOut"}}}},"410":{"description":"Setup already complete."},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/v1/projects":{"get":{"tags":["v1.projects"],"summary":"List projects visible to the current user","description":"Owner/admin sees every project in the tenant; members see only their own.","operationId":"list_projects_v1_projects_get","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"items":{"$ref":"#/components/schemas/ProjectSummary"},"type":"array","title":"Response List Projects V1 Projects Get"}}}}}},"post":{"tags":["v1.projects"],"summary":"Create a new project (admin/owner only)","operationId":"create_project__v1_projects_post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProjectIn"}}},"required":true},"responses":{"201":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProjectOut"}}}},"409":{"description":"Slug already exists in this tenant."},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/v1/projects/{slug}":{"get":{"tags":["v1.projects"],"summary":"Get a project's detail (with feedback counts)","operationId":"get_project_v1_projects__slug__get","parameters":[{"name":"slug","in":"path","required":true,"schema":{"type":"string","title":"Slug"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProjectOut"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"delete":{"tags":["v1.projects"],"summary":"Delete a project and all its data (admin/owner only)","operationId":"delete_project__v1_projects__slug__delete","parameters":[{"name":"slug","in":"path","required":true,"schema":{"type":"string","title":"Slug"}}],"responses":{"204":{"description":"Successful Response"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/v1/projects/{slug}/api-keys":{"get":{"tags":["v1.projects"],"summary":"List API keys for a project (admin only)","operationId":"list_keys_v1_projects__slug__api_keys_get","parameters":[{"name":"slug","in":"path","required":true,"schema":{"type":"string","title":"Slug"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/ApiKeyOut"},"title":"Response List Keys V1 Projects  Slug  Api Keys Get"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"post":{"tags":["v1.projects"],"summary":"Issue a new API key — secret is shown ONCE (admin only)","operationId":"create_key_v1_projects__slug__api_keys_post","parameters":[{"name":"slug","in":"path","required":true,"schema":{"type":"string","title":"Slug"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiKeyIn"}}}},"responses":{"201":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiKeyCreated"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/v1/projects/{slug}/api-keys/{key_id}":{"delete":{"tags":["v1.projects"],"summary":"Revoke an API key (admin only). Idempotent.","operationId":"revoke_key_v1_projects__slug__api_keys__key_id__delete","parameters":[{"name":"slug","in":"path","required":true,"schema":{"type":"string","title":"Slug"}},{"name":"key_id","in":"path","required":true,"schema":{"type":"integer","title":"Key Id"}}],"responses":{"204":{"description":"Successful Response"},"404":{"description":"Key not found in this project."},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/v1/projects/{slug}/chat-links":{"get":{"tags":["v1.projects"],"summary":"List chat links for a project","operationId":"get_chat_links_v1_projects__slug__chat_links_get","parameters":[{"name":"slug","in":"path","required":true,"schema":{"type":"string","title":"Slug"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/ChatLinkOut"},"title":"Response Get Chat Links V1 Projects  Slug  Chat Links Get"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/v1/projects/{slug}/chat-link-tokens":{"post":{"tags":["v1.projects"],"summary":"Issue a 15-min deep-link token to bind a Telegram chat to this project (admin only)","operationId":"create_chat_link_token_v1_projects__slug__chat_link_tokens_post","parameters":[{"name":"slug","in":"path","required":true,"schema":{"type":"string","title":"Slug"}}],"responses":{"201":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ChatLinkTokenOut"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/v1/projects/{slug}/chat-links/{link_id}":{"delete":{"tags":["v1.projects"],"summary":"Disconnect a chat from this project (admin only)","operationId":"delete_chat_link_v1_projects__slug__chat_links__link_id__delete","parameters":[{"name":"slug","in":"path","required":true,"schema":{"type":"string","title":"Slug"}},{"name":"link_id","in":"path","required":true,"schema":{"type":"integer","title":"Link Id"}}],"responses":{"204":{"description":"Successful Response"},"404":{"description":"Chat link not found in this project."},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/v1/projects/{slug}/feedbacks":{"get":{"tags":["v1.feedbacks"],"summary":"List feedback in a project (filterable)","operationId":"list__v1_projects__slug__feedbacks_get","parameters":[{"name":"slug","in":"path","required":true,"schema":{"type":"string","title":"Slug"}},{"name":"status","in":"query","required":false,"schema":{"anyOf":[{"$ref":"#/components/schemas/FeedbackStatus"},{"type":"null"}],"title":"Status"}},{"name":"type","in":"query","required":false,"schema":{"anyOf":[{"$ref":"#/components/schemas/FeedbackType"},{"type":"null"}],"title":"Type"}},{"name":"severity","in":"query","required":false,"schema":{"anyOf":[{"$ref":"#/components/schemas/Severity"},{"type":"null"}],"title":"Severity"}},{"name":"limit","in":"query","required":false,"schema":{"type":"integer","maximum":200,"minimum":1,"default":50,"title":"Limit"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/FeedbackOut"},"title":"Response List  V1 Projects  Slug  Feedbacks Get"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/v1/projects/{slug}/feedbacks/{public_id}":{"get":{"tags":["v1.feedbacks"],"summary":"Get one feedback by FB-XXXXXX","operationId":"get__v1_projects__slug__feedbacks__public_id__get","parameters":[{"name":"slug","in":"path","required":true,"schema":{"type":"string","title":"Slug"}},{"name":"public_id","in":"path","required":true,"schema":{"type":"string","title":"Public Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/FeedbackOut"}}}},"404":{"description":"Feedback not in this project."},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"patch":{"tags":["v1.feedbacks"],"summary":"Update status / append a note / queue a reply","operationId":"patch__v1_projects__slug__feedbacks__public_id__patch","parameters":[{"name":"slug","in":"path","required":true,"schema":{"type":"string","title":"Slug"}},{"name":"public_id","in":"path","required":true,"schema":{"type":"string","title":"Public Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/FeedbackPatch"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/FeedbackOut"}}}},"404":{"description":"Feedback not in this project."},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/v1/projects/{slug}/feedbacks-stats":{"get":{"tags":["v1.feedbacks"],"summary":"Counts grouped by status (cookie-authed)","operationId":"stats__v1_projects__slug__feedbacks_stats_get","parameters":[{"name":"slug","in":"path","required":true,"schema":{"type":"string","title":"Slug"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/StatsOut"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/v1/llm/providers":{"get":{"tags":["v1.llm"],"summary":"List available LLM providers (from the registry)","description":"The SPA reads this to populate the provider/model dropdowns. Adding a\nnew provider in ``feedbot_core/llm/providers/`` makes it appear here\nautomatically — no UI change required.","operationId":"get_providers_v1_llm_providers_get","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProvidersOut"}}}}}}},"/v1/projects/{slug}/llm-settings":{"get":{"tags":["v1.llm"],"summary":"Read LLM settings for a project (admin only). The key is never returned.","operationId":"get_llm_settings_v1_projects__slug__llm_settings_get","parameters":[{"name":"slug","in":"path","required":true,"schema":{"type":"string","title":"Slug"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/LLMSettingsOut"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"put":{"tags":["v1.llm"],"summary":"Update LLM settings (admin only). See module docstring for api_key semantics.","operationId":"put_llm_settings_v1_projects__slug__llm_settings_put","parameters":[{"name":"slug","in":"path","required":true,"schema":{"type":"string","title":"Slug"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/LLMSettingsIn"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/LLMSettingsOut"}}}},"400":{"description":"Unknown provider, or enabled=true while clearing the api_key."},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/v1/projects/{slug}/llm-test":{"post":{"tags":["v1.llm"],"summary":"Run a single classification round-trip and record the outcome (admin only)","description":"Round-trip a fixed sample feedback through the configured provider.\n\nAlways returns 200 with a structured outcome — even when the provider\nrejects the request. The error_text on the response is truncated; the\nsame truncated value is persisted in ``last_test_error``.","operationId":"llm_test_v1_projects__slug__llm_test_post","parameters":[{"name":"slug","in":"path","required":true,"schema":{"type":"string","title":"Slug"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/LLMTestOut"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/v1/projects/{slug}/llm-calls":{"get":{"tags":["v1.llm"],"summary":"List recent LLM calls for the audit table (admin only)","operationId":"list_llm_calls_v1_projects__slug__llm_calls_get","parameters":[{"name":"slug","in":"path","required":true,"schema":{"type":"string","title":"Slug"}},{"name":"limit","in":"query","required":false,"schema":{"type":"integer","maximum":200,"minimum":1,"default":50,"title":"Limit"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/LLMCallOut"},"title":"Response List Llm Calls V1 Projects  Slug  Llm Calls Get"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/v1/tenant/users":{"get":{"tags":["v1.team"],"summary":"List every user in the tenant (admin/owner only)","operationId":"list_tenant_users__v1_tenant_users_get","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"items":{"$ref":"#/components/schemas/TenantUserOut"},"type":"array","title":"Response List Tenant Users  V1 Tenant Users Get"}}}}}}},"/v1/tenant/users/{user_id}":{"patch":{"tags":["v1.team"],"summary":"Change a user's role within the tenant (admin/owner only)","operationId":"patch_tenant_user_v1_tenant_users__user_id__patch","parameters":[{"name":"user_id","in":"path","required":true,"schema":{"type":"integer","title":"User Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/TenantUserPatchIn"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TenantUserOut"}}}},"403":{"description":"Cannot modify the owner; cannot grant the owner role here."},"404":{"description":"User not in this tenant."},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"delete":{"tags":["v1.team"],"summary":"Remove a user from the tenant (admin/owner only)","operationId":"delete_tenant_user_v1_tenant_users__user_id__delete","parameters":[{"name":"user_id","in":"path","required":true,"schema":{"type":"integer","title":"User Id"}}],"responses":{"204":{"description":"Successful Response"},"400":{"description":"Use logout to remove yourself."},"403":{"description":"Cannot delete the owner."},"404":{"description":"User not in this tenant."},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/v1/tenant/transfer-ownership":{"post":{"tags":["v1.team"],"summary":"Owner-only: hand the role to another admin in the same tenant","operationId":"transfer_ownership_v1_tenant_transfer_ownership_post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProjectMemberAddIn"}}},"required":true},"responses":{"204":{"description":"Successful Response"},"404":{"description":"Target user not in this tenant."},"400":{"description":"Cannot transfer to yourself."},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/v1/invites":{"get":{"tags":["v1.team"],"summary":"List pending invites for this tenant (admin/owner only)","operationId":"list_invites_v1_invites_get","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"items":{"$ref":"#/components/schemas/InviteOut"},"type":"array","title":"Response List Invites V1 Invites Get"}}}}}},"post":{"tags":["v1.team"],"summary":"Create an invite + send the email (admin/owner only)","operationId":"create_invite_v1_invites_post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/InviteIn"}}},"required":true},"responses":{"201":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/InviteOut"}}}},"400":{"description":"User already in this tenant; or invalid role; or unknown project_slug."},"403":{"description":"Owner role cannot be invited."},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/v1/invites/{invite_id}":{"delete":{"tags":["v1.team"],"summary":"Revoke a pending invite (admin/owner only)","operationId":"delete_invite_v1_invites__invite_id__delete","parameters":[{"name":"invite_id","in":"path","required":true,"schema":{"type":"integer","title":"Invite Id"}}],"responses":{"204":{"description":"Successful Response"},"404":{"description":"Invite not in this tenant or already used."},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/v1/invites/preview":{"get":{"tags":["v1.team"],"summary":"Preview an invite (no auth) — used by the SPA's accept page","description":"Open endpoint: anyone with the token can read the invite metadata. We\ndeliberately surface only what's needed to render the accept screen\n(workspace name, email, role, project name, expiry) — never the inviter's\nemail or anything that would help enumerate the tenant.","operationId":"preview_invite_v1_invites_preview_get","parameters":[{"name":"token","in":"query","required":true,"schema":{"type":"string","minLength":8,"maxLength":128,"title":"Token"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/InvitePreviewOut"}}}},"404":{"description":"Invalid or expired token."},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/v1/invites/accept":{"post":{"tags":["v1.team"],"summary":"Accept an invite (no auth) — creates the user and starts a session","operationId":"accept_invite_v1_invites_accept_post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/InviteAcceptIn"}}},"required":true},"responses":{"204":{"description":"Successful Response"},"400":{"description":"Invalid or expired token."},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/v1/projects/{slug}/members":{"get":{"tags":["v1.team"],"summary":"List members of a project (admin only)","operationId":"list_members_v1_projects__slug__members_get","parameters":[{"name":"slug","in":"path","required":true,"schema":{"type":"string","title":"Slug"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/TenantUserOut"},"title":"Response List Members V1 Projects  Slug  Members Get"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"post":{"tags":["v1.team"],"summary":"Add an existing tenant user to this project (admin only)","operationId":"add_member_v1_projects__slug__members_post","parameters":[{"name":"slug","in":"path","required":true,"schema":{"type":"string","title":"Slug"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProjectMemberAddIn"}}}},"responses":{"204":{"description":"Successful Response"},"404":{"description":"User not in this tenant."},"409":{"description":"User is already a member."},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/v1/projects/{slug}/members/{user_id}":{"delete":{"tags":["v1.team"],"summary":"Remove a member from this project (admin only)","operationId":"remove_member_v1_projects__slug__members__user_id__delete","parameters":[{"name":"slug","in":"path","required":true,"schema":{"type":"string","title":"Slug"}},{"name":"user_id","in":"path","required":true,"schema":{"type":"integer","title":"User Id"}}],"responses":{"204":{"description":"Successful Response"},"404":{"description":"User is not a member of this project."},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/v1/billing/subscription":{"get":{"tags":["v1.billing"],"summary":"Current plan, status, and usage for the signed-in tenant","description":"Returns plan + limits + usage. On self-host (billing disabled),\nsurfaces ``plan='self_host'`` with no limits and no usage so the SPA\ncan render an unconditional \"no limits\" state.","operationId":"get_subscription_v1_billing_subscription_get","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SubscriptionOut"}}}}}}},"/v1/billing/portal":{"post":{"tags":["v1.billing"],"summary":"Create a Stripe Customer Portal session (owner only)","operationId":"create_portal_v1_billing_portal_post","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PortalOut"}}}},"404":{"description":"Billing not enabled."},"409":{"description":"Tenant has no Stripe customer yet."}}}},"/v1/billing/checkout":{"post":{"tags":["v1.billing"],"summary":"Create a Stripe Checkout session for an upgrade (owner only)","operationId":"create_checkout_v1_billing_checkout_post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CheckoutIn"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CheckoutOut"}}}},"404":{"description":"Billing not enabled."},"400":{"description":"Invalid plan key, or plan has no Stripe price configured."},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/v1/internal/stripe-webhook":{"post":{"tags":["internal.stripe"],"summary":"Receive Stripe webhook events (signed)","description":"Verify the signature, dedupe by event_id, dispatch by event type.\n\nReturns 200 even on no-op events (Stripe retries on non-2xx). Errors\nin our own DB are logged and surface as 500 so Stripe retries; bad\nsignatures are 400 (Stripe doesn't retry those).","operationId":"stripe_webhook_v1_internal_stripe_webhook_post","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"400":{"description":"Invalid signature or malformed payload."}}}},"/v1/tenant/export":{"get":{"tags":["v1.tenant"],"summary":"Stream a ZIP of every row this tenant owns (owner only)","description":"GDPR data-export. Streams one zip with the per-table dumps.\n\nPerformance: we materialise everything into memory before zipping\nbecause (a) `zipfile.ZipFile` doesn't support streaming writes from\nasync iterators without extra glue, and (b) at our target volume\nthe whole archive fits well under available RAM. If volumes grow,\nswap this for chunked NDJSON + a streaming zip writer (zipstream).","operationId":"export_tenant_v1_tenant_export_get","responses":{"200":{"description":"application/zip stream — Content-Disposition includes the timestamp.","content":{"application/json":{"schema":{}}}}}}},"/v1/tenant/delete":{"post":{"tags":["v1.tenant"],"summary":"Permanently delete this tenant and all its data (owner only)","description":"Cascade-delete every row this tenant owns. Best-effort cancels\nthe Stripe subscription if billing is enabled.\n\nThe audit log row for ``tenant.deleted`` is written *before* the\ntenant cascade so it survives the delete (ON DELETE SET NULL on\naudit_events.tenant_id keeps the row but nulls the FK).","operationId":"delete_tenant_v1_tenant_delete_post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/DeleteTenantIn"}}},"required":true},"responses":{"204":{"description":"Successful Response"},"400":{"description":"confirm_email did not match the signed-in owner."},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/v1/admin/email/config":{"get":{"tags":["v1.admin"],"summary":"Read SMTP config (owner only). The password is never returned.","operationId":"get_config_v1_admin_email_config_get","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EmailConfigOut"}}}}}},"post":{"tags":["v1.admin"],"summary":"Update SMTP config + restart api (owner only).","operationId":"post_config_v1_admin_email_config_post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/EmailConfigIn"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EmailConfigOut"}}}},"502":{"description":"DB write succeeded but the api restart failed."},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/v1/admin/email/test":{"post":{"tags":["v1.admin"],"summary":"Send a test email using the stored SMTP creds (owner only).","description":"Round-trip a fixed message through the configured SMTP server.\n\nAlways returns 200 with a structured outcome — UI renders the raw\nerror on failure. We never persist the result, only the audit row\nthat ``apply_email`` already writes on save; a failed test is the\noperator's debugging signal, not an audit-worthy mutation.","operationId":"post_test_v1_admin_email_test_post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/EmailTestIn"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EmailTestOut"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/v1/admin/bot/config":{"get":{"tags":["v1.admin"],"summary":"Read bot config (owner only). The token is never returned.","operationId":"get_config_v1_admin_bot_config_get","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/feedbot_api__schemas__BotConfigOut"}}}}}},"post":{"tags":["v1.admin"],"summary":"Update bot config + start the bot service (owner only).","operationId":"post_config_v1_admin_bot_config_post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/BotConfigIn"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/feedbot_api__schemas__BotConfigOut"}}}},"502":{"description":"DB write succeeded but the bot service failed to start."},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"delete":{"tags":["v1.admin"],"summary":"Stop the bot service and clear stored credentials (owner only).","operationId":"delete_config_v1_admin_bot_config_delete","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/feedbot_api__schemas__BotConfigOut"}}}}}}},"/v1/admin/bot/test":{"post":{"tags":["v1.admin"],"summary":"Validate a Telegram bot token via getMe (owner only).","description":"Round-trip ``getMe`` against a fresh or stored token.\n\nAlways returns 200 with a structured outcome — UI renders the\nraw error on failure. No persistence: this is a \"did the user\npaste a working token?\" probe, not a settings mutation.","operationId":"post_test_v1_admin_bot_test_post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/BotTestIn"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/BotTestOut"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/v1/admin/bot/chats":{"get":{"tags":["v1.admin"],"summary":"List every chat linked across the tenant's projects (owner only).","operationId":"list_chats_v1_admin_bot_chats_get","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"items":{"$ref":"#/components/schemas/BotChatOut"},"type":"array","title":"Response List Chats V1 Admin Bot Chats Get"}}}}}}},"/v1/admin/proxy/config":{"get":{"tags":["v1.admin"],"summary":"Read the persisted domain / TLS config (owner only).","operationId":"get_config_v1_admin_proxy_config_get","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProxyConfigOut"}}}}}},"post":{"tags":["v1.admin"],"summary":"Set the domain + LE email and push a TLS Caddy config (owner only).","operationId":"post_config_v1_admin_proxy_config_post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProxyConfigIn"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProxyConfigOut"}}}},"502":{"description":"Caddy admin API rejected the new config or was unreachable."},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"delete":{"tags":["v1.admin"],"summary":"Clear the domain and revert Caddy to IP-only mode (owner only).","operationId":"delete_config_v1_admin_proxy_config_delete","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProxyConfigOut"}}}}}}},"/v1/admin/proxy/status":{"get":{"tags":["v1.admin"],"summary":"Polled view of cert provisioning state (owner only).","description":"Polled by the SPA every ~3s while a domain change is applying.\n\nAlways returns 200 — Caddy admin errors land in ``error`` so\nthe UI can render the chip without distinguishing an HTTP 200\n\"no domain\" from a 502 \"admin API down\".","operationId":"get_status_v1_admin_proxy_status_get","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProxyStatusOut"}}}}}}},"/v1/admin/proxy/dns-check":{"post":{"tags":["v1.admin"],"summary":"Pre-flight DNS check (owner only). Never persisted.","description":"Resolve ``domain`` and compare against the host's outbound IP.\n\nThe match is a *hint*, not a hard block — propagation lag and\nNAT make false negatives common. The SPA shows a warning\n(not an error) when ``matches`` is false and lets the user\nproceed anyway.","operationId":"dns_check_v1_admin_proxy_dns_check_post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProxyDnsCheckIn"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProxyDnsCheckOut"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/v1/admin/system/status":{"get":{"tags":["v1.admin"],"summary":"Health snapshot from ``docker compose ps`` (owner only).","operationId":"get_status_v1_admin_system_status_get","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SystemStatusOut"}}}}}}},"/v1/admin/system/restart":{"post":{"tags":["v1.admin"],"summary":"Restart all services or one (owner only).","operationId":"post_restart_v1_admin_system_restart_post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SystemRestartIn"}}},"required":true},"responses":{"204":{"description":"Successful Response"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/v1/admin/system/autostart":{"get":{"tags":["v1.admin"],"summary":"Read autostart state (owner only).","operationId":"get_autostart_v1_admin_system_autostart_get","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AutostartStatusOut"}}}}}},"post":{"tags":["v1.admin"],"summary":"Enable or disable autostart (owner only).","operationId":"post_autostart_v1_admin_system_autostart_post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/TelemetryConfigIn"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AutostartStatusOut"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/v1/admin/system/telemetry":{"get":{"tags":["v1.admin"],"summary":"Current telemetry opt-in state (owner only).","operationId":"get_telemetry_v1_admin_system_telemetry_get","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TelemetryConfigOut"}}}}}},"post":{"tags":["v1.admin"],"summary":"Toggle telemetry opt-in (owner only).","description":"Persist the toggle and audit it.\n\nTelemetry is opt-in (default off). We don't restart any service\n— the flag is read at telemetry-event-emit time, not boot.","operationId":"post_telemetry_v1_admin_system_telemetry_post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/TelemetryConfigIn"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TelemetryConfigOut"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/v1/admin/system/backups":{"get":{"tags":["v1.admin"],"summary":"List backup tarballs in <workdir>/backups (owner only).","operationId":"list_backups_v1_admin_system_backups_get","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"items":{"$ref":"#/components/schemas/BackupOut"},"type":"array","title":"Response List Backups V1 Admin System Backups Get"}}}}}},"post":{"tags":["v1.admin"],"summary":"Run pg_dump and create a fresh tar.gz backup (owner only).","operationId":"create_backup_v1_admin_system_backups_post","responses":{"201":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/BackupOut"}}}}}}},"/v1/admin/system/backups/{filename}/download":{"get":{"tags":["v1.admin"],"summary":"Download a backup tarball (owner only).","description":"Stream the tarball as ``application/gzip``.\n\n``orch_backup.get_backup`` validates the filename matches our\nnaming pattern and contains no path separators — anything else\nreturns 404 instead of letting a directory-traversal attempt\nsurface as a 500.","operationId":"download_backup_v1_admin_system_backups__filename__download_get","parameters":[{"name":"filename","in":"path","required":true,"schema":{"type":"string","title":"Filename"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"404":{"description":"Unknown filename or path traversal attempt."},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/v1/admin/system/updates":{"get":{"tags":["v1.admin"],"summary":"Compare the running version against GHCR's latest (owner only).","operationId":"get_updates_v1_admin_system_updates_get","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdatesOut"}}}}}}},"/v1/admin/system/updates/apply":{"post":{"tags":["v1.admin"],"summary":"Pull the latest images and recreate containers (owner only).","description":"Trigger a rolling update: pull, recreate, migrations on boot.\n\nThe api container's CMD is ``alembic upgrade head && uvicorn …``,\nso we don't run migrations here — recreating the container is\nenough. The route is best-effort: if pull or up fails we surface\na 502 with the compose error so the operator can debug.","operationId":"post_apply_update_v1_admin_system_updates_apply_post","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateApplyOut"}}}},"502":{"description":"compose pull/up failed; see body for the underlying error."}}}},"/v1/internal/ingest":{"post":{"tags":["internal"],"summary":"Ingest","operationId":"ingest_v1_internal_ingest_post","parameters":[{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/IngestIn"}}}},"responses":{"201":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/FeedbackOut"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/v1/internal/redeem-link":{"post":{"tags":["internal"],"summary":"Redeem Link","operationId":"redeem_link_v1_internal_redeem_link_post","parameters":[{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RedeemIn"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/RedeemOut"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/v1/internal/ingest-reply":{"post":{"tags":["internal"],"summary":"Ingest Reply","description":"The user replied (in chat) to one of our outbound messages.\n\nThe bot detects 'this Telegram message is a reply to one we sent' and posts\nhere. We resolve which feedback owns that outbound message_id and record\nthe body as user_reply, flipping status back to 'triaged' so Claude (or a\nhuman) sees it on the next read.","operationId":"ingest_reply_v1_internal_ingest_reply_post","parameters":[{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/IngestReplyIn"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/FeedbackOut"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/v1/internal/outbound-pending":{"get":{"tags":["internal"],"summary":"Outbound Pending","description":"The bot polls this every few seconds and delivers each item to chat.\n\nTwo kinds of outbound messages live in the same queue: queued replies (the\nteam or Claude wrote in `reply_to_user`) and done notifications (status\njust flipped to `done`). Both are delivered to the same chat where the\nfeedback was first reported, so the conversation stays in one thread.","operationId":"outbound_pending_v1_internal_outbound_pending_get","parameters":[{"name":"limit","in":"query","required":false,"schema":{"type":"integer","default":20,"title":"Limit"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/OutboundItem"},"title":"Response Outbound Pending V1 Internal Outbound Pending Get"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/v1/internal/outbound-ack":{"post":{"tags":["internal"],"summary":"Outbound Ack","description":"The bot confirms it delivered (or failed to deliver) an outbound message.","operationId":"outbound_ack_v1_internal_outbound_ack_post","parameters":[{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/OutboundAckIn"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"type":"object","additionalProperties":{"type":"string"},"title":"Response Outbound Ack V1 Internal Outbound Ack Post"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/v1/internal/bot-config":{"get":{"tags":["internal"],"summary":"Bot Config","operationId":"bot_config_v1_internal_bot_config_get","parameters":[{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/feedbot_api__routers__internal__BotConfigOut"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/healthz":{"get":{"summary":"Healthz","operationId":"healthz_healthz_get","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"additionalProperties":true,"type":"object","title":"Response Healthz Healthz Get"}}}}}}}},"components":{"schemas":{"ApiKeyCreated":{"properties":{"id":{"type":"integer","title":"Id"},"label":{"type":"string","title":"Label"},"prefix":{"type":"string","title":"Prefix","description":"First 12 chars of the key, e.g. 'fbk_live_AbCdEf12'."},"scope":{"type":"string","title":"Scope","description":"read | write | admin"},"created_at":{"type":"string","format":"date-time","title":"Created At"},"last_used_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Last Used At"},"revoked_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Revoked At"},"key":{"type":"string","title":"Key","description":"Full secret (fbk_*); shown once, never re-rendered."}},"type":"object","required":["id","label","prefix","scope","created_at","last_used_at","revoked_at","key"],"title":"ApiKeyCreated","description":"Response from ``POST /v1/projects/{slug}/api-keys``.\n\n``key`` is the **only** time the secret is exposed. Store it now or rotate."},"ApiKeyIn":{"properties":{"label":{"type":"string","maxLength":120,"minLength":1,"title":"Label"},"scope":{"type":"string","pattern":"^(read|write|admin)$","title":"Scope","default":"write"}},"type":"object","required":["label"],"title":"ApiKeyIn","description":"Body for ``POST /v1/projects/{slug}/api-keys``."},"ApiKeyOut":{"properties":{"id":{"type":"integer","title":"Id"},"label":{"type":"string","title":"Label"},"prefix":{"type":"string","title":"Prefix","description":"First 12 chars of the key, e.g. 'fbk_live_AbCdEf12'."},"scope":{"type":"string","title":"Scope","description":"read | write | admin"},"created_at":{"type":"string","format":"date-time","title":"Created At"},"last_used_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Last Used At"},"revoked_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Revoked At"}},"type":"object","required":["id","label","prefix","scope","created_at","last_used_at","revoked_at"],"title":"ApiKeyOut","description":"List/get view of an API key. The secret is never re-rendered."},"AutostartStatusOut":{"properties":{"platform":{"type":"string","title":"Platform"},"enabled":{"type":"boolean","title":"Enabled"},"unit_path":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Unit Path"},"manual_instructions":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Manual Instructions"}},"type":"object","required":["platform","enabled","unit_path"],"title":"AutostartStatusOut","description":"Result of ``GET /v1/admin/system/autostart``.\n\n``platform`` is the orchestrator's enum value\n(linux-systemd, linux-other, macos-launchd, unknown).\n``unit_path`` is the systemd unit / launchd plist path or\n``None`` on unsupported platforms; ``manual_instructions`` is\nset when the platform doesn't support auto-managed startup so\nthe UI can render copy-paste init snippets."},"BackupOut":{"properties":{"filename":{"type":"string","title":"Filename"},"size_bytes":{"type":"integer","title":"Size Bytes"},"created_at":{"type":"string","format":"date-time","title":"Created At"}},"type":"object","required":["filename","size_bytes","created_at"],"title":"BackupOut","description":"One row of the backups directory listing."},"BillingLimits":{"properties":{"project_limit":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Project Limit"},"monthly_feedback_limit":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Monthly Feedback Limit"},"member_limit":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Member Limit"}},"type":"object","required":["project_limit","monthly_feedback_limit","member_limit"],"title":"BillingLimits","description":"Plan limits exposed to the SPA. ``None`` means unlimited."},"BillingUsage":{"properties":{"projects":{"type":"integer","title":"Projects"},"monthly_feedback":{"type":"integer","title":"Monthly Feedback"},"members":{"type":"integer","title":"Members"}},"type":"object","required":["projects","monthly_feedback","members"],"title":"BillingUsage","description":"Current usage snapshot — drives progress bars in the SPA."},"BotChatOut":{"properties":{"id":{"type":"integer","title":"Id"},"platform":{"type":"string","title":"Platform"},"chat_id":{"type":"string","title":"Chat Id"},"title":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Title"},"project_slug":{"type":"string","title":"Project Slug"},"project_name":{"type":"string","title":"Project Name"},"created_at":{"type":"string","format":"date-time","title":"Created At"}},"type":"object","required":["id","platform","chat_id","title","project_slug","project_name","created_at"],"title":"BotChatOut","description":"One row of the tenant-wide chat-links list.\n\nMirrors ``ChatLinkOut`` but adds ``project_slug`` / ``project_name``\nso the Settings page can show \"@my-bot is linked in 4 projects\"\nwithout per-row joins from the SPA."},"BotConfigIn":{"properties":{"token":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Token","description":"Plaintext Telegram bot token; encrypted server-side."},"username":{"anyOf":[{"type":"string","maxLength":64},{"type":"null"}],"title":"Username"}},"type":"object","title":"BotConfigIn","description":"Body for ``POST /v1/admin/bot/config``.\n\n``token`` follows the same tri-state pattern as the SMTP\npassword — ``None`` keeps, ``\"\"`` clears, any other string\nrotates / sets. The username is plain replace semantics; the\nleading ``@`` is stripped server-side for forgiveness."},"BotProfileOut":{"properties":{"id":{"type":"integer","title":"Id"},"username":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Username"},"first_name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"First Name"},"can_join_groups":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Can Join Groups"},"can_read_all_group_messages":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Can Read All Group Messages"}},"type":"object","required":["id","username","first_name"],"title":"BotProfileOut","description":"Subset of Telegram's ``getMe`` payload safe to expose to the SPA.\n\nThe bot ``id`` is the public numeric identifier (it shows up in\nevery ``t.me/<username>`` link); ``username`` and ``first_name``\nare likewise public. We deliberately don't expose anything the\nbot's owner could rotate as a security measure (e.g. the secret\nchat-link path) — Telegram has no such field on ``getMe`` today\nbut the allow-list keeps us safe if it changes."},"BotTestIn":{"properties":{"token":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Token","description":"Optional override; tested as-is without persistence."}},"type":"object","title":"BotTestIn","description":"Body for ``POST /v1/admin/bot/test``.\n\nThe optional ``token`` lets the operator validate a fresh token\n*before* saving — common pattern when a user pastes from\nBotFather. When omitted the test runs against the currently\nstored token."},"BotTestOut":{"properties":{"ok":{"type":"boolean","title":"Ok"},"profile":{"anyOf":[{"$ref":"#/components/schemas/BotProfileOut"},{"type":"null"}]},"error":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Error","description":"Truncated Telegram error / network failure if ok=False."}},"type":"object","required":["ok"],"title":"BotTestOut","description":"Outcome of ``POST /v1/admin/bot/test``.\n\n``ok=True`` carries the bot profile straight back so the UI can\nshow \"Connected as @feedbot_acme_bot\" without a second round\ntrip. ``ok=False`` carries a truncated error."},"ChatLinkOut":{"properties":{"id":{"type":"integer","title":"Id"},"platform":{"type":"string","title":"Platform"},"chat_id":{"type":"string","title":"Chat Id"},"title":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Title"},"created_at":{"type":"string","format":"date-time","title":"Created At"}},"type":"object","required":["id","platform","chat_id","title","created_at"],"title":"ChatLinkOut"},"ChatLinkTokenOut":{"properties":{"token":{"type":"string","title":"Token"},"deep_link":{"type":"string","title":"Deep Link"},"expires_at":{"type":"string","format":"date-time","title":"Expires At"}},"type":"object","required":["token","deep_link","expires_at"],"title":"ChatLinkTokenOut","description":"Response from ``POST /v1/projects/{slug}/chat-link-tokens``.\n\n``deep_link`` is empty when ``FEEDBOT_TELEGRAM_BOT_USERNAME`` is unset on\nthe deployment; the SPA hides the \"Open Telegram\" button in that case."},"CheckoutIn":{"properties":{"plan":{"type":"string","title":"Plan","description":"Target plan key (free|pro|team). 'free' is rejected."}},"type":"object","required":["plan"],"title":"CheckoutIn","description":"Body for ``POST /v1/billing/checkout`` — start an upgrade flow."},"CheckoutOut":{"properties":{"url":{"type":"string","title":"Url","description":"Stripe-hosted Checkout session URL. The SPA navigates here."}},"type":"object","required":["url"],"title":"CheckoutOut","description":"Response from ``POST /v1/billing/checkout``."},"DeleteTenantIn":{"properties":{"confirm_email":{"type":"string","maxLength":255,"minLength":3,"title":"Confirm Email"}},"type":"object","required":["confirm_email"],"title":"DeleteTenantIn","description":"Body for ``POST /v1/tenant/delete``.\n\nThe owner re-types their own email as deliberate-friction. Anything\nelse returns 400; a CSRF that magically guesses the owner's email\nis already a much bigger problem than we're trying to solve here."},"EmailConfigIn":{"properties":{"host":{"anyOf":[{"type":"string","maxLength":255},{"type":"null"}],"title":"Host"},"port":{"anyOf":[{"type":"integer","maximum":65535.0,"minimum":1.0},{"type":"null"}],"title":"Port"},"user":{"anyOf":[{"type":"string","maxLength":255},{"type":"null"}],"title":"User"},"password":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Password","description":"Plaintext; encrypted server-side. Tri-state: None=keep, ''=clear, str=set."},"sender":{"anyOf":[{"type":"string","maxLength":255},{"type":"null"}],"title":"Sender"}},"type":"object","title":"EmailConfigIn","description":"Body for ``POST /v1/admin/email/config``.\n\n``password`` is **tri-state** (mirrors the LLM-key pattern):\n\n  * ``None``       — keep the stored password untouched.\n  * ``\"\"``         — clear it (fall back to no-auth SMTP / console).\n  * non-empty str  — set / rotate it.\n\nThe other fields are plain replace semantics: send the value you want\nto persist (or empty string to clear)."},"EmailConfigOut":{"properties":{"host":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Host"},"port":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Port"},"user":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"User"},"sender":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Sender"},"has_password":{"type":"boolean","title":"Has Password","description":"True when an encrypted password is stored."},"configured":{"type":"boolean","title":"Configured","description":"True when the API would route magic links through SMTP."}},"type":"object","required":["host","port","user","sender","has_password","configured"],"title":"EmailConfigOut","description":"Current SMTP config for the dashboard's Settings → Email section.\n\nThe encrypted password is **never** returned. ``has_password`` exposes\nonly whether one is stored. ``configured`` is true when the orchestrator\nhas enough to actually send mail (host + port + sender at minimum)."},"EmailTestIn":{"properties":{"to":{"type":"string","maxLength":255,"minLength":3,"title":"To","description":"Recipient address for the test send. Usually the owner's email."}},"type":"object","required":["to"],"title":"EmailTestIn","description":"Body for ``POST /v1/admin/email/test``."},"EmailTestOut":{"properties":{"ok":{"type":"boolean","title":"Ok"},"error":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Error","description":"Truncated SMTP / connection error if ``ok`` is false."}},"type":"object","required":["ok"],"title":"EmailTestOut","description":"Outcome of ``POST /v1/admin/email/test``.\n\nAlways returns 200 with a structured outcome — even when SMTP rejects\nthe request, so the UI can render the raw provider response. The error\nstring is truncated to 240 chars to limit accidental credential leakage\nif the SMTP server echoes part of the username."},"FeedbackIn":{"properties":{"title":{"type":"string","maxLength":255,"minLength":1,"title":"Title"},"body":{"type":"string","minLength":1,"title":"Body"},"type":{"$ref":"#/components/schemas/FeedbackType","default":"other"},"severity":{"$ref":"#/components/schemas/Severity","default":"medium"},"author_platform":{"type":"string","title":"Author Platform","default":"web"},"author_id":{"type":"string","title":"Author Id","default":""},"author_name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Author Name"}},"type":"object","required":["title","body"],"title":"FeedbackIn"},"FeedbackOut":{"properties":{"id":{"type":"string","title":"Id"},"project_slug":{"type":"string","title":"Project Slug"},"type":{"$ref":"#/components/schemas/FeedbackType"},"severity":{"$ref":"#/components/schemas/Severity"},"status":{"$ref":"#/components/schemas/FeedbackStatus"},"title":{"type":"string","title":"Title"},"body":{"type":"string","title":"Body"},"summary":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Summary"},"tags":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Tags"},"author_platform":{"type":"string","title":"Author Platform"},"author_name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Author Name"},"note":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Note"},"reply_to_user":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Reply To User"},"user_reply":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"User Reply"},"created_at":{"type":"string","format":"date-time","title":"Created At"},"updated_at":{"type":"string","format":"date-time","title":"Updated At"}},"type":"object","required":["id","project_slug","type","severity","status","title","body","summary","tags","author_platform","author_name","note","reply_to_user","user_reply","created_at","updated_at"],"title":"FeedbackOut"},"FeedbackPatch":{"properties":{"status":{"anyOf":[{"$ref":"#/components/schemas/FeedbackStatus"},{"type":"null"}]},"note":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Note"},"reply_to_user":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Reply To User"}},"type":"object","title":"FeedbackPatch"},"FeedbackStatus":{"type":"string","enum":["new","triaged","in_progress","done","wont_fix"],"title":"FeedbackStatus"},"FeedbackType":{"type":"string","enum":["bug","feature","question","other"],"title":"FeedbackType"},"HTTPValidationError":{"properties":{"detail":{"items":{"$ref":"#/components/schemas/ValidationError"},"type":"array","title":"Detail"}},"type":"object","title":"HTTPValidationError"},"IngestIn":{"properties":{"platform":{"type":"string","pattern":"^(telegram|whatsapp)$","title":"Platform"},"chat_id":{"type":"string","maxLength":128,"minLength":1,"title":"Chat Id"},"title":{"type":"string","maxLength":255,"minLength":1,"title":"Title"},"body":{"type":"string","minLength":1,"title":"Body"},"type":{"$ref":"#/components/schemas/FeedbackType","default":"other"},"severity":{"$ref":"#/components/schemas/Severity","default":"medium"},"author_id":{"type":"string","title":"Author Id"},"author_name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Author Name"}},"type":"object","required":["platform","chat_id","title","body","author_id"],"title":"IngestIn"},"IngestReplyIn":{"properties":{"platform":{"type":"string","pattern":"^(telegram|whatsapp)$","title":"Platform"},"chat_id":{"type":"string","maxLength":128,"minLength":1,"title":"Chat Id"},"replied_to_message_id":{"type":"string","maxLength":64,"minLength":1,"title":"Replied To Message Id"},"body":{"type":"string","minLength":1,"title":"Body"},"author_id":{"type":"string","title":"Author Id"},"author_name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Author Name"}},"type":"object","required":["platform","chat_id","replied_to_message_id","body","author_id"],"title":"IngestReplyIn","description":"Inbound reply from a user — they replied to one of our outbound messages."},"InviteAcceptIn":{"properties":{"token":{"type":"string","maxLength":128,"minLength":8,"title":"Token"}},"type":"object","required":["token"],"title":"InviteAcceptIn"},"InviteIn":{"properties":{"email":{"type":"string","maxLength":255,"minLength":3,"title":"Email"},"role":{"type":"string","pattern":"^(admin|member)$","title":"Role","default":"member"},"project_slug":{"anyOf":[{"type":"string","maxLength":64},{"type":"null"}],"title":"Project Slug"}},"type":"object","required":["email"],"title":"InviteIn"},"InviteOut":{"properties":{"id":{"type":"integer","title":"Id"},"email":{"type":"string","title":"Email"},"role":{"type":"string","title":"Role"},"project_slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Project Slug"},"created_at":{"type":"string","format":"date-time","title":"Created At"},"expires_at":{"type":"string","format":"date-time","title":"Expires At"},"used_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Used At"},"invited_by_email":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Invited By Email"}},"type":"object","required":["id","email","role","project_slug","created_at","expires_at","used_at","invited_by_email"],"title":"InviteOut"},"InvitePreviewOut":{"properties":{"email":{"type":"string","title":"Email"},"role":{"type":"string","title":"Role"},"tenant_name":{"type":"string","title":"Tenant Name"},"project_name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Project Name"},"expires_at":{"type":"string","format":"date-time","title":"Expires At"}},"type":"object","required":["email","role","tenant_name","project_name","expires_at"],"title":"InvitePreviewOut","description":"Response from ``GET /v1/invites/preview?token=...`` — no auth required.\n\nDesigned so the SPA's \"Accept invite\" page can show context (workspace name,\nemail, role) before the user clicks \"Accept\". Returns 404 for any invalid\nstate; never reveals whether the email exists in another tenant."},"LLMCallOut":{"properties":{"id":{"type":"integer","title":"Id"},"provider":{"type":"string","title":"Provider"},"model":{"type":"string","title":"Model"},"purpose":{"type":"string","title":"Purpose"},"input_tokens":{"type":"integer","title":"Input Tokens"},"output_tokens":{"type":"integer","title":"Output Tokens"},"total_tokens":{"type":"integer","title":"Total Tokens"},"usd_cost":{"type":"number","title":"Usd Cost"},"latency_ms":{"type":"integer","title":"Latency Ms"},"status":{"type":"string","title":"Status"},"error_text":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Error Text"},"created_at":{"type":"string","format":"date-time","title":"Created At"}},"type":"object","required":["id","provider","model","purpose","input_tokens","output_tokens","total_tokens","usd_cost","latency_ms","status","error_text","created_at"],"title":"LLMCallOut","description":"One row from ``GET /v1/projects/{slug}/llm-calls``. Used for the audit table."},"LLMSettingsIn":{"properties":{"provider":{"type":"string","maxLength":32,"minLength":1,"pattern":"^(none|[a-z][a-z0-9_-]*)$","title":"Provider"},"model":{"anyOf":[{"type":"string","maxLength":120},{"type":"null"}],"title":"Model"},"api_key":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Api Key","description":"Plaintext key; encrypted server-side."},"enabled":{"type":"boolean","title":"Enabled","default":false},"monthly_budget_usd":{"anyOf":[{"type":"number","minimum":0.0},{"type":"null"}],"title":"Monthly Budget Usd"}},"type":"object","required":["provider"],"title":"LLMSettingsIn","description":"Body for ``PUT /v1/projects/{slug}/llm-settings``.\n\nPartial-update semantics:\n  * Omit ``api_key`` to keep the existing encrypted key untouched.\n  * Send a non-empty ``api_key`` string to set / rotate it.\n  * Send an empty string to **clear** the key (must also set\n    ``enabled=false`` since classification cannot run without one).\nThe strict pattern is enforced server-side."},"LLMSettingsOut":{"properties":{"provider":{"type":"string","title":"Provider"},"model":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Model"},"enabled":{"type":"boolean","title":"Enabled"},"monthly_budget_usd":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Monthly Budget Usd"},"has_api_key":{"type":"boolean","title":"Has Api Key","description":"True when an encrypted key is stored."},"last_test_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Last Test At"},"last_test_ok":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Last Test Ok"},"last_test_error":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Last Test Error","description":"Truncated provider error from the last /llm-test call."},"month_to_date_usd":{"type":"number","title":"Month To Date Usd","description":"Sum of usd_cost for this calendar month."}},"type":"object","required":["provider","model","enabled","monthly_budget_usd","has_api_key","last_test_at","last_test_ok","last_test_error","month_to_date_usd"],"title":"LLMSettingsOut","description":"View of project LLM settings.\n\nThe encrypted API key is **never** included in this response. Whether one\nis configured is exposed only as the boolean ``has_api_key``. The provider\nerror from the last test call (``last_test_error``) is truncated to 240\ncharacters to limit accidental key leakage if the provider echoed it back."},"LLMTestOut":{"properties":{"ok":{"type":"boolean","title":"Ok"},"status":{"type":"string","title":"Status","description":"ok | refused | error | over_budget | disabled"},"provider":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Provider"},"model":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Model"},"latency_ms":{"type":"integer","title":"Latency Ms"},"usd_cost":{"type":"number","title":"Usd Cost"},"error_text":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Error Text","description":"Truncated to 240 chars; never echoes back the key."}},"type":"object","required":["ok","status","provider","model","latency_ms","usd_cost","error_text"],"title":"LLMTestOut","description":"Response from ``POST /v1/projects/{slug}/llm-test``."},"LoginIn":{"properties":{"email":{"type":"string","maxLength":255,"minLength":3,"title":"Email"}},"type":"object","required":["email"],"title":"LoginIn","description":"JSON body for ``POST /v1/auth/login``.\n\nThe SPA POSTs to this with the user's email. Same enumeration-resistant\nbehaviour as the legacy form endpoint: response is identical whether the\nemail exists or not."},"LoginOut":{"properties":{"sent":{"type":"boolean","title":"Sent","description":"Always true; whether the email exists is intentionally hidden."}},"type":"object","required":["sent"],"title":"LoginOut","description":"Response from ``POST /v1/auth/login``.\n\nThe PKCE nonce ships back in the ``mlnonce`` cookie (httpOnly), not in\nthis body — the SPA never sees it directly."},"MeOut":{"properties":{"user":{"additionalProperties":true,"type":"object","title":"User","description":"Identity + role. Keys: id, email, role, tenant_id."},"tenant":{"additionalProperties":true,"type":"object","title":"Tenant","description":"Workspace-level info. Keys: id, name."},"projects":{"items":{"$ref":"#/components/schemas/ProjectSummary"},"type":"array","title":"Projects","description":"Projects this user can see (admins/owners: all; members: their assignments)."},"is_setup_complete":{"type":"boolean","title":"Is Setup Complete","description":"False only when the database is empty (i.e. /setup is still active)."}},"type":"object","required":["user","tenant","projects","is_setup_complete"],"title":"MeOut","description":"Response from ``GET /v1/me`` — everything the SPA needs at boot."},"OutboundAckIn":{"properties":{"feedback_public_id":{"type":"string","title":"Feedback Public Id"},"kind":{"type":"string","title":"Kind"},"body":{"type":"string","title":"Body"},"sent_message_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Sent Message Id"},"ok":{"type":"boolean","title":"Ok"},"error":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Error"}},"type":"object","required":["feedback_public_id","kind","body","ok"],"title":"OutboundAckIn"},"OutboundItem":{"properties":{"feedback_public_id":{"type":"string","title":"Feedback Public Id"},"platform":{"type":"string","title":"Platform"},"chat_id":{"type":"string","title":"Chat Id"},"kind":{"type":"string","title":"Kind"},"body":{"type":"string","title":"Body"},"reply_to_message_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Reply To Message Id"}},"type":"object","required":["feedback_public_id","platform","chat_id","kind","body"],"title":"OutboundItem"},"PortalOut":{"properties":{"url":{"type":"string","title":"Url","description":"Stripe-hosted billing portal session URL. Single use; expires."}},"type":"object","required":["url"],"title":"PortalOut","description":"Response from ``POST /v1/billing/portal``."},"ProjectIn":{"properties":{"slug":{"type":"string","maxLength":64,"minLength":1,"pattern":"^[a-z0-9][a-z0-9_-]*$","title":"Slug"},"name":{"type":"string","maxLength":120,"minLength":1,"title":"Name"}},"type":"object","required":["slug","name"],"title":"ProjectIn","description":"Body for ``POST /v1/projects``."},"ProjectMemberAddIn":{"properties":{"user_id":{"type":"integer","title":"User Id"}},"type":"object","required":["user_id"],"title":"ProjectMemberAddIn"},"ProjectOut":{"properties":{"slug":{"type":"string","title":"Slug"},"name":{"type":"string","title":"Name"},"created_at":{"type":"string","format":"date-time","title":"Created At"},"feedback_count_by_status":{"additionalProperties":{"type":"integer"},"type":"object","title":"Feedback Count By Status","description":"Counts grouped by status for the badge in the UI."}},"type":"object","required":["slug","name","created_at"],"title":"ProjectOut","description":"Detailed project view — superset of ``ProjectSummary``."},"ProjectSummary":{"properties":{"slug":{"type":"string","title":"Slug"},"name":{"type":"string","title":"Name"},"created_at":{"type":"string","format":"date-time","title":"Created At"}},"type":"object","required":["slug","name","created_at"],"title":"ProjectSummary","description":"Compact view of a project — used in ``/v1/me`` and listings."},"ProvidersOut":{"properties":{"providers":{"additionalProperties":{"additionalProperties":true,"type":"object"},"type":"object","title":"Providers","description":"Keyed by provider name; each entry has default_model + available_models."}},"type":"object","required":["providers"],"title":"ProvidersOut","description":"Response from ``GET /v1/llm/providers`` — populated dynamically from the\nfeedbot_core.llm registry. The SPA uses this to render the provider/model\ndropdowns without hardcoding any names client-side."},"ProxyConfigIn":{"properties":{"domain":{"type":"string","maxLength":253,"minLength":3,"pattern":"^[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(\\.[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)+$","title":"Domain"},"letsencrypt_email":{"type":"string","maxLength":255,"minLength":3,"pattern":"^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$","title":"Letsencrypt Email"}},"type":"object","required":["domain","letsencrypt_email"],"title":"ProxyConfigIn","description":"Body for ``POST /v1/admin/proxy/config``.\n\nBoth fields are validated server-side before any orchestrator\nwork happens — a bad domain or empty email returns 422 *before*\nwe touch the Caddy admin API, so the SPA's pre-flight has a\nclear contract."},"ProxyConfigOut":{"properties":{"domain":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Domain"},"letsencrypt_email":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Letsencrypt Email"},"https_enabled":{"type":"boolean","title":"Https Enabled"},"configured":{"type":"boolean","title":"Configured"}},"type":"object","required":["domain","letsencrypt_email","https_enabled","configured"],"title":"ProxyConfigOut","description":"Current Caddy / domain config for Settings → Domain & HTTPS.\n\n``configured`` is true when both a domain and a Let's Encrypt\ncontact email are stored — that's the minimum the orchestrator\nneeds to push a TLS-enabled config. ``https_enabled`` reflects\nthe persisted toggle; the live cert provisioning state is\nsurfaced separately via ``ProxyStatusOut``."},"ProxyDnsCheckIn":{"properties":{"domain":{"type":"string","maxLength":253,"minLength":3,"pattern":"^[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(\\.[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)+$","title":"Domain"}},"type":"object","required":["domain"],"title":"ProxyDnsCheckIn","description":"Body for ``POST /v1/admin/proxy/dns-check``."},"ProxyDnsCheckOut":{"properties":{"domain":{"type":"string","title":"Domain"},"resolved_ips":{"items":{"type":"string"},"type":"array","title":"Resolved Ips"},"server_ip":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Server Ip"},"matches":{"type":"boolean","title":"Matches"},"error":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Error"}},"type":"object","required":["domain","resolved_ips","server_ip","matches"],"title":"ProxyDnsCheckOut","description":"Pre-flight DNS resolution result.\n\n``resolved_ips`` is the A/AAAA record set the resolver returned\nfor ``domain``. ``server_ip`` is best-effort: the API container\nsees its outbound NAT IP, not necessarily the public IP the\nuser pointed DNS at — so we surface ``matches`` only as a\nsoft hint, never as a hard block."},"ProxyStatusOut":{"properties":{"domain":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Domain"},"configured":{"type":"boolean","title":"Configured"},"https_enabled":{"type":"boolean","title":"Https Enabled"},"policy_count":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Policy Count"},"error":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Error"}},"type":"object","required":["domain","configured","https_enabled"],"title":"ProxyStatusOut","description":"Polled view of the current Caddy provisioning state.\n\nThe SPA hits this every ~3s while the chip shows \"applying\".\n``configured`` is the orchestrator's read on whether Caddy has\na TLS automation policy registered for ``domain``; ``error``\nis set on the unhappy path so the UI can surface the raw\nCaddy admin API response."},"RedeemIn":{"properties":{"platform":{"type":"string","pattern":"^(telegram|whatsapp)$","title":"Platform"},"chat_id":{"type":"string","maxLength":128,"minLength":1,"title":"Chat Id"},"chat_title":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Chat Title"},"token":{"type":"string","maxLength":64,"minLength":8,"title":"Token"}},"type":"object","required":["platform","chat_id","token"],"title":"RedeemIn"},"RedeemOut":{"properties":{"project_slug":{"type":"string","title":"Project Slug"},"project_name":{"type":"string","title":"Project Name"}},"type":"object","required":["project_slug","project_name"],"title":"RedeemOut"},"SessionOut":{"properties":{"id":{"type":"string","title":"Id"},"created_at":{"type":"string","format":"date-time","title":"Created At"},"last_seen_at":{"type":"string","format":"date-time","title":"Last Seen At"},"expires_at":{"type":"string","format":"date-time","title":"Expires At"},"user_agent":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"User Agent"},"ip":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Ip"},"is_current":{"type":"boolean","title":"Is Current","description":"True for the session that authenticated this request."}},"type":"object","required":["id","created_at","last_seen_at","expires_at","user_agent","ip","is_current"],"title":"SessionOut","description":"One row from ``GET /v1/auth/sessions``.\n\n``id`` is the full session token; sensitive — only the owner sees their\nown sessions, and the field is shown so the user can revoke a specific\none from the future Security page."},"SetupIn":{"properties":{"email":{"type":"string","maxLength":255,"minLength":3,"title":"Email"},"tenant_name":{"type":"string","maxLength":120,"title":"Tenant Name","default":""}},"type":"object","required":["email"],"title":"SetupIn","description":"Body for ``POST /v1/setup`` — first-run owner bootstrap.\n\nOnly accepted while the users table is empty; once an owner exists this\nendpoint returns 410 Gone. The owner gets a magic-link emailed (or, on\ndeployments without SMTP, surfaced in the response) so they can sign in\nimmediately."},"SetupOut":{"properties":{"email":{"type":"string","title":"Email"},"delivered":{"type":"boolean","title":"Delivered","description":"True when the magic-link email was handed off to the configured backend without raising."},"fallback_link":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Fallback Link","description":"When SMTP isn't configured, the magic link is returned here so the bootstrapping admin can copy it."}},"type":"object","required":["email","delivered"],"title":"SetupOut","description":"Response from ``POST /v1/setup``.\n\n``fallback_link`` is only populated when SMTP isn't configured (e.g.\n``EMAIL_BACKEND=console`` in production) so the owner isn't locked out\nof their own instance. It's a single-use link with a 15-minute TTL; the\nSPA renders it as a \"click here to sign in\" button."},"SetupStatusOut":{"properties":{"required":{"type":"boolean","title":"Required","description":"True only while the users table is empty. Once an owner exists the endpoint flips to false permanently for this DB."}},"type":"object","required":["required"],"title":"SetupStatusOut","description":"Response from ``GET /v1/setup-status``.\n\nThe SPA polls this once at boot and routes to ``/setup`` when ``required``\nis true. After bootstrap the endpoint reports ``required=False`` forever\n(until the deployment is wiped), so the SPA caches it conservatively."},"Severity":{"type":"string","enum":["low","medium","high","critical"],"title":"Severity"},"SignupIn":{"properties":{"email":{"type":"string","maxLength":255,"minLength":3,"title":"Email"},"tenant_name":{"type":"string","maxLength":120,"title":"Tenant Name","default":""}},"type":"object","required":["email"],"title":"SignupIn","description":"Body for ``POST /v1/signup`` — multi-tenant cloud self-serve.\n\nDistinct from ``SetupIn`` (single-tenant first-run bootstrap). Both\naccept ``tenant_name`` optionally; ``signup`` falls back to the email's\nlocal-part when omitted, ``setup`` falls back the same way."},"SignupOut":{"properties":{"sent":{"type":"boolean","title":"Sent","description":"Always true on success; whether the email was new is intentionally hidden."}},"type":"object","required":["sent"],"title":"SignupOut","description":"Response from ``POST /v1/signup``.\n\nMirrors ``LoginOut.sent`` deliberately — the SPA shows the same\n\"check your email\" success card after either flow, and the response\nis identical whether the email is new, already registered, or\nrejected by rate limiting. Hides whether an email is registered to\nprevent enumeration."},"StatsOut":{"properties":{"by_status":{"additionalProperties":{"type":"integer"},"type":"object","title":"By Status"},"total":{"type":"integer","title":"Total"}},"type":"object","required":["by_status","total"],"title":"StatsOut"},"SubscriptionOut":{"properties":{"plan":{"type":"string","title":"Plan"},"plan_display_name":{"type":"string","title":"Plan Display Name"},"status":{"type":"string","title":"Status"},"current_period_end":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Current Period End"},"trial_end":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Trial End"},"limits":{"anyOf":[{"$ref":"#/components/schemas/BillingLimits"},{"type":"null"}]},"usage":{"anyOf":[{"$ref":"#/components/schemas/BillingUsage"},{"type":"null"}]},"cancel_at_period_end":{"type":"boolean","title":"Cancel At Period End"}},"type":"object","required":["plan","plan_display_name","status","current_period_end","trial_end","limits","usage","cancel_at_period_end"],"title":"SubscriptionOut","description":"Response from ``GET /v1/billing/subscription``.\n\nOn self-host (billing disabled), ``plan='self_host'`` and both\n``limits`` + ``usage`` are ``None`` — the SPA renders a \"no limits\"\nstate without conditional logic."},"SystemRestartIn":{"properties":{"service":{"anyOf":[{"type":"string","maxLength":32},{"type":"null"}],"title":"Service"}},"type":"object","title":"SystemRestartIn","description":"Body for ``POST /v1/admin/system/restart``.\n\n``service`` is one of the known compose services (or ``None``\nto restart everything). The orchestrator validates against\n``compose.KNOWN_SERVICES`` before shelling out."},"SystemServiceOut":{"properties":{"name":{"type":"string","title":"Name"},"state":{"type":"string","title":"State"},"image":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Image"},"status":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Status"}},"type":"object","required":["name","state"],"title":"SystemServiceOut","description":"One service row from ``GET /v1/admin/system/status``.\n\nState strings come straight from ``docker compose ps`` (running,\nexited, restarting, paused, etc.); we don't normalise so\noperators see the same vocabulary they get from the CLI."},"SystemStatusOut":{"properties":{"ok":{"type":"boolean","title":"Ok"},"version":{"type":"string","title":"Version"},"services":{"items":{"$ref":"#/components/schemas/SystemServiceOut"},"type":"array","title":"Services"},"error":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Error"}},"type":"object","required":["ok","version","services"],"title":"SystemStatusOut","description":"High-level health snapshot.\n\n``ok=True`` when every known service is in ``running`` state.\nThe ``error`` field carries the raw compose error if the ``ps``\ninvocation fails — UI surfaces it so the operator can debug."},"TelemetryConfigIn":{"properties":{"enabled":{"type":"boolean","title":"Enabled"}},"type":"object","required":["enabled"],"title":"TelemetryConfigIn"},"TelemetryConfigOut":{"properties":{"enabled":{"type":"boolean","title":"Enabled"}},"type":"object","required":["enabled"],"title":"TelemetryConfigOut"},"TenantUserOut":{"properties":{"id":{"type":"integer","title":"Id"},"email":{"type":"string","title":"Email"},"role":{"type":"string","title":"Role"},"created_at":{"type":"string","format":"date-time","title":"Created At"}},"type":"object","required":["id","email","role","created_at"],"title":"TenantUserOut"},"TenantUserPatchIn":{"properties":{"role":{"type":"string","pattern":"^(admin|member)$","title":"Role"}},"type":"object","required":["role"],"title":"TenantUserPatchIn","description":"Body for ``PATCH /v1/tenant/users/{id}``. Only ``role`` is mutable."},"UpdateApplyOut":{"properties":{"ok":{"type":"boolean","title":"Ok"},"message":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Message"}},"type":"object","required":["ok"],"title":"UpdateApplyOut","description":"Outcome of ``POST /v1/admin/system/updates/apply``.\n\n``ok=True`` means ``compose pull`` and ``compose up -d``\nfinished without error; the api container then runs\n``alembic upgrade head`` on its boot command before serving\nagain."},"UpdatesOut":{"properties":{"current":{"type":"string","title":"Current"},"latest":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Latest"},"available":{"type":"boolean","title":"Available"},"error":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Error"}},"type":"object","required":["current","latest","available"],"title":"UpdatesOut","description":"Result of ``GET /v1/admin/system/updates``.\n\n``available`` is a server-side comparison so the SPA never has\nto ship semver logic. ``error`` is set on registry failures —\nthe UI surfaces it as a soft warning rather than a hard fail\nso an offline registry doesn't block the rest of the page."},"ValidationError":{"properties":{"loc":{"items":{"anyOf":[{"type":"string"},{"type":"integer"}]},"type":"array","title":"Location"},"msg":{"type":"string","title":"Message"},"type":{"type":"string","title":"Error Type"},"input":{"title":"Input"},"ctx":{"type":"object","title":"Context"}},"type":"object","required":["loc","msg","type"],"title":"ValidationError"},"feedbot_api__routers__internal__BotConfigOut":{"properties":{"token":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Token"},"username":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Username"}},"type":"object","required":["token","username"],"title":"BotConfigOut","description":"Telegram bot credentials the bot service polls for at startup.\n\nThe bot can be configured two ways:\n  - ``TELEGRAM_BOT_TOKEN`` env var (boot-time, immutable per restart).\n  - Saved in ``InstanceConfig`` via the admin panel (mutable, picked up\n    by the bot polling this endpoint).\n\nThe bot prefers env when set, otherwise falls back to this endpoint and\nre-polls every 30s so an admin saving a token in the UI activates the\nbot without an explicit restart.\n\nThe token is a high-value secret — this endpoint requires the bot\nshared secret (``FEEDBOT_BOT_TOKEN``) like every other ``/v1/internal/*``\nroute."},"feedbot_api__schemas__BotConfigOut":{"properties":{"username":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Username"},"has_token":{"type":"boolean","title":"Has Token"},"configured":{"type":"boolean","title":"Configured"}},"type":"object","required":["username","has_token","configured"],"title":"BotConfigOut","description":"Current Telegram bot config for Settings → Telegram bot.\n\nThe encrypted token is **never** returned. ``has_token`` exposes\nonly whether one is stored. ``configured`` is true when both\ntoken and username are present (the username powers the\n``t.me/<username>?startgroup=…`` deep link the SPA shows)."}}}}