{"openapi": "3.1.0", "info": {"title": "Tritonium API", "version": "1.0.0", "description": "REST API powering the Tritonium customer feedback analytics platform. This specification captures the production endpoints that back the dashboard, automation workflows, and status pages.\n\n**A credential that could not be checked.** Every signed-in route checks the caller's token or API key against stored records on each request. When those records cannot be read, the request is refused with `503` `SERVICE_UNAVAILABLE`, `retryable: true`, `retry_after_seconds` and `Retry-After`, on every signed-in route, whether or not the route lists a `503` of its own. Nothing ran, and the answer says nothing about the credential: keep it and retry. Only `401` means that the credential is missing, invalid, expired or revoked.\n\n**Ids outside your workspace.** When a route looks an object up by its id (an id in the path or the query string, or the one app or review that a request body names as its subject) and that id is not in the caller's workspace, the answer is `404`. The answer is the same whether the id belongs to another workspace or to nobody, so it never confirms that an id exists. For an app, every route under `/api/v1/apps/{appUuid}` answers `404` `APP_NOT_FOUND`, with the one body `{\"error\": {\"code\": \"APP_NOT_FOUND\", \"message\": \"App not found\"}}`. A refusal that is about the request rather than the app can come first and says nothing about it: the caller's role (`403`), the workspace's subscription (`402`), a feature the workspace does not have yet (`503`), or a malformed request (`400`). For a review, `GET /api/v1/reviews/{reviewId}` and `POST /api/v1/reviews/reply` answer `REVIEW_NOT_FOUND`, and reading or generating the reply to a review that one of your apps does not have answers `REVIEW_CONTEXT_NOT_FOUND`. Other resources (a webhook, a tag, a draft, a team member and so on) answer their own code or `NOT_FOUND`, so branch on the status for those. `403` is kept for what the caller's role or credential may not do.\n\nThis rule does not cover lists of ids inside a request body. The route that takes such a list decides: a product group's `app_uuids` answers `400` for an id that is not in the workspace, and an alert rule's `app_filter` and an export's `filters.app_uuids` answer `404` `APP_NOT_FOUND`, with the one body above, for an id that is not one of the workspace's apps (a deleted app and a product group included). Either way nothing is saved. None of these answers says whether the id exists elsewhere."}, "servers": [{"url": "https://api.tritonium.com", "description": "Production"}], "tags": [{"name": "Health", "description": "Public health probes."}, {"name": "Public", "description": "Unauthenticated marketing, benchmark, lead capture, and feedback endpoints."}, {"name": "Auth", "description": "Authentication, verification, and SSO."}, {"name": "Users", "description": "Operations on the signed-in user's profile."}, {"name": "Apps", "description": "Manage customer apps and review ingestion."}, {"name": "Reviews", "description": "Review retrieval and AI-generated replies."}, {"name": "Insights", "description": "Insight and analytics retrieval."}, {"name": "Ratings", "description": "Rating history and comparison endpoints."}, {"name": "Predictions", "description": "Predictive analytics pipelines."}, {"name": "Alerts", "description": "Alert feeds and rule management."}, {"name": "Team", "description": "Team member operations."}, {"name": "Reply Drafts", "description": "AI-generated reply draft approval workflow."}, {"name": "Credentials", "description": "Secure storage for third-party credentials."}, {"name": "Reply Templates", "description": "Customer reply template library operations."}, {"name": "Activity", "description": "Per-tenant activity feed, stats, and milestones."}, {"name": "Billing", "description": "Subscription management and Stripe webhooks."}, {"name": "Integrations", "description": "Third-party integration lifecycle."}, {"name": "API Keys", "description": "Manage API keys for programmatic access."}, {"name": "Agent connections", "description": "AI agents connected to the MCP server (https://api.tritonium.com/mcp) with OAuth sign-in instead of an API key. The OAuth 2.1 authorization server itself is described by its metadata at https://api.tritonium.com/.well-known/oauth-authorization-server and the protected resource by https://api.tritonium.com/.well-known/oauth-protected-resource/mcp."}, {"name": "Exports", "description": "Data export jobs for reviews, insights, alerts, and analytics."}, {"name": "Reports", "description": "Report templates, scheduled reports, and on-demand generation."}, {"name": "MFA", "description": "Multi-factor authentication setup and verification."}, {"name": "PAM", "description": "Privileged Access Management for time-bound elevated access."}, {"name": "Workflows", "description": "Automation workflows for review alerts and integrations."}, {"name": "Review Tags", "description": "Custom tags for organizing and categorizing reviews."}, {"name": "SLA", "description": "Service Level Agreement configuration and compliance tracking."}, {"name": "Webhooks", "description": "Webhook configuration and delivery management."}, {"name": "Competitive Intelligence", "description": "Track competitor apps, reviews, and market positioning."}, {"name": "Categories", "description": "Custom categories for AI-powered review classification."}, {"name": "Support", "description": "AI-powered support chat and conversation history."}, {"name": "Dashboards", "description": "Custom dashboard layouts with configurable widgets."}, {"name": "Releases", "description": "App version release tracking and impact analysis."}, {"name": "Push Notifications", "description": "Web Push subscription management for real-time alerts."}, {"name": "Email Analytics", "description": "Admin endpoints for email tracking metrics and analytics."}, {"name": "Auto-Response Rules", "description": "Automated review response rules and configuration."}, {"name": "Routing Rules", "description": "Automatic review assignment to team members."}, {"name": "Segments", "description": "User segmentation and classification analytics."}, {"name": "Language Analytics", "description": "Language distribution and sentiment by language."}, {"name": "Feature Suggestions", "description": "AI-extracted feature requests from reviews with impact analysis."}, {"name": "Template Analytics", "description": "Reply template performance metrics and analytics by tone/category."}, {"name": "Presentations", "description": "Asynchronous app and product-group presentations with persisted job status and private PDF and PPTX downloads."}], "security": [{"BearerAuth": []}, {"ApiKeyAuth": []}], "paths": {"/api/v1/activity": {"get": {"tags": ["Activity"], "summary": "Paginated activity feed for the current tenant.", "description": "The workspace's activity events, newest first, one page at a time. Events are kept for a year. Any member can read the feed, and so can an API key with the `analytics:read` scope.", "operationId": "getActivity", "parameters": [{"name": "limit", "in": "query", "description": "Events per page, from 1 to 100 (default 20). Anything else answers `400 VALIDATION_ERROR`.", "schema": {"type": "integer", "minimum": 1, "maximum": 100, "default": 20}}, {"name": "cursor", "in": "query", "description": "The `next_cursor` of the previous page. A cursor that was not issued for this workspace and query answers `400 INVALID_CURSOR`.", "schema": {"type": "string"}}, {"name": "event_type", "in": "query", "description": "Only events of this type, for example `reviews.synced`, `reply.sent` or `outcome.earned`.", "schema": {"type": "string"}}, {"name": "start_date", "in": "query", "description": "Inclusive lower bound, as `YYYY-MM-DD` or a full ISO 8601 timestamp. An unparseable value is rejected with `400` `INVALID_DATE` rather than ignored. A `start_date` later than `end_date` is rejected with `400` `INVALID_DATE_RANGE`.", "schema": {"type": "string"}}, {"name": "end_date", "in": "query", "description": "Inclusive upper bound, as `YYYY-MM-DD` or a full ISO 8601 timestamp. A bare date covers the whole of that day. Validated like `start_date`.", "schema": {"type": "string"}}], "responses": {"200": {"description": "Activity events (newest first).", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ActivityFeed"}}}}, "400": {"description": "`VALIDATION_ERROR` (`limit`), `INVALID_DATE` (`start_date` or `end_date`), `INVALID_DATE_RANGE` or `INVALID_CURSOR`.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "401": {"$ref": "#/components/responses/Unauthorized"}, "402": {"$ref": "#/components/responses/SubscriptionRequired"}, "403": {"$ref": "#/components/responses/ActivityKeyForbidden"}, "500": {"description": "`INTERNAL_ERROR`: the feed could not be read, or its page cursors cannot be made right now. Retry.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "503": {"$ref": "#/components/responses/SubscriptionUnverified"}}}}, "/api/v1/activity/stats": {"get": {"tags": ["Activity"], "summary": "Activity counts by type and by day over a recent window.", "description": "How many events the workspace had over the last `days` days, by event type and by day. Events that cannot be read fail the answer (`503`), so a failure is never shown as no activity. Any member can read them, and so can an API key with the `analytics:read` scope.", "operationId": "getActivityStats", "parameters": [{"name": "days", "in": "query", "description": "The window, from 1 to 365 days (default 30). Anything else answers `400 VALIDATION_ERROR`.", "schema": {"type": "integer", "minimum": 1, "maximum": 365, "default": 30}}], "responses": {"200": {"description": "Aggregated activity statistics.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ActivityStats"}}}}, "400": {"description": "`VALIDATION_ERROR`: `days` is not a whole number from 1 to 365.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "401": {"$ref": "#/components/responses/Unauthorized"}, "402": {"$ref": "#/components/responses/SubscriptionRequired"}, "403": {"$ref": "#/components/responses/ActivityKeyForbidden"}, "503": {"$ref": "#/components/responses/ReadUnavailable"}}}}, "/api/v1/activity/outcomes": {"get": {"tags": ["Activity"], "summary": "Outcome milestones: evidence-backed customer outcomes along the review-to-resolution journey.", "description": "Returns the five outcome milestones with their current state (`not_eligible`, `candidate`, `awaiting_verification`, `earned`, `evidence_unavailable`, `withdrawn`), structured progress, the next useful action, and machine-readable limitation codes. Only `earned` states are achievements, and every achievement is backed by retrievable product evidence, never by a model-generated claim alone. States are computed on read and cached per tenant for up to 15 minutes; `refresh=true` forces one recomputation per minute. Disabled via `OUTCOME_MILESTONES_ENABLED=false` (routes answer 404). Any member can read them, and so can an API key with the `analytics:read` scope.", "operationId": "getActivityOutcomes", "parameters": [{"name": "refresh", "in": "query", "description": "Force re-evaluation (honored at most once a minute).", "schema": {"type": "boolean"}}], "responses": {"200": {"description": "Outcome milestone states.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/OutcomeSummary"}}}}, "401": {"$ref": "#/components/responses/Unauthorized"}, "402": {"$ref": "#/components/responses/SubscriptionRequired"}, "403": {"$ref": "#/components/responses/ActivityKeyForbidden"}, "404": {"description": "`NOT_FOUND`: the outcome-milestone surface is disabled in this deployment.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "503": {"$ref": "#/components/responses/SubscriptionUnverified"}}}}, "/api/v1/activity/outcomes/{milestoneId}": {"get": {"tags": ["Activity"], "summary": "Outcome milestone detail with evidence references and audit history.", "description": "The full achievement record: why it qualifies, supporting evidence references (app ids, review ids, action ids, analysis versions and timestamps, never review text), coverage and limitation notes, and the state-transition history that explains withdrawals. Any member can read it, and so can an API key with the `analytics:read` scope.", "operationId": "getActivityOutcomeDetail", "parameters": [{"name": "milestoneId", "in": "path", "required": true, "description": "A milestone id. Any other value answers `404 NOT_FOUND`.", "schema": {"type": "string", "enum": ["first_recurring_issue", "first_issue_action", "first_cross_app_issue", "first_reply_followup", "first_verified_improvement"]}}], "responses": {"200": {"description": "Achievement detail.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/OutcomeDetail"}}}}, "401": {"$ref": "#/components/responses/Unauthorized"}, "402": {"$ref": "#/components/responses/SubscriptionRequired"}, "403": {"$ref": "#/components/responses/ActivityKeyForbidden"}, "404": {"description": "`NOT_FOUND`: no milestone has this id, or the outcome-milestone surface is disabled in this deployment.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "503": {"description": "`STORE_UNAVAILABLE`: the milestone record could not be read; `retryable` is true. `SERVICE_UNAVAILABLE` (`SubscriptionUnverified`): the workspace subscription could not be verified.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}}}}, "/api/v1/activity/outcomes/{milestoneId}/verify": {"post": {"tags": ["Activity"], "summary": "Human confirmation (or rejection) of an outcome milestone awaiting verification.", "description": "Milestones whose verification method is `human_confirm` earn only after an authorized reviewer inspects the evidence. Confirmation moves `awaiting_verification \u2192 earned`; rejection moves it to `withdrawn` with the reviewer's note preserved in the audit history. The write is preconditioned on the awaiting state, so a milestone that changed since the reviewer loaded it answers `409`. Honours `Idempotency-Key`. Human verification is a signed-in-user action (admin or owner role): it is not available to API keys.", "operationId": "verifyActivityOutcome", "security": [{"BearerAuth": []}], "parameters": [{"name": "milestoneId", "in": "path", "required": true, "description": "A milestone id. Any other value answers `404 NOT_FOUND`.", "schema": {"type": "string", "enum": ["first_recurring_issue", "first_issue_action", "first_cross_app_issue", "first_reply_followup", "first_verified_improvement"]}}, {"$ref": "#/components/parameters/IdempotencyKey"}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"type": "object", "required": ["decision", "expected_updated_at"], "properties": {"decision": {"type": "string", "enum": ["confirm", "reject"]}, "note": {"type": "string", "maxLength": 2000}, "expected_updated_at": {"type": "string", "description": "The `updated_at` of the milestone record the reviewer loaded (required). The write is bound to that version: a re-evaluation that refreshed the evidence in between answers `409 STATE_CHANGED` instead of awarding on evidence nobody reviewed."}}}}}}, "responses": {"200": {"description": "The milestone's new state.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/OutcomeVerifyResult"}}}}, "400": {"description": "`VERIFICATION_NOT_REQUIRED`: the milestone is verified automatically. `INVALID_DECISION`, `INVALID_NOTE` or `INVALID_VERSION`: `decision`, `note` or `expected_updated_at` is missing or not as described. `VALIDATION_ERROR`: the body is not a JSON object.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "401": {"$ref": "#/components/responses/Unauthorized"}, "402": {"$ref": "#/components/responses/SubscriptionRequired"}, "403": {"description": "`FORBIDDEN`: only an admin or owner can confirm or reject a milestone, and never with an API key.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "404": {"description": "`NOT_FOUND`: no milestone has this id, or the outcome-milestone surface is disabled in this deployment.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "409": {"description": "`NOT_AWAITING_VERIFICATION`: the milestone is not awaiting verification. `STATE_CHANGED`: it changed under the reviewer; reload and try again. `IDEMPOTENCY_IN_PROGRESS` or `IDEMPOTENCY_OUTCOME_UNKNOWN`: see `Idempotency-Key`.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "422": {"$ref": "#/components/responses/IdempotencyKeyReused"}, "500": {"description": "`INTERNAL_ERROR`: the transition could not be stored; nothing was awarded.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "503": {"description": "`STORE_UNAVAILABLE`: the milestone record could not be read; `retryable` is true. `SERVICE_UNAVAILABLE` (`SubscriptionUnverified`): the workspace subscription could not be verified. `IDEMPOTENCY_UNAVAILABLE`: the `Idempotency-Key` could not be recorded and nothing ran.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}}}}, "/api/v1/activity/milestones/progress": {"get": {"tags": ["Activity"], "summary": "Milestone progress computed from aggregate tenant counts.", "description": "Progress toward the count milestones (reviews synced, replies sent and apps connected), worked out from the workspace's counts on every read. A count that cannot be read fails the answer (`503`), so a failure is never shown as a count of 0. Any member can read it, and so can an API key with the `analytics:read` scope.", "operationId": "getActivityMilestoneProgress", "responses": {"200": {"description": "Milestone progress.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/MilestoneProgress"}}}}, "401": {"$ref": "#/components/responses/Unauthorized"}, "402": {"$ref": "#/components/responses/SubscriptionRequired"}, "403": {"$ref": "#/components/responses/ActivityKeyForbidden"}, "503": {"$ref": "#/components/responses/ReadUnavailable"}}}}, "/health": {"get": {"tags": ["Health"], "summary": "Check API availability.", "operationId": "getHealth", "security": [], "responses": {"200": {"description": "API is reachable.", "content": {"application/json": {"schema": {"type": "object", "properties": {"status": {"type": "string", "example": "healthy"}, "service": {"type": "string", "example": "tritonium-public"}, "version": {"type": "string", "example": "1.0.0"}}}}}}, "500": {"$ref": "#/components/responses/InternalError"}}}}, "/": {"get": {"tags": ["Health"], "summary": "Root health probe.", "operationId": "getRoot", "security": [], "responses": {"200": {"description": "API is reachable.", "content": {"application/json": {"schema": {"type": "object", "additionalProperties": true}}}}, "500": {"$ref": "#/components/responses/InternalError"}}}}, "/api/v1/public/benchmarks": {"get": {"tags": ["Public"], "summary": "Get illustrative benchmark sample data.", "description": "Returns static illustrative sample values, not measured production performance\nor an actual review sample. Do not use these numbers to establish analysis\nspeed, forecast lead time or processing capacity. No tenant data is queried.\nThe current marketing page does not consume these sample figures.\n", "operationId": "getPublicBenchmarks", "security": [], "responses": {"200": {"description": "Explicitly labeled illustrative sample data.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/PublicBenchmarksResponse"}}}}}}}, "/api/v1/public/playground": {"get": {"tags": ["Public"], "summary": "Get playground demo data.", "description": "Returns sample data for the interactive playground on the marketing site.\nSupports different resource types for demonstrating platform capabilities.\n", "operationId": "getPublicPlayground", "security": [], "parameters": [{"name": "resource", "in": "query", "description": "Type of resource to query.", "schema": {"type": "string", "enum": ["reviews", "alerts", "health"], "default": "health"}}], "responses": {"200": {"description": "Playground data.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/PublicPlaygroundResponse"}}}}}}}, "/api/v1/public/feedback": {"get": {"tags": ["Public"], "summary": "Follow a feedback link from an email.", "description": "Redirects an email feedback link (cancellation, re-engagement, etc.) to\nthe thank-you page. No authentication required.\n\n**This is a safe method: it records nothing.** The link sits in an\nemail, and enterprise mail security fetches every URL in a message\nbefore the recipient opens it, so a GET that recorded feedback recorded\nwhatever a scanner touched: six contradictory cancellation reasons for\none unopened message. The thank-you page posts the answer with `POST`\nonce it has rendered in the visitor's browser.\n\nThis endpoint never changes which emails an address receives. With\n`reason=unsubscribe` it records nothing and answers 200 with an HTML\npage that points to the signed-in Product emails setting, the\nnewsletter's own unsubscribe link and the support address.\n", "operationId": "getPublicFeedback", "security": [], "parameters": [{"name": "reason", "in": "query", "required": true, "description": "Feedback reason code. Passed through to the thank-you page.", "schema": {"type": "string"}}, {"name": "source", "in": "query", "description": "Source of feedback (email type).", "schema": {"type": "string"}}, {"name": "email", "in": "query", "schema": {"type": "string", "format": "email"}, "description": "Accepted for backward compatibility and ignored on this method, which records nothing."}], "responses": {"200": {"description": "`reason=unsubscribe` only. An HTML page explaining where to unsubscribe; nothing is recorded.", "content": {"text/html": {"schema": {"type": "string"}}}}, "302": {"description": "Redirect to the thank-you page. Nothing is recorded.", "headers": {"Location": {"schema": {"type": "string"}, "description": "Thank you page URL."}, "Cache-Control": {"schema": {"type": "string"}, "description": "`no-store`, so a prefetched redirect is not reused as the answer for a later, real click."}}}}}, "post": {"tags": ["Public"], "summary": "Submit feedback via API.", "description": "Records user feedback from various sources. No authentication required\nto allow feedback collection from email links and public forms.\n\nThis endpoint never changes which emails an address receives:\n`reason=unsubscribe` records nothing and answers 400\n`UNSUBSCRIBE_NOT_ACCEPTED` with directions to the signed-in setting.\n\nSend one client-generated `idempotency_key` for an attempt and reuse it\nwith the same feedback when retrying an ambiguous failure. Repeating a\nkey and payload after the first write commits returns the same\nsuccessful `feedback_id` without creating another row or consuming\nanother submission-rate slot. If concurrent requests race before the\nfirst write commits at a rate-limit boundary, retry the same key after\na 429. Reusing the key for different feedback returns\n`IDEMPOTENCY_KEY_REUSED`.\n", "operationId": "postPublicFeedback", "security": [], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/PublicFeedbackRequest"}}}}, "parameters": [{"name": "reason", "in": "query", "schema": {"type": "string", "maxLength": 100}, "description": "Why the user is giving feedback. Required. Read from the query string as well as the body, so a one-click link in an email can carry it."}, {"name": "source", "in": "query", "schema": {"type": "string", "maxLength": 50}, "description": "Where the feedback came from (e.g. the email template that linked here). Stored as `unknown` when absent."}, {"name": "email", "in": "query", "schema": {"type": "string", "format": "email"}, "description": "Optional submitter address, used only to rate-limit per address. Rejected with 400 when present and malformed."}], "responses": {"200": {"description": "Feedback recorded.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/PublicFeedbackResponse"}}}}, "400": {"$ref": "#/components/responses/BadRequest"}, "409": {"description": "`IDEMPOTENCY_KEY_REUSED`: this `idempotency_key` was already used for different feedback. Nothing new was recorded; use a new key for a new submission.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "429": {"description": "Rate limited.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}}}}, "/api/v1/contact": {"post": {"tags": ["Public"], "summary": "Submit a contact form inquiry.", "description": "Captures contact form submissions from the marketing website and the dashboard's support page, and notifies the Tritonium team. Rate limited to 5 requests per IP address and 3 per email address per hour.", "operationId": "postContact", "security": [], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ContactRequest"}}}}, "responses": {"202": {"description": "Contact form submitted successfully.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ContactAccepted"}}}}, "400": {"description": "`VALIDATION_ERROR`, and the message says what is wrong: the body is not a JSON object; `name`, `email`, `company` or `message` is missing; the email address is not valid; the company name is not 2 to 100 characters; or the message is shorter than 6 characters.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "429": {"description": "`RATE_LIMITED`: too many submissions from this IP address or email address in the last hour, or the limit could not be checked. Retry after `retry_after_seconds`.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "500": {"description": "`INTERNAL_ERROR`: the submission could not be saved. Nothing was sent.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}}}}, "/api/v1/contact/chat": {"post": {"tags": ["Public"], "summary": "Send a message to the AI support chat.", "description": "Public AI-powered chat for the marketing website. Handles product questions, feature inquiries, and general support after email verification. The email and verification token must come from the /api/v1/contact/send-code and /api/v1/contact/verify-code flow.", "operationId": "postContactChat", "security": [], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ContactChatRequest"}}}}, "responses": {"200": {"description": "Chat response generated.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ContactChatResponse"}}}}, "400": {"$ref": "#/components/responses/ValidationError"}, "401": {"description": "Email verification required.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "403": {"description": "Turnstile is enforced and the request carried no valid token (`HUMAN_VERIFICATION_REQUIRED`).", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "429": {"$ref": "#/components/responses/RateLimited"}, "503": {"$ref": "#/components/responses/ServiceUnavailable"}, "500": {"$ref": "#/components/responses/InternalError"}}}}, "/api/v1/contact/send-code": {"post": {"tags": ["Public"], "summary": "Send an email verification code.", "description": "Sends a 6-digit verification code to the provided email address. Used for email verification before lead capture or demo requests.", "operationId": "postContactSendCode", "security": [], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/SendVerificationCodeRequest"}}}}, "responses": {"200": {"description": "Verification code sent.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/GenericSuccess"}}}}, "400": {"$ref": "#/components/responses/ValidationError"}, "403": {"description": "Turnstile is enforced and the request carried no valid token (`HUMAN_VERIFICATION_REQUIRED`).", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "429": {"$ref": "#/components/responses/RateLimited"}, "503": {"description": "Turnstile is enforced but the token could not be checked (`HUMAN_VERIFICATION_UNAVAILABLE`).", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}}}}, "/api/v1/contact/verify-code": {"post": {"tags": ["Public"], "summary": "Verify an email verification code.", "description": "Verifies the 6-digit code sent to the user's email address. Returns a verification token for subsequent lead capture requests.", "operationId": "postContactVerifyCode", "security": [], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/VerifyCodeRequest"}}}}, "responses": {"200": {"description": "Code verified successfully.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/VerifyCodeResponse"}}}}, "400": {"$ref": "#/components/responses/ValidationError"}, "401": {"description": "Invalid or expired verification code.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}}}}, "/api/v1/support/chat": {"post": {"tags": ["Support"], "summary": "Send a message to the authenticated support chat.", "description": "AI-powered support chat for signed-in people. The message and the answer are kept for 90 days as one exchange of the session. When the model cannot answer, the answer is a fixed message asking to retry or to write to support, and it is kept like any other.", "operationId": "postSupportChat", "security": [{"BearerAuth": []}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/SupportChatRequest"}}}}, "responses": {"200": {"description": "Support chat response generated.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/SupportChatResponse"}}}}, "400": {"description": "`VALIDATION_ERROR`, and the message says what is wrong: the body is not a JSON object; `message` is missing, empty or longer than 5,000 characters; `session_id` is not in the form given; or `conversation_history` is not a list, has more than 20 entries or is larger than 100,000 bytes.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "401": {"$ref": "#/components/responses/Unauthorized"}, "403": {"$ref": "#/components/responses/SupportKeyForbidden"}, "500": {"description": "`INTERNAL_ERROR`: the request could not be answered, for example when a `conversation_history` entry has no `content`.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "503": {"description": "`STORAGE_UNAVAILABLE`: the exchange could not be saved. Retry.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}}}}, "/api/v1/support/conversations": {"get": {"tags": ["Support"], "summary": "Get support conversation history.", "description": "Retrieves the authenticated user's support chat conversation history: every session, with message counts and timestamps, most recent first. The response is not paged.", "operationId": "getSupportConversations", "security": [{"BearerAuth": []}], "responses": {"200": {"description": "Conversation history retrieved.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ConversationListResponse"}}}}, "401": {"$ref": "#/components/responses/Unauthorized"}, "403": {"$ref": "#/components/responses/SupportKeyForbidden"}, "500": {"$ref": "#/components/responses/SupportUnread"}}}}, "/api/v1/support/conversations/{sessionId}": {"get": {"tags": ["Support"], "summary": "Get a specific support conversation.", "description": "The exchanges of one of the signed-in person's support chat sessions, oldest first: at most the newest 500.", "operationId": "getSupportConversation", "security": [{"BearerAuth": []}], "parameters": [{"name": "sessionId", "in": "path", "required": true, "schema": {"type": "string", "pattern": "^[a-zA-Z0-9_-]{1,64}$", "maxLength": 64}, "description": "The conversation session ID. Same value set as `SupportChatRequest.session_id`: every id the chat write path accepts is readable here, not only UUIDs."}], "responses": {"200": {"description": "Conversation retrieved.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ConversationDetailResponse"}}}}, "401": {"$ref": "#/components/responses/Unauthorized"}, "403": {"$ref": "#/components/responses/SupportKeyForbidden"}, "404": {"description": "`NOT_FOUND`: the person has no exchange in a session with this id (another person's session answers the same), or the id is not in the form given.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "500": {"$ref": "#/components/responses/SupportUnread"}}}}, "/api/v1/signup": {"post": {"tags": ["Public"], "summary": "Submit a waitlist signup.", "description": "Captures waitlist signups from the marketing website. Rate limited to 5 requests per IP per hour.", "operationId": "postSignup", "security": [], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/SignupRequest"}}}}, "responses": {"202": {"description": "Waitlist signup submitted successfully.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/GenericSuccess"}}}}, "400": {"$ref": "#/components/responses/ValidationError"}, "429": {"$ref": "#/components/responses/RateLimited"}}}}, "/api/v1/newsletter": {"post": {"tags": ["Public"], "summary": "Start newsletter subscription confirmation.", "description": "Captures newsletter subscription requests and sends a double-opt-in confirmation email. The response is intentionally generic for duplicate and suppressed addresses. Rate limited to 10 requests per IP per hour. Abuse guards: requests with the honeypot field filled or a foreign Origin header are dropped with the generic response, and a global hourly circuit breaker caps confirmation sends; skipped recipients can still confirm through a later resend. When the API enforces Cloudflare Turnstile, a request without a valid `turnstile_token` is refused with 403.", "operationId": "postNewsletter", "security": [], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/NewsletterRequest"}}}}, "responses": {"200": {"description": "Newsletter confirmation email accepted or safely suppressed.", "content": {"application/json": {"schema": {"type": "object", "properties": {"success": {"type": "boolean", "example": true}, "message": {"type": "string", "example": "Check your email to confirm your subscription."}}}}}}, "400": {"$ref": "#/components/responses/ValidationError"}, "403": {"description": "Turnstile is enforced and the request carried no valid token (`HUMAN_VERIFICATION_REQUIRED`).", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "429": {"$ref": "#/components/responses/RateLimited"}, "503": {"description": "Turnstile is enforced but the token could not be checked (`HUMAN_VERIFICATION_UNAVAILABLE`).", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}}}}, "/api/v1/newsletter/confirm": {"get": {"tags": ["Public"], "summary": "Open the newsletter confirmation page.", "description": "Validates the signed, expiring action token from the confirmation email and shows a confirmation button. This safe method does not confirm the subscription; mail security may fetch the link first.", "operationId": "getNewsletterConfirm", "security": [], "parameters": [{"name": "token", "in": "query", "required": true, "schema": {"type": "string", "minLength": 32, "maxLength": 4096}}], "responses": {"200": {"description": "Confirmation page; subscription remains pending.", "content": {"text/html": {"schema": {"type": "string"}}}}, "400": {"description": "Token is invalid, expired, or no longer confirmable.", "content": {"text/html": {"schema": {"type": "string"}}}}}}, "post": {"tags": ["Public"], "summary": "Confirm a newsletter subscription.", "description": "Confirms a pending subscriber after the recipient presses the button on the confirmation page. Accepts the signed token in the query string, JSON body, or form body.", "operationId": "postNewsletterConfirm", "security": [], "parameters": [{"name": "token", "in": "query", "required": false, "schema": {"type": "string", "minLength": 32, "maxLength": 4096}}], "requestBody": {"required": false, "content": {"application/json": {"schema": {"type": "object", "properties": {"token": {"type": "string", "minLength": 32, "maxLength": 4096}}}}, "application/x-www-form-urlencoded": {"schema": {"type": "object", "properties": {"token": {"type": "string", "minLength": 32, "maxLength": 4096}}}}}}, "responses": {"200": {"description": "Subscription confirmed.", "content": {"text/html": {"schema": {"type": "string"}}}}, "400": {"description": "Token is invalid, expired, or no longer confirmable.", "content": {"text/html": {"schema": {"type": "string"}}}}}}}, "/api/v1/newsletter/unsubscribe": {"get": {"tags": ["Public"], "summary": "Open the newsletter unsubscribe confirmation page.", "description": "Validates the signed action token and returns an HTML page with an\nUnsubscribe button.\n\n**This is a safe method: it unsubscribes nobody.** This is the visible\nlink at the foot of the newsletter, and enterprise mail security\nfetches every URL in a message before the recipient opens it, so a GET\nthat unsubscribed on sight removed readers who never clicked. The\nbutton posts the token back to `POST /api/v1/newsletter/unsubscribe`,\nwhich is what acts.\n\nOne button on one page still satisfies CAN-SPAM's \"no more than ... a\nsingle web page\" opt-out, and the RFC 8058 one-click path is\nunaffected: `List-Unsubscribe-Post` makes the mail client POST\ndirectly, without rendering this page.\n", "operationId": "getNewsletterUnsubscribe", "security": [], "parameters": [{"name": "token", "in": "query", "required": true, "schema": {"type": "string", "minLength": 32, "maxLength": 4096}}], "responses": {"200": {"description": "The confirmation page. Nothing has been unsubscribed; the form posts the token back to this path.", "content": {"text/html": {"schema": {"type": "string"}}}}, "400": {"description": "Token is invalid or expired.", "content": {"text/html": {"schema": {"type": "string"}}}}}}, "post": {"tags": ["Public"], "summary": "One-click newsletter unsubscribe.", "description": "RFC 8058-compatible one-click unsubscribe endpoint. Accepts the token in the query string, JSON body, or form body.", "operationId": "postNewsletterUnsubscribe", "security": [], "parameters": [{"name": "token", "in": "query", "required": false, "schema": {"type": "string", "minLength": 32, "maxLength": 4096}}], "requestBody": {"required": false, "content": {"application/json": {"schema": {"type": "object", "properties": {"token": {"type": "string", "minLength": 32, "maxLength": 4096}}}}, "application/x-www-form-urlencoded": {"schema": {"type": "object", "properties": {"token": {"type": "string", "minLength": 32, "maxLength": 4096}}}}}}, "responses": {"200": {"description": "Recipient unsubscribed or already in a non-sendable state.", "content": {"text/html": {"schema": {"type": "string"}}}}, "400": {"description": "Token is invalid or expired.", "content": {"text/html": {"schema": {"type": "string"}}}}}}}, "/api/v1/public/status": {"get": {"tags": ["Public"], "summary": "Get public API status.", "description": "Returns the current operational status of the API.", "operationId": "getPublicStatus", "security": [], "responses": {"200": {"description": "API status returned.", "content": {"application/json": {"schema": {"type": "object", "properties": {"status": {"type": "string", "example": "operational"}, "service": {"type": "string", "example": "tritonium-api"}, "version": {"type": "string", "example": "1.0.31"}}}}}}}}}, "/api/v1/auth/invitation-status": {"get": {"tags": ["Auth"], "summary": "Check whether a team invitation can still be accepted.", "description": "Checks the unguessable ID from an invitation link before collecting\ncredentials. Returns only a boolean, with no email, name, role or\nworkspace data. Missing, malformed, revoked and used IDs return false.\nThis check does not reserve or consume an invitation. Registration\nvalidates it again when accepting it. Responses must not be cached.\n", "operationId": "getInvitationStatus", "security": [], "parameters": [{"name": "invitation", "in": "query", "required": true, "schema": {"type": "string"}, "description": "Invitation ID from the team invitation link."}], "responses": {"200": {"description": "Current invitation validity, without account information.", "content": {"application/json": {"schema": {"type": "object", "required": ["valid"], "properties": {"valid": {"type": "boolean"}}}}}}, "429": {"$ref": "#/components/responses/RateLimited"}, "503": {"$ref": "#/components/responses/ServiceUnavailable"}}}}, "/api/v1/auth/register": {"post": {"tags": ["Auth"], "summary": "Register a new workspace owner or accept a team invitation.", "operationId": "postRegister", "security": [], "requestBody": {"required": true, "content": {"application/json": {"schema": {"anyOf": [{"$ref": "#/components/schemas/AuthRegisterRequest"}, {"$ref": "#/components/schemas/AuthInvitedRegisterRequest"}]}}}}, "responses": {"201": {"description": "Registration or invitation accepted. A reused account must sign in again.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/AuthRegisterResponse"}}}}, "400": {"$ref": "#/components/responses/ValidationError"}, "401": {"description": "Existing account password did not prove ownership of the invitation.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "409": {"$ref": "#/components/responses/Conflict"}, "429": {"description": "Registration rate limit exceeded.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "500": {"$ref": "#/components/responses/InternalError"}, "503": {"$ref": "#/components/responses/ServiceUnavailable"}}}}, "/api/v1/auth/verify-email": {"post": {"tags": ["Auth"], "summary": "Confirm email verification, optionally returning a session.", "description": "Confirms the verification code and marks the email as verified. If\nthe request also includes the user's password, the backend will\nattempt to authenticate the user and return a session in the same\nresponse, so the signup flow can proceed straight to plan selection\nwithout a separate /login round-trip. If `password` is omitted or\nauto-login is not possible (e.g. the tenant requires MFA enrollment),\nthe response sets `requires_login: true` and the client must\nredirect to /login before any authenticated action.\n", "operationId": "postVerifyEmail", "security": [], "requestBody": {"required": true, "content": {"application/json": {"schema": {"type": "object", "required": ["email", "code"], "properties": {"email": {"type": "string", "format": "email"}, "code": {"type": "string", "description": "Verification code emailed to the user."}, "token": {"type": "string", "description": "Alias for `code` (legacy clients)."}, "password": {"type": "string", "format": "password", "description": "Optional. When supplied alongside a valid code, the\nbackend will issue tokens in the response.\n"}, "locale": {"type": "string", "description": "Optional dashboard locale for the registration welcome\nemail sent after owner verification. Supported locales\nfall back to English when unrecognized.\n"}}}}}}, "responses": {"200": {"description": "Email verified. The answer has either `requires_login` (sign in next) or `data`, the new session, never both. `data` is sent only when `password` was sent and signing in needed nothing more.", "content": {"application/json": {"schema": {"type": "object", "required": ["message"], "properties": {"message": {"type": "string"}, "requires_login": {"type": "boolean", "enum": [true], "description": "Sent when no session was issued: no `password`, a password that did not sign in, a further sign-in step such as an MFA code, or a workspace that requires MFA enrollment."}, "mfa_setup_required": {"type": "boolean", "enum": [true], "description": "Sent with `requires_login` when the workspace requires MFA enrollment first."}, "data": {"$ref": "#/components/schemas/SignInSession"}}}}}}, "400": {"description": "`VALIDATION_ERROR` (no `email` or no code, or a body that is not a JSON object) or `INVALID_CODE` (the code is wrong or has expired).", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "500": {"description": "`INTERNAL_ERROR`: the account could not be read, or confirming the code failed. When the account could not be read, the code is not spent; send it again.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}}}}, "/api/v1/auth/resend-verification": {"post": {"tags": ["Auth"], "summary": "Trigger a new verification email.", "description": "Sends a new confirmation code to an address whose account is not confirmed yet. The answer is the same whether or not an account exists, is already confirmed, or the email could be sent, so it says nothing about the address.", "operationId": "postResendVerification", "security": [], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/EmailRequest"}}}}, "responses": {"200": {"description": "The request was taken.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/AuthNotice"}}}}, "400": {"description": "`VALIDATION_ERROR`: no `email`, an address that is not one, or a body that is not a JSON object.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "429": {"$ref": "#/components/responses/AuthRateLimited"}, "500": {"description": "`INTERNAL_ERROR`: the account could not be read. Nothing was sent; send the request again.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}}}}, "/api/v1/auth/forgot-password": {"post": {"tags": ["Auth"], "summary": "Request a password reset email.", "description": "Sends a reset code to the address. The answer is the same whether or not an account exists or the email could be sent, so it says nothing about the address.", "operationId": "postForgotPassword", "security": [], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ForgotPasswordRequest"}}}}, "responses": {"200": {"description": "The request was taken.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/AuthNotice"}}}}, "400": {"description": "`VALIDATION_ERROR`: no `email`, an address that is not one, or a body that is not a JSON object.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "429": {"$ref": "#/components/responses/AuthRateLimited"}}}}, "/api/v1/auth/reset-password": {"post": {"tags": ["Auth"], "summary": "Reset password with a valid token.", "operationId": "postResetPassword", "security": [], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ResetPasswordRequest"}}}}, "responses": {"200": {"description": "Password updated, and every earlier session ended.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/PasswordResetDone"}}}}, "400": {"description": "The password was not changed. `error.code` says why: `VALIDATION_ERROR` (a field is missing, or the new password does not meet the policy; the code is not used up), `RESET_FAILED` (the code is wrong or has expired) or `RESET_CODE_SPENT` (this code was already used or tried; a code gets one attempt, so request a new one).", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "429": {"description": "Too many reset attempts from this network or for this account (`RATE_LIMITED`). The password was not changed and the code was not tried. Wait before sending the request again.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "500": {"description": "`INTERNAL_ERROR`: the identity provider failed while changing the password, so whether it changed is not known. Sign in with the new password, or request a new code.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "503": {"description": "The reset could not be carried out for a reason on our side. The password was not changed. Send the same request again.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}}}}, "/api/v1/auth/login": {"post": {"tags": ["Auth"], "summary": "Authenticate via email and password.", "operationId": "postLogin", "security": [], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/AuthLoginRequest"}}}}, "responses": {"200": {"description": "Login succeeded or Cognito requires another authentication step.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/AuthLoginResponse"}}}}, "400": {"$ref": "#/components/responses/ValidationError"}, "401": {"$ref": "#/components/responses/Unauthorized"}, "403": {"description": "Sign-in is forbidden or the workspace requires MFA enrollment before access.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/AuthLoginForbiddenResponse"}}}}, "429": {"description": "Too many sign-in attempts. `Retry-After` carries the wait in seconds, and `error.details.retry_after` repeats it in the body for browser clients, which cannot read the header without an `Access-Control-Expose-Headers` grant.", "headers": {"Retry-After": {"description": "Seconds until the sign-in limit resets.", "schema": {"type": "integer", "minimum": 1}}}, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}, "example": {"error": {"code": "RATE_LIMITED", "message": "Too many failed sign-in attempts for this account. If you have forgotten your password, reset it from the sign-in page. You can try again in about 13 minutes.", "details": {"retry_after": 754}}}}}}, "503": {"$ref": "#/components/responses/ServiceUnavailable"}}}}, "/api/v1/auth/google/sso": {"post": {"tags": ["Auth"], "summary": "Start Google Workspace SSO.", "operationId": "postGoogleSso", "security": [], "requestBody": {"required": true, "content": {"application/json": {"schema": {"anyOf": [{"$ref": "#/components/schemas/SsoRequest"}, {"$ref": "#/components/schemas/SsoInvitationRequest"}]}}}}, "responses": {"200": {"description": "Google Hosted UI authorization URL created.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/SsoStartResponse"}}}}, "400": {"description": "`VALIDATION_ERROR`: `redirect_uri` is missing, not HTTPS or not on an allowed domain, the invitation is invalid or already used, or an invitation came without the terms and privacy consent.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "404": {"description": "Hosted SSO is disabled.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}, "example": {"error": {"code": "SSO_DISABLED", "message": "SSO is not available"}}}}}, "500": {"description": "`SSO_NOT_CONFIGURED` (sign-in with this provider is not set up), or `INTERNAL_ERROR` when the invitation could not be read.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}}}}, "/api/v1/auth/microsoft/sso": {"post": {"tags": ["Auth"], "summary": "Start Microsoft Entra ID SSO.", "operationId": "postMicrosoftSso", "security": [], "requestBody": {"required": true, "content": {"application/json": {"schema": {"anyOf": [{"$ref": "#/components/schemas/SsoRequest"}, {"$ref": "#/components/schemas/SsoInvitationRequest"}]}}}}, "responses": {"200": {"description": "Microsoft Hosted UI authorization URL created.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/SsoStartResponse"}}}}, "400": {"description": "`VALIDATION_ERROR`: `redirect_uri` is missing, not HTTPS or not on an allowed domain, the invitation is invalid or already used, or an invitation came without the terms and privacy consent.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "404": {"description": "Hosted SSO is disabled.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}, "example": {"error": {"code": "SSO_DISABLED", "message": "SSO is not available"}}}}}, "500": {"description": "`SSO_NOT_CONFIGURED` (sign-in with this provider is not set up), or `INTERNAL_ERROR` when the invitation could not be read.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}}}}, "/api/v1/auth/mfa/setup/start": {"post": {"tags": ["MFA"], "summary": "Initiate MFA enrollment.", "description": "Starts the MFA enrollment process by generating a TOTP secret and QR code URI. Call it with the signed-in person's access token, or, for an enrollment a workspace requires before the first sign-in, with the `mfa_setup_token` the sign-in answered and no access token.", "operationId": "postMfaSetupStart", "security": [{"BearerAuth": []}], "parameters": [{"$ref": "#/components/parameters/MfaSetupToken"}], "responses": {"200": {"description": "MFA setup initiated successfully.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/MfaSetupStartResponse"}}}}, "400": {"description": "`MFA_SETUP_FAILED`: the identity provider refused to start an enrollment.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "401": {"$ref": "#/components/responses/MfaSetupSessionInvalid"}, "503": {"$ref": "#/components/responses/AccountUnavailable"}}}}, "/api/v1/auth/mfa/setup/verify": {"post": {"tags": ["MFA"], "summary": "Complete MFA enrollment.", "description": "Verifies the initial TOTP code to complete MFA enrollment. After successful verification, MFA will be required for future logins. Authorized like `POST /api/v1/auth/mfa/setup/start`; with `X-MFA-Setup-Token`, the answer carries the session the sign-in was waiting for.", "operationId": "postMfaSetupVerify", "security": [{"BearerAuth": []}], "parameters": [{"$ref": "#/components/parameters/MfaSetupToken"}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/MfaVerifyRequest"}}}}, "responses": {"200": {"description": "MFA enrollment completed.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/MfaSetupVerifyResponse"}}}}, "400": {"description": "`VALIDATION_ERROR` (not a 6-digit code), `INVALID_CODE` (a wrong code; send the code the authenticator shows now) or `MFA_SETUP_FAILED` (the identity provider refused for a reason a new code cannot change: the sign-in has ended, or no enrollment was started).", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "401": {"$ref": "#/components/responses/MfaSetupSessionInvalid"}, "429": {"$ref": "#/components/responses/MfaCodeRateLimited"}, "503": {"$ref": "#/components/responses/AccountUnavailable"}}}}, "/api/v1/auth/mfa/status": {"get": {"tags": ["MFA"], "summary": "Get MFA enrollment status.", "description": "Returns the current user's MFA enrollment status including enabled methods and enrollment timestamp.", "operationId": "getMfaStatus", "security": [{"BearerAuth": []}], "responses": {"200": {"description": "MFA status retrieved.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/MfaStatusResponse"}}}}, "400": {"description": "`MFA_STATUS_UNAVAILABLE`: the identity provider could not be asked.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "401": {"$ref": "#/components/responses/MfaSessionInvalid"}, "503": {"$ref": "#/components/responses/AccountUnavailable"}}}}, "/api/v1/auth/mfa/challenge": {"post": {"tags": ["MFA"], "summary": "Complete MFA challenge during login.", "description": "Verifies an MFA code during the login flow. Called after initial password authentication when MFA is enabled for the account. The answer is the same as a sign-in without MFA.", "operationId": "postMfaChallenge", "security": [], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/MfaChallengeRequest"}}}}, "responses": {"200": {"description": "MFA challenge completed, tokens returned.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/MfaChallengeResponse"}}}}, "400": {"description": "`VALIDATION_ERROR` (no `email` or `session`, or not a 6-digit code) or `UNSUPPORTED_MFA_CHALLENGE` (a `challenge` other than `SOFTWARE_TOKEN_MFA` or `SMS_MFA`).", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "401": {"description": "`INVALID_MFA_CODE` (the code is wrong, or the sign-in session has expired), `AUTH_FAILED` (no active account for `email`) or `MFA_CHALLENGE_INCOMPLETE` (the identity provider issued no session; sign in again).", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "429": {"description": "Too many verification attempts. `Retry-After` carries the wait in seconds, and `error.details.retry_after` repeats it in the body for browser clients, which cannot read the header without an `Access-Control-Expose-Headers` grant.", "headers": {"Retry-After": {"description": "Seconds until the verification limit resets.", "schema": {"type": "integer", "minimum": 1}}}, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}, "example": {"error": {"code": "RATE_LIMITED", "message": "Too many verification attempts. You can try again in about 3 minutes.", "details": {"retry_after": 180}}}}}}, "500": {"description": "`MFA_ERROR`: checking the code failed for an unexpected reason. The attempt was not counted.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "503": {"$ref": "#/components/responses/AccountUnavailable"}}}}, "/api/v1/auth/mfa/setup/start-login": {"post": {"tags": ["MFA"], "summary": "Start MFA setup during login flow.", "description": "Initiates MFA enrollment during the login flow when the user is prompted to set up MFA. Uses session token instead of access token.", "operationId": "postMfaSetupStartLogin", "security": [], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/MfaSetupStartLoginRequest"}}}}, "responses": {"200": {"description": "MFA setup initiated with new session.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/MfaSetupStartLoginResponse"}}}}, "400": {"description": "`VALIDATION_ERROR` (no `session` or `email`) or `MFA_SETUP_FAILED` (the enrollment could not be started: the session has expired, or there is no account for `email`; the two answer alike).", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "503": {"$ref": "#/components/responses/AccountUnavailable"}}}}, "/api/v1/auth/mfa/setup/verify-login": {"post": {"tags": ["MFA"], "summary": "Complete MFA setup during login flow.", "description": "Completes MFA enrollment during login flow and returns authentication tokens. Called after the user enters their first TOTP code during initial setup.", "operationId": "postMfaSetupVerifyLogin", "security": [], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/MfaSetupVerifyLoginRequest"}}}}, "responses": {"200": {"description": "MFA setup completed, tokens returned (`success` true), or the identity provider asks for one more step and no session is issued (`success` false).", "content": {"application/json": {"schema": {"oneOf": [{"$ref": "#/components/schemas/MfaChallengeResponse"}, {"$ref": "#/components/schemas/MfaSetupLoginChallenge"}]}}}}, "400": {"description": "`VALIDATION_ERROR` (no `session` or `email`, or not a 6-digit code), `INVALID_CODE` (a wrong code; send the code the authenticator shows now) or `MFA_SETUP_FAILED` (the sign-in session has expired, or the identity provider refused it for another reason a new code cannot change; sign in again).", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "401": {"description": "`AUTH_FAILED`: no active account for `email`.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "429": {"$ref": "#/components/responses/MfaCodeRateLimited"}, "503": {"$ref": "#/components/responses/AccountUnavailable"}}}}, "/api/v1/auth/mfa/disable": {"post": {"tags": ["MFA"], "summary": "Disable MFA for the authenticated user.", "description": "Requires a valid 6-digit MFA code to confirm disabling.", "operationId": "postMfaDisable", "security": [{"BearerAuth": []}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/MfaDisableRequest"}}}}, "responses": {"200": {"description": "MFA disabled successfully.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/AuthNotice"}}}}, "400": {"description": "`VALIDATION_ERROR` (not a 6-digit code), `INVALID_CODE` (a wrong code; send the code the authenticator shows now) or `MFA_DISABLE_FAILED` (the identity provider refused the change for a reason a new code cannot change).", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "401": {"$ref": "#/components/responses/MfaSessionInvalid"}, "403": {"description": "`MFA_REQUIRED`: the workspace requires MFA, so it cannot be turned off.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "429": {"$ref": "#/components/responses/MfaCodeRateLimited"}, "503": {"$ref": "#/components/responses/AccountUnavailable"}}}}, "/api/v1/auth/refresh": {"post": {"tags": ["Authentication"], "summary": "Refresh access tokens.", "description": "Exchange a refresh token for new access and ID tokens. The answer carries no refresh token.", "operationId": "postAuthRefresh", "security": [], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/RefreshTokenRequest"}}}}, "responses": {"200": {"description": "New tokens returned.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/RefreshTokenResponse"}}}}, "400": {"description": "`VALIDATION_ERROR`: no `refresh_token`, or a body that is not a JSON object.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "401": {"description": "`INVALID_TOKEN`: the refresh token is unknown, expired, from before the account's current membership, or refused by the identity provider. Sign in again.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "403": {"description": "The account may not have new tokens, and its other sessions were ended: `ACCOUNT_LOCKED`, `ACCOUNT_INACTIVE`, `EMAIL_NOT_VERIFIED` or `MFA_REQUIRED` (the workspace requires MFA and the account has not enrolled).", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "429": {"$ref": "#/components/responses/AuthRateLimited"}, "500": {"description": "`INTERNAL_ERROR`: the session or the account could not be read. No token was issued.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "503": {"$ref": "#/components/responses/ServiceUnavailable"}}}}, "/api/v1/auth/logout": {"post": {"tags": ["Authentication"], "summary": "Log out the current user.", "description": "Invalidate all sessions and revoke tokens. Success confirms both\nsession-store cleanup and provider revocation. If either cannot be\nconfirmed, return 503; clients should still clear local credentials.\nNo request body is read.\n", "operationId": "postAuthLogout", "security": [{"BearerAuth": []}], "responses": {"200": {"description": "Logged out successfully.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/AuthNotice"}}}}, "401": {"$ref": "#/components/responses/Unauthorized"}, "403": {"$ref": "#/components/responses/SessionOnlyForbidden"}, "503": {"description": "Sign-out could not be confirmed. Clear local credentials and retry after reconnecting.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}}}}, "/api/v1/auth/sso/callback": {"post": {"tags": ["Authentication"], "summary": "Exchange an SSO authorization code or accept a team invitation.", "description": "Called by frontend after Cognito Hosted UI redirect.\nExchanges the authorization code for API session tokens. When signed\nOAuth state contains a pending team invitation, the callback accepts\nthe invitation, revokes the exchanged tokens, and returns\n`data.requires_login: true` instead. The client must clear any old\nsession and start a fresh SSO sign-in for current membership claims.\n", "operationId": "postSsoCallback", "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/SsoCallbackRequest"}}}}, "responses": {"200": {"description": "SSO authentication successful, or invitation accepted with fresh sign-in required.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/SsoCallbackResponse"}}}}, "400": {"description": "Invalid SSO callback request or token set.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}, "examples": {"invalidState": {"summary": "State token invalid or missing provider binding.", "value": {"error": {"code": "INVALID_STATE", "message": "Invalid state parameter"}}}, "providerMismatch": {"summary": "Token provider does not match signed state.", "value": {"error": {"code": "SSO_PROVIDER_MISMATCH", "message": "SSO provider mismatch"}}}}}}}, "403": {"description": "The provider domain is blocked or the destination workspace requires an MFA factor.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "404": {"description": "Hosted SSO is disabled.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}, "example": {"error": {"code": "SSO_DISABLED", "message": "SSO is not available"}}}}}, "409": {"$ref": "#/components/responses/Conflict"}, "500": {"description": "SSO configuration error.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}, "example": {"error": {"code": "SSO_NOT_CONFIGURED", "message": "SSO is not configured"}}}}}, "503": {"$ref": "#/components/responses/ServiceUnavailable"}}}}, "/api/v1/me": {"get": {"tags": ["Users"], "summary": "Retrieve the authenticated caller's profile and, for an API key, the key's own access.", "description": "With a signed-in session, `user` is that person. With an API key, `user` is the person the key acts for (including *their* `role`): the person who created it, or whoever last rotated it, since rotating hands them the new secret. `api_key` describes the credential actually making the call: its id, name, scopes, `effective_role` (always `member`) and `created_by_user_id`. Plan automated work from `api_key`, not from `user.role`. If the person a key acts for no longer has a user record the key still authenticates, so the response is `200` with `user: null` rather than `404`. Null `company_name` and `plan` mean the workspace has no such record or no access. An account or workspace that could not be read is `503`, never one of these answers.", "operationId": "getMe", "security": [{"BearerAuth": []}, {"ApiKeyAuth": []}], "responses": {"200": {"description": "Profile details.", "content": {"application/json": {"schema": {"type": "object", "required": ["user"], "properties": {"user": {"oneOf": [{"$ref": "#/components/schemas/UserSummary"}, {"type": "null"}], "description": "Null only for an API key whose person has no user record."}, "api_key": {"$ref": "#/components/schemas/ApiKeyCallerContext"}}}}}}, "401": {"$ref": "#/components/responses/Unauthorized"}, "404": {"description": "Session callers only: the signed-in user has no user record (`USER_NOT_FOUND`).", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "429": {"$ref": "#/components/responses/CredentialRateLimited"}, "500": {"description": "`INTERNAL_ERROR`: an unexpected failure.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "503": {"$ref": "#/components/responses/AccountUnavailable"}}}}, "/api/v1/profile": {"put": {"tags": ["Users"], "summary": "Replace profile attributes for the active user.", "description": "Changes only the members sent, like `PATCH`. The answer is the profile after the change.", "operationId": "putProfile", "security": [{"BearerAuth": []}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ProfileUpdateRequest"}}}}, "responses": {"200": {"description": "Profile updated.", "content": {"application/json": {"schema": {"type": "object", "required": ["user"], "properties": {"user": {"$ref": "#/components/schemas/UserSummary"}}}}}}, "400": {"$ref": "#/components/responses/ProfileChangeRefused"}, "401": {"$ref": "#/components/responses/Unauthorized"}, "403": {"$ref": "#/components/responses/ProfileChangeForbidden"}, "404": {"$ref": "#/components/responses/ProfileUserNotFound"}, "415": {"$ref": "#/components/responses/ProfileNotJson"}, "500": {"$ref": "#/components/responses/ProfileChangeFailed"}, "503": {"$ref": "#/components/responses/AccountUnavailable"}}}, "patch": {"tags": ["Users"], "summary": "Update profile attributes for the active user.", "operationId": "patchProfile", "security": [{"BearerAuth": []}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ProfileUpdateRequest"}}}}, "responses": {"200": {"description": "Profile updated.", "content": {"application/json": {"schema": {"type": "object", "required": ["user"], "properties": {"user": {"$ref": "#/components/schemas/UserSummary"}}}}}}, "400": {"$ref": "#/components/responses/ProfileChangeRefused"}, "401": {"$ref": "#/components/responses/Unauthorized"}, "403": {"$ref": "#/components/responses/ProfileChangeForbidden"}, "404": {"$ref": "#/components/responses/ProfileUserNotFound"}, "415": {"$ref": "#/components/responses/ProfileNotJson"}, "500": {"$ref": "#/components/responses/ProfileChangeFailed"}, "503": {"$ref": "#/components/responses/AccountUnavailable"}}}}, "/api/v1/profile/picture/upload-url": {"post": {"tags": ["Users"], "summary": "Get presigned URL for profile picture upload.", "description": "Generate a presigned S3 URL to upload a profile picture. Uploads that no saved profile names are removed later.", "operationId": "postProfilePictureUploadUrl", "security": [{"BearerAuth": []}], "requestBody": {"required": false, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ProfilePictureUploadRequest"}}}}, "responses": {"200": {"description": "Presigned URL generated.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ProfilePictureUploadResponse"}}}}, "400": {"description": "`INVALID_TYPE` (a `content_type` other than the three accepted) or `VALIDATION_ERROR` (a body that is not a JSON object).", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "401": {"$ref": "#/components/responses/Unauthorized"}, "403": {"$ref": "#/components/responses/SessionOnlyForbidden"}, "429": {"description": "`RATE_LIMITED`: more than ten upload URLs in an hour. Try again later.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "500": {"description": "`UPLOAD_URL_FAILED`: the upload URL could not be signed.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "503": {"description": "`FEATURE_UNAVAILABLE` (profile picture uploads are not set up) or `SERVICE_UNAVAILABLE` (the account could not be read; the session stays valid and the same request can succeed in a moment).", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}}}}, "/api/v1/users/email-unsubscribe": {"get": {"tags": ["Users"], "summary": "Open the unsubscribe confirmation page.", "description": "The \"Unsubscribe\" link at the foot of every digest and product email,\nand the URL in their `List-Unsubscribe` headers. The signed token names\none user in one workspace and one list (`digest` or `product`), so no\nsign-in is needed.\n\n**This is a safe method: it unsubscribes nobody.** Enterprise mail\nsecurity fetches every URL in a message before the recipient opens it,\nso the page only offers an Unsubscribe button, which posts the token to\n`POST /api/v1/users/email-unsubscribe`. Nothing is read or written.\n", "operationId": "getEmailUnsubscribe", "security": [], "parameters": [{"name": "token", "in": "query", "required": true, "schema": {"type": "string", "minLength": 32, "maxLength": 1024}}], "responses": {"200": {"description": "The confirmation page. Nothing has changed yet.", "content": {"text/html": {"schema": {"type": "string"}}}}, "400": {"description": "The token is invalid or expired. The page points to the general email preferences (no list is trusted without verified claims).", "content": {"text/html": {"schema": {"type": "string"}}}}, "503": {"description": "The link cannot be checked right now. Nothing has changed.", "content": {"text/html": {"schema": {"type": "string"}}}}}}, "post": {"tags": ["Users"], "summary": "Turn an email list off from a signed unsubscribe link.", "description": "RFC 8058 one-click unsubscribe (the mail client posts `List-Unsubscribe=One-Click` to the URL in `List-Unsubscribe`, token in the query string) and the confirmation page's button (token in the form body). Acts on the list the token names: the weekly digest (as choosing Off in Settings > Digest does) or the non-transactional product emails (as turning Product emails off in Settings > Profile > Email preferences does), for the token's user only, and only while that user still belongs to the token's workspace. Posting again for a list that is already off changes nothing.", "operationId": "postEmailUnsubscribe", "security": [], "parameters": [{"name": "token", "in": "query", "required": false, "schema": {"type": "string", "minLength": 32, "maxLength": 1024}}], "requestBody": {"required": false, "content": {"application/x-www-form-urlencoded": {"schema": {"type": "object", "properties": {"token": {"type": "string", "minLength": 32, "maxLength": 1024}, "List-Unsubscribe": {"type": "string", "enum": ["One-Click"]}}}}, "application/json": {"schema": {"type": "object", "properties": {"token": {"type": "string", "minLength": 32, "maxLength": 1024}}}}}}, "responses": {"200": {"description": "The list the token named is off for this user (now, or already): the digest, or the non-transactional product emails.", "content": {"text/html": {"schema": {"type": "string"}}}}, "400": {"description": "The token is invalid or expired, or its user no longer belongs to its workspace. Nothing was written.", "content": {"text/html": {"schema": {"type": "string"}}}}, "503": {"description": "The change could not be saved. Nothing was written; try again.", "content": {"text/html": {"schema": {"type": "string"}}}}}}}, "/api/v1/users/me/digest-preferences": {"get": {"tags": ["Users"], "summary": "Get digest email preferences.", "description": "Get the current user's digest email preferences.", "operationId": "getDigestPreferences", "security": [{"BearerAuth": []}], "responses": {"200": {"description": "Digest preferences returned.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/DigestPreferencesResponse"}}}}, "401": {"$ref": "#/components/responses/Unauthorized"}, "503": {"$ref": "#/components/responses/AccountUnavailable"}}}, "put": {"tags": ["Users"], "summary": "Update digest email preferences.", "description": "Update the current user's digest email preferences.", "operationId": "putDigestPreferences", "security": [{"BearerAuth": []}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/DigestPreferencesRequest"}}}}, "responses": {"200": {"description": "Preferences updated.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/DigestPreferencesResponse"}}}}, "400": {"$ref": "#/components/responses/ValidationError"}, "401": {"$ref": "#/components/responses/Unauthorized"}, "503": {"$ref": "#/components/responses/AccountUnavailable"}}}}, "/api/v1/users/me/digest-preferences/test": {"post": {"tags": ["Users"], "summary": "Send a test digest to yourself.", "description": "Queue a one-off email digest to the signed-in user's own address, composed from their saved digest preferences over the current period and sent immediately with a \"[Test]\" subject prefix, regardless of whether the digest is enabled or due. The email leaves from the digest worker, so the request answers 202 once it is queued. One test per user every ten minutes; a second request inside that window answers 429 RATE_LIMITED.", "operationId": "sendTestDigest", "security": [{"BearerAuth": []}], "responses": {"202": {"description": "Test digest queued.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/DigestTestSendResponse"}}}}, "401": {"$ref": "#/components/responses/Unauthorized"}, "404": {"$ref": "#/components/responses/NotFound"}, "429": {"$ref": "#/components/responses/RateLimited"}, "503": {"description": "The account could not be read or the digest worker could not be reached; nothing was queued.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}}}}, "/api/v1/users/me/email-preferences": {"get": {"tags": ["Users"], "summary": "Get email preferences.", "description": "The signed-in user's opt-in for non-transactional product emails (milestones and celebrations). Transactional email (receipts, password resets, security notices) is not affected by this setting. This is the control behind the \"Manage email preferences\" link in every email the platform sends.", "operationId": "getEmailPreferences", "security": [{"BearerAuth": []}], "responses": {"200": {"description": "Email preferences returned.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/EmailPreferencesResponse"}}}}, "401": {"$ref": "#/components/responses/Unauthorized"}, "503": {"$ref": "#/components/responses/AccountUnavailable"}}}, "put": {"tags": ["Users"], "summary": "Update email preferences.", "description": "Set whether the signed-in user receives non-transactional product emails.", "operationId": "putEmailPreferences", "security": [{"BearerAuth": []}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/EmailPreferencesRequest"}}}}, "responses": {"200": {"description": "Preferences updated.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/EmailPreferencesResponse"}}}}, "400": {"$ref": "#/components/responses/ValidationError"}, "401": {"$ref": "#/components/responses/Unauthorized"}, "503": {"$ref": "#/components/responses/AccountUnavailable"}}}}, "/api/v1/users/me/onboarding": {"post": {"tags": ["Users"], "summary": "Mark onboarding tour completion for the signed-in user.", "operationId": "postUserOnboardingState", "security": [{"BearerAuth": []}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"type": "object", "properties": {"completed": {"type": "boolean", "description": "Whether the signed-in user has completed the onboarding tour."}, "onboarding_tour_completed": {"type": "boolean", "deprecated": true, "description": "Deprecated alias for `completed`; accepted for generated clients built from the earlier contract."}}}}}}, "responses": {"200": {"description": "User updated.", "content": {"application/json": {"schema": {"type": "object", "properties": {"onboarding_tour_completed": {"type": "boolean"}, "message": {"type": "string"}}}}}}, "400": {"$ref": "#/components/responses/ValidationError"}, "401": {"$ref": "#/components/responses/Unauthorized"}, "503": {"$ref": "#/components/responses/AccountUnavailable"}}}}, "/api/v1/apps": {"get": {"tags": ["Apps"], "summary": "List apps connected to the current tenant.", "operationId": "getApps", "parameters": [{"name": "limit", "in": "query", "required": false, "schema": {"type": "integer", "minimum": 1, "maximum": 500, "default": 100}, "description": "Page size. A value below the minimum, or a non-integer, is rejected with 400 naming the parameter. A value above the maximum is capped to it, matching `/alerts` and `/billing/invoices`."}, {"name": "cursor", "in": "query", "required": false, "schema": {"type": "string"}, "description": "Opaque cursor from the previous page's `next_cursor`. Pass it back unmodified. A cursor this endpoint did not issue, or one minted for another tenant, is rejected with 400 rather than silently restarting at page 1."}], "responses": {"200": {"description": "Apps retrieved.", "content": {"application/json": {"schema": {"type": "object", "required": ["apps", "count", "next_cursor", "has_more"], "properties": {"apps": {"type": "array", "items": {"$ref": "#/components/schemas/AppSummary"}}, "count": {"type": "integer", "description": "Number of apps in this page, not the tenant's total."}, "next_cursor": {"type": ["string", "null"], "description": "Pass as `cursor` to fetch the next page. Null on the last page."}, "has_more": {"type": "boolean", "description": "Whether a further page exists."}}}}}}, "400": {"$ref": "#/components/responses/ValidationError"}, "401": {"$ref": "#/components/responses/Unauthorized"}, "429": {"$ref": "#/components/responses/CredentialRateLimited"}, "503": {"description": "`SERVICE_UNAVAILABLE`: the app list could not be read, so an empty list cannot be inferred. `error.retryable` is true and `error.retry_after_seconds` repeats the `Retry-After` delay for clients that cannot read the header.", "headers": {"Retry-After": {"description": "Seconds to wait before retrying the list request.", "schema": {"type": "integer", "minimum": 1}}}, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}}}, "post": {"tags": ["Apps"], "summary": "Connect a new app using stored credentials.", "operationId": "postAppsConnect", "security": [{"BearerAuth": []}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/AppConnectionRequest"}}}}, "responses": {"200": {"description": "Previously deleted app reconnected with its existing data.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/AppReactivationResponse"}}}}, "201": {"description": "New app connected.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/AppCreateResponse"}}}}, "400": {"$ref": "#/components/responses/ValidationError"}, "401": {"$ref": "#/components/responses/Unauthorized"}, "402": {"$ref": "#/components/responses/AppCapacityRefused"}, "403": {"$ref": "#/components/responses/Forbidden"}, "500": {"$ref": "#/components/responses/InternalError"}, "503": {"$ref": "#/components/responses/AppChangeUnavailable"}, "404": {"$ref": "#/components/responses/NotFound"}, "409": {"$ref": "#/components/responses/Conflict"}}}}, "/api/v1/apps/search": {"get": {"tags": ["Apps"], "summary": "Search app stores for apps.", "description": "Search the App Store or Google Play for apps by name or keyword. Used for finding apps to connect to Tritonium.\n\nAn empty `results` array means the store answered and had no match. A store that could not be reached is a `503 STORE_UNAVAILABLE` instead, so a transient store outage is never presented to the user as \"no apps found\"; retry the request.", "operationId": "searchApps", "parameters": [{"name": "q", "in": "query", "required": true, "schema": {"type": "string"}, "description": "Search query (app name or keyword)."}, {"name": "platform", "in": "query", "schema": {"type": "string", "enum": ["app_store", "google_play"], "default": "app_store"}, "description": "App store platform to search."}, {"name": "country", "in": "query", "schema": {"type": "string", "default": "us"}, "description": "Country code for regional results."}, {"name": "limit", "in": "query", "schema": {"type": "integer", "default": 10, "maximum": 50}, "description": "Maximum number of results to return."}], "responses": {"200": {"description": "App search results.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/AppSearchResponse"}}}}, "400": {"$ref": "#/components/responses/ValidationError"}, "401": {"$ref": "#/components/responses/Unauthorized"}, "503": {"$ref": "#/components/responses/ServiceUnavailable"}}}}, "/api/v1/apps/resolve-url": {"get": {"tags": ["Apps"], "summary": "Resolve an App Store or Google Play URL to a single app.", "description": "Resolves a pasted store link to one app record, so the connect-app wizard can accept a URL as well as a name search. Returns the same row shape as the entries in `GET /api/v1/apps/search`. A link that is not a recognised store URL, or that names an app the store does not return, is a 400 whose `error.message` is written to be shown to the user directly. A store that could not be reached is a `503 STORE_UNAVAILABLE`: the link may be fine, so the user should retry rather than go looking for a different URL.\n\nThis is a GET because it is an idempotent lookup that mutates nothing, which also means an API key needs only `apps:read`, the same scope as `/api/v1/apps/search`.", "operationId": "resolveAppStoreUrl", "parameters": [{"name": "store_url", "in": "query", "required": true, "schema": {"type": "string", "maxLength": 2048}, "description": "App Store or Google Play URL for the app.", "example": "https://apps.apple.com/us/app/slack/id618783545"}], "responses": {"200": {"description": "The resolved app.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ResolvedStoreApp"}}}}, "400": {"$ref": "#/components/responses/ValidationError"}, "401": {"$ref": "#/components/responses/Unauthorized"}, "503": {"$ref": "#/components/responses/ServiceUnavailable"}}}}, "/api/v1/apps/connect": {"post": {"tags": ["Apps"], "summary": "Alias endpoint for connecting apps.", "operationId": "postAppsConnectAlias", "security": [{"BearerAuth": []}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/AppConnectionRequest"}}}}, "responses": {"200": {"description": "Existing app connected or reactivated; background sync may have started.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ConnectAppAsyncResponse"}}}}, "201": {"description": "New app connected and background sync started.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ConnectAppAsyncResponse"}}}}, "400": {"$ref": "#/components/responses/ValidationError"}, "401": {"$ref": "#/components/responses/Unauthorized"}, "402": {"$ref": "#/components/responses/AppCapacityRefused"}, "403": {"$ref": "#/components/responses/Forbidden"}, "500": {"$ref": "#/components/responses/InternalError"}, "503": {"$ref": "#/components/responses/AppChangeUnavailable"}}}}, "/api/v1/apps/{appUuid}/sync": {"post": {"tags": ["Apps"], "summary": "Trigger an on-demand review sync for a specific app.", "description": "Manual syncs have no daily cap. Requests are deduplicated while a sync is active or was just completed; configured schedule limits apply only to automatic clock-time slots. Requires the owner or admin role. The body is optional: without `force`, a sync that started in the last ten minutes and is still running, or one that completed in the last three minutes, is answered instead of starting another.", "operationId": "postAppsSync", "security": [{"BearerAuth": []}], "parameters": [{"name": "appUuid", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid"}}], "requestBody": {"required": false, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/AppSyncRequest"}}}}, "responses": {"202": {"description": "A sync was queued (`sync_queued`), or nothing was started because one is running (`sync_in_progress`) or has just completed (`already_synced`).", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/AppSyncStarted"}}}}, "400": {"$ref": "#/components/responses/AppSyncRefused"}, "401": {"$ref": "#/components/responses/Unauthorized"}, "402": {"$ref": "#/components/responses/SubscriptionRequired"}, "403": {"$ref": "#/components/responses/SyncAdminForbidden"}, "404": {"$ref": "#/components/responses/AppNotFound"}, "502": {"description": "`SYNC_ENQUEUE_FAILED`: the sync could not be queued. The app's sync status is now `sync_failed`; try again.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "503": {"$ref": "#/components/responses/ReadUnavailable"}}}}, "/api/v1/apps/analyze": {"post": {"tags": ["Apps", "Insights"], "summary": "Queue AI-powered analysis for an app (admin, or an API key with apps:write).", "operationId": "postAppsAnalyze", "description": "Send an `Idempotency-Key` to make retries safe: a retry with the same key and body returns the first outcome instead of queuing (or, with `force_refresh`, paying for) another analysis. Every 2xx is stored, including `stale_lock_cleared: true` (nothing was queued): to start the analysis after that answer, call again with a new key.", "security": [{"BearerAuth": []}, {"ApiKeyAuth": []}], "parameters": [{"$ref": "#/components/parameters/IdempotencyKey"}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/AnalysisRequest"}}}}, "responses": {"200": {"description": "Nothing was queued: an analysis completed in the last 5 minutes (`message: Analysis completed recently`, with `last_analysis`), or a stale lock was cleared and the latest stored analysis is returned in `insights` (`cached: true`, `stale_lock_cleared: true`).", "headers": {"Idempotent-Replayed": {"$ref": "#/components/headers/IdempotentReplayed"}}, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/AnalysisStartResponse"}}}}, "202": {"description": "Analysis accepted and queued (`analysis_queued`), or already in progress (`analysis_running`: a start never runs two analyses of one app at once, with or without `force_refresh`). Both name the run to follow in `run_id` when the analysis is tracked; poll `run_url` until the run is terminal. `poll_url` remains for app-level status. A 202 with `analysis_status: pending` and `stale_lock_cleared: true` means a stale lock was cleared and nothing was queued; send the request again.", "headers": {"Idempotent-Replayed": {"$ref": "#/components/headers/IdempotentReplayed"}}, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/AnalysisStartResponse"}}}}, "400": {"$ref": "#/components/responses/ValidationError"}, "401": {"$ref": "#/components/responses/Unauthorized"}, "402": {"$ref": "#/components/responses/SubscriptionRequired"}, "403": {"$ref": "#/components/responses/Forbidden"}, "404": {"$ref": "#/components/responses/NotFound"}, "409": {"$ref": "#/components/responses/IdempotencyInProgress"}, "422": {"$ref": "#/components/responses/IdempotencyKeyReused"}, "429": {"$ref": "#/components/responses/CredentialRateLimited"}, "503": {"description": "`IDEMPOTENCY_UNAVAILABLE`: the `Idempotency-Key` could not be recorded and nothing ran; retry with the same key after `Retry-After`. `LOCK_FAILED` or `ANALYSIS_QUEUE_FAILED`: the lock or queue call failed, but an analysis may have started anyway (for example when the queue accepted the run and only its confirmation was lost). Read the app's status (`GET /api/v1/apps/{appUuid}/status`) or its newest analysis runs (`GET /api/v1/apps/{appUuid}/analysis-runs`) before retrying. With a key, the key is spent (a retry answers `IDEMPOTENCY_OUTCOME_UNKNOWN`); use a new key to start again. `SERVICE_UNAVAILABLE`: the workspace's subscription could not be verified; nothing ran and the key is not spent, so retry with the same key.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}}}}, "/api/v1/apps/check": {"get": {"tags": ["Apps"], "summary": "Check whether this tenant already has an app connected.", "description": "Used by the connect flow to avoid adding a duplicate app. Returns\n`exists: true` plus the stored app_uuid/app_name/status on a match,\nor `exists: false` alone. Soft-deleted apps count as not connected.\n", "operationId": "getAppsCheck", "parameters": [{"name": "app_id", "in": "query", "required": true, "schema": {"type": "string"}, "description": "Platform app identifier."}, {"name": "platform", "in": "query", "required": true, "schema": {"type": "string", "enum": ["app_store", "google_play", "steam"]}, "description": "Platform name."}], "responses": {"200": {"description": "App check result.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/AppCheckResponse"}}}}, "400": {"$ref": "#/components/responses/ValidationError"}, "401": {"$ref": "#/components/responses/Unauthorized"}, "503": {"$ref": "#/components/responses/ReadUnavailable"}}}}, "/api/v1/apps/connect-async": {"post": {"tags": ["Apps"], "summary": "Connect a new app with async processing.", "description": "Returns immediately after creating the app record, then triggers\nbackground processing to fetch reviews and generate insights.\n", "operationId": "postAppsConnectAsync", "security": [{"BearerAuth": []}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ConnectAppAsyncRequest"}}}}, "responses": {"200": {"description": "Existing app connected or reactivated; background sync may have started.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ConnectAppAsyncResponse"}}}}, "201": {"description": "New app connected and background sync started.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ConnectAppAsyncResponse"}}}}, "400": {"$ref": "#/components/responses/ValidationError"}, "401": {"$ref": "#/components/responses/Unauthorized"}, "402": {"$ref": "#/components/responses/AppCapacityRefused"}, "403": {"$ref": "#/components/responses/Forbidden"}, "500": {"$ref": "#/components/responses/InternalError"}, "503": {"$ref": "#/components/responses/AppChangeUnavailable"}}}}, "/api/v1/apps/settings": {"get": {"tags": ["Apps"], "summary": "Get global app settings.", "operationId": "getAppsSettings", "responses": {"200": {"description": "App settings.", "content": {"application/json": {"schema": {"type": "object", "properties": {"settings": {"type": "object", "description": "Global app settings for the tenant."}}}}}}, "401": {"$ref": "#/components/responses/Unauthorized"}}}}, "/api/v1/apps/refresh": {"post": {"tags": ["Apps"], "summary": "Refresh app data.", "description": "Queue a refresh of app data including reviews and metadata.", "operationId": "postAppsRefresh", "security": [{"BearerAuth": []}], "responses": {"202": {"description": "Refresh queued. If some apps failed to enqueue, the response includes a `failed` count.", "content": {"application/json": {"schema": {"type": "object", "properties": {"status": {"type": "string", "example": "refresh_queued"}, "message": {"type": "string", "example": "Refresh has been queued for 5 apps"}, "failed": {"type": "integer", "description": "Number of apps that failed to enqueue (only present on partial failure).", "example": 1}}}}}}, "401": {"$ref": "#/components/responses/Unauthorized"}, "402": {"$ref": "#/components/responses/SubscriptionRequired"}, "502": {"$ref": "#/components/responses/BadGateway"}, "503": {"$ref": "#/components/responses/ReadUnavailable"}}}}, "/api/v1/apps/auto-sync/settings": {"patch": {"tags": ["Apps"], "summary": "Update global auto-sync settings.", "operationId": "patchAppsAutoSyncSettings", "security": [{"BearerAuth": []}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/AutoSyncSettingsRequest"}}}}, "responses": {"200": {"description": "Auto-sync settings updated.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/GenericSuccess"}}}}, "400": {"$ref": "#/components/responses/ValidationError"}, "401": {"$ref": "#/components/responses/Unauthorized"}, "402": {"$ref": "#/components/responses/SubscriptionRequired"}, "503": {"$ref": "#/components/responses/ReadUnavailable"}}}}, "/api/v1/apps/{appUuid}/auto-sync": {"get": {"tags": ["Apps"], "summary": "Get auto-sync status for an app.", "description": "Returns the current auto-sync configuration, sync status, and usage metrics for a specific app.", "operationId": "getAppAutoSyncStatus", "parameters": [{"name": "appUuid", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid"}}], "responses": {"200": {"description": "Auto-sync status retrieved successfully. Every member is always sent.", "content": {"application/json": {"schema": {"type": "object", "properties": {"auto_sync_enabled": {"type": "boolean", "description": "Whether automatic sync is enabled for this app."}, "sync_status": {"type": "string", "enum": ["idle", "syncing", "scheduled", "error"], "description": "The app's sync state in four words: `syncing` while a sync or its analysis runs, `scheduled` while one waits to start, `error` when the last one failed (or was recorded as limited while auto-sync is off), otherwise `idle`."}, "syncs_today": {"type": ["integer", "null"], "deprecated": true, "description": "Always null. A daily manual-sync counter is not tracked."}, "max_syncs_per_day": {"type": ["integer", "null"], "deprecated": true, "description": "Always null. Manual syncs are deduplicated, with no daily cap. Plan limits apply to configured schedule times only."}, "last_sync": {"type": ["string", "null"], "format": "date-time", "description": "Timestamp of last successful sync. Null before the first one."}, "next_sync_at": {"type": ["string", "null"], "format": "date-time", "description": "When the next automatic sync is due. Null when auto-sync is off or no time is set yet."}, "error_message": {"type": ["string", "null"], "description": "The last sync's error, as stored; null when none was recorded."}}, "required": ["auto_sync_enabled", "sync_status", "syncs_today", "max_syncs_per_day", "last_sync", "next_sync_at", "error_message"]}}}}, "401": {"$ref": "#/components/responses/Unauthorized"}, "402": {"$ref": "#/components/responses/SubscriptionRequired"}, "403": {"$ref": "#/components/responses/AppsKeyForbidden"}, "404": {"$ref": "#/components/responses/AppNotFound"}, "503": {"$ref": "#/components/responses/ReadUnavailable"}}}}, "/api/v1/apps/{appUuid}/templates": {"get": {"tags": ["Reply Templates"], "summary": "List an app's reply templates.", "operationId": "getAppTemplates", "parameters": [{"name": "appUuid", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid"}}], "responses": {"200": {"description": "App reply templates.", "content": {"application/json": {"schema": {"type": "object", "properties": {"templates": {"type": "array", "items": {"$ref": "#/components/schemas/AppReplyTemplate"}}, "count": {"type": "integer"}}}}}}, "400": {"$ref": "#/components/responses/BadRequest"}, "401": {"$ref": "#/components/responses/Unauthorized"}, "403": {"$ref": "#/components/responses/Forbidden"}, "404": {"$ref": "#/components/responses/NotFound"}, "503": {"$ref": "#/components/responses/ReadUnavailable"}}}, "post": {"tags": ["Reply Templates"], "summary": "Create a reply template for an app.", "operationId": "createAppTemplate", "security": [{"BearerAuth": []}], "parameters": [{"name": "appUuid", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid"}}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/AppReplyTemplateRequest"}}}}, "responses": {"201": {"description": "Template created.", "content": {"application/json": {"schema": {"type": "object", "properties": {"template": {"$ref": "#/components/schemas/AppReplyTemplate"}}}}}}, "400": {"$ref": "#/components/responses/ValidationError"}, "401": {"$ref": "#/components/responses/Unauthorized"}, "403": {"$ref": "#/components/responses/Forbidden"}, "404": {"$ref": "#/components/responses/NotFound"}, "503": {"$ref": "#/components/responses/ReadUnavailable"}}}}, "/api/v1/apps/{appUuid}/templates/seed": {"post": {"tags": ["Reply Templates"], "summary": "Seed the default reply templates for an app (idempotent).", "description": "Writes the starter set once, into an app that has no templates. The\nseeded templates belong to the workspace from then on: a later seed\nor read never rewrites them, in any language.\n", "operationId": "seedAppTemplates", "security": [{"BearerAuth": []}], "parameters": [{"name": "appUuid", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid"}}, {"name": "locale", "in": "query", "required": false, "description": "Language the starter templates' titles, bodies and tags are\nwritten in: `en`, `de`, `es`, `fr`, `it`, `ja`, `ko`, `nl`, `pt`\nor `zh`. A region or script tag (`pt-BR`, `zh_CN`) resolves to its\nbase language. English when absent or empty. Any other value is\nrefused with a 400 and nothing is written: the seeded templates\nstay in the workspace, so a mistyped language (`jp` for `ja`) is\nnot replaced with English. Categories, tones, template ids and\nthe `{app_name}` and `{support_email}` placeholders are the same\nin every language.\n", "schema": {"type": "string"}, "example": "de"}], "responses": {"200": {"description": "Seed result.", "content": {"application/json": {"schema": {"type": "object", "properties": {"templates_created": {"type": "integer"}, "message": {"type": "string"}}}}}}, "400": {"$ref": "#/components/responses/BadRequest"}, "401": {"$ref": "#/components/responses/Unauthorized"}, "403": {"$ref": "#/components/responses/Forbidden"}, "404": {"$ref": "#/components/responses/NotFound"}, "503": {"$ref": "#/components/responses/ReadUnavailable"}}}}, "/api/v1/apps/{appUuid}/templates/{templateId}": {"put": {"tags": ["Reply Templates"], "summary": "Update an app reply template.", "operationId": "updateAppTemplate", "security": [{"BearerAuth": []}], "parameters": [{"name": "appUuid", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid"}}, {"name": "templateId", "in": "path", "required": true, "schema": {"type": "string"}}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/AppReplyTemplateRequest"}}}}, "responses": {"200": {"description": "Template updated.", "content": {"application/json": {"schema": {"type": "object", "properties": {"template": {"$ref": "#/components/schemas/AppReplyTemplate"}}}}}}, "400": {"$ref": "#/components/responses/ValidationError"}, "401": {"$ref": "#/components/responses/Unauthorized"}, "403": {"$ref": "#/components/responses/Forbidden"}, "404": {"$ref": "#/components/responses/NotFound"}, "503": {"$ref": "#/components/responses/ReadUnavailable"}}}, "delete": {"tags": ["Reply Templates"], "summary": "Delete an app reply template.", "operationId": "deleteAppTemplate", "security": [{"BearerAuth": []}], "parameters": [{"name": "appUuid", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid"}}, {"name": "templateId", "in": "path", "required": true, "schema": {"type": "string"}}], "responses": {"200": {"description": "Template deleted.", "content": {"application/json": {"schema": {"type": "object", "properties": {"message": {"type": "string"}}}}}}, "400": {"$ref": "#/components/responses/BadRequest"}, "401": {"$ref": "#/components/responses/Unauthorized"}, "403": {"$ref": "#/components/responses/Forbidden"}, "404": {"$ref": "#/components/responses/NotFound"}, "503": {"$ref": "#/components/responses/ReadUnavailable"}}}}, "/api/v1/apps/{appUuid}/reviews": {"get": {"tags": ["Reviews"], "summary": "Paginated reviews for an app.", "operationId": "getAppReviews", "description": "List reviews for one app with the same filters as GET /api/v1/reviews. The app determines its platform. Reviews are ordered newest first by review date; reviews without a date follow dated reviews.", "parameters": [{"$ref": "#/components/parameters/AppUuidPath"}, {"$ref": "#/components/parameters/ReviewLimit"}, {"$ref": "#/components/parameters/ReviewCursor"}, {"$ref": "#/components/parameters/ReviewRating"}, {"$ref": "#/components/parameters/ReviewMinRating"}, {"$ref": "#/components/parameters/ReviewMaxRating"}, {"$ref": "#/components/parameters/ReviewSearch"}, {"$ref": "#/components/parameters/ReviewSearchHex"}, {"$ref": "#/components/parameters/ReviewSentiment"}, {"$ref": "#/components/parameters/ReviewLanguage"}, {"$ref": "#/components/parameters/ReviewAssignee"}, {"$ref": "#/components/parameters/ReviewTagFilter"}, {"$ref": "#/components/parameters/ReviewStartDate"}, {"$ref": "#/components/parameters/ReviewEndDate"}], "responses": {"200": {"description": "Reviews returned.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ReviewListResponse"}}}}, "400": {"description": "Invalid query parameters: a rating outside 1-5, a malformed `search_hex` or `search` sent with it (`INVALID_SEARCH`), or a `cursor` that is not one this endpoint issued (`INVALID_CURSOR`). An unusable cursor is rejected rather than silently restarting at page 1.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "401": {"$ref": "#/components/responses/Unauthorized"}, "403": {"$ref": "#/components/responses/Forbidden"}, "404": {"$ref": "#/components/responses/NotFound"}, "503": {"description": "`SERVICE_UNAVAILABLE`: app access could not be checked. This does not establish that the app is missing or has no reviews. Retry after `Retry-After`; `retryable` is true.", "headers": {"Retry-After": {"description": "Seconds to wait before retrying the read.", "schema": {"type": "integer", "minimum": 1}}}, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}}}}, "/api/v1/apps/{appUuid}/reviews/{reviewId}/reply": {"get": {"tags": ["Reviews"], "summary": "Retrieve stored reply metadata for a review.", "description": "The developer reply stored on a review of this workspace's app, read from the store or posted through Tritonium. Who posted it (a user or workspace id) is never answered: other workspaces can track the same store listing. An API key needs the `reviews:read` scope.", "operationId": "getReviewReply", "parameters": [{"$ref": "#/components/parameters/ReplyAppUuid"}, {"$ref": "#/components/parameters/ReplyReviewId"}], "responses": {"200": {"description": "Reply status returned.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ReviewReplyStatus"}}}}, "401": {"$ref": "#/components/responses/Unauthorized"}, "402": {"$ref": "#/components/responses/SubscriptionRequired"}, "403": {"description": "`FORBIDDEN`: an API key without the `reviews:read` scope.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "404": {"$ref": "#/components/responses/ReplyContextNotFound"}, "500": {"$ref": "#/components/responses/InternalError"}, "503": {"$ref": "#/components/responses/SubscriptionUnverified"}}}}, "/api/v1/apps/{appUuid}/reviews/{reviewId}/reply/generate": {"post": {"tags": ["Reviews"], "summary": "Generate an AI-generated reply draft for a review.", "description": "Saves a draft for human approval; nothing is published. If a draft is already pending for the review it is returned (`existing: true`) instead of generating another. Send an `Idempotency-Key` to make a retry after a timeout return the first outcome. Any workspace role (owner, admin or member) may generate a draft; approving, rejecting and posting it need an admin or owner. An API key needs the `reviews:write` scope.", "operationId": "postGenerateReviewReply", "parameters": [{"$ref": "#/components/parameters/ReplyAppUuid"}, {"$ref": "#/components/parameters/ReplyReviewId"}, {"$ref": "#/components/parameters/IdempotencyKey"}], "requestBody": {"required": false, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ReviewReplyGenerateRequest"}}}}, "responses": {"200": {"description": "Draft generated, or the draft already awaiting approval (`existing` true).", "headers": {"Idempotent-Replayed": {"$ref": "#/components/headers/IdempotentReplayed"}}, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ReplyDraftGenerated"}}}}, "400": {"$ref": "#/components/responses/ValidationError"}, "401": {"$ref": "#/components/responses/Unauthorized"}, "402": {"$ref": "#/components/responses/SubscriptionRequired"}, "403": {"description": "`FORBIDDEN`: an API key without the `reviews:write` scope.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "404": {"$ref": "#/components/responses/ReplyContextNotFound"}, "409": {"description": "A draft is already being generated for this review (`DRAFT_GENERATION_IN_PROGRESS`), or the first request with this `Idempotency-Key` is still running (`IDEMPOTENCY_IN_PROGRESS`, with `Retry-After`). Both are retryable. `IDEMPOTENCY_OUTCOME_UNKNOWN` is not: the first keyed request failed or ended without a recorded outcome, so read the review's drafts and use a new key to try again.", "headers": {"Retry-After": {"description": "Seconds to wait before retrying (IDEMPOTENCY_IN_PROGRESS).", "schema": {"type": "integer", "minimum": 1}}}, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "422": {"$ref": "#/components/responses/IdempotencyKeyReused"}, "429": {"$ref": "#/components/responses/CredentialRateLimited"}, "500": {"description": "`DRAFT_SAVE_FAILED`: the reply was written but the draft could not be saved.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "503": {"description": "`AI_SERVICE_UNAVAILABLE`: the model did not answer and no draft was saved; retry after a short delay (with a key, use a new key: a server error spends it). `SERVICE_UNAVAILABLE`: the review's existing drafts could not be read, so nothing was generated, saved or billed; `retryable` is true, retry after `Retry-After` with the same key, which is released. `IDEMPOTENCY_UNAVAILABLE`: the `Idempotency-Key` could not be recorded and nothing ran; retry with the same key after `Retry-After`. The subscription check answers `SERVICE_UNAVAILABLE` too when the workspace's subscription could not be verified.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}}}}, "/api/v1/apps/{appUuid}/reviews/{reviewId}/reply/post": {"post": {"tags": ["Reviews"], "summary": "Publish a reply back to the originating store.", "description": "Admin or owner only. The reply goes out publicly under the team's name, so a member generates a draft for an admin to approve instead. The app must have store credentials. With `reply_template`, the reply is first saved as a draft, approved and then published as a draft is, so the template it came from is recorded.", "operationId": "publishReviewReply", "security": [{"BearerAuth": []}], "parameters": [{"$ref": "#/components/parameters/ReplyAppUuid"}, {"$ref": "#/components/parameters/ReplyReviewId"}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ReviewReplyPostRequest"}}}}, "responses": {"200": {"description": "The store accepted the reply.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ReplyPublication"}}}}, "400": {"description": "Nothing was published. `INVALID_REQUEST`: no reply text, or a reply text or language that is not text or is too long. `REPLY_HAS_PLACEHOLDER`: the reply still has an unfilled `{placeholder}`. `INVALID_REPLY_TEMPLATE`: the template could not be resolved. `REPLY_POST_FAILED`: the store refused the reply or could not be reached; the envelope may carry the `draft_id` of a saved reply.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "401": {"$ref": "#/components/responses/Unauthorized"}, "402": {"$ref": "#/components/responses/SubscriptionRequired"}, "403": {"description": "`FORBIDDEN`: the caller is not an owner or admin, or is an API key. `CREDENTIALS_REQUIRED`: the app has no store credentials, so nothing can be published.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "404": {"description": "`APP_NOT_FOUND`: the workspace has no such app. `NOT_FOUND`: the review could not be found.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "409": {"description": "`DRAFT_STATUS_CONFLICT`: a saved reply for this review is being posted or was rejected. `REPLY_CONFLICT`: the review's reply changed on the store. The envelope may carry the `draft_id`.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "500": {"description": "`REPLY_POSTED_NOT_RECORDED`: the reply IS live on the store but Tritonium could not finish recording it. Do not retry: a retry publishes a duplicate reply.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "503": {"description": "`REPLY_TEMPORARILY_UNAVAILABLE`: the saved reply could not be read or claimed; nothing was published. `SERVICE_UNAVAILABLE`: the workspace's subscription could not be verified, or something this answer needs could not be read; nothing was published.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}}}}, "/api/v1/apps/{appUuid}/replies/analytics": {"get": {"tags": ["Reviews", "Insights"], "summary": "Aggregate reply performance metrics for an app.", "description": "Replies this workspace posted through Tritonium on the period's reviews: how many, their share of the period's reviews, response times and escalations. The stores do not report every developer reply (the App Store reports none written in App Store Connect, and Google Play shows a reply only on the reviews it serves when they are read), so no reply rate is given: `reply_rate` is null with `reply_rate_reason`.", "operationId": "getReplyAnalytics", "parameters": [{"name": "appUuid", "in": "path", "required": true, "schema": {"type": "string"}}, {"name": "days", "in": "query", "required": false, "description": "Reporting window in days.", "schema": {"type": "integer", "default": 30, "minimum": 1, "maximum": 365}}, {"$ref": "#/components/parameters/AnalyticsPeriod"}], "responses": {"200": {"description": "Reply analytics returned.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ReplyAnalytics"}}}}, "401": {"$ref": "#/components/responses/Unauthorized"}, "403": {"$ref": "#/components/responses/Forbidden"}, "404": {"$ref": "#/components/responses/NotFound"}, "503": {"$ref": "#/components/responses/ServiceUnavailable"}}}}, "/api/v1/apps/{appUuid}/insights": {"get": {"tags": ["Insights"], "summary": "Retrieve the latest insight bundle for an app.", "operationId": "getAppInsights", "parameters": [{"name": "appUuid", "in": "path", "required": true, "schema": {"type": "string"}}], "responses": {"200": {"description": "The app's latest stored analysis.", "content": {"application/json": {"schema": {"type": "object", "required": ["success", "insights"], "properties": {"success": {"type": "boolean"}, "insights": {"$ref": "#/components/schemas/InsightBundle"}, "created_at": {"type": "string", "format": "date-time", "description": "When the analysis was stored. Absent when `source` is `centralized`."}, "source": {"type": "string", "description": "`centralized` when the app has no analysis of its own and the latest shared analysis of its store listing is returned instead. Absent otherwise."}}}}}}, "401": {"$ref": "#/components/responses/Unauthorized"}, "403": {"$ref": "#/components/responses/Forbidden"}, "404": {"description": "`APP_NOT_FOUND`: the app is not in the caller's workspace. `INSIGHTS_NOT_FOUND`: the app has no stored analysis yet; start one with `POST /api/v1/apps/analyze`.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "503": {"$ref": "#/components/responses/ServiceUnavailable"}}}}, "/api/v1/apps/{appUuid}/insights/history": {"get": {"tags": ["Insights"], "summary": "Historical insight summaries for an app.", "operationId": "getAppInsightHistory", "parameters": [{"name": "appUuid", "in": "path", "required": true, "schema": {"type": "string"}}, {"name": "limit", "in": "query", "schema": {"type": "integer", "minimum": 1, "maximum": 50, "default": 12}, "description": "Maximum number of history entries to return. A value below the minimum, or a non-integer, is rejected with 400 rather than silently clamped; a value above the maximum is capped at it. The maximum was previously documented as 100, but the endpoint has always capped at 50."}, {"name": "cursor", "in": "query", "deprecated": true, "schema": {"type": "string"}, "description": "Ignored. The history is not paginated: it returns the newest `limit` entries. Documented here by mistake; it was never read."}, {"name": "view", "in": "query", "schema": {"type": "string", "enum": ["full", "compact"], "default": "full"}, "description": "`full` returns each stored analysis whole. `compact` returns, for each entry, `created_at` and an `insights` object holding only `generated_at`, `status`, `analysis_mode`, `analysis_degraded`, `analysis_stopped_reason`, `analysis_sample_limited`, `total_reviews`, `reviews_analyzed`, `analyzed_reviews`, `analysis_sampled_reviews`, `analysis_corpus_reviews`, `analysis_corpus_reviews_known`, `analysis_coverage_percent`, `batches_processed`, `total_batches` and the numeric fields of `sentiment_summary` (such as `average_rating` and `positive_percent`): what a trend line reads, at about 1% of the size. Every value is the one the full view returns for the same entry. Any other value is rejected with 400 `INVALID_VIEW`."}], "responses": {"200": {"description": "Insight history returned, oldest first. With `view=compact` each `insights` object carries only the fields listed under `view`.", "content": {"application/json": {"schema": {"type": "object", "required": ["history", "count"], "properties": {"history": {"type": "array", "items": {"type": "object", "required": ["created_at", "insights"], "properties": {"created_at": {"type": "string", "format": "date-time", "description": "When the analysis was stored."}, "insights": {"description": "The stored analysis: an `InsightBundle` with `view=full`, an `InsightBundleCompact` with `view=compact`. The history does not carry `analysis_version`.", "anyOf": [{"$ref": "#/components/schemas/InsightBundle"}, {"$ref": "#/components/schemas/InsightBundleCompact"}]}}}}, "count": {"type": "integer", "minimum": 0, "description": "Entries in `history`."}}}}}}, "400": {"$ref": "#/components/responses/ValidationError"}, "401": {"$ref": "#/components/responses/Unauthorized"}, "403": {"$ref": "#/components/responses/Forbidden"}, "404": {"$ref": "#/components/responses/NotFound"}, "503": {"$ref": "#/components/responses/ServiceUnavailable"}}}}, "/api/v1/apps/{appUuid}/ratings/history": {"get": {"tags": ["Ratings"], "summary": "Historical rating snapshots for an app.", "operationId": "getRatingsHistory", "parameters": [{"name": "appUuid", "in": "path", "required": true, "schema": {"type": "string"}}, {"name": "days", "in": "query", "description": "Size of the trailing window to aggregate, in days. The response covers the last `days` whole UTC days up to and including today: from 00:00 UTC `days - 1` days ago until now. Every point but today's is a complete UTC day; today's holds the reviews so far. `/ratings/summary` uses a rolling window of `days` \u00d7 24 hours ending now instead, so its totals can include reviews from the hours before this chart's first day.", "schema": {"type": "integer", "minimum": 1, "maximum": 365, "default": 30}}, {"name": "platform", "in": "query", "description": "Restrict the aggregation to a single store platform.", "schema": {"type": "string"}}], "responses": {"200": {"description": "Daily rating aggregates for the requested window, oldest day first. Days with no reviews are omitted rather than returned as zero.", "content": {"application/json": {"schema": {"type": "object", "properties": {"app_uuid": {"type": "string"}, "history": {"type": "array", "items": {"$ref": "#/components/schemas/RatingHistoryPoint"}}, "days": {"type": "integer", "description": "The window size the response was computed over."}, "reviews_scanned": {"type": "integer", "description": "Reviews the scan produced inside the window, before the rating and platform filters, with the same meaning the field carries on `/ratings/summary`. Not the sum of `history[].count`, which counts only rated rows that survived those filters."}, "truncated": {"type": "boolean", "description": "True only when a runaway safety bound stopped the scan, in which case the chart covers part of the window. False in normal operation: the window is read in full."}}}}}}, "401": {"$ref": "#/components/responses/Unauthorized"}, "403": {"$ref": "#/components/responses/Forbidden"}, "404": {"$ref": "#/components/responses/NotFound"}, "503": {"$ref": "#/components/responses/ServiceUnavailable"}}}}, "/api/v1/apps/{appUuid}/ratings/summary": {"get": {"tags": ["Ratings"], "summary": "Latest rating snapshot for an app.", "operationId": "getRatingsSummary", "parameters": [{"name": "appUuid", "in": "path", "required": true, "schema": {"type": "string"}}, {"name": "platform", "in": "query", "schema": {"type": "string"}}, {"name": "days", "in": "query", "description": "Summary window length in days (default 30).", "schema": {"type": "integer", "minimum": 1, "maximum": 365, "default": 30}}], "responses": {"200": {"description": "Rating summary returned.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/RatingSummary"}}}}, "401": {"$ref": "#/components/responses/Unauthorized"}, "403": {"$ref": "#/components/responses/Forbidden"}, "404": {"$ref": "#/components/responses/NotFound"}, "503": {"$ref": "#/components/responses/ServiceUnavailable"}}}}, "/api/v1/apps/{appUuid}/ratings/comparison": {"get": {"tags": ["Ratings"], "summary": "Compare app ratings against competitors.", "operationId": "getRatingsComparison", "parameters": [{"name": "appUuid", "in": "path", "required": true, "schema": {"type": "string"}}], "responses": {"200": {"description": "Comparison returned.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/RatingsComparison"}}}}, "401": {"$ref": "#/components/responses/Unauthorized"}, "403": {"$ref": "#/components/responses/Forbidden"}, "404": {"$ref": "#/components/responses/NotFound"}}}}, "/api/v1/apps/{appUuid}/competitors": {"get": {"tags": ["Apps"], "summary": "List competitor apps registered for benchmarking.", "description": "Every competitor the workspace tracks for the app, in one answer (not paged). Any member can list them, and so can an API key with the `analytics:read` scope.", "operationId": "getAppCompetitors", "parameters": [{"$ref": "#/components/parameters/CompetitorAppUuid"}], "responses": {"200": {"description": "The app's tracked competitors.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/CompetitorList"}}}}, "401": {"$ref": "#/components/responses/Unauthorized"}, "402": {"$ref": "#/components/responses/SubscriptionRequired"}, "403": {"$ref": "#/components/responses/CompetitorKeyForbidden"}, "404": {"$ref": "#/components/responses/CompetitorAppNotFound"}, "500": {"$ref": "#/components/responses/CompetitorUnread"}, "503": {"$ref": "#/components/responses/SubscriptionUnverified"}}}, "post": {"tags": ["Apps"], "summary": "Register a competitor app.", "description": "Requires an admin or owner. The competitor is looked up in its store first: an id the store answers it has no app for is refused, and a store that cannot be reached does not stop the add. A name or icon not sent is taken from the store's listing when the store answered. The answer is the competitor as it is listed, with no figures until its first sync. Adding a competitor already tracked for the app replaces it, figures included.", "operationId": "addAppCompetitor", "security": [{"BearerAuth": []}], "parameters": [{"$ref": "#/components/parameters/CompetitorAppUuid"}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/CompetitorUpsertRequest"}}}}, "responses": {"201": {"description": "The competitor added.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Competitor"}}}}, "400": {"$ref": "#/components/responses/CompetitorAddRefused"}, "401": {"$ref": "#/components/responses/Unauthorized"}, "402": {"$ref": "#/components/responses/SubscriptionRequired"}, "403": {"$ref": "#/components/responses/CompetitorAdminForbidden"}, "404": {"$ref": "#/components/responses/CompetitorAppNotFound"}, "500": {"$ref": "#/components/responses/CompetitorUnread"}, "503": {"$ref": "#/components/responses/SubscriptionUnverified"}}}}, "/api/v1/apps/{appUuid}/competitors/{platform}/{externalAppId}": {"delete": {"tags": ["Apps"], "summary": "Remove a competitor mapping.", "description": "Requires an admin or owner. The competitor is no longer tracked for the app; the reviews and rating history already fetched for it are kept.", "operationId": "removeAppCompetitor", "security": [{"BearerAuth": []}], "parameters": [{"$ref": "#/components/parameters/CompetitorAppUuid"}, {"name": "platform", "in": "path", "required": true, "description": "The competitor's `platform`, as it is listed. Lowercase letters and underscores only; anything else answers `404 NOT_FOUND`.", "schema": {"type": "string", "pattern": "^[a-z_]+$"}}, {"name": "externalAppId", "in": "path", "required": true, "description": "The competitor's `external_app_id`, as it is listed. Letters, digits, dots, underscores and hyphens only; anything else answers `404 NOT_FOUND`.", "schema": {"type": "string", "pattern": "^[a-zA-Z0-9._-]+$"}}], "responses": {"200": {"description": "The competitor was removed.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/CompetitorRemoved"}}}}, "401": {"$ref": "#/components/responses/Unauthorized"}, "402": {"$ref": "#/components/responses/SubscriptionRequired"}, "403": {"$ref": "#/components/responses/CompetitorAdminForbidden"}, "404": {"description": "`APP_NOT_FOUND`: the workspace has no such app (another workspace's app answers the same). `NOT_FOUND`: the app does not track this competitor, or an id in the path is not in the form given above.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "500": {"$ref": "#/components/responses/CompetitorUnread"}, "503": {"$ref": "#/components/responses/SubscriptionUnverified"}}}}, "/api/v1/apps/{appUuid}/features/history": {"get": {"tags": ["Apps", "Insights"], "summary": "Historical feature impact entries for an app release.", "description": "The app's recorded feature launches, newest first: at most `limit` of them (25 when not given). The list is not paged yet, so `next_cursor` is null and `cursor` is not read.", "operationId": "getFeatureHistory", "parameters": [{"name": "appUuid", "in": "path", "required": true, "description": "The app's id. Anything that is not a UUID answers `404 NOT_FOUND`.", "schema": {"type": "string", "format": "uuid"}}, {"name": "limit", "in": "query", "schema": {"type": "integer", "minimum": 1, "maximum": 100}}, {"name": "cursor", "in": "query", "schema": {"type": "string"}}], "responses": {"200": {"description": "Feature history returned.", "content": {"application/json": {"schema": {"type": "object", "required": ["entries", "next_cursor"], "properties": {"entries": {"type": "array", "items": {"$ref": "#/components/schemas/FeatureImpactEntry"}}, "next_cursor": {"type": ["string", "null"], "description": "Pass as `cursor` to read the next page. Null on the last page, which today is every page."}}}}}}, "400": {"description": "`VALIDATION_ERROR`: `limit` is not a whole number from 1 to 100.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "401": {"$ref": "#/components/responses/Unauthorized"}, "402": {"$ref": "#/components/responses/SubscriptionRequired"}, "403": {"$ref": "#/components/responses/AnalyticsKeyForbidden"}, "404": {"$ref": "#/components/responses/AppNotFound"}, "500": {"description": "`INTERNAL_ERROR`: the feature history could not be read. Retry.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "503": {"$ref": "#/components/responses/ReadUnavailable"}}}, "post": {"tags": ["Apps"], "summary": "Create feature history.", "description": "Record a feature launch so its rating impact can be tracked. Requires an admin or owner role. `rating_delta` is the change in star rating attributed to the launch, so it must be a finite number within [-4, 4], the widest possible delta on a 1-5 scale.", "operationId": "createFeatureHistory", "security": [{"BearerAuth": []}], "parameters": [{"name": "appUuid", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid"}}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"type": "object", "required": ["feature_name"], "properties": {"feature_name": {"type": "string", "minLength": 1, "maxLength": 200, "description": "Name of the launched feature."}, "rating_delta": {"type": ["number", "null"], "minimum": -4, "maximum": 4, "description": "Star-rating change attributed to the launch. Omit when not measured; must not be sent as a non-numeric value."}, "launch_date": {"type": "string", "format": "date-time", "description": "ISO-8601 launch timestamp. Defaults to now."}, "metadata": {"type": "object", "description": "Free-form annotations, at most 4096 bytes serialised. `metadata.notes` is surfaced as the entry's `notes`.", "properties": {"notes": {"type": "string", "maxLength": 2000}}}}}}}}, "responses": {"201": {"description": "Feature history entry created.", "content": {"application/json": {"schema": {"type": "object", "properties": {"success": {"type": "boolean"}, "record": {"type": "object", "properties": {"record_id": {"type": "string"}, "app_uuid": {"type": "string"}, "feature_name": {"type": "string"}, "rating_delta": {"type": ["number", "null"]}, "launch_date": {"type": "string", "format": "date-time"}, "metadata": {"type": "object", "additionalProperties": true}, "created_at": {"type": "string", "format": "date-time"}}}}}}}}, "400": {"$ref": "#/components/responses/BadRequest"}, "401": {"$ref": "#/components/responses/Unauthorized"}, "403": {"$ref": "#/components/responses/Forbidden"}, "404": {"$ref": "#/components/responses/NotFound"}, "503": {"$ref": "#/components/responses/ReadUnavailable"}}}}, "/api/v1/insights": {"get": {"tags": ["Insights"], "summary": "Latest insights for one app.", "description": "Query-parameter form of `GET /api/v1/apps/{appUuid}/insights`. This is a single-app lookup, not a tenant-wide feed, and it is not paginated: it returns the most recent insights document for the app.", "operationId": "getTenantInsights", "parameters": [{"name": "app_uuid", "in": "query", "required": true, "schema": {"type": "string", "format": "uuid"}, "description": "App to return insights for. Must belong to the caller's tenant."}], "responses": {"200": {"description": "The app's latest stored analysis, exactly as `GET /api/v1/apps/{appUuid}/insights` returns it.", "content": {"application/json": {"schema": {"type": "object", "required": ["success", "insights"], "properties": {"success": {"type": "boolean"}, "insights": {"$ref": "#/components/schemas/InsightBundle"}, "created_at": {"type": "string", "format": "date-time", "description": "When the analysis was stored. Absent when `source` is `centralized`."}, "source": {"type": "string", "description": "`centralized` when the app has no analysis of its own and the latest shared analysis of its store listing is returned instead. Absent otherwise."}}}}}}, "400": {"$ref": "#/components/responses/ValidationError"}, "401": {"$ref": "#/components/responses/Unauthorized"}, "404": {"description": "`APP_NOT_FOUND`: the app is not in the caller's workspace. `INSIGHTS_NOT_FOUND`: the app has no stored analysis yet; start one with `POST /api/v1/apps/analyze`.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "503": {"$ref": "#/components/responses/ServiceUnavailable"}}}}, "/api/v1/insights/resolve": {"post": {"tags": ["Insights"], "summary": "Resolve or unresolve an insight.", "description": "Mark an insight as resolved (addressed) or unresolve it. Resolved insights are tracked separately from active insights.", "operationId": "resolveInsight", "security": [{"BearerAuth": []}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"type": "object", "required": ["insight_id"], "properties": {"insight_id": {"type": "string", "description": "UUID of the insight to resolve."}, "resolved": {"type": "boolean", "default": true, "description": "Whether to resolve (true) or unresolve (false) the insight."}}}}}}, "responses": {"200": {"description": "Insight resolution status updated.", "content": {"application/json": {"schema": {"type": "object", "properties": {"success": {"type": "boolean"}, "insight_id": {"type": "string"}, "resolved": {"type": "boolean"}}}}}}, "400": {"$ref": "#/components/responses/ValidationError"}, "401": {"$ref": "#/components/responses/Unauthorized"}, "404": {"$ref": "#/components/responses/NotFound"}, "503": {"$ref": "#/components/responses/ServiceUnavailable"}}}}, "/api/v1/reviews": {"get": {"tags": ["Reviews"], "summary": "Discover reviews across all connected apps.", "operationId": "getTenantReviews", "parameters": [{"$ref": "#/components/parameters/ReviewLimit"}, {"$ref": "#/components/parameters/ReviewCursor"}, {"name": "app_uuid", "in": "query", "schema": {"type": "string", "format": "uuid"}, "description": "Required app identifier. `GET /api/v1/apps/{appUuid}/reviews` is the equivalent RESTful route and takes the same filters.", "required": true}, {"$ref": "#/components/parameters/ReviewRating"}, {"$ref": "#/components/parameters/ReviewMinRating"}, {"$ref": "#/components/parameters/ReviewMaxRating"}, {"$ref": "#/components/parameters/ReviewSearch"}, {"$ref": "#/components/parameters/ReviewSearchHex"}, {"$ref": "#/components/parameters/ReviewSentiment"}, {"$ref": "#/components/parameters/ReviewLanguage"}, {"$ref": "#/components/parameters/ReviewAssignee"}, {"$ref": "#/components/parameters/ReviewTagFilter"}, {"$ref": "#/components/parameters/ReviewStartDate"}, {"$ref": "#/components/parameters/ReviewEndDate"}], "responses": {"200": {"description": "Reviews returned.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ReviewListResponse"}}}}, "400": {"$ref": "#/components/responses/BadRequest"}, "401": {"$ref": "#/components/responses/Unauthorized"}, "404": {"$ref": "#/components/responses/NotFound"}, "503": {"description": "`SERVICE_UNAVAILABLE`: app access could not be checked. This does not establish that the app is missing or has no reviews. Retry after `Retry-After`; `retryable` is true.", "headers": {"Retry-After": {"description": "Seconds to wait before retrying the read.", "schema": {"type": "integer", "minimum": 1}}}, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}}}}, "/api/v1/reviews/my-reviews": {"get": {"tags": ["Reviews"], "summary": "Get reviews assigned to current user.", "description": "Returns reviews that have been assigned to the authenticated user for response.", "operationId": "getMyReviews", "parameters": [{"name": "app_uuid", "in": "query", "schema": {"type": "string"}, "description": "Optional app filter."}, {"name": "limit", "in": "query", "schema": {"type": "integer", "minimum": 1, "maximum": 100, "default": 50}, "description": "Number of reviews to return."}], "responses": {"200": {"description": "Assigned reviews returned.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/MyReviewsResponse"}}}}, "401": {"$ref": "#/components/responses/Unauthorized"}, "503": {"$ref": "#/components/responses/ReadUnavailable"}}}}, "/api/v1/reviews/{reviewId}/assign": {"post": {"tags": ["Reviews"], "summary": "Assign a review to a team member.", "description": "Assign a review to a team member. Any workspace role (owner, admin or member) may assign. Signed-in users only; an API key receives 403.", "operationId": "assignReview", "security": [{"BearerAuth": []}], "parameters": [{"name": "reviewId", "in": "path", "required": true, "schema": {"type": "string"}, "description": "Review ID."}, {"name": "app_uuid", "in": "query", "required": true, "schema": {"type": "string"}, "description": "App UUID that owns the review."}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"type": "object", "required": ["assignee_id"], "properties": {"assignee_id": {"type": "string", "description": "User ID to assign the review to."}}}}}}, "responses": {"200": {"description": "Review assigned successfully.", "content": {"application/json": {"schema": {"type": "object", "properties": {"success": {"type": "boolean"}, "review_id": {"type": "string"}, "assignee_id": {"type": "string"}}}}}}, "400": {"$ref": "#/components/responses/ValidationError"}, "401": {"$ref": "#/components/responses/Unauthorized"}, "403": {"$ref": "#/components/responses/Forbidden"}, "404": {"$ref": "#/components/responses/NotFound"}, "503": {"$ref": "#/components/responses/ReadUnavailable"}}}, "delete": {"tags": ["Reviews"], "summary": "Unassign a review.", "description": "Remove the assignment from a review. Any workspace role (owner, admin or member) may unassign. Signed-in users only; an API key receives 403.", "operationId": "unassignReview", "security": [{"BearerAuth": []}], "parameters": [{"name": "reviewId", "in": "path", "required": true, "schema": {"type": "string"}, "description": "Review ID."}, {"name": "app_uuid", "in": "query", "required": true, "schema": {"type": "string"}, "description": "App UUID that owns the review."}], "responses": {"204": {"description": "Review unassigned successfully."}, "400": {"$ref": "#/components/responses/ValidationError"}, "401": {"$ref": "#/components/responses/Unauthorized"}, "403": {"$ref": "#/components/responses/Forbidden"}, "503": {"$ref": "#/components/responses/ReadUnavailable"}}}}, "/api/v1/reviews/{reviewId}/tags": {"put": {"tags": ["Reviews"], "summary": "Add, remove or replace the tags on a review.", "description": "Applies the workspace's own tags to one review. Open to every role, like the rest of review triage; creating and editing tags stays with admins (`/api/v1/review-tags`). Idempotent: repeating a request changes nothing, and each tag's `usage_count` moves only by what the request actually added or removed. Signed-in users only; an API key receives 403.", "operationId": "setReviewTags", "security": [{"BearerAuth": []}], "parameters": [{"name": "reviewId", "in": "path", "required": true, "schema": {"type": "string"}, "description": "Review ID."}, {"name": "app_uuid", "in": "query", "required": true, "schema": {"type": "string", "format": "uuid"}, "description": "App UUID that owns the review."}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ReviewTagsUpdateRequest"}}}}, "responses": {"200": {"description": "The review's tags after the change.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ReviewTagsUpdateResponse"}}}}, "400": {"$ref": "#/components/responses/ValidationError"}, "401": {"$ref": "#/components/responses/Unauthorized"}, "403": {"$ref": "#/components/responses/Forbidden"}, "404": {"$ref": "#/components/responses/NotFound"}, "503": {"$ref": "#/components/responses/ReadUnavailable"}}}}, "/api/v1/reviews/tags/bulk": {"post": {"tags": ["Reviews"], "summary": "Add, remove or replace tags on up to 100 reviews of one app.", "description": "The Reviews tab's bulk Tag action. Every tag id must be one of the workspace's tags; review ids that are not reviews of the app are skipped and returned in `not_found`. Idempotent, with one usage-count update per tag for the whole request. Signed-in users only; an API key receives 403.", "operationId": "bulkTagReviews", "security": [{"BearerAuth": []}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/BulkReviewTagsRequest"}}}}, "responses": {"200": {"description": "Per-review outcome.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/BulkReviewTagsResponse"}}}}, "400": {"$ref": "#/components/responses/ValidationError"}, "401": {"$ref": "#/components/responses/Unauthorized"}, "403": {"$ref": "#/components/responses/Forbidden"}, "404": {"$ref": "#/components/responses/NotFound"}, "503": {"$ref": "#/components/responses/ReadUnavailable"}}}}, "/api/v1/reviews/reply": {"post": {"tags": ["Reviews"], "summary": "Post a reply to a review (deprecated; use the app-scoped route).", "deprecated": true, "description": "**Not implemented.** This legacy endpoint never actually posted to the\napp store: it validated and authorized the request, then returned a\nfake success. It now returns `501 NOT_IMPLEMENTED`. Use\n`POST /api/v1/apps/{appUuid}/reviews/{reviewId}/reply/post`, which really\nposts to App Store Connect / Google Play.\n", "operationId": "postReviewReply", "security": [{"BearerAuth": []}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/PostReplyRequest"}}}}, "responses": {"400": {"$ref": "#/components/responses/ValidationError"}, "401": {"$ref": "#/components/responses/Unauthorized"}, "501": {"description": "Reply posting via this legacy route is not implemented.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "503": {"$ref": "#/components/responses/ReadUnavailable"}}}}, "/api/v1/reviews/predict-metadata": {"post": {"tags": ["Reviews"], "summary": "Predict metadata for reviews (not implemented).", "deprecated": true, "description": "**Not implemented.** This endpoint previously returned a hardcoded\n`neutral` / `[]` / `low` prediction for every review while advertising an\nAI capability. It now returns `501 NOT_IMPLEMENTED`. For real AI output\nuse `POST /api/v1/reviews/predict-replies` or the per-app analysis\npipeline.\n", "operationId": "postPredictMetadata", "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/PredictMetadataRequest"}}}}, "responses": {"400": {"$ref": "#/components/responses/ValidationError"}, "401": {"$ref": "#/components/responses/Unauthorized"}, "501": {"description": "Review metadata prediction is not implemented.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}}}}, "/api/v1/reviews/predict-replies": {"post": {"tags": ["Reviews"], "summary": "Generate AI reply suggestions.", "description": "Use AI to generate suggested replies for reviews.", "operationId": "postPredictReplies", "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/PredictRepliesRequest"}}}}, "responses": {"200": {"description": "Reply suggestions returned.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/PredictRepliesResponse"}}}}, "400": {"$ref": "#/components/responses/ValidationError"}, "401": {"$ref": "#/components/responses/Unauthorized"}, "429": {"$ref": "#/components/responses/CredentialRateLimited"}}}}, "/api/v1/predictions/rating-forecast": {"get": {"tags": ["Predictions"], "summary": "Retrieve rating forecasts for an app.", "description": "Estimates trends in the star ratings attached to retrieved written reviews. The input excludes ratings without written reviews and does not establish displayed store-rating ground truth. Accuracy against future displayed store ratings has not been established. The returned bounds are uncalibrated model ranges, not validated confidence intervals. A cached forecast is a read. With the default generate=true, a cache miss starts metered AI generation. API keys and MCP OAuth grants then need both analytics:read and apps:write; a read-only credential can use generate=false to inspect the cache or a generation already in progress. Signed-in dashboard users retain the existing generation behavior.", "operationId": "getRatingForecast", "parameters": [{"name": "app_uuid", "in": "query", "required": true, "schema": {"type": "string"}}, {"name": "horizon_days", "in": "query", "description": "Forecast horizon in days. Only the five declared values are supported. One forecast is generated, for 90 days, and stored once. A shorter horizon is the first days of that same forecast, so two horizons give the same rating, change and range for every date they share, and a stored forecast answers any horizon without new AI work. How old a stored forecast may be still depends on the horizon: 12 hours for 7 and 14 days, 24 hours for the others. One generation runs at a time for all horizons: while the forecast is being generated or replaced every horizon answers 202, and an `insufficient_data` or `generation_failed` answer is given at every horizon.", "schema": {"type": "integer", "enum": [7, 14, 30, 60, 90], "default": 30}}, {"name": "platform", "in": "query", "description": "Restrict the forecast to one store. Defaults to all platforms.", "schema": {"type": "string", "enum": ["ios", "android", "all"], "default": "all"}}, {"name": "force_refresh", "in": "query", "description": "Bypass the cache and regenerate. Also re-checks an `insufficient_data` verdict, which is otherwise held until more reviews are synced.", "schema": {"type": "string", "enum": ["true", "false"]}}, {"name": "generate", "in": "query", "description": "Set false for a read-only request: return a cached forecast, an in-progress status, or 404 FORECAST_NOT_CACHED. It never acquires a generation lock or calls the model. force_refresh=true is invalid with generate=false. Defaults to true for existing clients.", "schema": {"type": "string", "enum": ["true", "false"], "default": "true"}}], "responses": {"200": {"description": "Forecast returned (cached), or `insufficient_data: true` when the app has too few reviews to forecast. Both are terminal: stop polling.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/RatingForecastResponse"}}}}, "202": {"description": "Forecast is being generated asynchronously; poll until 200.", "content": {"application/json": {"schema": {"type": "object", "required": ["status", "app_uuid"], "properties": {"status": {"type": "string", "enum": ["generating"]}, "app_uuid": {"type": "string"}, "horizon_days": {"type": "integer"}, "platform": {"type": "string"}}}}}}, "400": {"$ref": "#/components/responses/ValidationError"}, "401": {"$ref": "#/components/responses/Unauthorized"}, "403": {"description": "A cache miss would start AI work, but the machine credential lacks apps:write. Read-only callers can use generate=false.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "404": {"$ref": "#/components/responses/NotFound"}, "429": {"$ref": "#/components/responses/CredentialRateLimited"}, "503": {"description": "Forecast cache unavailable; retry after the Retry-After delay.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}}}}, "/api/v1/analytics/sentiment-baseline": {"get": {"tags": ["Analytics"], "summary": "Tenant-wide sentiment baseline for alert configuration.", "description": "The sentiment of the workspace's apps, each app's newest analysis in the window weighted by the app's review count. An app with no analysis in the window, or no reviews, is left out. An app whose analysis could not be read fails the answer, so a baseline always covers every app that counts.", "operationId": "getSentimentBaseline", "parameters": [{"name": "window_days", "in": "query", "description": "How old, in days, the newest analysis of an app may be for the app to count.", "schema": {"type": "integer", "minimum": 1, "maximum": 365, "default": 30}}], "responses": {"200": {"description": "Baseline returned.", "content": {"application/json": {"schema": {"type": "object", "required": ["baseline"], "properties": {"baseline": {"$ref": "#/components/schemas/SentimentBaseline"}}}}}}, "400": {"description": "`VALIDATION_ERROR`: `window_days` is not a whole number from 1 to 365.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "401": {"$ref": "#/components/responses/Unauthorized"}, "402": {"$ref": "#/components/responses/SubscriptionRequired"}, "403": {"description": "`FORBIDDEN`: an API key without the `analytics:read` scope.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "404": {"description": "`NOT_FOUND`: no app of the workspace has an analysis in the window that counts, so there is no baseline yet.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "500": {"description": "`QUERY_FAILED`: the analysis of at least one app could not be read, so no baseline is answered over the others. `retryable` is true.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "503": {"$ref": "#/components/responses/ReadUnavailable"}}}}, "/api/v1/predictions/feature-impact": {"post": {"tags": ["Predictions"], "summary": "Predict the rating impact of one proposed feature.", "description": "Scores a single feature described in `feature_description`. Each call\nruns one AI analysis. A body without `app_uuid` and\n`feature_description` is rejected with 400.\n", "operationId": "postFeatureImpactPrediction", "security": [{"BearerAuth": []}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/FeatureImpactPredictionRequest"}}}}, "responses": {"200": {"description": "Feature impact predicted.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/PredictionResponse"}}}}, "400": {"$ref": "#/components/responses/ValidationError"}, "401": {"$ref": "#/components/responses/Unauthorized"}, "403": {"$ref": "#/components/responses/Forbidden"}, "500": {"$ref": "#/components/responses/InternalError"}, "503": {"$ref": "#/components/responses/ServiceUnavailable"}}}}, "/api/v1/predictions/issue-severity": {"post": {"tags": ["Predictions"], "summary": "Predict the severity of one issue reported in reviews.", "description": "Scores a single issue described in `issue_description`. Each call\nruns one AI analysis. A body without `app_uuid` and\n`issue_description` is rejected with 400.\n", "operationId": "postIssueSeverityPrediction", "security": [{"BearerAuth": []}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/IssueSeverityPredictionRequest"}}}}, "responses": {"200": {"description": "Severity predictions returned.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/PredictionResponse"}}}}, "400": {"$ref": "#/components/responses/ValidationError"}, "401": {"$ref": "#/components/responses/Unauthorized"}, "403": {"$ref": "#/components/responses/Forbidden"}, "500": {"$ref": "#/components/responses/InternalError"}, "503": {"$ref": "#/components/responses/ServiceUnavailable"}}}}, "/api/v1/predictions/model-status": {"get": {"tags": ["Predictions"], "summary": "Retrieve AI analysis engine status.", "description": "Returns the status of the LLM-based analysis system (Claude via AWS Bedrock).", "operationId": "getModelStatus", "responses": {"200": {"description": "AI analysis engine status returned.", "content": {"application/json": {"schema": {"type": "object", "additionalProperties": true}}}}, "401": {"$ref": "#/components/responses/Unauthorized"}}}}, "/api/v1/predictions/sentiment-trends": {"get": {"tags": ["Predictions"], "summary": "Get sentiment trend predictions.", "description": "Returns predicted sentiment trends for an app. A cache miss with the default generate=true invokes metered AI work; API keys and MCP OAuth grants then need both analytics:read and apps:write. generate=false reads only the cache. Signed-in dashboard users retain the existing generation behavior. The forecast is generated inside the request that misses the cache, one generation at a time for an app and platform: a request that arrives while another one is generating it answers 202, starts nothing and is charged nothing.", "operationId": "getSentimentTrends", "parameters": [{"name": "app_uuid", "in": "query", "required": true, "schema": {"type": "string"}, "description": "App UUID."}, {"name": "horizon_days", "in": "query", "schema": {"type": "integer", "enum": [7, 14, 30, 60, 90], "default": 30}, "description": "Prediction horizon in days. Only the five declared values are supported. The split is estimated once, for 90 days, and stored once. A shorter horizon is read from that estimate: the point on the straight line from `current_sentiment` to it, so a shorter horizon shows a smaller share of the same move and a stored forecast answers any horizon without new AI work. Without a measured `current_sentiment` there is no line, and the 90-day estimate is returned as it is; the response's `forecast_horizon_days` then says 90. How old a stored forecast may be still depends on the horizon: 12 hours for 7 and 14 days, 24 hours for the others."}, {"name": "metric", "in": "query", "schema": {"type": "string", "enum": ["all", "positive_percent", "negative_percent", "neutral_percent", "mixed_percent"], "default": "all"}, "description": "Specific metric to predict."}, {"name": "platform", "in": "query", "schema": {"type": "string", "enum": ["ios", "android", "all"], "default": "all"}, "description": "Platform filter."}, {"name": "force_refresh", "in": "query", "schema": {"type": "boolean", "default": false}, "description": "Force recalculation ignoring cache."}, {"name": "generate", "in": "query", "schema": {"type": "string", "enum": ["true", "false"], "default": "true"}, "description": "Set false to read a cached forecast only; 404 FORECAST_NOT_CACHED means none exists. force_refresh=true is invalid with generate=false."}], "responses": {"200": {"description": "Sentiment trend predictions.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/SentimentTrendsResponse"}}}}, "202": {"description": "Another request is generating this app's forecast, for this horizon or for another. Nothing was started or charged; ask again until 200.", "content": {"application/json": {"schema": {"type": "object", "required": ["status", "app_uuid"], "properties": {"status": {"type": "string", "enum": ["generating"]}, "app_uuid": {"type": "string"}, "horizon_days": {"type": "integer"}, "platform": {"type": "string"}}}}}}, "400": {"$ref": "#/components/responses/ValidationError"}, "401": {"$ref": "#/components/responses/Unauthorized"}, "403": {"description": "A cache miss would start AI work, but the machine credential lacks apps:write.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "404": {"$ref": "#/components/responses/NotFound"}, "429": {"$ref": "#/components/responses/CredentialRateLimited"}, "503": {"description": "Forecast cache unavailable; retry after the Retry-After delay. Also `SERVICE_UNAVAILABLE` when the app could not be read, which says nothing about whether it exists.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}}}}, "/api/v1/predictions/accuracy/report": {"get": {"tags": ["Predictions"], "summary": "Get recorded outcome checks.", "description": "Returns descriptive checks on the loaded resolved records and submitted outcomes, not independently verified forecast accuracy. The sample may omit records and combine prediction types, units and horizons. See the response descriptions for missing-error and uncalibrated-range limits.", "operationId": "getAccuracyReport", "parameters": [{"name": "app_uuid", "in": "query", "required": true, "schema": {"type": "string"}, "description": "UUID of the app to report on. Required so the report is scoped to an app the caller owns."}, {"name": "days", "in": "query", "schema": {"type": "integer", "minimum": 1, "maximum": 3650, "default": 90}, "description": "Number of days to analyze."}], "responses": {"200": {"description": "Accuracy report.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/AccuracyReportResponse"}}}}, "400": {"$ref": "#/components/responses/ValidationError"}, "401": {"$ref": "#/components/responses/Unauthorized"}, "403": {"$ref": "#/components/responses/Forbidden"}, "503": {"$ref": "#/components/responses/ServiceUnavailable"}}}}, "/api/v1/predictions/accuracy/metrics": {"get": {"tags": ["Predictions"], "summary": "Get recorded outcome metrics for a prediction type.", "description": "Returns descriptive metrics for the loaded resolved subset. Submitted outcomes are not independently verified, queries are not paginated, and horizons may be mixed. These figures do not establish forecast accuracy or calibrated range coverage.", "operationId": "getAccuracyMetrics", "parameters": [{"name": "app_uuid", "in": "query", "required": true, "schema": {"type": "string"}, "description": "UUID of the app to report on. Required so the metrics are scoped to an app the caller owns."}, {"name": "prediction_type", "in": "query", "schema": {"type": "string"}, "description": "Type of prediction to get metrics for."}, {"name": "days", "in": "query", "schema": {"type": "integer", "minimum": 1, "maximum": 3650, "default": 90}, "description": "Number of days to analyze."}], "responses": {"200": {"description": "Accuracy metrics.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/AccuracyMetricsResponse"}}}}, "400": {"$ref": "#/components/responses/ValidationError"}, "401": {"$ref": "#/components/responses/Unauthorized"}, "403": {"$ref": "#/components/responses/Forbidden"}, "503": {"$ref": "#/components/responses/ServiceUnavailable"}}}}, "/api/v1/predictions/accuracy/outcomes": {"post": {"tags": ["Predictions"], "summary": "Record a submitted prediction outcome.", "description": "Submits a value for recorded outcome checks. A submission can resolve every overdue pending record of the same type returned by the query; exact prediction, target, source, territory and horizon matching are not established. The query is not paginated and provenance is not independently verified.", "operationId": "postAccuracyOutcome", "security": [{"BearerAuth": []}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/RecordOutcomeRequest"}}}}, "responses": {"200": {"description": "Outcome recorded.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/GenericSuccess"}}}}, "400": {"$ref": "#/components/responses/ValidationError"}, "401": {"$ref": "#/components/responses/Unauthorized"}, "503": {"$ref": "#/components/responses/ServiceUnavailable"}}}}, "/api/v1/predictions/churn-risk": {"get": {"tags": ["Predictions"], "summary": "Get churn risk prediction for an app.", "description": "Returns a risk score computed from what the app's public reviews show: the average rating, the share of negative and positive reviews, the review count, the rating trend and the age of the last sync. The rating trend compares the average star rating of two equal windows of whole days whose stored reviews are complete, the windows the review-volume forecast compares, and is left out when the app has no such days to compare. No reply rate is scored: the App Store does not report developer replies, and Google Play reports one only on the reviews it serves when they are read. `factors` lists what raised the score. Tritonium reads public reviews only: it has no view of the app's users, so the score does not measure users leaving the app.", "operationId": "getChurnRisk", "parameters": [{"name": "app_uuid", "in": "query", "required": true, "schema": {"type": "string"}, "description": "UUID of the app to analyze."}], "responses": {"200": {"description": "Churn risk prediction.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ChurnRiskResponse"}}}}, "400": {"$ref": "#/components/responses/ValidationError"}, "401": {"$ref": "#/components/responses/Unauthorized"}, "404": {"$ref": "#/components/responses/NotFound"}, "503": {"$ref": "#/components/responses/ServiceUnavailable"}}}}, "/api/v1/predictions/review-volume": {"get": {"tags": ["Predictions"], "summary": "Get review volume predictions.", "description": "Returns predicted daily review counts for the next 30 days based on historical patterns and trends.", "operationId": "getReviewVolumePrediction", "parameters": [{"name": "app_uuid", "in": "query", "required": true, "schema": {"type": "string"}, "description": "UUID of the app to predict for."}, {"name": "horizon_days", "in": "query", "schema": {"type": "integer", "default": 14, "minimum": 1, "maximum": 30}, "description": "How many days ahead to forecast, up to 30. The default is 7 days. The projection uses an eight-week lookback."}], "responses": {"200": {"description": "Review volume predictions.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ReviewVolumePredictionResponse"}}}}, "400": {"$ref": "#/components/responses/ValidationError"}, "401": {"$ref": "#/components/responses/Unauthorized"}, "404": {"$ref": "#/components/responses/NotFound"}, "503": {"$ref": "#/components/responses/ServiceUnavailable"}}}}, "/api/v1/analytics/events": {"post": {"tags": ["Analytics"], "summary": "Record user analytics events.", "description": "Record user behavior analytics events from the frontend.\nAccepts events from both authenticated and anonymous users.\n", "operationId": "postAnalyticsEvents", "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/AnalyticsEventsRequest"}}}}, "responses": {"200": {"description": "At least one event was recorded. Returned even when part of the\nbatch was rejected, in which case `errors` lists the reasons.\n", "content": {"application/json": {"schema": {"type": "object", "properties": {"processed": {"type": "integer", "description": "Number of events recorded."}, "total": {"type": "integer", "description": "Number of events in the batch."}, "errors": {"type": "array", "description": "Reason strings for the events that were rejected,\ntruncated to the first 5. Absent when every event in the\nbatch was recorded.\n", "items": {"type": "string"}}}}}}}, "400": {"description": "No event in the batch could be recorded, or the body was malformed.\n"}, "429": {"description": "Rate limit exceeded."}}}}, "/api/v1/alerts": {"get": {"tags": ["Alerts"], "summary": "List alert notifications for the tenant.", "description": "The workspace's alerts, newest first, one page at a time. Every filter is applied by the API before the page is cut, so a filtered page holds matches from all of the workspace's alerts. A filtered call reads at most 2,000 alerts: when it stops there it answers the matches it found with a `next_cursor`, so a short page with a cursor does not mean there are no more matches.", "operationId": "getAlerts", "parameters": [{"name": "limit", "in": "query", "schema": {"type": "integer", "minimum": 1, "maximum": 100, "default": 25}, "description": "Page size. A non-integer, zero or negative value is rejected with 400; a value above the maximum is capped at it rather than rejected."}, {"name": "cursor", "in": "query", "schema": {"type": "string"}, "description": "The `next_cursor` of the previous page, with the same `app_uuid`. A cursor this listing did not issue, or one issued with another `app_uuid`, is rejected with 400 `INVALID_CURSOR`."}, {"name": "severity", "in": "query", "schema": {"type": "string", "enum": ["critical", "high", "medium", "low"]}, "description": "Only alerts of this severity, in any letter case. Any other value is rejected with 400 `INVALID_SEVERITY_FILTER`."}, {"name": "status", "in": "query", "schema": {"type": "string", "enum": ["unread", "read", "acknowledged", "snoozed"]}, "description": "Filter by status as of now, in any letter case. A snooze ends by itself when its `snooze_until` passes, so `unread` includes alerts whose snooze has ended and `snoozed` holds only alerts still snoozed. Any other value is rejected with 400 `INVALID_STATUS_FILTER`."}, {"name": "trigger_type", "in": "query", "schema": {"type": "string"}, "description": "Only alerts raised by this trigger type (an alert rule's `trigger_type`, such as `rating_drop`)."}, {"name": "app_uuid", "in": "query", "schema": {"type": "string", "format": "uuid"}, "description": "Scope the alert list to a single app. Omitted, the response covers every app in the tenant, so a caller who means \"one app\" and does not send this receives a tenant-wide aggregate. An id that is not one of the workspace's apps answers 404 `APP_NOT_FOUND`."}, {"name": "view", "in": "query", "schema": {"type": "string", "enum": ["full", "compact"], "default": "full"}, "description": "`full` returns each alert whole. `compact` returns the same page (same filters, order, `count` and `next_cursor`) with each alert reduced to `alert_id`, `alert_code`, `trigger_type`, `severity`, `status`, `app_uuid` and `triggered_at`, each only when the alert has it (a test alert has no `app_uuid`): what a count or a badge reads, without the text, `context` and delivery details. Any other value is rejected with 400 `INVALID_VIEW`."}], "responses": {"200": {"description": "Alerts returned. With `view=compact` each alert carries only the fields listed under `view`.", "content": {"application/json": {"schema": {"type": "object", "required": ["alerts", "next_cursor", "count"], "properties": {"alerts": {"type": "array", "items": {"$ref": "#/components/schemas/Alert"}}, "next_cursor": {"type": ["string", "null"], "description": "Send it as `cursor` for the next page. `null` when no older alert is left to read."}, "count": {"type": "integer", "minimum": 0, "description": "Number of alerts returned in this response, rather than the workspace total."}}}}}}, "400": {"description": "`VALIDATION_ERROR` (a `limit` that is not a whole number of at least 1), `INVALID_VIEW`, `INVALID_STATUS_FILTER`, `INVALID_SEVERITY_FILTER` or `INVALID_CURSOR`.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "401": {"$ref": "#/components/responses/Unauthorized"}, "402": {"$ref": "#/components/responses/SubscriptionRequired"}, "403": {"description": "`FORBIDDEN`: an API key without the `alerts:read` scope.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "404": {"description": "`APP_NOT_FOUND`: `app_uuid` is not one of the workspace's apps.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "500": {"$ref": "#/components/responses/InternalError"}, "503": {"$ref": "#/components/responses/SubscriptionUnverified"}}}, "post": {"tags": ["Alerts"], "summary": "Create alert (not supported).", "description": "Not supported: alerts are raised by the alert engine. An owner or admin always gets 501 `NOT_IMPLEMENTED`, and nothing is written.", "operationId": "createAlert", "security": [{"BearerAuth": []}], "responses": {"401": {"$ref": "#/components/responses/Unauthorized"}, "402": {"$ref": "#/components/responses/SubscriptionRequired"}, "403": {"$ref": "#/components/responses/Forbidden"}, "501": {"description": "`NOT_IMPLEMENTED`: manual alert creation is not supported.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "503": {"$ref": "#/components/responses/SubscriptionUnverified"}}}}, "/api/v1/alerts/summary": {"get": {"tags": ["Alerts"], "summary": "Count the tenant's alerts by status and severity.", "operationId": "getAlertSummary", "description": "One pass over the tenant's alerts, newest first. A status is the status as of now, as the `status` filter of `GET /api/v1/alerts` reads it: an alert whose snooze has ended counts as `unread`. The pass stops after 10,000 alerts or 5 seconds; `complete` is then `false` and every number is a lower bound (\"at least\"), never the total.", "responses": {"200": {"description": "Counts returned.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/AlertSummary"}}}}, "401": {"$ref": "#/components/responses/Unauthorized"}, "402": {"$ref": "#/components/responses/SubscriptionRequired"}, "403": {"description": "`FORBIDDEN`: an API key without the `alerts:read` scope.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "500": {"$ref": "#/components/responses/InternalError"}, "503": {"$ref": "#/components/responses/SubscriptionUnverified"}}}}, "/api/v1/alerts/mark-all-read": {"post": {"tags": ["Alerts"], "summary": "Mark every unread alert as read.", "operationId": "markAllAlertsRead", "description": "The bulk form of `PATCH /api/v1/alerts/{alertId}` with `mark_read`, and limited to the same roles (admin or owner). Every alert that reads as `unread` (including one whose snooze has ended) becomes `read`; alerts still snoozed, read or acknowledged are left alone. Each write is conditional on the status just read, so an alert acknowledged or snoozed meanwhile keeps that change. Safe to repeat. One call works through the alerts newest first for about 10 seconds; when it stops before the oldest, `remaining` is `true` and `next_cursor` continues with the older alerts. Alerts that arrive after the first call are newer than the cursor and stay unread.", "security": [{"BearerAuth": []}], "requestBody": {"required": false, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/MarkAllAlertsReadRequest"}}}}, "responses": {"200": {"description": "Unread alerts marked read.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/MarkAllAlertsReadResponse"}}}}, "400": {"$ref": "#/components/responses/ValidationError"}, "401": {"$ref": "#/components/responses/Unauthorized"}, "402": {"$ref": "#/components/responses/SubscriptionRequired"}, "403": {"$ref": "#/components/responses/Forbidden"}, "500": {"$ref": "#/components/responses/InternalError"}, "503": {"$ref": "#/components/responses/SubscriptionUnverified"}}}}, "/api/v1/alerts/{alertId}": {"get": {"tags": ["Alerts"], "summary": "Retrieve alert details.", "operationId": "getAlert", "parameters": [{"name": "alertId", "in": "path", "required": true, "schema": {"type": "string"}, "description": "The alert's `alert_id`. Letters, digits and hyphens only; any other value answers 404 `NOT_FOUND`."}], "responses": {"200": {"description": "The alert, whole, as a full listing returns it, under `alert`.", "content": {"application/json": {"schema": {"type": "object", "required": ["alert"], "properties": {"alert": {"$ref": "#/components/schemas/Alert"}}}}}}, "401": {"$ref": "#/components/responses/Unauthorized"}, "402": {"$ref": "#/components/responses/SubscriptionRequired"}, "403": {"description": "`FORBIDDEN`: an API key without the `alerts:read` scope.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "404": {"description": "`ALERT_NOT_FOUND`: the workspace has no alert with this id.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "500": {"$ref": "#/components/responses/InternalError"}, "503": {"$ref": "#/components/responses/SubscriptionUnverified"}}}, "patch": {"tags": ["Alerts"], "summary": "Update alert status or acknowledgement metadata.", "description": "Requires the owner or admin role. Acknowledging also records `acknowledged_at` and `acknowledged_by` on the alert.", "operationId": "patchAlert", "security": [{"BearerAuth": []}], "parameters": [{"name": "alertId", "in": "path", "required": true, "schema": {"type": "string"}, "description": "The alert's `alert_id`. Letters, digits and hyphens only; any other value answers 404 `NOT_FOUND`."}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/AlertUpdateRequest"}}}}, "responses": {"200": {"description": "Alert updated. `alert` is the whole alert after the change, as a full listing returns it.", "content": {"application/json": {"schema": {"type": "object", "required": ["alert"], "properties": {"alert": {"$ref": "#/components/schemas/Alert"}}}}}}, "400": {"description": "`NO_UPDATES` (no recognised field), `INVALID_STATUS`, `INVALID_SNOOZE`, or `VALIDATION_ERROR` for a body that is not a JSON object.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "401": {"$ref": "#/components/responses/Unauthorized"}, "402": {"$ref": "#/components/responses/SubscriptionRequired"}, "403": {"description": "`FORBIDDEN`: the caller is not an owner or admin, or is an API key. Changing alerts needs a scope no API key can be given, so a key is always refused.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "404": {"description": "`ALERT_NOT_FOUND`: the workspace has no alert with this id.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "500": {"$ref": "#/components/responses/InternalError"}, "503": {"$ref": "#/components/responses/SubscriptionUnverified"}}}, "delete": {"tags": ["Alerts"], "summary": "Delete alert.", "description": "Requires the owner or admin role.", "operationId": "deleteAlert", "security": [{"BearerAuth": []}], "parameters": [{"name": "alertId", "in": "path", "required": true, "schema": {"type": "string"}, "description": "The alert's `alert_id`. Letters, digits and hyphens only; any other value answers 404 `NOT_FOUND`."}], "responses": {"200": {"description": "Alert deleted. Always `{\"deleted\": true}`.", "content": {"application/json": {"schema": {"type": "object", "required": ["deleted"], "properties": {"deleted": {"type": "boolean", "description": "Always `true`."}}}}}}, "401": {"$ref": "#/components/responses/Unauthorized"}, "402": {"$ref": "#/components/responses/SubscriptionRequired"}, "403": {"$ref": "#/components/responses/Forbidden"}, "404": {"description": "`ALERT_NOT_FOUND`: the workspace has no alert with this id.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "500": {"$ref": "#/components/responses/InternalError"}, "503": {"$ref": "#/components/responses/SubscriptionUnverified"}}}, "put": {"tags": ["Alerts"], "summary": "Update alert.", "description": "The same update as `PATCH /api/v1/alerts/{alertId}`, with the same body, rules and answer.", "operationId": "updateAlert", "security": [{"BearerAuth": []}], "parameters": [{"name": "alertId", "in": "path", "required": true, "schema": {"type": "string"}, "description": "The alert's `alert_id`. Letters, digits and hyphens only; any other value answers 404 `NOT_FOUND`."}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/AlertUpdateRequest"}}}}, "responses": {"200": {"description": "Alert updated. `alert` is the whole alert after the change, as a full listing returns it.", "content": {"application/json": {"schema": {"type": "object", "required": ["alert"], "properties": {"alert": {"$ref": "#/components/schemas/Alert"}}}}}}, "400": {"description": "`NO_UPDATES` (no recognised field), `INVALID_STATUS`, `INVALID_SNOOZE`, or `VALIDATION_ERROR` for a body that is not a JSON object.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "401": {"$ref": "#/components/responses/Unauthorized"}, "402": {"$ref": "#/components/responses/SubscriptionRequired"}, "403": {"description": "`FORBIDDEN`: the caller is not an owner or admin, or is an API key. Changing alerts needs a scope no API key can be given, so a key is always refused.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "404": {"description": "`ALERT_NOT_FOUND`: the workspace has no alert with this id.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "500": {"$ref": "#/components/responses/InternalError"}, "503": {"$ref": "#/components/responses/SubscriptionUnverified"}}}}, "/api/v1/alert-rules": {"get": {"tags": ["Alerts"], "summary": "List alert rules.", "description": "The workspace's alert rules. Requires the owner or admin role. The rules the workspace saved come first, newest first. After them comes each built-in rule (`is_default: true`) whose trigger type has no saved rule: a saved rule replaces every built-in rule of its trigger type, here and in the alert engine. A workspace with no saved rule gets the 12 built-in rules. Built-in rules are not stored, so they have no `name`, `app_filter`, `conditions`, `routing`, `created_by`, `created_at` or `updated_at`, and only some have `window_minutes` or `baseline_days`. The answer holds every rule; it is not paged.", "operationId": "getAlertRules", "security": [{"BearerAuth": []}], "parameters": [{"in": "query", "name": "trigger_type", "schema": {"type": "string", "enum": ["rating_drop", "sentiment_spike_negative", "volume_spike", "volume_drop", "crash_spike", "sync_failure", "critical_technical_issue", "low_rating_spike", "negative_sentiment_spike", "custom_category_spike", "sla_warning", "sla_critical", "sla_breach", "release_sentiment_drop"]}, "description": "Filter by trigger type. Any other value is rejected with 400 `INVALID_TRIGGER_TYPE`."}, {"in": "query", "name": "severity", "schema": {"type": "string", "enum": ["critical", "high", "medium", "low"]}, "description": "Filter by severity level, in any letter case. A rule stored without a severity counts as `medium`. Any other value is rejected with 400 `INVALID_SEVERITY`."}, {"in": "query", "name": "enabled", "schema": {"type": "boolean"}, "description": "Filter by enabled status. A rule stored without `enabled` counts as enabled. A value that is not a boolean is rejected with 400 `INVALID_ENABLED`."}], "responses": {"200": {"description": "List of alert rules.", "content": {"application/json": {"schema": {"type": "object", "required": ["rules"], "properties": {"rules": {"type": "array", "items": {"$ref": "#/components/schemas/AlertRule"}}}}}}}, "400": {"description": "`INVALID_TRIGGER_TYPE`, `INVALID_SEVERITY` or `INVALID_ENABLED`: a filter value the API does not accept.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "401": {"$ref": "#/components/responses/Unauthorized"}, "403": {"description": "`FORBIDDEN`: the caller is not an owner or admin.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "500": {"$ref": "#/components/responses/InternalError"}}}, "post": {"tags": ["Alerts"], "summary": "Create an alert rule.", "description": "Save an alert rule. Requires the owner or admin role. From then on the rule replaces every built-in rule of its trigger type. Omitted fields get these values: `name` the trigger type followed by \" rule\" (for example `rating_drop rule`), `window_minutes` 60, `cooldown_minutes` 120, `severity` `medium`, `destinations` `[in_app]`, `enabled` true and `app_filter` empty (every app).", "operationId": "postAlertRule", "security": [{"BearerAuth": []}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/CreateAlertRuleRequest"}}}}, "responses": {"201": {"description": "Alert rule created.", "content": {"application/json": {"schema": {"type": "object", "required": ["rule"], "properties": {"rule": {"$ref": "#/components/schemas/AlertRule"}}}}}}, "400": {"description": "`INVALID_REQUEST`: no body. `VALIDATION_ERROR`: no `trigger_type` or `threshold`, or a field that is out of range or of the wrong type. `INTEGRATION_NOT_IMPLEMENTED`: a `teams` destination in a workspace without the Teams Workflows connection. Nothing is saved.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "401": {"$ref": "#/components/responses/Unauthorized"}, "403": {"description": "`FORBIDDEN`: the caller is not an owner or admin.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "404": {"description": "`APP_NOT_FOUND`: an id in `app_filter` is not one of the workspace's apps. Nothing is saved.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "500": {"description": "`CREATE_FAILED`: the rule could not be saved.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}}}}, "/api/v1/alert-rules/{rule_id}": {"put": {"tags": ["Alerts"], "summary": "Update alert rule.", "description": "Change a saved alert rule. Requires the owner or admin role. Only the fields sent change, as with `PATCH`, which does the same. A built-in rule (`is_default: true`) cannot be changed (400); save a rule of the same trigger type instead. A body that names none of the updatable fields changes nothing and answers the rule as it is.", "operationId": "putAlertRule", "security": [{"BearerAuth": []}], "parameters": [{"in": "path", "name": "rule_id", "required": true, "schema": {"type": "string"}, "description": "The alert rule ID."}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/UpdateAlertRuleRequest"}}}}, "responses": {"200": {"description": "Alert rule updated.", "content": {"application/json": {"schema": {"type": "object", "required": ["rule"], "properties": {"rule": {"$ref": "#/components/schemas/AlertRule"}}}}}}, "400": {"description": "`INVALID_REQUEST`: no body, or an empty one. `VALIDATION_ERROR`: a built-in rule, or a field that is out of range or of the wrong type. `INTEGRATION_NOT_IMPLEMENTED`: a `teams` destination in a workspace without the Teams Workflows connection, or enabling a rule that still sends to one. Nothing is changed.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "401": {"$ref": "#/components/responses/Unauthorized"}, "403": {"description": "`FORBIDDEN`: the caller is not an owner or admin.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "404": {"description": "`NOT_FOUND`: the workspace has no saved rule with this id. `APP_NOT_FOUND`: an id in `app_filter` is not one of the workspace's apps. Nothing is changed.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "500": {"description": "`UPDATE_FAILED`: the rule could not be saved.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}}}, "delete": {"tags": ["Alerts"], "summary": "Delete alert rule.", "description": "Delete a saved alert rule. Requires the owner or admin role. A built-in rule cannot be deleted (400); it can be replaced by saving a rule of its trigger type. Once the last saved rule of a trigger type is deleted, the built-in rules of that type apply again.", "operationId": "deleteAlertRule", "security": [{"BearerAuth": []}], "parameters": [{"in": "path", "name": "rule_id", "required": true, "schema": {"type": "string"}, "description": "The alert rule ID."}], "responses": {"200": {"description": "Alert rule deleted. Always `{\"message\": \"Alert rule deleted\", \"rule_id\": <the id>}`.", "content": {"application/json": {"schema": {"type": "object", "properties": {"success": {"type": "boolean", "deprecated": true, "description": "Not returned and never was: the delete has answered `message` and `rule_id` since its first version."}, "message": {"type": "string", "description": "Always `Alert rule deleted`."}, "rule_id": {"type": "string", "description": "The id of the deleted rule."}}}}}}, "400": {"description": "`VALIDATION_ERROR`: a built-in rule, which cannot be deleted.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "401": {"$ref": "#/components/responses/Unauthorized"}, "403": {"description": "`FORBIDDEN`: the caller is not an owner or admin.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "404": {"description": "`NOT_FOUND`: the workspace has no saved rule with this id.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}}}, "patch": {"tags": ["Alerts"], "summary": "Update alert rule (partial).", "description": "The same partial update as `PUT /api/v1/alert-rules/{rule_id}`, with the same body, rules and answer.", "operationId": "updateRule", "security": [{"BearerAuth": []}], "parameters": [{"in": "path", "name": "rule_id", "required": true, "schema": {"type": "string"}, "description": "The alert rule ID."}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/UpdateAlertRuleRequest"}}}}, "responses": {"200": {"description": "Alert rule updated.", "content": {"application/json": {"schema": {"type": "object", "required": ["rule"], "properties": {"rule": {"$ref": "#/components/schemas/AlertRule"}}}}}}, "400": {"description": "`INVALID_REQUEST`: no body, or an empty one. `VALIDATION_ERROR`: a built-in rule, or a field that is out of range or of the wrong type. `INTEGRATION_NOT_IMPLEMENTED`: a `teams` destination in a workspace without the Teams Workflows connection, or enabling a rule that still sends to one. Nothing is changed.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "401": {"$ref": "#/components/responses/Unauthorized"}, "403": {"description": "`FORBIDDEN`: the caller is not an owner or admin.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "404": {"description": "`NOT_FOUND`: the workspace has no saved rule with this id. `APP_NOT_FOUND`: an id in `app_filter` is not one of the workspace's apps. Nothing is changed.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "500": {"description": "`UPDATE_FAILED`: the rule could not be saved.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}}}}, "/api/v1/sla/config": {"get": {"tags": ["SLA"], "summary": "Get SLA configuration.", "description": "Get the SLA (Service Level Agreement) configuration for the current tenant. Returns response time targets by rating and sentiment, plus alert thresholds.", "operationId": "getSlaConfig", "responses": {"200": {"description": "SLA configuration returned. A workspace that has never saved one gets the defaults, with tracking off and `updated_at` and `updated_by` null.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/SLAConfig"}}}}, "401": {"$ref": "#/components/responses/Unauthorized"}, "402": {"$ref": "#/components/responses/SubscriptionRequired"}, "403": {"$ref": "#/components/responses/SlaKeyForbidden"}, "500": {"description": "`INTERNAL_ERROR`: the stored configuration could not be read. Retry.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "503": {"$ref": "#/components/responses/SubscriptionUnverified"}}}, "put": {"tags": ["SLA"], "summary": "Update SLA configuration.", "description": "Update the SLA configuration for the current tenant. Allows setting response time targets by rating/sentiment and alert thresholds. Only the members sent are changed, but `targets.by_rating` and `targets.by_sentiment` each replace the stored map as a whole.", "operationId": "putSlaConfig", "security": [{"BearerAuth": []}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/UpdateSLAConfigRequest"}}}}, "responses": {"200": {"description": "SLA configuration updated. The whole configuration after the change, and `message`.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/SLAConfigUpdated"}}}}, "400": {"description": "`VALIDATION_ERROR`, and the message names what is wrong: no body or an empty one, a body that is not a JSON object, `alerts` or `targets` (or one of its maps) that is not an object, a threshold that is not a whole number from 0 to 100, `critical_at_percent` not above `warn_at_percent` (as sent, or with the stored value of the other), a target that is not a whole number of minutes from 0, `sla_enabled` that is not true or false, `destinations` that is not a list of text, Microsoft Teams in `destinations`, or a workspace that does not exist.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "401": {"$ref": "#/components/responses/Unauthorized"}, "402": {"$ref": "#/components/responses/SubscriptionRequired"}, "403": {"description": "`FORBIDDEN`: a member, or an API key. Only an admin or owner can change the SLA configuration, and no API key can: it needs `analytics:write`, which no key can hold.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "500": {"description": "`INTERNAL_ERROR`: the configuration could not be read or saved. Nothing was changed if it could not be read. Retry.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "503": {"$ref": "#/components/responses/SubscriptionUnverified"}}}}, "/api/v1/sla/metrics": {"get": {"tags": ["SLA"], "summary": "Get SLA compliance metrics.", "description": "Get SLA compliance metrics: replies this workspace posted through Tritonium timed against its targets, reviews past their target with no reply seen, and compliance percentages. A reply written in App Store Connect is not reported by the store, and one written in Google Play Console only on the reviews Google Play serves when they are read, so such a review can count as past target. Metrics can be filtered by app and time period.", "operationId": "getSlaMetrics", "parameters": [{"in": "query", "name": "app_uuid", "schema": {"type": "string"}, "description": "Filter metrics by specific app UUID."}, {"in": "query", "name": "days", "schema": {"type": "integer", "minimum": 1, "maximum": 365, "default": 30}, "description": "Number of days to analyze (default 30)."}], "responses": {"200": {"description": "SLA metrics returned. With SLA tracking off every count is 0, the rates and times are null and `by_rating`, `by_status` and `trend` are empty.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/SLAMetrics"}}}}, "400": {"description": "`VALIDATION_ERROR`: `days` is not a whole number from 1 to 365.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "401": {"$ref": "#/components/responses/Unauthorized"}, "402": {"$ref": "#/components/responses/SubscriptionRequired"}, "403": {"$ref": "#/components/responses/SlaKeyForbidden"}, "404": {"description": "`APP_NOT_FOUND`: `app_uuid` is not an app of this workspace.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "500": {"description": "`INTERNAL_ERROR`: the configuration, the apps or the reviews could not be read, so no figure is given. Retry.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "503": {"$ref": "#/components/responses/SubscriptionUnverified"}}}}, "/api/v1/sla/at-risk": {"get": {"tags": ["SLA"], "summary": "Get at-risk reviews.", "description": "Get reviews that are approaching SLA breach (warning/critical status) or have already breached their SLA targets. Useful for prioritizing review responses.", "operationId": "getSlaAtRisk", "parameters": [{"in": "query", "name": "app_uuid", "schema": {"type": "string"}, "description": "Filter by specific app UUID."}, {"in": "query", "name": "limit", "schema": {"type": "integer", "minimum": 1, "maximum": 200, "default": 50}, "description": "Maximum number of reviews to return."}], "responses": {"200": {"description": "At-risk reviews returned.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/AtRiskReviewsResponse"}}}}, "400": {"$ref": "#/components/responses/ValidationError"}, "401": {"$ref": "#/components/responses/Unauthorized"}}}}, "/api/v1/webhooks": {"get": {"tags": ["Webhooks"], "summary": "List webhooks.", "description": "List all configured webhooks for the current tenant. Webhooks allow receiving real-time notifications about Tritonium events to external endpoints.", "operationId": "getWebhooks", "responses": {"200": {"description": "List of webhooks.", "content": {"application/json": {"schema": {"type": "object", "required": ["webhooks", "count", "trigger_events"], "properties": {"webhooks": {"type": "array", "items": {"$ref": "#/components/schemas/Webhook"}}, "count": {"type": "integer", "description": "Number of webhooks configured for this tenant."}, "trigger_events": {"type": "object", "additionalProperties": {"type": "string"}, "description": "The catalogue of deliverable events, as event name -> human-readable description. This is the authoritative list `POST /api/v1/webhooks` validates `trigger_events` against, so clients should build their event picker from it rather than hard-coding names."}}}}}}, "401": {"$ref": "#/components/responses/Unauthorized"}, "402": {"$ref": "#/components/responses/SubscriptionRequired"}, "503": {"$ref": "#/components/responses/ReadUnavailable"}}}, "post": {"tags": ["Webhooks"], "summary": "Create a webhook.", "description": "Create a new webhook configuration. The URL must use HTTPS. Specify which events should trigger the webhook delivery. Requires the owner or admin role. A new webhook is enabled; the request has no `enabled` field.", "operationId": "postWebhook", "security": [{"BearerAuth": []}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/CreateWebhookRequest"}}}}, "responses": {"201": {"description": "Webhook created.", "content": {"application/json": {"schema": {"type": "object", "required": ["webhook"], "properties": {"webhook": {"$ref": "#/components/schemas/Webhook"}}}}}}, "400": {"$ref": "#/components/responses/ValidationError"}, "401": {"$ref": "#/components/responses/Unauthorized"}, "402": {"$ref": "#/components/responses/SubscriptionRequired"}, "403": {"$ref": "#/components/responses/Forbidden"}, "503": {"$ref": "#/components/responses/SubscriptionUnverified"}}}}, "/api/v1/webhooks/{webhook_id}": {"get": {"tags": ["Webhooks"], "summary": "Get webhook details.", "description": "Get details of a specific webhook including delivery statistics.", "operationId": "getWebhook", "parameters": [{"in": "path", "name": "webhook_id", "required": true, "schema": {"type": "string"}, "description": "The webhook ID."}], "responses": {"200": {"description": "Webhook details.", "content": {"application/json": {"schema": {"type": "object", "required": ["webhook"], "properties": {"webhook": {"$ref": "#/components/schemas/Webhook"}}}}}}, "401": {"$ref": "#/components/responses/Unauthorized"}, "402": {"$ref": "#/components/responses/SubscriptionRequired"}, "404": {"$ref": "#/components/responses/NotFound"}, "503": {"$ref": "#/components/responses/SubscriptionUnverified"}}}, "put": {"tags": ["Webhooks"], "summary": "Update webhook.", "description": "Update webhook configuration including URL, events, or authentication. Requires the owner or admin role. A body that names none of the updatable fields changes nothing and answers the webhook as it is.", "operationId": "putWebhook", "security": [{"BearerAuth": []}], "parameters": [{"in": "path", "name": "webhook_id", "required": true, "schema": {"type": "string"}, "description": "The webhook ID."}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/UpdateWebhookRequest"}}}}, "responses": {"200": {"description": "Webhook updated.", "content": {"application/json": {"schema": {"type": "object", "required": ["webhook"], "properties": {"webhook": {"$ref": "#/components/schemas/Webhook"}}}}}}, "400": {"$ref": "#/components/responses/ValidationError"}, "401": {"$ref": "#/components/responses/Unauthorized"}, "402": {"$ref": "#/components/responses/SubscriptionRequired"}, "403": {"$ref": "#/components/responses/Forbidden"}, "404": {"$ref": "#/components/responses/NotFound"}, "503": {"$ref": "#/components/responses/SubscriptionUnverified"}}}, "delete": {"tags": ["Webhooks"], "summary": "Delete webhook.", "description": "Delete a webhook. This stops all future deliveries to this endpoint. Requires the owner or admin role.", "operationId": "deleteWebhook", "security": [{"BearerAuth": []}], "parameters": [{"in": "path", "name": "webhook_id", "required": true, "schema": {"type": "string"}, "description": "The webhook ID."}], "responses": {"200": {"description": "Webhook deleted.", "content": {"application/json": {"schema": {"type": "object", "required": ["success", "message"], "properties": {"success": {"type": "boolean"}, "message": {"type": "string"}}}}}}, "401": {"$ref": "#/components/responses/Unauthorized"}, "402": {"$ref": "#/components/responses/SubscriptionRequired"}, "403": {"$ref": "#/components/responses/Forbidden"}, "404": {"$ref": "#/components/responses/NotFound"}, "503": {"$ref": "#/components/responses/SubscriptionUnverified"}}}}, "/api/v1/webhooks/{webhook_id}/test": {"post": {"tags": ["Webhooks"], "summary": "Test webhook.", "description": "Send a test event to the webhook endpoint to verify connectivity and authentication, at once and with the same retries as a real delivery. Requires the owner or admin role. The answer is 200 whether or not the endpoint accepted the delivery: read `success`, and `result` for the delivery itself. The delivery is recorded in the webhook's logs and counted in its delivery totals.", "operationId": "postWebhookTest", "security": [{"BearerAuth": []}], "parameters": [{"in": "path", "name": "webhook_id", "required": true, "schema": {"type": "string"}, "description": "The webhook ID."}], "requestBody": {"required": false, "content": {"application/json": {"schema": {"type": "object", "description": "What the test delivery carries; every member is optional, and no body sends a plain test event. The delivery always adds `webhook_id`, `timestamp` and `is_test: true`.", "properties": {"event_type": {"type": "string", "description": "The event the delivery is labelled with. `review.new`, `review.updated`, `insight.new` and `alert.triggered` send a sample of that event, filled from the members below; any other value sends the plain test message under that label. Left out, `test`."}, "message": {"type": "string", "description": "Left out, `This is a test webhook from Tritonium`."}, "data": {"type": "object", "additionalProperties": true, "description": "Merged over the sample, so its members replace the sample's."}, "app_name": {"type": "string", "description": "`review.new` and `review.updated`."}, "rating": {"type": "integer", "description": "`review.new`, and `review.updated` as its new rating."}, "old_rating": {"type": "integer", "description": "`review.updated`."}, "title": {"type": "string", "description": "`review.new` and `insight.new`."}, "content": {"type": "string", "description": "`review.new`, `review.updated`, and `insight.new` as its description. Left out, the message."}, "author": {"type": "string", "description": "`review.new`."}, "platform": {"type": "string", "description": "`review.new`."}, "insight_type": {"type": "string", "description": "`insight.new`."}, "confidence": {"type": "number", "description": "`insight.new`."}, "review_count": {"type": "integer", "description": "`insight.new`."}, "alert_name": {"type": "string", "description": "`alert.triggered`."}, "condition": {"type": "string", "description": "`alert.triggered`."}}}}}}, "responses": {"200": {"description": "Test delivery result.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/WebhookTestResponse"}}}}, "401": {"$ref": "#/components/responses/Unauthorized"}, "402": {"$ref": "#/components/responses/SubscriptionRequired"}, "403": {"$ref": "#/components/responses/Forbidden"}, "404": {"$ref": "#/components/responses/NotFound"}, "503": {"$ref": "#/components/responses/SubscriptionUnverified"}}}}, "/api/v1/webhooks/{webhook_id}/logs": {"get": {"tags": ["Webhooks"], "summary": "Get webhook delivery logs.", "description": "Delivery logs for a webhook (kept 30 days), newest first, showing success/failure status, response codes, and retry attempts. `next_cursor` is set only when older deliveries exist.", "operationId": "getWebhookLogs", "parameters": [{"in": "path", "name": "webhook_id", "required": true, "schema": {"type": "string"}, "description": "The webhook ID."}, {"in": "query", "name": "limit", "schema": {"type": "integer", "minimum": 1, "maximum": 50, "default": 10}, "description": "Maximum number of logs to return. Values above 50 are treated as 50."}, {"$ref": "#/components/parameters/ListCursor"}], "responses": {"200": {"description": "Delivery logs.", "content": {"application/json": {"schema": {"type": "object", "required": ["logs", "count", "webhook_id", "next_cursor", "has_more"], "properties": {"logs": {"type": "array", "items": {"$ref": "#/components/schemas/WebhookDeliveryLog"}}, "count": {"type": "integer"}, "webhook_id": {"type": "string"}, "next_cursor": {"type": ["string", "null"], "description": "Pass as `cursor` for older deliveries; `null` on the last page."}, "has_more": {"type": "boolean", "description": "`true` exactly when `next_cursor` is set."}}}}}}, "400": {"$ref": "#/components/responses/ValidationError"}, "401": {"$ref": "#/components/responses/Unauthorized"}, "402": {"$ref": "#/components/responses/SubscriptionRequired"}, "404": {"$ref": "#/components/responses/NotFound"}, "429": {"$ref": "#/components/responses/CredentialRateLimited"}, "503": {"$ref": "#/components/responses/SubscriptionUnverified"}}}}, "/api/v1/competitive-intelligence/search": {"post": {"tags": ["Competitive Intelligence"], "summary": "Search for competitor apps.", "description": "Search app stores for potential competitor apps to track. Returns app metadata including name, developer, rating, and icon.\n\nAn empty `results` array means the store answered and had no match. A store that could not be reached is a `503 STORE_UNAVAILABLE` instead, so a transient store outage is never presented to the user as \"no competitors found\"; retry the request.", "operationId": "postCompetitiveSearch", "security": [{"BearerAuth": []}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/CompetitorSearchRequest"}}}}, "responses": {"200": {"description": "Search results.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/CompetitorSearchResponse"}}}}, "400": {"$ref": "#/components/responses/ValidationError"}, "401": {"$ref": "#/components/responses/Unauthorized"}, "503": {"$ref": "#/components/responses/ServiceUnavailable"}}}}, "/api/v1/competitive-intelligence/competitors": {"get": {"tags": ["Competitive Intelligence"], "summary": "List tracked competitors.", "description": "List all competitor apps being tracked for the current tenant.", "operationId": "getTrackedCompetitors", "responses": {"200": {"description": "List of competitors.", "content": {"application/json": {"schema": {"type": "object", "properties": {"competitors": {"type": "array", "items": {"$ref": "#/components/schemas/TrackedCompetitor"}}, "count": {"type": "integer"}}}}}}, "401": {"$ref": "#/components/responses/Unauthorized"}}}, "post": {"tags": ["Competitive Intelligence"], "summary": "Add a competitor.", "description": "Add a competitor app to track. Automatically fetches initial reviews from the app store. Reviews are updated daily via background job. `external_app_id` is looked up in the named store first: an id the store has no app for is rejected with `400`, so a competitor that could never be synced is never created. If the store cannot be reached the competitor is still registered. When `name`/`developer`/`icon_url` are omitted and the lookup succeeds, the store's own values are used.", "operationId": "addTrackedCompetitor", "security": [{"BearerAuth": []}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/AddCompetitorRequest"}}}}, "responses": {"201": {"description": "Competitor added. The competitor is returned unwrapped: this route responds with the record itself, not a `{competitor: \u2026}` envelope, and carries no initial review count.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/TrackedCompetitor"}}}}, "400": {"$ref": "#/components/responses/ValidationError"}, "401": {"$ref": "#/components/responses/Unauthorized"}, "403": {"$ref": "#/components/responses/Forbidden"}}}}, "/api/v1/competitive-intelligence/competitors/{platform}/{external_id}": {"delete": {"tags": ["Competitive Intelligence"], "summary": "Remove a competitor.", "description": "Stop tracking a competitor app and remove its data.", "operationId": "deleteCompetitor", "security": [{"BearerAuth": []}], "parameters": [{"in": "path", "name": "platform", "required": true, "schema": {"type": "string", "enum": ["app_store", "google_play", "steam"]}, "description": "Platform of the competitor app."}, {"in": "path", "name": "external_id", "required": true, "schema": {"type": "string"}, "description": "External app ID (iTunes ID, package name, etc.)."}], "responses": {"200": {"description": "Competitor removed.", "content": {"application/json": {"schema": {"type": "object", "properties": {"success": {"type": "boolean"}, "message": {"type": "string"}}}}}}, "401": {"$ref": "#/components/responses/Unauthorized"}, "403": {"$ref": "#/components/responses/Forbidden"}, "404": {"$ref": "#/components/responses/NotFound"}}}}, "/api/v1/competitive-intelligence/competitors/{competitor_id}/fetch-reviews": {"post": {"tags": ["Competitive Intelligence"], "summary": "Fetch competitor reviews.", "description": "Trigger fetching of competitor reviews from the app store. Uses caching to avoid redundant fetches (6-hour cooldown).\n\nA store that could not be read, for the listing or the reviews, is a `502 FETCH_FAILED` and nothing is saved, so a failed read never replaces the competitor's rating or stored reviews; retry the request. No reviews at all from a listing that is still there, on a storefront whose previous fetch had reviews and was saved less than seven days ago, is such a read: stores answer a throttled read with an empty page. Otherwise, including after seven days of such reads, a store that answered with no reviews is a `200` with `reviews_fetched: 0`.", "operationId": "postCompetitorFetchReviews", "security": [{"BearerAuth": []}], "parameters": [{"in": "path", "name": "competitor_id", "required": true, "schema": {"type": "string"}, "description": "The competitor ID."}], "requestBody": {"content": {"application/json": {"schema": {"type": "object", "properties": {"max_reviews": {"type": "integer", "minimum": 1, "maximum": 600, "default": 500, "description": "Maximum reviews to fetch."}, "country": {"type": "string", "default": "us", "description": "Country code for reviews."}, "force_refresh": {"type": "boolean", "default": false, "description": "Bypass cache and force fresh fetch."}}}}}}, "responses": {"200": {"description": "Fetch result.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/CompetitorFetchResult"}}}}, "401": {"$ref": "#/components/responses/Unauthorized"}, "403": {"$ref": "#/components/responses/Forbidden"}, "404": {"$ref": "#/components/responses/NotFound"}, "502": {"$ref": "#/components/responses/BadGateway"}}}}, "/api/v1/competitive-intelligence/competitors/{competitor_id}/reviews": {"get": {"tags": ["Competitive Intelligence"], "summary": "Get competitor reviews.", "description": "The reviews fetched from a competitor's public store listing, newest first, one page at a time. There is no filter: a row carries its star rating and no sentiment.", "operationId": "getCompetitorReviews", "parameters": [{"in": "path", "name": "competitor_id", "required": true, "schema": {"type": "string"}, "description": "The competitor ID."}, {"in": "query", "name": "limit", "schema": {"type": "integer", "minimum": 1, "maximum": 100, "default": 50}, "description": "Maximum reviews to return on this page."}, {"in": "query", "name": "cursor", "schema": {"type": "string"}, "description": "Opaque cursor from the previous page's `cursor` field. Omit for the first page. Reviews are returned newest first; page until `has_more` is false. A cursor minted for a different competitor is rejected with `INVALID_CURSOR` rather than silently replaying the first page."}], "responses": {"200": {"description": "Competitor reviews.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/CompetitorReviewsResponse"}}}}, "400": {"$ref": "#/components/responses/BadRequest"}, "401": {"$ref": "#/components/responses/Unauthorized"}, "404": {"$ref": "#/components/responses/NotFound"}}}}, "/api/v1/competitive-intelligence/competitors/{competitor_id}/stats": {"get": {"tags": ["Competitive Intelligence"], "summary": "Get competitor statistics.", "description": "The figures from the latest fetch of a competitor's public store listing: the average rating, the rating count and the fetched reviews by star rating.", "operationId": "getCompetitorStats", "parameters": [{"in": "path", "name": "competitor_id", "required": true, "schema": {"type": "string"}, "description": "The competitor ID."}], "responses": {"200": {"description": "Competitor statistics.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/CompetitorStats"}}}}, "401": {"$ref": "#/components/responses/Unauthorized"}, "404": {"$ref": "#/components/responses/NotFound"}}}}, "/api/v1/competitive-intelligence/competitors/{competitor_id}/insights": {"get": {"tags": ["Competitive Intelligence"], "summary": "Get competitor insights.", "description": "A short summary of the competitor's public store listing, written at the latest fetch: its store rating and rating count, and the share of the fetched reviews rated 1 or 2 stars. The sentences are computed from those figures; no model writes them, and `insights` is in English whatever the workspace's language. `insight_parts` carries the same sentences as template codes with their values, so a client can write each one in its own language and number format.", "operationId": "getCompetitorInsights", "parameters": [{"in": "path", "name": "competitor_id", "required": true, "schema": {"type": "string"}, "description": "The competitor ID."}], "responses": {"200": {"description": "Competitor insights.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/CompetitorInsights"}}}}, "401": {"$ref": "#/components/responses/Unauthorized"}, "404": {"$ref": "#/components/responses/NotFound"}}}}, "/api/v1/competitive-intelligence/competitors/{competitor_id}/rating-history": {"get": {"tags": ["Competitive Intelligence"], "summary": "Get competitor rating history.", "description": "Get historical rating data for trend analysis.", "operationId": "getCompetitorRatingHistory", "parameters": [{"in": "path", "name": "competitor_id", "required": true, "schema": {"type": "string"}, "description": "The competitor ID."}, {"in": "query", "name": "days", "schema": {"type": "integer", "minimum": 7, "maximum": 365, "default": 90}, "description": "Number of days of history."}], "responses": {"200": {"description": "Rating history.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/CompetitorRatingHistory"}}}}, "401": {"$ref": "#/components/responses/Unauthorized"}, "404": {"$ref": "#/components/responses/NotFound"}}}}, "/api/v1/competitive-intelligence/comparison": {"get": {"tags": ["Competitive Intelligence"], "summary": "Get competitive comparison.", "description": "Compare all competitors tracked by the workspace across ratings, sentiment, review volume and feature coverage. The comparison is workspace-wide and has no per-app filter.", "operationId": "getCompetitiveComparison", "responses": {"200": {"description": "Competitive comparison.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/CompetitiveComparison"}}}}, "401": {"$ref": "#/components/responses/Unauthorized"}}}}, "/api/v1/competitive-intelligence/competitors/{competitorId}/feature-launches": {"get": {"tags": ["Competitive Intelligence"], "summary": "Detect competitor feature launches.", "description": "Analyze competitor reviews to detect feature launches and their impact on ratings.\nSurfaces insights like \"Competitor launched dark mode and rating improved 0.3 stars\".\n", "operationId": "getCompetitorFeatureLaunches", "parameters": [{"name": "competitorId", "in": "path", "required": true, "description": "Competitor identifier (format platform_external_app_id).", "schema": {"type": "string"}}], "responses": {"200": {"description": "Feature launches detected.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/FeatureLaunchesResponse"}}}}, "400": {"$ref": "#/components/responses/BadRequest"}, "401": {"$ref": "#/components/responses/Unauthorized"}, "404": {"$ref": "#/components/responses/NotFound"}}}}, "/api/v1/competitive-intelligence/competitors/{competitorId}/snapshot": {"post": {"tags": ["Competitive Intelligence"], "summary": "Capture competitor metrics snapshot.", "description": "Capture a point-in-time snapshot of competitor metrics (rating, review count).\nUsed to build historical data for trend analysis.\n", "operationId": "captureCompetitorSnapshot", "security": [{"BearerAuth": []}], "parameters": [{"name": "competitorId", "in": "path", "required": true, "description": "Competitor identifier.", "schema": {"type": "string"}}], "responses": {"201": {"description": "Snapshot captured.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/CompetitorSnapshotResponse"}}}}, "400": {"$ref": "#/components/responses/BadRequest"}, "401": {"$ref": "#/components/responses/Unauthorized"}, "403": {"$ref": "#/components/responses/Forbidden"}, "404": {"$ref": "#/components/responses/NotFound"}, "502": {"$ref": "#/components/responses/BadGateway"}}}}, "/api/v1/apps/{appUuid}/competitive-sentiment": {"get": {"tags": ["Competitive Intelligence"], "summary": "Compare sentiment with competitors.", "description": "Compare what users praise in competitor apps vs your app.\nIdentifies feature gaps (what competitors are praised for that your app lacks)\nand competitive advantages (where your app excels).\n", "operationId": "getCompetitiveSentimentComparison", "parameters": [{"$ref": "#/components/parameters/AppUuidPath"}], "responses": {"200": {"description": "Competitive sentiment comparison.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/CompetitiveSentimentResponse"}}}}, "400": {"$ref": "#/components/responses/BadRequest"}, "401": {"$ref": "#/components/responses/Unauthorized"}, "404": {"$ref": "#/components/responses/NotFound"}}}}, "/api/v1/categories": {"get": {"tags": ["Categories"], "summary": "List custom categories.", "description": "List the workspace's custom categories, sorted by name without regard to letter case. Categories are user-defined classification buckets used by AI during review analysis. The answer holds every category; it is not paged.", "operationId": "getCategories", "parameters": [{"in": "query", "name": "enabled_only", "required": false, "schema": {"type": "boolean"}, "description": "`true` (in any letter case) answers only the enabled categories. Any other value, or none, answers all of them."}], "responses": {"200": {"description": "List of categories.", "content": {"application/json": {"schema": {"type": "object", "required": ["categories", "count"], "properties": {"categories": {"type": "array", "items": {"$ref": "#/components/schemas/Category"}}, "count": {"type": "integer", "minimum": 0, "description": "Number of categories returned in this response, rather than the workspace total."}}}}}}, "401": {"$ref": "#/components/responses/Unauthorized"}, "402": {"$ref": "#/components/responses/SubscriptionRequired"}, "403": {"description": "`FORBIDDEN`: an API key without the `analytics:read` scope.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "500": {"$ref": "#/components/responses/InternalError"}, "503": {"$ref": "#/components/responses/SubscriptionUnverified"}}}, "post": {"tags": ["Categories"], "summary": "Create a custom category.", "description": "Create a new custom category for AI-powered review classification. Categories are injected into AI prompts to identify matching reviews. Requires the owner or admin role. A new category is always enabled; `enabled` is not read here.", "operationId": "postCategory", "security": [{"BearerAuth": []}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/CreateCategoryRequest"}}}}, "responses": {"201": {"description": "Category created.", "content": {"application/json": {"schema": {"type": "object", "required": ["category"], "properties": {"category": {"$ref": "#/components/schemas/Category"}}}}}}, "400": {"$ref": "#/components/responses/ValidationError"}, "401": {"$ref": "#/components/responses/Unauthorized"}, "402": {"$ref": "#/components/responses/SubscriptionRequired"}, "403": {"description": "`FORBIDDEN`: the caller is not an owner or admin, or is an API key. These writes need `analytics:write`, a scope no API key can be given, so a key is always refused.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "500": {"$ref": "#/components/responses/InternalError"}, "503": {"$ref": "#/components/responses/SubscriptionUnverified"}}}}, "/api/v1/categories/{category_id}": {"get": {"tags": ["Categories"], "summary": "Get category details.", "description": "Get a specific custom category by ID.", "operationId": "getCategory", "parameters": [{"in": "path", "name": "category_id", "required": true, "schema": {"type": "string"}, "description": "The category ID, a UUID. Any other value answers 404."}], "responses": {"200": {"description": "Category details.", "content": {"application/json": {"schema": {"type": "object", "required": ["category"], "properties": {"category": {"$ref": "#/components/schemas/Category"}}}}}}, "401": {"$ref": "#/components/responses/Unauthorized"}, "402": {"$ref": "#/components/responses/SubscriptionRequired"}, "403": {"description": "`FORBIDDEN`: an API key without the `analytics:read` scope.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "404": {"$ref": "#/components/responses/NotFound"}, "500": {"$ref": "#/components/responses/InternalError"}, "503": {"$ref": "#/components/responses/SubscriptionUnverified"}}}, "put": {"tags": ["Categories"], "summary": "Update a category.", "description": "Update a custom category. Requires the owner or admin role. A body that names none of the updatable fields changes nothing and answers the category as it is.", "operationId": "putCategory", "security": [{"BearerAuth": []}], "parameters": [{"in": "path", "name": "category_id", "required": true, "schema": {"type": "string"}, "description": "The category ID, a UUID. Any other value answers 404."}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/UpdateCategoryRequest"}}}}, "responses": {"200": {"description": "Category updated.", "content": {"application/json": {"schema": {"type": "object", "required": ["category"], "properties": {"category": {"$ref": "#/components/schemas/Category"}}}}}}, "400": {"$ref": "#/components/responses/ValidationError"}, "401": {"$ref": "#/components/responses/Unauthorized"}, "402": {"$ref": "#/components/responses/SubscriptionRequired"}, "403": {"description": "`FORBIDDEN`: the caller is not an owner or admin, or is an API key. These writes need `analytics:write`, a scope no API key can be given, so a key is always refused.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "404": {"$ref": "#/components/responses/NotFound"}, "500": {"$ref": "#/components/responses/InternalError"}, "503": {"$ref": "#/components/responses/SubscriptionUnverified"}}}, "delete": {"tags": ["Categories"], "summary": "Delete a category.", "description": "Delete a custom category. Requires the owner or admin role.", "operationId": "deleteCategory", "security": [{"BearerAuth": []}], "parameters": [{"in": "path", "name": "category_id", "required": true, "schema": {"type": "string"}, "description": "The category ID, a UUID. Any other value answers 404."}], "responses": {"200": {"description": "Category deleted.", "content": {"application/json": {"schema": {"type": "object", "description": "Always `{\"success\": true, \"message\": \"Category deleted\"}`.", "properties": {"deleted": {"type": "boolean", "deprecated": true, "description": "Not returned and never was. Read `success`."}, "success": {"type": "boolean", "description": "Always `true`."}, "message": {"type": "string", "description": "Always `Category deleted`."}}}}}}, "401": {"$ref": "#/components/responses/Unauthorized"}, "402": {"$ref": "#/components/responses/SubscriptionRequired"}, "403": {"description": "`FORBIDDEN`: the caller is not an owner or admin, or is an API key. These writes need `analytics:write`, a scope no API key can be given, so a key is always refused.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "404": {"$ref": "#/components/responses/NotFound"}, "500": {"$ref": "#/components/responses/InternalError"}, "503": {"$ref": "#/components/responses/SubscriptionUnverified"}}}}, "/api/v1/team/members": {"get": {"tags": ["Team"], "summary": "List team members for the current tenant.", "description": "Returns all team members associated with the current tenant. Used for alert assignment and collaboration features.", "operationId": "getTeamMembers", "security": [{"BearerAuth": []}], "responses": {"200": {"description": "Team members returned.", "content": {"application/json": {"schema": {"type": "object", "properties": {"members": {"type": "array", "items": {"$ref": "#/components/schemas/TeamMember"}}}}}}}, "401": {"$ref": "#/components/responses/Unauthorized"}}}}, "/api/v1/team/members/invite": {"post": {"tags": ["Team"], "summary": "Invite a new team member.", "description": "Send an invitation email to add a new team member to the tenant.", "operationId": "postTeamMembersInvite", "security": [{"BearerAuth": []}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/TeamInviteRequest"}}}}, "responses": {"201": {"description": "Invitation sent successfully.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/TeamInviteResponse"}}}}, "400": {"$ref": "#/components/responses/ValidationError"}, "401": {"$ref": "#/components/responses/Unauthorized"}, "402": {"description": "No seat can be added. `SEAT_LIMIT_EXCEEDED`: every paid seat is in\nuse (add seats in Billing). `SUBSCRIPTION_CANCELED`,\n`SUBSCRIPTION_UNPAID` or `TRIAL_EXPIRED`: the workspace's\nsubscription has ended; resubscribe to invite.\n`SUBSCRIPTION_REQUIRED`: a subscription is awaiting its first\npayment.\n", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "409": {"description": "The address cannot be invited. No seat is taken.\n`USER_EXISTS`: the address is already a member of this workspace.\n`ALREADY_INVITED`: the address already has a pending invitation\nto this workspace; resend it instead.\n`EMAIL_NOT_INVITABLE`: an invitation to this address could not be\naccepted. The answer gives no reason and is the same for every\nsuch address; it does not say whether the address has an account\nelsewhere. It is given only to a workspace that could add the\nmember (otherwise the answer is the 402).\n", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "429": {"description": "`INVITE_RATE_LIMITED`: this workspace has checked too many\naddresses in the last hour; every invitation that would need the\ncheck is refused until `Retry-After` seconds pass. Every checked\naddress counts, whether or not the invitation is sent. An address\nalready in the workspace, and a workspace with no free seat, are\nanswered without the check and do not count.\n", "headers": {"Retry-After": {"description": "Seconds until invitations are accepted again.", "schema": {"type": "integer"}}}, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "503": {"$ref": "#/components/responses/ReadUnavailable"}}}}, "/api/v1/team/members/{userId}": {"patch": {"tags": ["Team"], "summary": "Update a team member's role.", "description": "Change the role of an existing team member.", "operationId": "patchTeamMember", "security": [{"BearerAuth": []}], "parameters": [{"name": "userId", "in": "path", "required": true, "schema": {"type": "string"}, "description": "User ID of the team member."}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/UpdateTeamMemberRequest"}}}}, "responses": {"200": {"description": "Member role updated.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/TeamMemberUpdateResponse"}}}}, "400": {"$ref": "#/components/responses/ValidationError"}, "401": {"$ref": "#/components/responses/Unauthorized"}, "403": {"description": "The role cannot be changed. `CANNOT_CHANGE_OWN_ROLE`: the request\nchanges the caller's own role; another admin or the owner makes\nthat change. `FORBIDDEN`: the caller is not an admin or owner, or\nthe member is the workspace owner, whose role cannot be changed.\n", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "404": {"description": "User not found."}}}, "delete": {"tags": ["Team"], "summary": "Remove a team member.", "description": "Remove a user from the team.", "operationId": "deleteTeamMember", "security": [{"BearerAuth": []}], "parameters": [{"name": "userId", "in": "path", "required": true, "schema": {"type": "string"}, "description": "User ID of the team member to remove."}], "responses": {"200": {"description": "Member removed.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/TeamMemberDeleteResponse"}}}}, "401": {"$ref": "#/components/responses/Unauthorized"}, "403": {"description": "Cannot remove this user."}, "404": {"description": "User not found."}}}}, "/api/v1/team/members/{userId}/resend-invite": {"post": {"tags": ["Team"], "summary": "Resend invitation to a pending team member.", "description": "Resend the invitation email to a team member who has not yet accepted.", "operationId": "postResendInvite", "security": [{"BearerAuth": []}], "parameters": [{"name": "userId", "in": "path", "required": true, "schema": {"type": "string"}, "description": "User ID of the pending team member."}], "responses": {"200": {"description": "Invitation resent.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/TeamInviteResendResponse"}}}}, "400": {"description": "Member is already active."}, "401": {"$ref": "#/components/responses/Unauthorized"}, "403": {"description": "Admin access required."}, "404": {"description": "Team member not found."}}}}, "/api/v1/reply-drafts": {"get": {"tags": ["Reply Drafts"], "summary": "List pending AI-generated reply drafts awaiting approval.", "description": "Returns pending drafts that require approval before posting, one page at a time in the same order as before cursors existed. `next_cursor` is set only when more matching drafts exist. Used by the alerts PWA for draft review workflows. With `status`, the list can also (or instead) hold drafts that were approved but not yet posted to the store: a draft approved without `auto_post`, or whose publish failed before the store accepted it. Publish those with `POST /api/v1/reply-drafts/{draftId}/post`. Any workspace role may list; an API key needs the `reviews:read` scope.", "operationId": "getPendingDrafts", "parameters": [{"name": "status", "in": "query", "required": false, "schema": {"type": "string", "default": "pending_approval", "example": "pending_approval,approved"}, "description": "Comma-separated statuses to list: `pending_approval`, `approved`, or both. Omitted, only drafts awaiting approval are returned, as before. Any other status answers 400 `VALIDATION_ERROR`. A `cursor` continues only the status filter it was issued for."}, {"name": "app_uuid", "in": "query", "schema": {"type": "string", "format": "uuid"}, "description": "Scope the draft list to a single app. Omitted, the response covers every app in the tenant, so a caller who means \"one app\" and does not send this receives a tenant-wide aggregate."}, {"name": "limit", "in": "query", "schema": {"type": "integer", "minimum": 1, "maximum": 100, "default": 50}, "description": "Maximum drafts to return."}, {"$ref": "#/components/parameters/ListCursor"}], "responses": {"200": {"description": "Pending drafts returned.", "content": {"application/json": {"schema": {"type": "object", "required": ["drafts", "count", "message", "next_cursor", "has_more"], "properties": {"drafts": {"type": "array", "items": {"$ref": "#/components/schemas/PendingDraft"}}, "count": {"type": "integer", "minimum": 0, "description": "Number of drafts in this page."}, "message": {"type": "string", "description": "A sentence with the page's count, for example `3 pending draft(s) awaiting approval`."}, "next_cursor": {"type": ["string", "null"], "description": "Pass as `cursor` (with the same `app_uuid` and `status`) for the next page; `null` on the last page."}, "has_more": {"type": "boolean", "description": "`true` exactly when `next_cursor` is set."}, "scan_truncated": {"type": "boolean", "description": "Present and `true` when the read stopped at its scan bound (2,000 stored drafts) before filling the page; the page may be short, and `next_cursor` continues the read."}}}}}}, "400": {"description": "`VALIDATION_ERROR`: `limit` or `status` is not one this list takes. `INVALID_CURSOR`: the cursor is not one this list issued. `INVALID_TENANT`: the caller has no workspace.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "401": {"$ref": "#/components/responses/Unauthorized"}, "402": {"$ref": "#/components/responses/SubscriptionRequired"}, "403": {"description": "`FORBIDDEN`: an API key without the `reviews:read` scope.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "429": {"$ref": "#/components/responses/CredentialRateLimited"}, "500": {"$ref": "#/components/responses/InternalError"}, "503": {"description": "`SERVICE_UNAVAILABLE`: the drafts could not be read, so whether any are waiting is unknown. An empty list is never the answer for a failed read. Retry after `Retry-After` (`retryable` is true). The subscription check answers the same code when the workspace's subscription could not be verified.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}}}}, "/api/v1/reply-drafts/{draftId}/approve": {"post": {"tags": ["Reply Drafts"], "summary": "Approve a pending draft for posting.", "description": "Approves the draft. Publication to the app store happens only when `auto_post` is true (it defaults to false), or via the dedicated `/post` route. When publication is attempted, the response reports whether the store actually accepted the reply. Admin or owner only.", "operationId": "postDraftApprove", "security": [{"BearerAuth": []}], "parameters": [{"$ref": "#/components/parameters/ReplyDraftId"}], "requestBody": {"required": false, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/DraftApproveRequest"}}}}, "responses": {"200": {"description": "Draft approved. With `auto_post`, `post_result` says what the store answered: a refused publication is still a 200, with `posted_to_store` false and the draft left `approved` (or `posting`, see `status`). So is a draft that could not be read again to post it: the approval stands and nothing reached the store.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ReplyDraftDecision"}}}}, "400": {"$ref": "#/components/responses/ReplyDraftEditRefused"}, "401": {"$ref": "#/components/responses/Unauthorized"}, "402": {"$ref": "#/components/responses/SubscriptionRequired"}, "403": {"$ref": "#/components/responses/ReplyDraftAdminForbidden"}, "404": {"$ref": "#/components/responses/ReplyDraftNotFound"}, "409": {"$ref": "#/components/responses/ReplyDraftConflict"}, "500": {"description": "`DRAFT_UPDATE_FAILED`: the edited text could not be saved, and the draft was not approved. `INTERNAL_ERROR`: the draft could not be read or written.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "503": {"$ref": "#/components/responses/ReadUnavailable"}}}}, "/api/v1/reply-drafts/{draftId}/reject": {"post": {"tags": ["Reply Drafts"], "summary": "Reject a pending draft.", "description": "Rejects and discards the draft so it will not be posted. Only drafts that have not been published may be rejected; a draft already posted to the store returns 409, since its reply is live and a status change would not retract it. Admin or owner only.", "operationId": "postDraftReject", "security": [{"BearerAuth": []}], "parameters": [{"$ref": "#/components/parameters/ReplyDraftId"}], "requestBody": {"required": false, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/DraftRejectRequest"}}}}, "responses": {"200": {"description": "Draft rejected.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ReplyDraftDecision"}}}}, "401": {"$ref": "#/components/responses/Unauthorized"}, "402": {"$ref": "#/components/responses/SubscriptionRequired"}, "403": {"$ref": "#/components/responses/ReplyDraftAdminForbidden"}, "404": {"$ref": "#/components/responses/ReplyDraftNotFound"}, "409": {"$ref": "#/components/responses/ReplyDraftConflict"}, "500": {"$ref": "#/components/responses/InternalError"}, "503": {"$ref": "#/components/responses/SubscriptionUnverified"}}}}, "/api/v1/credentials": {"get": {"tags": ["Credentials"], "summary": "List stored credentials for the tenant.", "description": "Requires admin or owner role.", "operationId": "getCredentials", "security": [{"BearerAuth": []}], "responses": {"200": {"description": "Credentials returned.", "content": {"application/json": {"schema": {"type": "object", "required": ["credentials", "count"], "properties": {"credentials": {"type": "array", "items": {"$ref": "#/components/schemas/Credential"}}, "count": {"type": "integer", "minimum": 0, "description": "Number of credentials returned in this response, rather than the workspace total."}}}}}}, "401": {"$ref": "#/components/responses/Unauthorized"}, "403": {"$ref": "#/components/responses/Forbidden"}}}, "post": {"tags": ["Credentials"], "summary": "Store a new credential set.", "description": "Requires admin or owner role. The answer is the stored credential itself, not wrapped in an envelope.", "operationId": "postCredential", "security": [{"BearerAuth": []}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/CredentialRequest"}}}}, "responses": {"201": {"description": "Credential stored.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Credential"}}}}, "400": {"$ref": "#/components/responses/ValidationError"}, "401": {"$ref": "#/components/responses/Unauthorized"}, "403": {"$ref": "#/components/responses/Forbidden"}}}}, "/api/v1/credentials/{credentialId}": {"put": {"tags": ["Credentials"], "summary": "Rename a credential or rotate its stored secret.", "description": "Requires admin or owner role. Updating in place keeps `credential_id` stable, so apps already linked to this credential keep syncing, which deleting and re-adding does not.", "operationId": "updateCredential", "security": [{"BearerAuth": []}], "parameters": [{"name": "credentialId", "in": "path", "required": true, "schema": {"type": "string"}}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/CredentialUpdateRequest"}}}}, "responses": {"200": {"description": "Credential updated.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Credential"}}}}, "400": {"$ref": "#/components/responses/ValidationError"}, "401": {"$ref": "#/components/responses/Unauthorized"}, "403": {"$ref": "#/components/responses/Forbidden"}, "404": {"$ref": "#/components/responses/NotFound"}, "409": {"$ref": "#/components/responses/Conflict"}}}, "delete": {"tags": ["Credentials"], "summary": "Delete a credential.", "description": "Requires admin or owner role. The stored secret is scheduled for deletion with a seven-day recovery window.", "operationId": "deleteCredential", "security": [{"BearerAuth": []}], "parameters": [{"name": "credentialId", "in": "path", "required": true, "schema": {"type": "string"}}], "responses": {"200": {"description": "Credential removed.", "content": {"application/json": {"schema": {"type": "object", "required": ["message"], "properties": {"message": {"type": "string", "description": "`Credential deleted successfully`."}}}}}}, "401": {"$ref": "#/components/responses/Unauthorized"}, "403": {"$ref": "#/components/responses/Forbidden"}, "404": {"$ref": "#/components/responses/NotFound"}}}}, "/api/v1/credentials/{credentialId}/apps": {"get": {"tags": ["Credentials"], "summary": "List apps linked to a credential.", "description": "Requires admin or owner role.", "operationId": "getCredentialApps", "security": [{"BearerAuth": []}], "parameters": [{"name": "credentialId", "in": "path", "required": true, "schema": {"type": "string"}}], "responses": {"200": {"description": "Linked apps returned.", "content": {"application/json": {"schema": {"type": "object", "required": ["apps", "count"], "properties": {"apps": {"type": "array", "items": {"$ref": "#/components/schemas/CredentialLinkedApp"}}, "count": {"type": "integer", "description": "Number of apps in `apps`."}}}}}}, "401": {"$ref": "#/components/responses/Unauthorized"}, "403": {"$ref": "#/components/responses/Forbidden"}, "404": {"$ref": "#/components/responses/NotFound"}}}}, "/api/v1/credentials/validate": {"post": {"tags": ["Credentials"], "summary": "Check the format of a credential before storing it.", "description": "Requires admin or owner role. Checks the format of the submitted secret only: that each field the store needs is present (and, for Google Play, that the JSON is a service account). Nothing is sent to the store, nothing is stored, and no apps are listed. A secret that fails the check answers 200 with `valid: false`; an unsupported `platform` answers 400.", "operationId": "postCredentialValidate", "security": [{"BearerAuth": []}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/CredentialValidateRequest"}}}}, "responses": {"200": {"description": "Validation outcome.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/CredentialValidationResult"}}}}, "400": {"$ref": "#/components/responses/ValidationError"}, "401": {"$ref": "#/components/responses/Unauthorized"}, "403": {"$ref": "#/components/responses/Forbidden"}}}}, "/api/v1/reply-templates": {"get": {"tags": ["Reply Templates"], "summary": "List system and custom reply templates.", "operationId": "getReplyTemplates", "parameters": [{"name": "locale", "in": "query", "required": false, "description": "Language for the built-in templates' `name` and `content`: `en`,\n`de`, `es`, `fr`, `it`, `ja`, `ko`, `nl`, `pt` or `zh`. A region\nor script tag (`pt-BR`, `zh_CN`) resolves to its base language.\nEnglish when absent or not one of these. Their `template_id`,\n`category`, `tone` and `platforms`, and the `{app_name}` and\n`{support_email}` placeholders, are the same in every language.\nThe workspace's own templates are returned as they were saved,\nwhatever this says.\n", "schema": {"type": "string"}, "example": "de"}], "responses": {"200": {"description": "Templates returned.", "content": {"application/json": {"schema": {"type": "object", "properties": {"templates": {"type": "array", "items": {"$ref": "#/components/schemas/ReplyTemplate"}}, "count": {"type": "integer", "minimum": 0, "description": "Number of templates returned in this response, rather than the workspace total."}}}}}}, "401": {"$ref": "#/components/responses/Unauthorized"}}}, "post": {"tags": ["Reply Templates"], "summary": "Create a custom reply template.", "operationId": "postReplyTemplate", "security": [{"BearerAuth": []}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ReplyTemplateRequest"}}}}, "responses": {"201": {"description": "Template created.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ReplyTemplate"}}}}, "400": {"$ref": "#/components/responses/ValidationError"}, "401": {"$ref": "#/components/responses/Unauthorized"}}}}, "/api/v1/reply-templates/{templateId}": {"put": {"tags": ["Reply Templates"], "summary": "Replace a reply template.", "operationId": "putReplyTemplate", "security": [{"BearerAuth": []}], "parameters": [{"name": "templateId", "in": "path", "required": true, "schema": {"type": "string"}}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ReplyTemplateRequest"}}}}, "responses": {"200": {"description": "Template updated.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ReplyTemplate"}}}}, "400": {"$ref": "#/components/responses/ValidationError"}, "401": {"$ref": "#/components/responses/Unauthorized"}, "404": {"$ref": "#/components/responses/NotFound"}}}, "delete": {"tags": ["Reply Templates"], "summary": "Remove a custom reply template.", "operationId": "deleteReplyTemplate", "security": [{"BearerAuth": []}], "parameters": [{"name": "templateId", "in": "path", "required": true, "schema": {"type": "string"}}], "responses": {"204": {"description": "Template removed."}, "401": {"$ref": "#/components/responses/Unauthorized"}, "404": {"$ref": "#/components/responses/NotFound"}}}}, "/api/v1/api-keys": {"get": {"tags": ["API Keys"], "summary": "List all API keys for the tenant.", "description": "Every API key of the workspace, newest first. Requires the owner or admin role. A listed key never carries its secret or a hash of it: the secret is returned once, by the create or the rotation that made it. A key past its `expires_at` is listed as `active` and is refused on every request. Agents connected by signing in (OAuth) are not API keys and are not listed here; `GET /api/v1/oauth/grants` lists them. The answer is not paged.", "operationId": "listApiKeys", "security": [{"BearerAuth": []}], "responses": {"200": {"description": "The workspace's API keys, newest first.", "content": {"application/json": {"schema": {"type": "object", "required": ["api_keys"], "properties": {"api_keys": {"type": "array", "items": {"$ref": "#/components/schemas/ApiKey"}}}}}}}, "401": {"$ref": "#/components/responses/Unauthorized"}, "403": {"$ref": "#/components/responses/ApiKeyManagementForbidden"}, "500": {"$ref": "#/components/responses/InternalError"}}}, "post": {"tags": ["API Keys"], "summary": "Create a new API key.", "description": "Make a key. Requires the owner or admin role. The key is `active`, holds the scopes asked for, and acts for the person who made it (see `ApiKeyAuth`). This answer is the only one that carries its secret (`api_key.key`): store it now. The API keeps only a hash of it.", "operationId": "createApiKey", "security": [{"BearerAuth": []}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/CreateApiKeyRequest"}}}}, "responses": {"201": {"description": "The key, with its secret. The secret is not shown again.", "content": {"application/json": {"schema": {"type": "object", "required": ["api_key", "message"], "properties": {"api_key": {"$ref": "#/components/schemas/ApiKeyWithSecret"}, "message": {"type": "string", "description": "Always `API key created. Save the key value - it will not be shown again.`"}}}}}}, "400": {"description": "`INVALID_REQUEST`: no body, or an empty one. `VALIDATION_ERROR`: a body that is not a JSON object; no `name`, or one that is not a string, is spaces alone or is longer than 100 characters; `scopes` that is not a list, holds a retired write scope, or holds no scope a key can be given; or `expires_in_days` that is not a whole number from 1 to 365. Nothing is stored.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "401": {"$ref": "#/components/responses/Unauthorized"}, "403": {"$ref": "#/components/responses/ApiKeyManagementForbidden"}, "500": {"description": "`CREATE_FAILED`: the key could not be stored. The key and the lookup its secret is checked against are stored in one write, and a write whose answer was lost is sent again under the same request token, which is applied at most once (a key stored that way is answered 201), so no new key is listed. Only when the outcome is still unknown after that can a key this answer did not return be listed; delete it. `retryable` is true: make the request again.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}}}}, "/api/v1/oauth/consent/preview": {"post": {"tags": ["Agent connections"], "summary": "Describe an AI agent's pending OAuth connection request for the consent screen.", "description": "The MCP authorization server (`GET https://api.tritonium.com/oauth/authorize`) validates a client's request and sends the browser to `https://app.tritonium.com/oauth/consent?request=...`. The signed-in person's dashboard calls this with that `request` value. Requests expire after 15 minutes and can be answered once. Only a signed-in person can call it: an API key or an agent's token is refused with `401`.", "operationId": "previewOAuthConsent", "security": [{"BearerAuth": []}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/OAuthConsentRequest"}}}}, "responses": {"200": {"description": "The client, where the browser returns to, and the scopes involved.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/OAuthConsentPreview"}}}}, "400": {"description": "`OAUTH_REQUEST_INVALID`, `OAUTH_REQUEST_EXPIRED` or `OAUTH_REQUEST_USED`; start the connection again from the agent. `VALIDATION_ERROR`: the body is not JSON or is larger than 16 KB.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "401": {"$ref": "#/components/responses/OAuthPersonUnauthorized"}, "403": {"$ref": "#/components/responses/OAuthPersonForbidden"}, "500": {"$ref": "#/components/responses/OAuthUnexpected"}, "503": {"$ref": "#/components/responses/OAuthUnavailable"}}}}, "/api/v1/oauth/consent": {"post": {"tags": ["Agent connections"], "summary": "Approve (with chosen scopes) or deny an AI agent's OAuth connection request.", "description": "Returns where to send the browser: the client's redirect URI with an authorization code, `state` and `iss` (approve), or with `error=access_denied` (deny). The resulting access tokens act as a workspace member limited to the approved scopes, and only on the MCP server. The answer carries no token: the agent exchanges the code for its tokens itself, with the proof key it started with.", "operationId": "decideOAuthConsent", "security": [{"BearerAuth": []}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/OAuthConsentDecisionRequest"}}}}, "responses": {"200": {"description": "The redirect for the browser.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/OAuthConsentDecision"}}}}, "400": {"description": "`OAUTH_REQUEST_INVALID`, `OAUTH_REQUEST_EXPIRED`, `OAUTH_REQUEST_USED`, `OAUTH_SCOPE_NOT_ALLOWED` or `VALIDATION_ERROR` (the body is not JSON, `decision` is neither `approve` nor `deny`, or no scope was chosen to approve).", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "401": {"$ref": "#/components/responses/OAuthPersonUnauthorized"}, "403": {"$ref": "#/components/responses/OAuthPersonForbidden"}, "500": {"$ref": "#/components/responses/OAuthUnexpected"}, "503": {"$ref": "#/components/responses/OAuthUnavailable"}}}}, "/api/v1/oauth/grants": {"get": {"tags": ["Agent connections"], "summary": "List AI agents connected with OAuth sign-in.", "description": "Admins and owners see every connection in the workspace; members see their own. Newest first. No token or client secret is listed.", "operationId": "listOAuthGrants", "security": [{"BearerAuth": []}], "responses": {"200": {"description": "Connected agents.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/OAuthGrantList"}}}}, "401": {"$ref": "#/components/responses/OAuthPersonUnauthorized"}, "403": {"$ref": "#/components/responses/OAuthPersonForbidden"}, "500": {"$ref": "#/components/responses/OAuthUnexpected"}, "503": {"$ref": "#/components/responses/OAuthUnavailable"}}}}, "/api/v1/oauth/grants/{grantId}": {"delete": {"tags": ["Agent connections"], "summary": "Disconnect an AI agent; its tokens stop working immediately.", "description": "Members can disconnect their own connections; admins and owners any in the workspace.", "operationId": "revokeOAuthGrant", "security": [{"BearerAuth": []}], "parameters": [{"name": "grantId", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid"}}], "responses": {"200": {"description": "Disconnected.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/OAuthGrantRevoked"}}}}, "401": {"$ref": "#/components/responses/OAuthPersonUnauthorized"}, "403": {"$ref": "#/components/responses/OAuthPersonForbidden"}, "404": {"description": "`NOT_FOUND`: the workspace has no connection with this id, it is a member's request for another person's connection, or the id is not a UUID.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "500": {"$ref": "#/components/responses/OAuthUnexpected"}, "503": {"$ref": "#/components/responses/OAuthUnavailable"}}}}, "/api/v1/api-keys/scopes": {"get": {"tags": ["API Keys"], "summary": "List the API-key scopes and scope groups that can be assigned.", "description": "The scopes a new key can be given, and two named groups of them. Requires the owner or admin role.", "operationId": "getApiKeyScopes", "security": [{"BearerAuth": []}], "responses": {"200": {"description": "Available scopes and convenience scope groups.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ApiKeyScopes"}}}}, "401": {"$ref": "#/components/responses/Unauthorized"}, "403": {"$ref": "#/components/responses/ApiKeyManagementForbidden"}}}}, "/api/v1/api-keys/{keyId}/rotate": {"post": {"tags": ["API Keys"], "summary": "Rotate an API key's secret (the previous key stops working immediately).", "description": "The new secret is returned once, to the caller, and from then on the key acts for the caller: its scopes are capped by the caller's current role, and it stops working if the caller leaves the workspace. Its `created_by` is unchanged. Requires the owner or admin role. A revoked or expired key cannot be rotated (400). The new secret replaces the previous one in a single write, so a rotation that fails (500) changes nothing and the previous secret keeps working. A rotation whose answer was lost is sent again under the same request token, which is applied at most once, and answered 200 with its new secret when it goes through.", "operationId": "rotateApiKey", "security": [{"BearerAuth": []}], "parameters": [{"$ref": "#/components/parameters/ApiKeyId"}], "responses": {"200": {"description": "The key, with its new secret. The secret is not shown again.", "content": {"application/json": {"schema": {"type": "object", "required": ["api_key", "message"], "properties": {"api_key": {"$ref": "#/components/schemas/ApiKeyWithSecret"}, "message": {"type": "string", "description": "Always `API key rotated. Save the new key value - it will not be shown again.`"}}}}}}, "400": {"description": "`VALIDATION_ERROR`: the key is revoked or expired, or its expiry cannot be read. Nothing is changed.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "401": {"$ref": "#/components/responses/Unauthorized"}, "403": {"$ref": "#/components/responses/ApiKeyManagementForbidden"}, "404": {"$ref": "#/components/responses/ApiKeyNotFound"}, "500": {"description": "`ROTATE_FAILED`: the new secret could not be stored. Nothing is changed, and the previous secret keeps working, unless the outcome was still unknown after the rotation was sent again. `retryable` is true: rotating again gives a working secret either way.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}}}}, "/api/v1/api-keys/{keyId}": {"patch": {"tags": ["API Keys"], "summary": "Update an API key's name, scopes, and/or status.", "description": "Change a key's name, scopes or status. Requires the owner or admin role. Only the fields sent change, and at least one of them must be sent. A change applies to the next request made with the key. A key whose holder was removed from the workspace cannot be made `active` again (400): create a new key instead.", "operationId": "updateApiKey", "security": [{"BearerAuth": []}], "parameters": [{"$ref": "#/components/parameters/ApiKeyId"}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/UpdateApiKeyRequest"}}}}, "responses": {"200": {"description": "The key after the change, with `updated_at`.", "content": {"application/json": {"schema": {"type": "object", "required": ["api_key"], "properties": {"api_key": {"$ref": "#/components/schemas/ApiKey"}}}}}}, "400": {"description": "`VALIDATION_ERROR`: a body that is not a JSON object, or one that gives a value for none of `name`, `scopes` and `status`; a `name` that is not a string, is spaces alone or is longer than 100 characters; `scopes` that is not a list, adds a retired write scope, or holds no scope a key can be given; a `status` other than `active` or `revoked`; or making `active` a key whose holder was removed. Nothing is changed.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "401": {"$ref": "#/components/responses/Unauthorized"}, "403": {"$ref": "#/components/responses/ApiKeyManagementForbidden"}, "404": {"$ref": "#/components/responses/ApiKeyNotFound"}, "500": {"description": "`UPDATE_FAILED`: the change could not be confirmed as stored. A change whose answer was lost is checked with a fresh read and, if it was made, answered 200. `retryable` is true: the same change made again changes nothing more.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}}}, "delete": {"tags": ["API Keys"], "summary": "Delete an API key.", "description": "Delete a key for good: the key and the lookup its secret is checked against are removed in one write, and requests made with it are refused at once. Requires the owner or admin role. To stop a key for a while instead, revoke it (`PATCH` with `status: revoked`).", "operationId": "deleteApiKey", "security": [{"BearerAuth": []}], "parameters": [{"$ref": "#/components/parameters/ApiKeyId"}], "responses": {"200": {"description": "API key deleted. Always `{\"message\": \"API key deleted\", \"key_id\": <the id>}`.", "content": {"application/json": {"schema": {"type": "object", "properties": {"success": {"type": "boolean", "deprecated": true, "description": "Not returned and never was: the delete has answered `message` and `key_id` since its first version."}, "message": {"type": "string", "description": "Always `API key deleted`."}, "key_id": {"type": "string", "description": "The id of the deleted key."}}}}}}, "401": {"$ref": "#/components/responses/Unauthorized"}, "403": {"$ref": "#/components/responses/ApiKeyManagementForbidden"}, "404": {"$ref": "#/components/responses/ApiKeyNotFound"}, "500": {"description": "`DELETE_FAILED`: the key could not be deleted. It may still be listed, and its secret may still work. `retryable` is true: delete again.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}}}}, "/api/v1/billing/checkout": {"post": {"tags": ["Billing"], "summary": "Create a Stripe Checkout session.", "operationId": "postBillingCheckout", "security": [{"BearerAuth": []}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/BillingCheckoutRequest"}}}}, "responses": {"200": {"description": "Checkout session created.", "content": {"application/json": {"schema": {"type": "object", "properties": {"session_id": {"type": "string"}, "checkout_url": {"type": "string", "description": "Stripe Checkout URL (canonical)."}, "url": {"type": "string", "description": "Alias of checkout_url for legacy clients."}}}}}}, "400": {"$ref": "#/components/responses/ValidationError"}, "401": {"$ref": "#/components/responses/Unauthorized"}}}}, "/api/v1/billing/subscription": {"get": {"tags": ["Billing"], "summary": "Retrieve current subscription details.", "operationId": "getSubscription", "security": [{"BearerAuth": []}], "responses": {"200": {"description": "Subscription returned.", "content": {"application/json": {"schema": {"type": "object", "properties": {"subscription": {"$ref": "#/components/schemas/Subscription"}}}}}}, "401": {"$ref": "#/components/responses/Unauthorized"}}}, "post": {"tags": ["Billing"], "summary": "Create subscription (deprecated; use create-subscription-v2).", "deprecated": true, "description": "The older create route. Use `POST /api/v1/billing/create-subscription-v2`,\nwhich also takes the number of apps and team seats.\n\nA workspace that already has a subscription (active, trialing, past\ndue, or waiting for its first payment) is refused with `409 CONFLICT`\nand nothing is created; `details` carries the `subscription_id` and\nits `status`. Change that subscription with\n`POST /api/v1/billing/update-subscription-v2`.\n", "operationId": "createSubscription", "security": [{"BearerAuth": []}], "responses": {"200": {"description": "Successful response.", "content": {"application/json": {"schema": {"type": "object", "additionalProperties": true}}}}, "400": {"$ref": "#/components/responses/BadRequest"}, "401": {"$ref": "#/components/responses/Unauthorized"}, "403": {"$ref": "#/components/responses/Forbidden"}, "404": {"$ref": "#/components/responses/NotFound"}, "409": {"$ref": "#/components/responses/Conflict"}, "500": {"description": "`SUBSCRIPTION_CANCEL_FAILED`: a subscription was created that could be neither recorded nor cancelled. `SUBSCRIPTION_CANCELLED_AFTER_SETTLEMENT`: it was cancelled, but its first invoice had been settled at creation. `details.subscription_id` names it. Do not try again.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "503": {"description": "`SUBSCRIPTION_WRITE_FAILED`: the subscription could not be recorded and was cancelled. Nothing is billed; try again.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}}}, "put": {"tags": ["Billing"], "summary": "Update subscription (deprecated; use update-subscription-v2).", "deprecated": true, "description": "The older update route. Use `POST /api/v1/billing/update-subscription-v2`\nto change the plan or the billing cycle.\n", "operationId": "updateSubscription", "security": [{"BearerAuth": []}], "responses": {"200": {"description": "Successful response.", "content": {"application/json": {"schema": {"type": "object", "additionalProperties": true}}}}, "400": {"$ref": "#/components/responses/BadRequest"}, "401": {"$ref": "#/components/responses/Unauthorized"}, "403": {"$ref": "#/components/responses/Forbidden"}, "404": {"$ref": "#/components/responses/NotFound"}}}}, "/api/v1/billing/subscription-v2": {"get": {"tags": ["Billing"], "summary": "Retrieve per-integration subscription details.", "operationId": "getSubscriptionV2", "security": [{"BearerAuth": []}], "responses": {"200": {"description": "Per-integration subscription returned.", "content": {"application/json": {"schema": {"type": "object", "properties": {"subscription": {"type": "object", "properties": {"subscription_id": {"type": ["string", "null"], "description": "Null for a workspace that no subscription bills."}, "tenant_id": {"type": "string"}, "status": {"type": "string"}, "pricing_model": {"type": "string"}, "billing_cycle": {"type": "string", "enum": ["monthly", "annual"]}, "billed": {"type": "boolean", "description": "Whether a Stripe subscription bills this workspace: the workspace has one and it is active, trialing or past due. When false (no plan checked out yet, a subscription that has ended or that Stripe is not collecting for, or a workspace provisioned by hand) nothing is billed and every amount in this object is 0."}, "integration_count": {"type": "integer", "description": "The app slots the subscription bills (what was bought, not what is connected). Kept after the subscription ends. For a workspace that never had this plan, the apps it has connected."}, "member_count": {"type": "integer", "description": "The team seats the subscription bills, the included one among them. For a workspace that never had this plan, the members it has."}, "integration_subtotal": {"type": "number"}, "member_subtotal": {"type": "number"}, "monthly_cost": {"type": "number", "description": "What the billed quantities cost per month at the monthly prices of the workspace's tier, in USD. 0 when `billed` is false."}, "annual_discount_applied": {"type": ["number", "null"]}, "effective_monthly_rate": {"type": "number"}, "current_cost": {"type": "number"}, "pricing_tier": {"type": "string", "enum": ["full", "tier20", "tier40", "tier60"], "description": "The regional pricing tier the workspace is locked to. A plan change is billed on this tier."}, "per_app_price": {"type": "number", "description": "What one app costs per month on this tier, in USD."}, "regional_discount_percentage": {"type": "integer", "description": "The tier's regional discount on the per-app price (0, 20, 40 or 60)."}, "billing_period_total": {"type": "number", "description": "What one billing period costs in USD: a month's charge on a monthly plan, a year's on an annual plan."}, "stripe_customer_id": {"type": "string"}, "stripe_subscription_id": {"type": "string"}, "additional_members": {"type": "integer"}, "app_count": {"type": "integer"}, "billed_app_count": {"type": "integer"}, "billed_member_count": {"type": "integer"}, "included_members": {"type": "integer"}, "app_subtotal": {"type": "number", "description": "App subtotal from the established billing calculation."}, "current_period_start": {"type": ["string", "null"], "format": "date-time"}, "current_period_end": {"type": ["string", "null"], "format": "date-time"}, "next_billing_date": {"type": ["string", "null"], "format": "date-time"}, "trial_end": {"type": ["string", "null"], "format": "date-time"}, "cancel_at_period_end": {"type": "boolean"}, "default_payment_method": {"type": ["object", "null"], "description": "Saved card display information. Never a full card number.", "properties": {"brand": {"type": ["string", "null"]}, "last4": {"type": ["string", "null"]}, "exp_month": {"type": ["integer", "null"]}, "exp_year": {"type": ["integer", "null"]}}}}}}}}}}, "401": {"$ref": "#/components/responses/Unauthorized"}}}}, "/api/v1/billing/cancel-subscription": {"post": {"tags": ["Billing"], "summary": "Cancel the active subscription at period end.", "operationId": "postCancelSubscription", "security": [{"BearerAuth": []}], "responses": {"200": {"description": "Subscription cancellation scheduled.", "content": {"application/json": {"schema": {"type": "object", "properties": {"canceled": {"type": "boolean"}, "cancel_at_period_end": {"type": "boolean", "description": "True when the subscription runs to the end of the paid period; false when it was ended immediately."}}}}}}, "401": {"$ref": "#/components/responses/Unauthorized"}}}}, "/api/v1/billing/reactivate-subscription": {"post": {"tags": ["Billing"], "summary": "Reactivate a cancelled subscription.", "operationId": "postReactivateSubscription", "security": [{"BearerAuth": []}], "responses": {"200": {"description": "Subscription reactivated.", "content": {"application/json": {"schema": {"type": "object", "properties": {"reactivated": {"type": "boolean"}}}}}}, "401": {"$ref": "#/components/responses/Unauthorized"}}}}, "/api/v1/billing/payment-method": {"post": {"tags": ["Billing"], "summary": "Attach a payment method and set it as default.", "operationId": "postPaymentMethod", "security": [{"BearerAuth": []}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/PaymentMethodRequest"}}}}, "responses": {"200": {"description": "Payment method attached.", "content": {"application/json": {"schema": {"type": "object", "properties": {"id": {"type": "string"}, "brand": {"type": "string"}, "last4": {"type": "string"}, "exp_month": {"type": "integer"}, "exp_year": {"type": "integer"}, "is_default": {"type": "boolean"}}}}}}, "400": {"$ref": "#/components/responses/ValidationError"}, "401": {"$ref": "#/components/responses/Unauthorized"}}}}, "/api/v1/billing/payment-method/default": {"post": {"tags": ["Billing"], "summary": "Set an attached payment method as the tenant's default.", "operationId": "postDefaultPaymentMethod", "security": [{"BearerAuth": []}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/PaymentMethodRequest"}}}}, "responses": {"200": {"description": "Default payment method updated.", "content": {"application/json": {"schema": {"type": "object", "properties": {"message": {"type": "string"}}}}}}, "400": {"$ref": "#/components/responses/ValidationError"}, "401": {"$ref": "#/components/responses/Unauthorized"}, "404": {"description": "Payment method not found."}}}}, "/api/v1/billing/payment-method/{paymentMethodId}": {"delete": {"tags": ["Billing"], "summary": "Detach a payment method from the tenant's Stripe customer.", "operationId": "deletePaymentMethod", "security": [{"BearerAuth": []}], "parameters": [{"name": "paymentMethodId", "in": "path", "required": true, "schema": {"type": "string", "pattern": "^pm_[A-Za-z0-9]+$"}}], "responses": {"204": {"description": "Payment method detached."}, "400": {"$ref": "#/components/responses/ValidationError"}, "401": {"$ref": "#/components/responses/Unauthorized"}, "404": {"description": "Payment method not found."}}}}, "/api/v1/billing/invoices": {"get": {"tags": ["Billing"], "summary": "List invoices for the tenant.", "operationId": "getInvoices", "security": [{"BearerAuth": []}], "parameters": [{"name": "limit", "in": "query", "schema": {"type": "integer", "minimum": 1, "maximum": 100, "default": 12}, "description": "Page size. A non-integer, zero or negative value is rejected with 400; a value above the maximum is capped at it rather than rejected."}, {"name": "cursor", "in": "query", "schema": {"type": "string"}, "description": "The `next_cursor` from a previous page. A cursor this endpoint did not issue is rejected with 400 `INVALID_CURSOR` rather than silently restarting at page 1."}], "responses": {"200": {"description": "Invoices returned.", "content": {"application/json": {"schema": {"type": "object", "properties": {"invoices": {"type": "array", "items": {"$ref": "#/components/schemas/Invoice"}}, "next_cursor": {"type": ["string", "null"], "description": "Cursor for the next (older) page, or null when this is the last page. Pass it back unmodified as `cursor`."}, "invoices_unavailable": {"type": "boolean", "description": "True when Stripe (the upstream invoice source) is degraded and the list could not be loaded. The empty `invoices` array then means \"temporarily unavailable\", not \"none\"; clients should surface that distinction rather than showing \"no invoices\"."}}}}}}, "401": {"$ref": "#/components/responses/Unauthorized"}}}}, "/api/v1/billing/webhooks": {"post": {"tags": ["Billing"], "summary": "Stripe webhook endpoint.", "operationId": "postBillingWebhook", "security": [], "requestBody": {"required": true, "content": {"application/json": {"schema": {"type": "object", "description": "Stripe event payload.", "additionalProperties": true}}}}, "responses": {"200": {"description": "Event processed."}, "400": {"$ref": "#/components/responses/ValidationError"}, "401": {"$ref": "#/components/responses/Unauthorized"}}}}, "/api/v1/webhooks/stripe": {"post": {"tags": ["Billing"], "summary": "Stripe webhook endpoint (canonical path used by Stripe).", "description": "Canonical Stripe webhook URL: this is what is registered in the Stripe\ndashboard. /api/v1/billing/webhooks remains as an alias for legacy\nclients.\n", "operationId": "postStripeWebhook", "security": [], "requestBody": {"required": true, "content": {"application/json": {"schema": {"type": "object", "description": "Stripe event payload.", "additionalProperties": true}}}}, "responses": {"200": {"description": "Event processed."}, "400": {"$ref": "#/components/responses/ValidationError"}, "401": {"$ref": "#/components/responses/Unauthorized"}}}}, "/api/v1/billing/calculate-pricing": {"post": {"tags": ["Billing"], "summary": "Calculate pricing totals.", "operationId": "postCalculatePricing", "security": [{"BearerAuth": []}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"type": "object", "properties": {"app_count": {"type": "integer", "minimum": 1, "maximum": 1000, "description": "Number of apps/integrations to include in the quote."}, "integration_count": {"type": "integer", "minimum": 1, "maximum": 1000, "deprecated": true, "description": "Deprecated alias for `app_count`; if both are provided they must match."}, "member_count": {"type": "integer", "minimum": 1, "maximum": 10000}, "billing_cycle": {"type": "string", "enum": ["monthly", "annual"]}}}}}}, "responses": {"200": {"description": "Pricing calculated.", "content": {"application/json": {"schema": {"type": "object", "properties": {"calculation": {"type": "object", "additionalProperties": true}, "breakdown": {"type": "object", "additionalProperties": true}}}}}}, "400": {"$ref": "#/components/responses/ValidationError"}}}}, "/api/v1/billing/calculate-price": {"post": {"tags": ["Billing"], "summary": "Calculate pricing totals (alias).", "description": "Alias for /api/v1/billing/calculate-pricing. Provided for backward compatibility.\nNew integrations should use /api/v1/billing/calculate-pricing instead.\n", "operationId": "postCalculatePrice", "deprecated": true, "security": [{"BearerAuth": []}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"type": "object", "properties": {"app_count": {"type": "integer", "minimum": 1, "maximum": 1000, "description": "Number of apps/integrations to include in the quote."}, "integration_count": {"type": "integer", "minimum": 1, "maximum": 1000, "deprecated": true, "description": "Deprecated alias for `app_count`; if both are provided they must match."}, "member_count": {"type": "integer", "minimum": 1, "maximum": 10000}, "billing_cycle": {"type": "string", "enum": ["monthly", "annual"]}}}}}}, "responses": {"200": {"description": "Pricing calculated.", "content": {"application/json": {"schema": {"type": "object", "properties": {"calculation": {"type": "object", "additionalProperties": true}, "breakdown": {"type": "object", "additionalProperties": true}}}}}}, "400": {"$ref": "#/components/responses/ValidationError"}}}}, "/api/v1/billing/create-subscription-v2": {"post": {"tags": ["Billing"], "summary": "Create per-integration subscription.", "description": "Starts the workspace's subscription on the card in `payment_method_id`.\n\nThe per-app price is the workspace's regional pricing tier. A workspace\nthat has subscribed before keeps the tier of its first subscription. A\nnew one gets the tier of the country that issued the card (not the\nbilling address), and that tier is locked on the workspace with the\nsubscription: later cards and plan changes stay on it. Every tier is\nbilled in USD; additional members cost the same on every tier.\n\nQuote the card first with `POST /api/v1/billing/regional-pricing`\n(`payment_method_id`), then pass the quoted tier as\n`expected_pricing_tier`. If the card's tier differs, nothing is created\nand the response is `409 PRICING_TIER_CHANGED`.\n", "operationId": "postCreateSubscriptionV2", "security": [{"BearerAuth": []}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"type": "object", "properties": {"payment_method_id": {"type": "string"}, "integration_count": {"type": "integer"}, "member_count": {"type": "integer"}, "billing_cycle": {"type": "string", "enum": ["monthly", "annual"]}, "expected_pricing_tier": {"type": "string", "enum": ["full", "tier20", "tier40", "tier60"], "description": "The pricing tier the customer was shown and confirmed. When the tier this card is sold on differs, nothing is created and the response is 409 PRICING_TIER_CHANGED."}}}}}}, "responses": {"201": {"description": "Subscription created. `client_secret` is set when the first\ninvoice's PaymentIntent needs confirming (`stripe.confirmCardPayment`).\n`setup_intent_client_secret` is set when the card must be confirmed\nfor later off-session charges, typically 3-D Secure during a trial\n(`stripe.confirmCardSetup`).\n", "content": {"application/json": {"schema": {"type": "object", "properties": {"subscription_id": {"type": "string"}, "status": {"type": "string"}, "pricing_model": {"type": "string"}, "integration_count": {"type": "integer"}, "pricing_tier": {"type": "string", "enum": ["full", "tier20", "tier40", "tier60"], "description": "The regional pricing tier the subscription is billed on."}, "pricing_country": {"type": ["string", "null"], "description": "The country the tier was taken from (ISO 3166-1 alpha-2): the card's issuing country, or the country the workspace was locked on earlier. Null when the card reports none."}, "per_app_price": {"type": "number", "description": "What one app costs per month on this tier, in USD."}, "regional_discount_percentage": {"type": "integer"}, "monthly_cost": {"type": "number", "description": "The plan's monthly list total on this tier, in USD."}, "total": {"type": "number", "description": "What one billing period costs, in USD."}, "effective_monthly_rate": {"type": "number"}, "billing_cycle": {"type": "string", "enum": ["monthly", "annual"]}, "currency": {"type": "string"}, "client_secret": {"type": "string"}, "payment_intent_status": {"type": "string"}, "setup_intent_client_secret": {"type": "string"}, "setup_intent_status": {"type": "string", "enum": ["requires_action", "requires_confirmation", "requires_payment_method"]}}}}}}, "400": {"$ref": "#/components/responses/ValidationError"}, "402": {"description": "The bank refused the card (`CARD_DECLINED`): declined, insufficient funds, expired, or wrong details. Nothing was created; the workspace can submit another card. The reason the bank gave is not returned.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "404": {"$ref": "#/components/responses/NotFound"}, "409": {"description": "The workspace already has a subscription (`CONFLICT`), or the card's pricing tier is not `expected_pricing_tier` (`PRICING_TIER_CHANGED`, with `details.pricing_tier`). Nothing was created.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}}}}, "/api/v1/billing/update-subscription-v2": {"post": {"tags": ["Billing"], "summary": "Update per-integration subscription quantities.", "description": "Changes the purchased app slots (`integration_count`, or the legacy\n`app_count`), team seats (`member_count`) and/or billing cycle. A\nreduction below what the workspace uses is refused with 409\n`PLAN_BELOW_USAGE`; `error.details` carries `resource` (`apps` or\n`members`), `in_use` and `requested`. Remove apps or members first.\nKeeping or raising a quantity is never refused. Changes for one\nworkspace are serialized; one made while another is being saved is\nrefused with 409 `PLAN_CHANGE_IN_PROGRESS` and can be retried. 503\n`USAGE_UNAVAILABLE`: usage could not be verified; 503\n`PLAN_CHANGE_INCOMPLETE`: Stripe refused the change and undoing the\nreserved quota did not complete; retry. 503 `PLAN_CHANGE_NOT_SYNCED`:\nStripe has the change but the workspace record was not updated yet.\n503 `PLAN_CHANGE_TRY_AGAIN`: Stripe was too slow to start the change\nsafely; nothing was changed, retry.\n\n**Quantities** change at once and are prorated on the next invoice.\n\n**A change of billing cycle** (monthly to annual or back) on an active\nsubscription starts a new billing period at the moment of the change\nand is invoiced then. Going to annual, the year is charged at once,\nless the unused time of the current month; the next renewal is one\nyear later. Going to monthly, the unused time of the year is credited:\nnothing is charged, nothing is refunded, and the credit stays on the\naccount, where it pays the following invoices. During a trial the\ncycle changes without a charge and the trial's first invoice is for\nthe new cycle. While a payment is outstanding, or while the\nsubscription is set to end (`error.details.cancellation_scheduled`,\ntrialing or active; with `error.details.cancel_at_period_end` it\nends with its period and can be reactivated first), the cycle\ncannot be changed (409 `BILLING_CYCLE_CHANGE_UNAVAILABLE`).\n\nSend `preview: true` to read what a cycle change will charge without\nchanging anything, then send the change with `expected_amount_due` set\nto the previewed `amount_due_today` (and, when the change leaves\ncredit on the account, `expected_credit_after` set to the previewed\n`credit_balance_after`). A cycle change on an active\nsubscription is never made without it: a request that leaves it out\nis answered 409 `PLAN_AMOUNT_NOT_CONFIRMED`, and one whose amount is\nno longer Stripe's 409 `PLAN_PREVIEW_CHANGED`, both with the current\npreview in `error.details.preview` and nothing changed. If Stripe's\npreview cannot be read, the change is refused (503\n`PLAN_PREVIEW_UNAVAILABLE`).\n\nWhat the confirmed amount is compared with is Stripe's preview taken\nin the same request, immediately before the change. Stripe computes\nthe credit for unused time to the second when it makes the change\nand does not let that moment be fixed in advance, so the invoice can\ndiffer from the confirmed amount by a cent or two when the credit\ncrosses a rounding step in between. The change is not sent when more\nthan two seconds have passed since that preview (503\n`PLAN_CHANGE_TRY_AGAIN`). `charge.amount_charged` is what the invoice\nwas paid.\n\nA cycle change is paid in the same request or not made. A card the\nbank refuses is 402 `CARD_DECLINED`. A card whose bank wants the\ncustomer to confirm the payment (3-D Secure) is 402\n`PAYMENT_NEEDS_BANK`: that confirmation cannot be done through this\noperation yet. In both cases the plan and the billing are unchanged\nand nothing is left open at the payment provider.\n", "operationId": "postUpdateSubscriptionV2", "security": [{"BearerAuth": []}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"type": "object", "properties": {"integration_count": {"type": "integer"}, "member_count": {"type": "integer"}, "billing_cycle": {"type": "string", "enum": ["monthly", "annual"]}, "preview": {"type": "boolean", "description": "Answer with what a change of billing cycle will charge (`preview`) and change nothing. `preview` is null when the request does not change the cycle."}, "expected_amount_due": {"type": "number", "description": "The `amount_due_today` the customer was shown for a cycle change, in the invoice currency. Required for a cycle change on an active subscription (409 PLAN_AMOUNT_NOT_CONFIRMED without it). When Stripe's preview is another amount, nothing is changed (409 PLAN_PREVIEW_CHANGED). Ignored for any other change."}, "expected_credit_after": {"type": "number", "description": "The `credit_balance_after` the customer was shown for a cycle change. Required when the change leaves credit on the account (a switch to monthly billing charges nothing, so the amount due alone does not say what was confirmed): 409 PLAN_AMOUNT_NOT_CONFIRMED without it, 409 PLAN_PREVIEW_CHANGED when the credit is another amount by then. Ignored for any other change."}}}}}}, "responses": {"200": {"description": "The subscription was updated (`updated: true`, with `charge` for a cycle change), or the answer to `preview: true`.", "content": {"application/json": {"schema": {"type": "object", "properties": {"updated": {"type": "boolean"}, "pricing_model": {"type": "string"}, "integration_count": {"type": "integer"}, "member_count": {"type": "integer"}, "billing_cycle": {"type": "string", "enum": ["monthly", "annual"]}, "charge": {"allOf": [{"$ref": "#/components/schemas/BillingCycleChangePreview"}], "description": "What an applied cycle change was invoiced: `amount_charged` is the amount paid on that invoice."}, "preview": {"oneOf": [{"$ref": "#/components/schemas/BillingCycleChangePreview"}, {"type": "null"}]}}}}}}, "400": {"$ref": "#/components/responses/ValidationError"}, "402": {"description": "The charge of a cycle change was not taken: the bank refused the card (`CARD_DECLINED`) or wants the customer to confirm the payment (`PAYMENT_NEEDS_BANK`). The plan is unchanged and nothing stays open.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "409": {"$ref": "#/components/responses/Conflict"}, "503": {"$ref": "#/components/responses/ServiceUnavailable"}}}}, "/api/v1/billing/migrate-subscription": {"post": {"tags": ["Billing"], "summary": "Migrate legacy subscriptions to per-integration pricing.", "description": "Serialized with plan changes for the same workspace: while one is being\nsaved this answers 409 `PLAN_CHANGE_IN_PROGRESS` and can be retried.\nA migration keeps at least the team seats already bought (409\n`MIGRATION_KEEPS_SEATS`, `error.details.seats`); reduce them afterwards\nwith update-subscription-v2. `member_count` below the members in use\nanswers 409 `PLAN_BELOW_USAGE`.\n", "operationId": "postMigrateSubscription", "security": [{"BearerAuth": []}], "requestBody": {"required": false, "content": {"application/json": {"schema": {"type": "object", "properties": {"billing_cycle": {"type": "string", "enum": ["monthly", "annual"]}, "member_count": {"type": "integer"}}}}}}, "responses": {"200": {"description": "Migration initiated.", "content": {"application/json": {"schema": {"type": "object", "properties": {"migration": {"type": "object", "additionalProperties": true}}}}}}, "400": {"$ref": "#/components/responses/ValidationError"}, "401": {"$ref": "#/components/responses/Unauthorized"}, "402": {"$ref": "#/components/responses/ValidationError"}, "409": {"$ref": "#/components/responses/Conflict"}, "503": {"$ref": "#/components/responses/ServiceUnavailable"}}}}, "/api/v1/billing/regional-pricing": {"post": {"tags": ["Billing"], "summary": "Quote the per-app plan on a regional pricing tier.", "description": "Returns the per-app plan's price on a regional pricing tier. There are\nfour tiers (purchasing power parity): the full price, and 20%, 40% and\n60% off the per-app price. Additional members cost the same on every\ntier. Every tier is billed in USD, and the amounts returned are the\namounts charged.\n\nWhich tier is quoted, reported as `regional_pricing.source`:\n\n1. `locked`: the workspace's own tier, when it has subscribed before.\n2. `card`: the tier of the country that issued `payment_method_id`.\n   This is the checkout quote, derived as `create-subscription-v2`\n   derives the price it charges. Workspace owner only.\n3. `country`: the tier of `country_code`, an estimate before a card\n   is known.\n", "operationId": "postRegionalPricing", "security": [{"BearerAuth": []}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/RegionalPricingRequest"}}}}, "responses": {"200": {"description": "Regional pricing calculated.", "content": {"application/json": {"schema": {"type": "object", "properties": {"regional_pricing": {"$ref": "#/components/schemas/RegionalPricingResponse"}, "calculation": {"type": "object", "additionalProperties": true, "description": "The same quote in the shape `calculate-pricing` returns."}, "breakdown": {"type": "object", "additionalProperties": true}}}}}}, "400": {"$ref": "#/components/responses/ValidationError"}, "401": {"$ref": "#/components/responses/Unauthorized"}, "403": {"$ref": "#/components/responses/Forbidden"}, "404": {"$ref": "#/components/responses/NotFound"}}}}, "/api/v1/billing/detect-country": {"get": {"tags": ["Billing"], "summary": "Detect user's country from IP address.", "description": "Detects the user's country based on their IP address for display purposes. Note: Final pricing is always determined by card country, not IP location.", "operationId": "getDetectCountry", "security": [{"BearerAuth": []}], "responses": {"200": {"description": "Country detected successfully.", "content": {"application/json": {"schema": {"type": "object", "properties": {"detected_country": {"type": "string", "description": "ISO 3166-1 alpha-2 country code", "example": "US"}, "pricing_info": {"type": "object", "properties": {"tier": {"type": "string", "enum": ["full", "tier20", "tier40", "tier60"], "description": "Pricing tier based on PPP"}, "discount_rate": {"type": "number", "format": "float", "description": "Discount rate (0.0 to 0.6)", "example": 0.4}, "discount_percentage": {"type": "integer"}, "currency": {"type": "string", "description": "The billing currency. Every tier is billed in USD.", "example": "usd"}, "country_code": {"type": ["string", "null"]}, "monthly_per_app_usd": {"type": "number", "format": "float"}, "annual_per_app_usd": {"type": "number", "format": "float"}, "has_regional_discount": {"type": "boolean"}, "message": {"type": ["string", "null"]}}}, "note": {"type": "string", "example": "Final price will be determined by your payment card country"}, "detection_method": {"type": "string", "enum": ["default", "cloudfront", "geoip_api", "accept_language"], "description": "Evidence used to select the returned country."}}}}}}, "401": {"$ref": "#/components/responses/Unauthorized"}}}}, "/api/v1/billing/usage-dashboard": {"get": {"tags": ["Billing"], "summary": "Get usage dashboard data.", "description": "Returns comprehensive usage data with the subscription billing period, apps, members, AI usage, and cost projections. The AI usage counter is for the current UTC calendar month, which may differ from the Stripe billing period. A temporarily unreadable AI counter returns 503 rather than a usage value of zero.", "operationId": "getUsageDashboard", "security": [{"BearerAuth": []}], "responses": {"200": {"description": "Usage dashboard data returned.", "content": {"application/json": {"schema": {"type": "object", "properties": {"billing_period": {"type": "object", "properties": {"start": {"type": "string", "format": "date"}, "end": {"type": "string", "format": "date"}, "days_remaining": {"type": "integer"}}}, "apps": {"type": "object", "properties": {"used": {"type": "integer"}, "limit": {"type": "integer"}}}, "members": {"type": "object", "properties": {"used": {"type": "integer"}, "limit": {"type": "integer"}}}, "syncs": {"type": "object", "description": "AI-analysis usage and allowance for the current UTC calendar month.", "properties": {"used": {"type": "integer", "description": "AI usage units recorded in the current UTC calendar month."}, "limit": {"type": "integer", "description": "Included units when `allowance_available` is true. It is 0 when the live connected-listing count is confirmed to be zero, matching the monthly overage worker's zero-listing allowance."}, "allowance_available": {"type": "boolean", "description": "False only when the live connected-listing count cannot be confirmed. A confirmed zero count means a zero included allowance."}, "unit": {"type": "string"}, "unit_description": {"type": "string"}}}, "current_cost": {"type": "number", "format": "float", "description": "What the subscription costs per month, in USD: the app slots and seats Stripe bills (not `apps.used` and `members.used`). 0 when no subscription will be charged again."}, "projected_cost": {"type": "number", "format": "float", "description": "`current_cost` plus the projected AI overage when that projection is available. If `ai_overage_projection_available` is false, this is the recurring subscription amount only."}, "ai_overage_billing_enabled": {"type": "boolean", "description": "Whether AI analyses beyond the included amount (`syncs.limit`) are billed. While false, no overage is invoiced and `projected_ai_overage_cost` is 0."}, "ai_overage_projection_available": {"type": "boolean", "description": "False only when the live connected-listing count cannot be confirmed. A confirmed zero count is a zero allowance and is included in the numeric AI overage projection."}, "projected_ai_overage_cost": {"type": ["number", "null"], "format": "float", "description": "The AI-analysis overage included in `projected_cost`; 0 while `ai_overage_billing_enabled` is false, and null only when the live connected-listing count cannot be confirmed."}, "potential_projected_ai_overage_cost": {"type": ["number", "null"], "format": "float", "description": "What the projected overage would cost if overage billing were enabled. Informational only; it is not part of `projected_cost` while `ai_overage_billing_enabled` is false. Null only when the live connected-listing count cannot be confirmed."}}}}}}, "401": {"$ref": "#/components/responses/Unauthorized"}, "404": {"$ref": "#/components/responses/NotFound"}, "503": {"$ref": "#/components/responses/ServiceUnavailable"}}}}, "/api/v1/billing/bill-forecast": {"get": {"tags": ["Billing"], "summary": "Get bill forecast data.", "description": "Returns projected billing amounts over a specified horizon (7d, 30d, or 90d) with confidence intervals. The amounts start from what Stripe bills the workspace (the app slots and seats of its subscription, per month), not from the apps connected. AI overage projections use the current UTC calendar month's usage counter. A confirmed count of zero means a zero included allowance, matching the monthly overage worker. If the live count cannot be confirmed, AI overage is omitted because its allowance is unresolved; the response marks this with `ai_overage_projection_available=false`. A temporarily unreadable AI counter returns 503 rather than an understated projection.", "operationId": "getBillForecast", "security": [{"BearerAuth": []}], "parameters": [{"name": "horizon", "in": "query", "schema": {"type": "string", "enum": ["7d", "30d", "90d"], "default": "30d"}, "description": "Forecast horizon"}], "responses": {"200": {"description": "Bill forecast returned.", "content": {"application/json": {"schema": {"type": "object", "properties": {"points": {"type": "array", "items": {"type": "object", "properties": {"date": {"type": "string", "format": "date"}, "amount": {"type": "number", "format": "float", "description": "The recurring price, on a date the subscription still exists, plus an AI overage when its projection is available and the month will be invoiced. If `ai_overage_projection_available` is false, this amount includes recurring subscription charges only. A subscription set to end is forecast nothing after its end."}, "confidence_lower": {"type": "number", "format": "float"}, "confidence_upper": {"type": "number", "format": "float"}}}}, "current_monthly": {"type": "number", "format": "float", "description": "What the next renewal is charged, per month; 0 when the subscription does not renew."}, "projected_monthly": {"type": "number", "format": "float", "description": "The `amount` of the last point."}, "change_amount": {"type": "number", "format": "float"}, "change_percent": {"type": "number", "format": "float"}, "billed": {"type": "boolean", "description": "Whether the projection contains a known recurring or AI overage charge. When false, no known amount is forecast; if AI overage projection is unavailable, a separate status explains that it is excluded."}, "app_count": {"type": "integer", "description": "The app slots `current_monthly` prices; 0 when the subscription does not renew."}, "member_count": {"type": "integer", "description": "The team seats `current_monthly` prices; 0 when the subscription does not renew."}, "ai_overage_billing_enabled": {"type": "boolean", "description": "Whether AI analyses beyond the included amount are billed. While false the forecast adds no overage."}, "ai_overage_projection_available": {"type": "boolean", "description": "False only when the live connected-listing count cannot be confirmed. A confirmed zero count is included in overage projections as a zero allowance."}, "ai_usage_unit": {"type": "string"}}}}}}, "401": {"$ref": "#/components/responses/Unauthorized"}, "503": {"$ref": "#/components/responses/ServiceUnavailable"}, "404": {"$ref": "#/components/responses/NotFound"}}}}, "/api/v1/billing/pricing": {"get": {"tags": ["Billing"], "summary": "Get pricing information.", "description": "Returns the current pricing model and rates.", "operationId": "getPricing", "security": [{"BearerAuth": []}], "responses": {"200": {"description": "Pricing information returned.", "content": {"application/json": {"schema": {"type": "object", "properties": {"pricing": {"type": "object", "properties": {"model": {"type": "string", "example": "per_app"}, "app_price": {"type": "number", "format": "float", "example": 19.99}, "additional_member_price": {"type": "number", "format": "float", "example": 9.99}, "included_members_per_app": {"type": "integer", "example": 1}, "included_ai_analyses_per_app": {"type": "integer", "example": 5000}, "ai_overage_rate": {"type": "number", "format": "float", "example": 0.005}, "annual_discount": {"type": "number", "format": "float", "example": 0.1}, "trial_days": {"type": "integer", "example": 7}, "ai_overage_billing_enabled": {"type": "boolean", "description": "Whether the established AI overage billing path is enabled."}, "ai_usage_unit": {"type": "string"}, "ai_usage_unit_description": {"type": "string"}}}}}}}}, "401": {"$ref": "#/components/responses/Unauthorized"}}}}, "/api/v1/billing/portal": {"post": {"tags": ["Billing"], "summary": "Create billing portal session.", "description": "Create a Stripe Billing Portal session where customers can manage their subscription, payment methods, and view invoices.", "operationId": "postBillingPortal", "security": [{"BearerAuth": []}], "requestBody": {"content": {"application/json": {"schema": {"type": "object", "properties": {"return_url": {"type": "string", "format": "uri", "description": "URL to redirect to after portal session."}}}}}}, "responses": {"200": {"description": "Portal session created.", "content": {"application/json": {"schema": {"type": "object", "properties": {"url": {"type": "string", "format": "uri", "description": "Stripe Billing Portal URL."}}}}}}, "401": {"$ref": "#/components/responses/Unauthorized"}}}}, "/api/v1/billing/payment-methods": {"get": {"tags": ["Billing"], "summary": "List payment methods.", "description": "List all saved payment methods (cards) for the tenant.", "operationId": "getBillingPaymentMethods", "security": [{"BearerAuth": []}], "responses": {"200": {"description": "Payment methods returned.", "content": {"application/json": {"schema": {"type": "object", "properties": {"payment_methods": {"type": "array", "items": {"$ref": "#/components/schemas/PaymentMethod"}}}}}}}, "401": {"$ref": "#/components/responses/Unauthorized"}}}}, "/api/v1/billing/plans": {"get": {"tags": ["Billing"], "summary": "Get available plans.", "description": "Get available subscription plans with pricing details. Requires authentication: an unauthenticated call answers 401, not a public price list.", "operationId": "getBillingPlans", "security": [{"BearerAuth": []}], "responses": {"200": {"description": "Plans returned.", "content": {"application/json": {"schema": {"type": "object", "required": ["plans"], "properties": {"plans": {"type": "array", "items": {"$ref": "#/components/schemas/BillingPlan"}}}}}}}}}}, "/api/v1/billing/setup-intent": {"post": {"tags": ["Billing"], "summary": "Create setup intent.", "description": "Create a Stripe SetupIntent for securely collecting payment method details without an immediate charge.", "operationId": "postBillingSetupIntent", "security": [{"BearerAuth": []}], "responses": {"200": {"description": "SetupIntent created.", "content": {"application/json": {"schema": {"type": "object", "properties": {"client_secret": {"type": "string", "description": "Client secret for Stripe.js."}, "setup_intent_id": {"type": "string", "description": "SetupIntent ID."}}}}}}, "401": {"$ref": "#/components/responses/Unauthorized"}}}}, "/api/v1/billing/validate-promo": {"post": {"tags": ["Billing"], "summary": "Validate promo code.", "description": "Validate a promotional or coupon code for discounts.", "operationId": "postBillingValidatePromo", "security": [{"BearerAuth": []}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"type": "object", "required": ["code"], "properties": {"code": {"type": "string", "description": "Promo code to validate.", "example": "SAVE20"}}}}}}, "responses": {"200": {"description": "Validation result.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/PromoValidationResult"}}}}, "401": {"$ref": "#/components/responses/Unauthorized"}}}}, "/api/v1/billing/upcoming-invoice": {"get": {"tags": ["Billing"], "summary": "Get upcoming invoice.", "description": "Get a preview of the next invoice including prorated charges.", "operationId": "getBillingUpcomingInvoice", "security": [{"BearerAuth": []}], "responses": {"200": {"description": "Upcoming invoice returned.", "content": {"application/json": {"schema": {"type": "object", "properties": {"upcoming_invoice": {"oneOf": [{"$ref": "#/components/schemas/UpcomingInvoice"}, {"type": "null"}], "description": "Null when the tenant has no Stripe subscription, so there is no forthcoming invoice to preview."}}}}}}, "401": {"$ref": "#/components/responses/Unauthorized"}}}}, "/api/v1/billing/usage": {"get": {"tags": ["Billing"], "summary": "Get usage data.", "description": "Returns usage metrics with the subscription period dates. The `ai_analyses` counter and included AI allowance cover the current UTC calendar month, which can differ from the subscription billing period. A temporarily unreadable AI counter returns 503 rather than a usage value of zero.", "operationId": "getBillingUsage", "security": [{"BearerAuth": []}], "responses": {"200": {"description": "Usage data returned.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/BillingUsage"}}}}, "401": {"$ref": "#/components/responses/Unauthorized"}, "503": {"$ref": "#/components/responses/ServiceUnavailable"}}}}, "/api/v1/integrations": {"get": {"tags": ["Integrations"], "summary": "List configured integrations.", "description": "The workspace's connections to other tools. Requires the owner or admin role. For Slack, Microsoft Teams, Jira, Linear, GitHub, Discord and Asana only the newest connection is listed, because connecting again replaces the earlier one. A disconnected connection is not listed, except a Microsoft Teams connection whose credential cleanup is still pending (`config.credential_cleanup_pending`), so that it can be disconnected again. The answer holds every connection; it is not paged.", "operationId": "getIntegrations", "security": [{"BearerAuth": []}], "responses": {"200": {"description": "Integrations returned.", "content": {"application/json": {"schema": {"type": "object", "required": ["availability", "integrations", "count"], "properties": {"availability": {"type": "object", "description": "What this workspace can connect. Always holds `teams`: whether Microsoft Teams can be connected and used. False keeps Teams unavailable, so a new connection is refused and an older connection delivers nothing.", "additionalProperties": {"type": "boolean"}}, "integrations": {"type": "array", "items": {"$ref": "#/components/schemas/Integration"}}, "count": {"type": "integer", "minimum": 0, "description": "Number of integrations returned in this response, rather than the workspace total."}}}}}}, "401": {"$ref": "#/components/responses/Unauthorized"}, "402": {"$ref": "#/components/responses/SubscriptionRequired"}, "403": {"$ref": "#/components/responses/IntegrationAdminForbidden"}, "500": {"description": "`QUERY_FAILED`: the connections could not be read. Nothing is answered as an empty list.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "503": {"$ref": "#/components/responses/SubscriptionUnverified"}}}, "post": {"tags": ["Integrations"], "summary": "Connect integration.", "description": "Connect a tool named by `integration_type`. The same as `POST /api/v1/integrations/{integrationType}/connect`, which takes the tool from the path; see that operation for what each tool needs and what it answers.", "operationId": "connectIntegration", "security": [{"BearerAuth": []}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/IntegrationConnectRequest"}}}}, "responses": {"200": {"$ref": "#/components/responses/IntegrationOAuthStarted"}, "201": {"$ref": "#/components/responses/IntegrationConnected"}, "400": {"$ref": "#/components/responses/IntegrationConnectRefused"}, "401": {"$ref": "#/components/responses/Unauthorized"}, "402": {"$ref": "#/components/responses/SubscriptionRequired"}, "403": {"$ref": "#/components/responses/IntegrationAdminForbidden"}, "500": {"$ref": "#/components/responses/IntegrationConnectFailed"}, "503": {"$ref": "#/components/responses/IntegrationConnectUnavailable"}}}}, "/api/v1/integrations/slack/channels": {"get": {"security": [{"BearerAuth": []}], "tags": ["Integrations"], "summary": "List public channels in the connected Slack workspace.", "description": "Requires an administrator or owner. Reads Slack using the workspace's stored Slack credential; never sends a message. Answers up to 100 public, unarchived channels per page, as Slack lists them.", "operationId": "getSlackChannels", "parameters": [{"name": "cursor", "in": "query", "schema": {"type": "string", "maxLength": 2048}, "description": "The `next_cursor` of the previous page. Longer than 2048 characters answers 400."}], "responses": {"200": {"description": "Available public channels and a cursor for the next page.", "content": {"application/json": {"schema": {"type": "object", "required": ["channels", "next_cursor"], "properties": {"channels": {"type": "array", "items": {"type": "object", "required": ["id", "name"], "properties": {"id": {"type": "string"}, "name": {"type": "string"}}}}, "next_cursor": {"type": ["string", "null"], "description": "Null on the last page."}}}}}}, "400": {"description": "`NO_INTEGRATION`: Slack is not connected. `NO_CREDENTIALS`: the connection has no Slack credential of this workspace; reconnect Slack. `VALIDATION_ERROR`: the cursor is too long.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "401": {"$ref": "#/components/responses/Unauthorized"}, "402": {"$ref": "#/components/responses/SubscriptionRequired"}, "403": {"$ref": "#/components/responses/IntegrationAdminForbidden"}, "500": {"$ref": "#/components/responses/InternalError"}, "502": {"description": "`CREDENTIALS_UNAVAILABLE`: the Slack credential could not be read. `SLACK_API_ERROR`: Slack could not be reached, refused the request, or answered a channel list that could not be read.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "503": {"description": "`LOOKUP_FAILED`: the Slack connection could not be read; try again. `SERVICE_UNAVAILABLE`: the workspace's subscription could not be verified.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}}}}, "/api/v1/integrations/{integrationId}": {"get": {"tags": ["Integrations"], "summary": "Retrieve one integration.", "description": "One connection of the workspace. An owner or admin can read any of them; anyone else only a connection they connected. An API key acts as a member, so it can read only a connection its holder connected, and needs the `integrations:read` scope.", "operationId": "getIntegration", "parameters": [{"$ref": "#/components/parameters/IntegrationId"}], "responses": {"200": {"description": "Integration returned.", "content": {"application/json": {"schema": {"type": "object", "required": ["integration"], "properties": {"integration": {"$ref": "#/components/schemas/Integration"}}}}}}, "401": {"$ref": "#/components/responses/Unauthorized"}, "402": {"$ref": "#/components/responses/SubscriptionRequired"}, "403": {"description": "`FORBIDDEN`: an API key without the `integrations:read` scope.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "404": {"$ref": "#/components/responses/IntegrationNotFound"}, "500": {"$ref": "#/components/responses/InternalError"}, "503": {"$ref": "#/components/responses/SubscriptionUnverified"}}}, "patch": {"tags": ["Integrations"], "summary": "Update an integration.", "description": "The same as `PUT /api/v1/integrations/{integrationId}`: the two methods are one operation.", "operationId": "patchIntegration", "security": [{"BearerAuth": []}], "parameters": [{"$ref": "#/components/parameters/IntegrationId"}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/IntegrationUpdateRequest"}}}}, "responses": {"200": {"$ref": "#/components/responses/IntegrationUpdated"}, "400": {"$ref": "#/components/responses/IntegrationUpdateRefused"}, "401": {"$ref": "#/components/responses/Unauthorized"}, "402": {"$ref": "#/components/responses/SubscriptionRequired"}, "403": {"$ref": "#/components/responses/IntegrationAdminForbidden"}, "404": {"$ref": "#/components/responses/IntegrationNotFound"}, "500": {"$ref": "#/components/responses/IntegrationUpdateFailed"}, "502": {"$ref": "#/components/responses/IntegrationSlackCheckFailed"}, "503": {"$ref": "#/components/responses/IntegrationTeamsUnavailable"}}}, "delete": {"tags": ["Integrations"], "summary": "Disconnect an integration.", "description": "Requires the owner or admin role. For Slack and Microsoft Teams the connection is kept as a hidden record, so that an older connection cannot become the current one again: delivery stops at once and the stored credential is deleted. A Slack or Teams connection disconnected while its credential cleanup failed can be disconnected again by its id. For every other tool the connection and its stored credential are deleted.", "operationId": "deleteIntegration", "security": [{"BearerAuth": []}], "parameters": [{"$ref": "#/components/parameters/IntegrationId"}], "responses": {"200": {"description": "Integration disconnected.", "content": {"application/json": {"schema": {"type": "object", "required": ["deleted", "integration_id"], "properties": {"deleted": {"type": "boolean", "const": true}, "integration_id": {"type": "string"}}}}}}, "401": {"$ref": "#/components/responses/Unauthorized"}, "402": {"$ref": "#/components/responses/SubscriptionRequired"}, "403": {"$ref": "#/components/responses/IntegrationAdminForbidden"}, "404": {"$ref": "#/components/responses/IntegrationNotFound"}, "500": {"description": "`DELETE_FAILED`: the connection could not be disconnected and is as it was. `INTERNAL_ERROR`: the connection could not be read.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "503": {"description": "`CREDENTIAL_CLEANUP_PENDING`: the Slack or Teams connection is disconnected and delivers nothing, but its stored credential could not be deleted yet; disconnect it again. `LOOKUP_FAILED`: a disconnected connection could not be read; try again. `SERVICE_UNAVAILABLE`: the workspace's subscription could not be verified.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}}}, "put": {"tags": ["Integrations"], "summary": "Update an integration.", "description": "Requires the owner or admin role. Changes the connection's `name`, `status`, `config` or `metadata`; `config` and `metadata` are merged into the stored objects. A disconnected connection answers 404.", "operationId": "updateIntegrationSafe", "security": [{"BearerAuth": []}], "parameters": [{"$ref": "#/components/parameters/IntegrationId"}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/IntegrationUpdateRequest"}}}}, "responses": {"200": {"$ref": "#/components/responses/IntegrationUpdated"}, "400": {"$ref": "#/components/responses/IntegrationUpdateRefused"}, "401": {"$ref": "#/components/responses/Unauthorized"}, "402": {"$ref": "#/components/responses/SubscriptionRequired"}, "403": {"$ref": "#/components/responses/IntegrationAdminForbidden"}, "404": {"$ref": "#/components/responses/IntegrationNotFound"}, "500": {"$ref": "#/components/responses/IntegrationUpdateFailed"}, "502": {"$ref": "#/components/responses/IntegrationSlackCheckFailed"}, "503": {"$ref": "#/components/responses/IntegrationTeamsUnavailable"}}}}, "/api/v1/integrations/{integrationId}/config": {"put": {"tags": ["Integrations"], "summary": "Save an integration's settings.", "description": "Requires the owner or admin role. The body is the settings themselves, not wrapped: it is merged into the connection's `config` as `PUT /api/v1/integrations/{integrationId}` merges a `config` member. Use that operation to change the name, status or metadata. An empty body changes nothing but `updated_at`.", "operationId": "putIntegrationConfig", "security": [{"BearerAuth": []}], "parameters": [{"$ref": "#/components/parameters/IntegrationId"}], "requestBody": {"required": false, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/IntegrationConfigUpdate"}}}}, "responses": {"200": {"$ref": "#/components/responses/IntegrationUpdated"}, "400": {"$ref": "#/components/responses/IntegrationUpdateRefused"}, "401": {"$ref": "#/components/responses/Unauthorized"}, "402": {"$ref": "#/components/responses/SubscriptionRequired"}, "403": {"$ref": "#/components/responses/IntegrationAdminForbidden"}, "404": {"$ref": "#/components/responses/IntegrationNotFound"}, "500": {"$ref": "#/components/responses/IntegrationUpdateFailed"}, "502": {"$ref": "#/components/responses/IntegrationSlackCheckFailed"}, "503": {"$ref": "#/components/responses/IntegrationTeamsUnavailable"}}}, "patch": {"tags": ["Integrations"], "summary": "Save an integration's settings.", "description": "The same as `PUT /api/v1/integrations/{integrationId}/config`: the two methods are one operation.", "operationId": "patchIntegrationConfig", "security": [{"BearerAuth": []}], "parameters": [{"$ref": "#/components/parameters/IntegrationId"}], "requestBody": {"required": false, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/IntegrationConfigUpdate"}}}}, "responses": {"200": {"$ref": "#/components/responses/IntegrationUpdated"}, "400": {"$ref": "#/components/responses/IntegrationUpdateRefused"}, "401": {"$ref": "#/components/responses/Unauthorized"}, "402": {"$ref": "#/components/responses/SubscriptionRequired"}, "403": {"$ref": "#/components/responses/IntegrationAdminForbidden"}, "404": {"$ref": "#/components/responses/IntegrationNotFound"}, "500": {"$ref": "#/components/responses/IntegrationUpdateFailed"}, "502": {"$ref": "#/components/responses/IntegrationSlackCheckFailed"}, "503": {"$ref": "#/components/responses/IntegrationTeamsUnavailable"}}}}, "/api/v1/integrations/{integrationId}/test": {"post": {"tags": ["Integrations"], "summary": "Send a test message through an integration.", "description": "Requires the owner or admin role. Only an active Discord or Microsoft Teams connection can be tested: the test posts one message in its channel. Any other tool answers 400 `TEST_NOT_SUPPORTED`, and nothing is sent. At most 10 tests a minute per workspace.", "operationId": "postIntegrationTest", "security": [{"BearerAuth": []}], "parameters": [{"$ref": "#/components/parameters/IntegrationId"}], "requestBody": {"required": false, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/IntegrationTestRequest"}}}}, "responses": {"200": {"description": "The message was sent. For Discord, `result.success` says whether Discord accepted it (any 2xx answer): a refused message still answers 200. For Teams, `success` and `result.success` are true.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/IntegrationTestResponse"}}}}, "400": {"description": "Nothing was sent. `INTEGRATION_INACTIVE`: the connection is not active. `TEST_NOT_SUPPORTED`: the tool cannot be tested. `INTEGRATION_NOT_IMPLEMENTED`: Microsoft Teams is unavailable for this workspace, or the tool has no delivery yet. `NO_WEBHOOK`: the Discord connection has no address. `INVALID_WEBHOOK_URL`: the stored address fails the checks a new one must pass; reconnect.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "401": {"$ref": "#/components/responses/Unauthorized"}, "402": {"$ref": "#/components/responses/SubscriptionRequired"}, "403": {"$ref": "#/components/responses/IntegrationAdminForbidden"}, "404": {"$ref": "#/components/responses/IntegrationNotFound"}, "429": {"$ref": "#/components/responses/IntegrationRateLimited"}, "500": {"description": "`TEST_FAILED`: Discord could not be reached, so the test was not sent.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "502": {"description": "For Discord, an error: `DNS_RESOLUTION_FAILED`, the address did not resolve, or `WEBHOOK_VALIDATION_FAILED`, it could not be checked; nothing was sent. For Teams, the test answer with `success` false: Teams did not accept the message (see `result.error`).", "content": {"application/json": {"schema": {"anyOf": [{"$ref": "#/components/schemas/ErrorResponse"}, {"$ref": "#/components/schemas/IntegrationTestResponse"}]}}}}, "503": {"$ref": "#/components/responses/IntegrationTeamsUnavailable"}}}}, "/api/v1/integrations/jira/callback": {"get": {"tags": ["Integrations"], "summary": "Complete Jira OAuth exchange.", "description": "Called by the dashboard's callback page after Atlassian sends the person back. Requires an owner or admin of the workspace that started the connection (`POST /api/v1/integrations/jira/connect`). Exchanges the code with Atlassian, stores the tokens and connects Jira with the first Jira site the person allowed.", "operationId": "getJiraCallback", "security": [{"BearerAuth": []}], "parameters": [{"name": "state", "in": "query", "schema": {"type": "string"}, "description": "The `state` the connect call answered. It works once and expires after 10 minutes."}, {"name": "code", "in": "query", "schema": {"type": "string"}}, {"name": "error", "in": "query", "schema": {"type": "string"}, "description": "OAuth error code Atlassian appends when the user denies consent. Present instead of `code`; the endpoint answers 400 `OAUTH_ERROR`."}, {"name": "error_description", "in": "query", "schema": {"type": "string"}, "description": "Human-readable detail for `error`, surfaced in the 400 message."}], "responses": {"201": {"$ref": "#/components/responses/IntegrationConnected"}, "400": {"$ref": "#/components/responses/IntegrationOAuthRefused"}, "401": {"$ref": "#/components/responses/Unauthorized"}, "402": {"$ref": "#/components/responses/SubscriptionRequired"}, "403": {"$ref": "#/components/responses/IntegrationOAuthForbidden"}, "500": {"$ref": "#/components/responses/IntegrationConnectFailed"}, "502": {"$ref": "#/components/responses/IntegrationOAuthUnreachable"}, "503": {"$ref": "#/components/responses/IntegrationOAuthUnavailable"}}}}, "/api/v1/integrations/jira/post": {"post": {"tags": ["Integrations"], "summary": "Create a Jira issue.", "description": "Requires the owner or admin role. Creates one issue through the workspace's Jira connection. At most 30 issues a minute per workspace, shared with the other create operations of this tag.", "operationId": "postJiraIncident", "security": [{"BearerAuth": []}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"type": "object", "description": "`summary` or `title` is required.", "properties": {"summary": {"type": "string", "description": "The issue's summary. Cut to 1000 characters."}, "title": {"type": "string", "description": "Read as the summary when `summary` is left out."}, "description": {"type": "string", "description": "The issue's description, as plain text. Cut to 10000 characters."}, "project_key": {"type": "string", "description": "The Jira project's key: a letter, then 1 to 9 letters or digits, in any letter case. Left out, the connection's `config.default_project_key` is used; with neither, 400."}, "issue_type": {"type": "string", "description": "`Task`, `Bug`, `Story`, `Epic`, `Sub-task`, `Improvement` or `New Feature`; any other value creates a `Task`. Left out, the connection's `config.default_issue_type`, then `Task`."}}}}}}, "responses": {"201": {"description": "Issue created.", "content": {"application/json": {"schema": {"type": "object", "required": ["ticket_id", "ticket_key", "url"], "properties": {"ticket_id": {"type": "string", "description": "Jira's numeric id of the issue."}, "ticket_key": {"type": "string", "description": "The issue's key, for example `APP-12`."}, "url": {"type": "string", "description": "The issue under `https://api.atlassian.com/ex/jira/<site>/browse/`, built from the site id."}}}}}}, "400": {"description": "`NO_INTEGRATION`: Jira is not connected. `NO_CREDENTIALS` or `INVALID_CREDENTIALS`: the connection's credential is missing or incomplete; reconnect Jira. `VALIDATION_ERROR`: no summary, or no usable project key.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "401": {"$ref": "#/components/responses/Unauthorized"}, "402": {"$ref": "#/components/responses/SubscriptionRequired"}, "403": {"$ref": "#/components/responses/IntegrationAdminForbidden"}, "429": {"$ref": "#/components/responses/IntegrationRateLimited"}, "500": {"description": "`CREDENTIALS_ERROR`: the Jira credential could not be read.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "502": {"description": "`JIRA_API_ERROR`: Jira could not be reached or refused the issue.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "503": {"$ref": "#/components/responses/SubscriptionUnverified"}}}}, "/api/v1/integrations/slack/callback": {"get": {"tags": ["Integrations"], "summary": "Complete Slack OAuth exchange.", "description": "Called by the dashboard's callback page after Slack sends the person back. Requires an owner or admin of the workspace that started the connection (`POST /api/v1/integrations/slack/connect`). Exchanges the code with Slack, stores the bot token and connects Slack; the new connection replaces any earlier one.", "operationId": "getSlackCallback", "security": [{"BearerAuth": []}], "parameters": [{"name": "code", "in": "query", "schema": {"type": "string"}}, {"name": "state", "in": "query", "schema": {"type": "string"}, "description": "The `state` the connect call answered. It works once and expires after 10 minutes."}, {"name": "error", "in": "query", "schema": {"type": "string"}, "description": "OAuth error code Slack appends when the user denies consent. Present instead of `code`; the endpoint answers 400 `OAUTH_ERROR`."}, {"name": "error_description", "in": "query", "schema": {"type": "string"}, "description": "Human-readable detail for `error`, surfaced in the 400 message."}], "responses": {"201": {"$ref": "#/components/responses/IntegrationConnected"}, "400": {"$ref": "#/components/responses/IntegrationOAuthRefused"}, "401": {"$ref": "#/components/responses/Unauthorized"}, "402": {"$ref": "#/components/responses/SubscriptionRequired"}, "403": {"$ref": "#/components/responses/IntegrationOAuthForbidden"}, "500": {"$ref": "#/components/responses/IntegrationConnectFailed"}, "502": {"$ref": "#/components/responses/IntegrationOAuthUnreachable"}, "503": {"$ref": "#/components/responses/IntegrationOAuthUnavailable"}}}}, "/api/v1/pam/requests": {"get": {"tags": ["PAM"], "summary": "List PAM requests.", "description": "List privileged access requests. The scope follows the caller's role: owners and admins receive every request for their tenant, and any other role receives only its own. The scope is not selectable and the list is unpaged; pass `status` to return only requests in that state.", "operationId": "getPamRequests", "security": [{"BearerAuth": []}], "parameters": [{"name": "status", "in": "query", "required": false, "description": "Return only requests in this state. Omit to return every request in scope.", "schema": {"type": "string", "enum": ["pending", "approved", "denied", "expired", "revoked"]}}], "responses": {"200": {"description": "List of PAM requests.", "content": {"application/json": {"schema": {"type": "object", "required": ["requests"], "properties": {"requests": {"type": "array", "items": {"$ref": "#/components/schemas/PamRequest"}}}}}}}, "401": {"$ref": "#/components/responses/Unauthorized"}, "403": {"$ref": "#/components/responses/Forbidden"}}}, "post": {"tags": ["PAM"], "summary": "Create a PAM request.", "description": "Create a new privileged access request. Support agents must supply tenant_id; tenant admins/members implicitly use their tenant. Request is sent to tenant owners/admins for approval.", "operationId": "postPamRequest", "security": [{"BearerAuth": []}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/PamRequestCreate"}}}}, "responses": {"201": {"description": "PAM request created.", "content": {"application/json": {"schema": {"type": "object", "required": ["request"], "properties": {"request": {"$ref": "#/components/schemas/PamRequest"}}}}}}, "400": {"$ref": "#/components/responses/ValidationError"}, "401": {"$ref": "#/components/responses/Unauthorized"}, "403": {"$ref": "#/components/responses/Forbidden"}, "429": {"description": "`RATE_LIMIT_EXCEEDED`: this person has asked for privileged access five times in the last hour. Nothing was stored.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}}}}, "/api/v1/pam/requests/{request_id}/approve": {"post": {"tags": ["PAM"], "summary": "Approve a PAM request.", "description": "Approve a pending privileged access request and issue a session. Only tenant owners and admins can approve requests. A request that does not exist in the caller's workspace, is no longer pending, or is the caller's own answers 400 `VALIDATION_ERROR`, not 404.", "operationId": "postPamRequestApprove", "security": [{"BearerAuth": []}], "parameters": [{"in": "path", "name": "request_id", "required": true, "schema": {"type": "string"}, "description": "The PAM request ID to approve."}], "requestBody": {"content": {"application/json": {"schema": {"type": "object", "properties": {"duration_minutes": {"type": "integer", "minimum": 5, "maximum": 480, "description": "Session length to grant, in minutes (5 to 480). When omitted, the request is approved for the length it asked for. A value outside the range is refused with a 400."}}}}}}, "responses": {"200": {"description": "Request approved, session issued.", "content": {"application/json": {"schema": {"type": "object", "required": ["request", "session"], "properties": {"request": {"$ref": "#/components/schemas/PamRequest"}, "session": {"$ref": "#/components/schemas/PamSession"}}}}}}, "400": {"$ref": "#/components/responses/ValidationError"}, "401": {"$ref": "#/components/responses/Unauthorized"}, "403": {"$ref": "#/components/responses/Forbidden"}, "404": {"$ref": "#/components/responses/NotFound"}}}}, "/api/v1/pam/requests/{request_id}/deny": {"post": {"tags": ["PAM"], "summary": "Deny a PAM request.", "description": "Deny a pending privileged access request with an optional reason. Only tenant owners and admins can deny requests. A request that does not exist in the caller's workspace or is no longer pending answers 400 `VALIDATION_ERROR`, not 404.", "operationId": "postPamRequestDeny", "security": [{"BearerAuth": []}], "parameters": [{"in": "path", "name": "request_id", "required": true, "schema": {"type": "string"}, "description": "The PAM request ID to deny."}], "requestBody": {"content": {"application/json": {"schema": {"type": "object", "properties": {"reason": {"type": "string", "maxLength": 500, "description": "Reason for denial."}}}}}}, "responses": {"200": {"description": "Request denied.", "content": {"application/json": {"schema": {"type": "object", "required": ["request"], "properties": {"request": {"$ref": "#/components/schemas/PamRequest"}}}}}}, "400": {"$ref": "#/components/responses/ValidationError"}, "401": {"$ref": "#/components/responses/Unauthorized"}, "403": {"$ref": "#/components/responses/Forbidden"}, "404": {"$ref": "#/components/responses/NotFound"}}}}, "/api/v1/pam/sessions": {"get": {"tags": ["PAM"], "summary": "List PAM sessions.", "description": "Privileged sessions for the workspace identified by the access token, newest first, up to 50: active and revoked sessions alike (read `status`). Requires the owner or admin role. A session names its requester by `requester_user_id` only; the access request it came from (`request_id`) carries their email. Cross-workspace filtering is not supported.", "operationId": "getPamSessions", "security": [{"BearerAuth": []}], "responses": {"200": {"description": "The workspace's sessions, newest first.", "content": {"application/json": {"schema": {"type": "object", "required": ["sessions", "count"], "properties": {"sessions": {"type": "array", "items": {"$ref": "#/components/schemas/PamSession"}}, "count": {"type": "integer", "minimum": 0, "description": "Number of sessions returned in this response, rather than the workspace total."}}}}}}, "401": {"$ref": "#/components/responses/Unauthorized"}, "403": {"$ref": "#/components/responses/Forbidden"}}}}, "/api/v1/pam/sessions/{session_id}": {"delete": {"tags": ["PAM"], "summary": "Revoke a PAM session.", "description": "Revoke an active privileged session in the caller's own workspace. Requires the owner or admin role. A session that does not exist, is in another workspace or is no longer active answers 404. A session that could not be read answers 500, and is still active.", "operationId": "deletePamSession", "security": [{"BearerAuth": []}], "parameters": [{"in": "path", "name": "session_id", "required": true, "schema": {"type": "string"}, "description": "The session ID to revoke."}], "responses": {"200": {"description": "Session revoked.", "content": {"application/json": {"schema": {"type": "object", "required": ["success"], "properties": {"success": {"type": "boolean", "description": "Always `true`."}, "message": {"type": "string", "deprecated": true, "description": "Not returned and never was."}}}}}}, "401": {"$ref": "#/components/responses/Unauthorized"}, "403": {"$ref": "#/components/responses/Forbidden"}, "404": {"$ref": "#/components/responses/NotFound"}, "500": {"description": "`REVOKE_FAILED`: the session could not be read or revoked, so it may still be active. Retry.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}}}}, "/api/v1/pam/events": {"get": {"tags": ["PAM"], "summary": "Query PAM audit events.", "description": "Privileged access audit log for the caller's own tenant: one event per access request, for the 50 most recent requests, newest first. Requires the owner or admin role. There is no filtering or paging: query parameters are ignored and `next_cursor` is always null.", "operationId": "getPamEvents", "security": [{"BearerAuth": []}], "responses": {"200": {"description": "List of audit events.", "content": {"application/json": {"schema": {"type": "object", "required": ["events", "next_cursor"], "properties": {"events": {"type": "array", "items": {"$ref": "#/components/schemas/PamEvent"}}, "next_cursor": {"type": ["string", "null"]}}}}}}, "401": {"$ref": "#/components/responses/Unauthorized"}, "403": {"$ref": "#/components/responses/Forbidden"}}}}, "/api/v1/workflows": {"get": {"tags": ["Workflows"], "summary": "List workflows.", "description": "List all workflows for the authenticated user's tenant. Supports filtering by status.", "operationId": "getWorkflows", "parameters": [{"in": "query", "name": "status", "schema": {"type": "string", "enum": ["active", "paused", "draft", "all"]}, "description": "Filter by workflow status."}, {"in": "query", "name": "limit", "schema": {"type": "integer", "minimum": 1, "maximum": 100, "default": 50}, "description": "Maximum number of workflows to return. A value below the minimum, or a non-integer, is rejected with 400 rather than silently clamped; a value above the maximum is capped at it."}, {"name": "cursor", "in": "query", "schema": {"type": "string"}, "description": "Opaque continuation token from a previous page. Pass it to read the next page."}], "responses": {"200": {"description": "List of workflows.", "content": {"application/json": {"schema": {"type": "object", "properties": {"action_availability": {"type": "object", "description": "Workspace-scoped action availability; absent or false keeps Teams unavailable.", "additionalProperties": {"type": "boolean"}}, "workflows": {"type": "array", "items": {"$ref": "#/components/schemas/Workflow"}}, "review_delivery": {"type": "object", "description": "Active review delivery limits, including staged rollout and rollback.", "required": ["mode", "reviews_per_sync", "reviews_when_long", "runs_per_delivery"], "properties": {"mode": {"type": "string", "enum": ["legacy", "retained"]}, "reviews_per_sync": {"type": "integer", "enum": [20, 500]}, "reviews_when_long": {"type": "integer", "enum": [5, 500]}, "runs_per_delivery": {"type": "integer", "enum": [25]}}}, "count": {"type": "integer", "description": "Number of workflows on this page."}, "next_cursor": {"type": ["string", "null"], "description": "Pass as `cursor` to read the next page. Null on the last page."}}}}}}, "401": {"$ref": "#/components/responses/Unauthorized"}}}, "post": {"tags": ["Workflows"], "summary": "Create a workflow.", "description": "Create a new automation workflow with triggers, conditions, and actions.", "operationId": "postWorkflow", "security": [{"BearerAuth": []}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/CreateWorkflowRequest"}}}}, "responses": {"201": {"description": "Workflow created.", "content": {"application/json": {"schema": {"type": "object", "properties": {"workflow": {"$ref": "#/components/schemas/Workflow"}, "warnings": {"type": "array", "description": "Present only when the workflow was created active and an action's connection is missing: the workflow is active, and each run will skip that action until the connection exists.", "items": {"$ref": "#/components/schemas/WorkflowActivationWarning"}}}}}}}, "400": {"$ref": "#/components/responses/ValidationError"}, "401": {"$ref": "#/components/responses/Unauthorized"}, "503": {"$ref": "#/components/responses/ServiceUnavailable"}}}}, "/api/v1/workflows/from-template": {"post": {"tags": ["Workflows"], "summary": "Create workflow from template.", "description": "Create a new workflow based on a built-in template. Placeholders in the template can be customized. A `cron` placeholder is held to the same rules as a hand-built schedule (see `ScheduleConfig`) and refused with the same 400 codes.", "operationId": "postWorkflowFromTemplate", "security": [{"BearerAuth": []}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/CreateFromTemplateRequest"}}}}, "responses": {"201": {"description": "Workflow created from template.", "content": {"application/json": {"schema": {"type": "object", "properties": {"workflow": {"$ref": "#/components/schemas/Workflow"}, "warnings": {"type": "array", "description": "Present only when the workflow was created active and an action's connection is missing: the workflow is active, and each run will skip that action until the connection exists.", "items": {"$ref": "#/components/schemas/WorkflowActivationWarning"}}}}}}}, "400": {"$ref": "#/components/responses/ValidationError"}, "401": {"$ref": "#/components/responses/Unauthorized"}, "404": {"$ref": "#/components/responses/NotFound"}, "503": {"$ref": "#/components/responses/ServiceUnavailable"}}}}, "/api/v1/workflow-templates": {"get": {"tags": ["Workflows"], "summary": "List workflow templates.", "description": "List available built-in workflow templates that can be used to quickly create common automation patterns.", "operationId": "getWorkflowTemplates", "parameters": [{"in": "query", "name": "category", "schema": {"type": "string", "enum": ["monitoring", "alerting", "reporting", "competitive"]}, "description": "Filter by template category."}], "responses": {"200": {"description": "List of templates.", "content": {"application/json": {"schema": {"type": "object", "properties": {"templates": {"type": "array", "items": {"$ref": "#/components/schemas/WorkflowTemplate"}}, "count": {"type": "integer", "minimum": 0, "description": "Number of templates returned in this response, rather than the workspace total."}}}}}}, "401": {"$ref": "#/components/responses/Unauthorized"}}}}, "/api/v1/workflows/{workflow_id}": {"get": {"tags": ["Workflows"], "summary": "Get workflow details.", "description": "Retrieve details of a specific workflow including its trigger, conditions, actions, and execution statistics.", "operationId": "getWorkflow", "parameters": [{"in": "path", "name": "workflow_id", "required": true, "schema": {"type": "string"}, "description": "The workflow ID."}], "responses": {"200": {"description": "Workflow details.", "content": {"application/json": {"schema": {"type": "object", "properties": {"workflow": {"$ref": "#/components/schemas/Workflow"}}}}}}, "401": {"$ref": "#/components/responses/Unauthorized"}, "404": {"$ref": "#/components/responses/NotFound"}}}, "put": {"tags": ["Workflows"], "summary": "Update workflow.", "description": "Update an existing workflow's configuration. Partial updates are supported: a field that is sent replaces the stored one whole, so `trigger` replaces the trigger together with its conditions (send the conditions to keep with it, in `trigger.conditions` or in `conditions`). Send `expected_revision` (the `revision` you read) to have the update refused with 409 `WORKFLOW_CHANGED` when the workflow was changed since. Setting `status` to `active` without sending both `trigger` and `actions` validates the stored ones, as a full save would, and is refused with the same 409 when the workflow is changed while that happens.", "operationId": "putWorkflow", "security": [{"BearerAuth": []}], "parameters": [{"in": "path", "name": "workflow_id", "required": true, "schema": {"type": "string"}, "description": "The workflow ID."}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/UpdateWorkflowRequest"}}}}, "responses": {"200": {"description": "Workflow updated.", "content": {"application/json": {"schema": {"type": "object", "properties": {"workflow": {"$ref": "#/components/schemas/Workflow"}, "warnings": {"type": "array", "description": "Present only when the update turned the workflow on, or changed the actions of an active workflow, and an action's connection is missing: the workflow is active, and each run will skip that action until the connection exists. An update that leaves an active workflow's actions as they were carries none.", "items": {"$ref": "#/components/schemas/WorkflowActivationWarning"}}}}}}}, "400": {"$ref": "#/components/responses/ValidationError"}, "401": {"$ref": "#/components/responses/Unauthorized"}, "404": {"$ref": "#/components/responses/NotFound"}, "409": {"description": "`WORKFLOW_CHANGED`: the workflow's revision is no longer `expected_revision`. Nothing was changed. `error.details` carries `expected_revision` and the current `revision`.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "503": {"$ref": "#/components/responses/ServiceUnavailable"}}}, "delete": {"tags": ["Workflows"], "summary": "Delete workflow.", "description": "Permanently delete a workflow. This action cannot be undone.", "operationId": "deleteWorkflow", "security": [{"BearerAuth": []}], "parameters": [{"in": "path", "name": "workflow_id", "required": true, "schema": {"type": "string"}, "description": "The workflow ID."}], "responses": {"200": {"description": "Workflow deleted.", "content": {"application/json": {"schema": {"type": "object", "properties": {"success": {"type": "boolean"}, "message": {"type": "string"}}}}}}, "401": {"$ref": "#/components/responses/Unauthorized"}, "404": {"$ref": "#/components/responses/NotFound"}}}, "patch": {"tags": ["Workflows"], "summary": "Update workflow (partial).", "description": "Partially update an existing workflow's configuration. Accepts `expected_revision` and validates a stored workflow on activation exactly as `PUT` does.", "operationId": "updateWorkflow", "security": [{"BearerAuth": []}], "parameters": [{"in": "path", "name": "workflow_id", "required": true, "schema": {"type": "string"}, "description": "The workflow ID."}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/UpdateWorkflowRequest"}}}}, "responses": {"200": {"description": "Workflow updated.", "content": {"application/json": {"schema": {"type": "object", "additionalProperties": true}}}}, "400": {"$ref": "#/components/responses/BadRequest"}, "401": {"$ref": "#/components/responses/Unauthorized"}, "403": {"$ref": "#/components/responses/Forbidden"}, "404": {"$ref": "#/components/responses/NotFound"}, "409": {"description": "`WORKFLOW_CHANGED`: the workflow's revision is no longer `expected_revision`. Nothing was changed.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "503": {"$ref": "#/components/responses/ServiceUnavailable"}}}}, "/api/v1/workflows/{workflow_id}/test": {"post": {"tags": ["Workflows"], "summary": "Test workflow.", "description": "Execute a workflow in test mode with sample data. No actual actions are performed; returns what would happen. Set `preview_inactive` to evaluate draft or paused configurations too. Without this option, inactive workflows return `skipped` for older clients.", "operationId": "postWorkflowTest", "security": [{"BearerAuth": []}], "parameters": [{"in": "path", "name": "workflow_id", "required": true, "schema": {"type": "string"}, "description": "The workflow ID."}], "requestBody": {"content": {"application/json": {"schema": {"type": "object", "properties": {"preview_inactive": {"type": "boolean", "default": false, "description": "Evaluate an inactive configuration without activating or delivering it. The caller must display that it remains inactive."}, "test_data": {"type": "object", "additionalProperties": true, "description": "Sample data to evaluate exactly as supplied, including an empty object. Takes precedence over the legacy payload alias. When omitted with no payload, a sample is generated for the event type."}, "payload": {"type": "object", "additionalProperties": true, "deprecated": true, "description": "Legacy sample alias, used only when test_data is omitted. An empty legacy object requests a generated sample."}}}}}}, "responses": {"200": {"description": "Test results.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/WorkflowTestResult"}}}}, "400": {"$ref": "#/components/responses/ValidationError"}, "401": {"$ref": "#/components/responses/Unauthorized"}, "404": {"$ref": "#/components/responses/NotFound"}}}}, "/api/v1/workflows/{workflow_id}/pause": {"post": {"tags": ["Workflows"], "summary": "Pause workflow.", "description": "Pause an active workflow. Paused workflows will not execute until resumed.", "operationId": "postWorkflowPause", "security": [{"BearerAuth": []}], "parameters": [{"in": "path", "name": "workflow_id", "required": true, "schema": {"type": "string"}, "description": "The workflow ID."}], "responses": {"200": {"description": "Workflow paused.", "content": {"application/json": {"schema": {"type": "object", "properties": {"workflow": {"$ref": "#/components/schemas/Workflow"}}}}}}, "400": {"$ref": "#/components/responses/ValidationError"}, "401": {"$ref": "#/components/responses/Unauthorized"}, "404": {"$ref": "#/components/responses/NotFound"}}}}, "/api/v1/workflows/{workflow_id}/resume": {"post": {"tags": ["Workflows"], "summary": "Resume workflow.", "description": "Turn on a paused or draft workflow. The workflow will begin executing again when triggered. Its stored trigger and actions are validated as a full save would validate them, and a workflow that would be refused there is refused here with the same 400: this includes a scheduled workflow whose stored schedule the scheduler does not run (see `ScheduleConfig`). One that is changed while it is being turned on is refused with 409 `WORKFLOW_CHANGED`. An email action whose recipients are no longer all members of this workspace is refused with 400 `RECIPIENTS_NOT_MEMBERS`, naming them. A scheduled minute that passed while the workflow was paused is not run; the response's `next_run_at` is its first run.", "operationId": "postWorkflowResume", "security": [{"BearerAuth": []}], "parameters": [{"in": "path", "name": "workflow_id", "required": true, "schema": {"type": "string"}, "description": "The workflow ID."}], "responses": {"200": {"description": "Workflow resumed.", "content": {"application/json": {"schema": {"type": "object", "properties": {"workflow": {"$ref": "#/components/schemas/Workflow"}, "warnings": {"type": "array", "description": "Present only when the workflow was turned on and an action's connection is missing: the workflow is active, and each run will skip that action until the connection exists.", "items": {"$ref": "#/components/schemas/WorkflowActivationWarning"}}}}}}}, "400": {"$ref": "#/components/responses/ValidationError"}, "401": {"$ref": "#/components/responses/Unauthorized"}, "404": {"$ref": "#/components/responses/NotFound"}, "409": {"description": "`WORKFLOW_CHANGED`: the workflow was changed while it was being turned on, so what was validated is no longer what is stored. Nothing was changed. `error.details` carries `expected_revision` (the revision that was validated) and the current `revision`.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "503": {"$ref": "#/components/responses/ServiceUnavailable"}}}}, "/api/v1/workflows/{workflow_id}/executions": {"get": {"tags": ["Workflows"], "summary": "List workflow executions.", "description": "List execution history for a workflow including status, timing, and action results, newest run first (by `started_at`). Runs recorded before the history was ordered by time come after every later run, in no particular order, until they expire. The first page also carries `not_run`: notes of matches that were not run because the shared run limit of one sync or event was used up.", "operationId": "getWorkflowExecutions", "parameters": [{"in": "path", "name": "workflow_id", "required": true, "schema": {"type": "string"}, "description": "The workflow ID."}, {"in": "query", "name": "limit", "schema": {"type": "integer", "minimum": 1, "maximum": 100, "default": 50}, "description": "Maximum number of executions to return."}, {"name": "cursor", "in": "query", "schema": {"type": "string"}, "description": "Opaque continuation token from a previous page. Pass it to read the next page."}], "responses": {"200": {"description": "List of executions.", "content": {"application/json": {"schema": {"type": "object", "properties": {"executions": {"type": "array", "items": {"$ref": "#/components/schemas/WorkflowExecution"}}, "count": {"type": "integer"}, "next_cursor": {"type": ["string", "null"], "description": "Pass as `cursor` to fetch the next page. Null on the last page. A page can hold fewer runs than `limit`, or none, and still carry a cursor."}, "not_run": {"description": "The workflow's 20 newest not-run notes, newest first, looked for among the newest 500 notes the history holds (notes of matches that have since run included). Sent with the first page only (a request without `cursor`). Null when the notes could not be read: that is not \"no notes\". A note is not an execution: it is in no run count.", "oneOf": [{"type": "array", "items": {"$ref": "#/components/schemas/WorkflowMatchesNotRun"}}, {"type": "null"}]}}}}}}, "401": {"$ref": "#/components/responses/Unauthorized"}, "404": {"$ref": "#/components/responses/NotFound"}}}}, "/api/v1/workflows/{workflow_id}/stats": {"get": {"tags": ["Workflows"], "summary": "Get workflow run statistics.", "description": "Aggregate run statistics for a workflow (total/successful/failed run counts, average duration, and last run time), computed from its execution history, today's use of its daily run limit (runs counted since 00:00 UTC, and when the limit first skipped a trigger), and the matches that the shared run limit of a sync or event left unrun.", "operationId": "getWorkflowStats", "parameters": [{"in": "path", "name": "workflow_id", "required": true, "schema": {"type": "string"}, "description": "The workflow ID."}], "responses": {"200": {"description": "Workflow run statistics.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/WorkflowStats"}}}}, "401": {"$ref": "#/components/responses/Unauthorized"}, "404": {"$ref": "#/components/responses/NotFound"}}}}, "/api/v1/review-tags": {"get": {"tags": ["Review Tags"], "summary": "List review tags.", "description": "List all custom review tags for the authenticated user's tenant, in no particular order and not paged.", "operationId": "getReviewTags", "responses": {"200": {"description": "Every review tag of the workspace.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ReviewTagList"}}}}, "401": {"$ref": "#/components/responses/Unauthorized"}, "402": {"$ref": "#/components/responses/SubscriptionRequired"}, "403": {"$ref": "#/components/responses/ReviewTagKeyForbidden"}, "500": {"$ref": "#/components/responses/ReviewTagStoreFailed"}, "503": {"$ref": "#/components/responses/SubscriptionUnverified"}}}, "post": {"tags": ["Review Tags"], "summary": "Create a review tag.", "description": "Create a new custom tag for categorizing reviews. A name is one tag per workspace: it is compared without regard to letter case or surrounding spaces, and a name another tag already has is refused with `409 TAG_NAME_TAKEN`.", "operationId": "postReviewTag", "security": [{"BearerAuth": []}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/CreateReviewTagRequest"}}}}, "responses": {"201": {"description": "Tag created. Returned unwrapped: the response is the tag record itself, not a `{tag: \u2026}` envelope.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ReviewTag"}}}}, "400": {"$ref": "#/components/responses/ReviewTagRefused"}, "401": {"$ref": "#/components/responses/Unauthorized"}, "402": {"$ref": "#/components/responses/SubscriptionRequired"}, "403": {"$ref": "#/components/responses/ReviewTagAdminForbidden"}, "409": {"$ref": "#/components/responses/TagNameTaken"}, "500": {"$ref": "#/components/responses/ReviewTagStoreFailed"}, "503": {"$ref": "#/components/responses/SubscriptionUnverified"}}}}, "/api/v1/review-tags/stats": {"get": {"tags": ["Review Tags"], "summary": "Get tag statistics.", "description": "Per-tag usage statistics, one entry per tag of the workspace. Sentiment breakdown and trend measurements are not available from this endpoint.", "operationId": "getReviewTagStats", "responses": {"200": {"description": "Tag statistics, returned as a bare array: the response is the list itself, not a `{stats: \u2026}` envelope.", "content": {"application/json": {"schema": {"type": "array", "items": {"$ref": "#/components/schemas/ReviewTagStats"}}}}}, "401": {"$ref": "#/components/responses/Unauthorized"}, "402": {"$ref": "#/components/responses/SubscriptionRequired"}, "403": {"$ref": "#/components/responses/ReviewTagKeyForbidden"}, "500": {"$ref": "#/components/responses/ReviewTagStoreFailed"}, "503": {"$ref": "#/components/responses/SubscriptionUnverified"}}}}, "/api/v1/review-tags/{tag_id}": {"get": {"tags": ["Review Tags"], "summary": "Get review tag.", "description": "Retrieve details of a specific review tag.", "operationId": "getReviewTag", "parameters": [{"in": "path", "name": "tag_id", "required": true, "schema": {"type": "string", "format": "uuid"}, "description": "The tag's `tag_id`. An id that is not a UUID answers `404 NOT_FOUND`."}], "responses": {"200": {"description": "Tag details, returned unwrapped (no `{tag: \u2026}` envelope).", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ReviewTag"}}}}, "401": {"$ref": "#/components/responses/Unauthorized"}, "402": {"$ref": "#/components/responses/SubscriptionRequired"}, "403": {"$ref": "#/components/responses/ReviewTagKeyForbidden"}, "404": {"$ref": "#/components/responses/ReviewTagNotFound"}, "500": {"$ref": "#/components/responses/ReviewTagStoreFailed"}, "503": {"$ref": "#/components/responses/SubscriptionUnverified"}}}, "put": {"tags": ["Review Tags"], "summary": "Update review tag.", "description": "Update an existing review tag's name, color, or description. Renaming a tag to a name another tag of the workspace already has (compared without regard to letter case or surrounding spaces) is refused with `409 TAG_NAME_TAKEN`. A save that leaves the tag's own name as it is is never refused for that reason.", "operationId": "putReviewTag", "security": [{"BearerAuth": []}], "parameters": [{"in": "path", "name": "tag_id", "required": true, "schema": {"type": "string", "format": "uuid"}, "description": "The tag's `tag_id`. An id that is not a UUID answers `404 NOT_FOUND`."}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/UpdateReviewTagRequest"}}}}, "responses": {"200": {"description": "Tag updated, returned unwrapped (no `{tag: \u2026}` envelope).", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ReviewTag"}}}}, "400": {"$ref": "#/components/responses/ReviewTagRefused"}, "401": {"$ref": "#/components/responses/Unauthorized"}, "402": {"$ref": "#/components/responses/SubscriptionRequired"}, "403": {"$ref": "#/components/responses/ReviewTagAdminForbidden"}, "404": {"$ref": "#/components/responses/ReviewTagNotFound"}, "409": {"description": "`TAG_NAME_TAKEN`: another tag of the workspace already has this name (compared without regard to letter case or surrounding spaces); `details.field` is `name`. `CONFLICT`: other requests renamed this tag while this one was being saved; read the tag again and retry. Nothing was written in either case.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}, "example": {"error": {"code": "TAG_NAME_TAKEN", "message": "A tag with this name already exists", "details": {"field": "name"}}}}}}, "500": {"$ref": "#/components/responses/ReviewTagStoreFailed"}, "503": {"$ref": "#/components/responses/SubscriptionUnverified"}}}, "delete": {"tags": ["Review Tags"], "summary": "Delete review tag.", "description": "Delete a review tag. Also removes all auto-tagging rules that reference this tag, and takes the tag off every review that carries it.", "operationId": "deleteReviewTag", "security": [{"BearerAuth": []}], "parameters": [{"in": "path", "name": "tag_id", "required": true, "schema": {"type": "string", "format": "uuid"}, "description": "The tag's `tag_id`. An id that is not a UUID answers `404 NOT_FOUND`."}], "responses": {"200": {"description": "Tag deleted.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ReviewTagDeleted"}}}}, "401": {"$ref": "#/components/responses/Unauthorized"}, "402": {"$ref": "#/components/responses/SubscriptionRequired"}, "403": {"$ref": "#/components/responses/ReviewTagAdminForbidden"}, "404": {"$ref": "#/components/responses/ReviewTagNotFound"}, "500": {"$ref": "#/components/responses/ReviewTagStoreFailed"}, "503": {"$ref": "#/components/responses/SubscriptionUnverified"}}}}, "/api/v1/review-tags/auto-rules": {"get": {"tags": ["Review Tags"], "summary": "List auto-tagging rules.", "description": "List all auto-tagging rules that automatically apply tags to reviews based on conditions, in no particular order and not paged.", "operationId": "getAutoTagRules", "responses": {"200": {"description": "Every auto-tagging rule of the workspace.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/AutoTagRuleList"}}}}, "401": {"$ref": "#/components/responses/Unauthorized"}, "402": {"$ref": "#/components/responses/SubscriptionRequired"}, "403": {"$ref": "#/components/responses/ReviewTagKeyForbidden"}, "500": {"$ref": "#/components/responses/ReviewTagStoreFailed"}, "503": {"$ref": "#/components/responses/SubscriptionUnverified"}}}, "post": {"tags": ["Review Tags"], "summary": "Create auto-tagging rule.", "description": "Create a new auto-tagging rule that applies a tag to reviews matching specified conditions.", "operationId": "postAutoTagRule", "security": [{"BearerAuth": []}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/CreateAutoTagRuleRequest"}}}}, "responses": {"201": {"description": "Rule created, returned unwrapped (no `{rule: \u2026}` envelope).", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/AutoTagRule"}}}}, "400": {"$ref": "#/components/responses/AutoTagRuleInvalid"}, "401": {"$ref": "#/components/responses/Unauthorized"}, "402": {"$ref": "#/components/responses/SubscriptionRequired"}, "403": {"$ref": "#/components/responses/ReviewTagAdminForbidden"}, "500": {"$ref": "#/components/responses/ReviewTagStoreFailed"}, "503": {"$ref": "#/components/responses/SubscriptionUnverified"}}}}, "/api/v1/review-tags/auto-rules/{rule_id}": {"get": {"tags": ["Review Tags"], "summary": "Get auto-tagging rule.", "description": "Retrieve details of a specific auto-tagging rule.", "operationId": "getAutoTagRule", "parameters": [{"in": "path", "name": "rule_id", "required": true, "schema": {"type": "string", "format": "uuid"}, "description": "The rule's `rule_id`. An id that is not a UUID answers `404 NOT_FOUND`."}], "responses": {"200": {"description": "Rule details, returned unwrapped (no `{rule: \u2026}` envelope).", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/AutoTagRule"}}}}, "401": {"$ref": "#/components/responses/Unauthorized"}, "402": {"$ref": "#/components/responses/SubscriptionRequired"}, "403": {"$ref": "#/components/responses/ReviewTagKeyForbidden"}, "404": {"$ref": "#/components/responses/AutoTagRuleNotFound"}, "500": {"$ref": "#/components/responses/ReviewTagStoreFailed"}, "503": {"$ref": "#/components/responses/SubscriptionUnverified"}}}, "put": {"tags": ["Review Tags"], "summary": "Update auto-tagging rule.", "description": "Update an existing auto-tagging rule's name, tag, conditions, match mode or enabled status. Only the members sent are changed; `conditions`, when sent, replaces the stored list.", "operationId": "putAutoTagRule", "security": [{"BearerAuth": []}], "parameters": [{"in": "path", "name": "rule_id", "required": true, "schema": {"type": "string", "format": "uuid"}, "description": "The rule's `rule_id`. An id that is not a UUID answers `404 NOT_FOUND`."}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/UpdateAutoTagRuleRequest"}}}}, "responses": {"200": {"description": "Rule updated, returned unwrapped (no `{rule: \u2026}` envelope).", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/AutoTagRule"}}}}, "400": {"$ref": "#/components/responses/AutoTagRuleInvalid"}, "401": {"$ref": "#/components/responses/Unauthorized"}, "402": {"$ref": "#/components/responses/SubscriptionRequired"}, "403": {"$ref": "#/components/responses/ReviewTagAdminForbidden"}, "404": {"$ref": "#/components/responses/AutoTagRuleNotFound"}, "500": {"$ref": "#/components/responses/ReviewTagStoreFailed"}, "503": {"$ref": "#/components/responses/SubscriptionUnverified"}}}, "delete": {"tags": ["Review Tags"], "summary": "Delete auto-tagging rule.", "description": "Delete an auto-tagging rule. The tags it already applied stay on the reviews.", "operationId": "deleteAutoTagRule", "security": [{"BearerAuth": []}], "parameters": [{"in": "path", "name": "rule_id", "required": true, "schema": {"type": "string", "format": "uuid"}, "description": "The rule's `rule_id`. An id that is not a UUID answers `404 NOT_FOUND`."}], "responses": {"200": {"description": "Rule deleted.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/AutoTagRuleDeleted"}}}}, "401": {"$ref": "#/components/responses/Unauthorized"}, "402": {"$ref": "#/components/responses/SubscriptionRequired"}, "403": {"$ref": "#/components/responses/ReviewTagAdminForbidden"}, "404": {"$ref": "#/components/responses/AutoTagRuleNotFound"}, "500": {"$ref": "#/components/responses/ReviewTagStoreFailed"}, "503": {"$ref": "#/components/responses/SubscriptionUnverified"}}}}, "/api/v1/reviews/{review_id}/engagement/history": {"get": {"tags": ["Insights", "Reviews"], "summary": "Retrieve engagement history for a review.", "operationId": "getReviewEngagementHistory", "parameters": [{"in": "path", "name": "review_id", "required": true, "schema": {"type": "string"}}, {"in": "query", "name": "app_uuid", "required": true, "schema": {"type": "string"}}], "responses": {"200": {"description": "History returned.", "content": {"application/json": {"schema": {"type": "object", "properties": {"history": {"type": "array", "items": {"type": "object", "additionalProperties": true}}, "metadata": {"type": "object", "additionalProperties": true}}}}}}, "401": {"$ref": "#/components/responses/Unauthorized"}, "403": {"$ref": "#/components/responses/Forbidden"}}}}, "/api/v1/reply-templates/performance": {"get": {"tags": ["Reply Templates", "Template Analytics"], "summary": "Get template performance analytics.", "description": "Analyze reply template performance across all tenant apps.\nReturns usage counts, follow-up rates, rating improvements,\nand impact scores for each template.\n", "operationId": "getTemplatePerformance", "parameters": [{"in": "query", "name": "days", "description": "Number of days to analyze.", "schema": {"type": "integer", "minimum": 1, "maximum": 365, "default": 30}}, {"in": "query", "name": "app_uuid", "description": "Restrict the metrics to one app. Omit for all apps.", "schema": {"type": "string", "format": "uuid"}}], "responses": {"200": {"description": "Template performance data retrieved.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/TemplatePerformanceResponse"}}}}, "401": {"$ref": "#/components/responses/Unauthorized"}, "403": {"$ref": "#/components/responses/Forbidden"}}}}, "/api/v1/reply-templates/comparison": {"get": {"tags": ["Reply Templates", "Insights"], "summary": "Compare reply templates against each other.", "description": "Compare recorded template usage and observed outcomes for a chosen set of templates. Historical replies without an explicit template selection remain unattributed.", "operationId": "getTemplateComparison", "parameters": [{"in": "query", "name": "days", "description": "Number of days to analyze.", "schema": {"type": "integer", "minimum": 1, "maximum": 365, "default": 30}}, {"in": "query", "name": "template_ids", "description": "Comma-separated template ids to restrict the comparison to. Omit to compare every template.", "schema": {"type": "string"}}, {"in": "query", "name": "app_uuid", "description": "Restrict the comparison to one app. Omit for all apps.", "schema": {"type": "string", "format": "uuid"}}], "responses": {"200": {"description": "Template analytics returned.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/TemplateComparisonResponse"}}}}, "401": {"$ref": "#/components/responses/Unauthorized"}, "403": {"$ref": "#/components/responses/Forbidden"}}}}, "/api/v1/exports": {"post": {"tags": ["Exports"], "summary": "Create a new data export job.", "description": "Requires an admin or owner. The job is stored, then made by the export worker; follow it with `GET /api/v1/exports/{job_id}`.", "operationId": "createExport", "security": [{"BearerAuth": []}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/CreateExportRequest"}}}}, "responses": {"202": {"description": "Export job accepted for asynchronous processing. The job is usually answered `pending`. When the export worker cannot be reached, the export is made during the request, with a lower record limit, and the job is answered as it finished.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ExportJobResponse"}}}}, "400": {"$ref": "#/components/responses/ExportRefused"}, "401": {"$ref": "#/components/responses/Unauthorized"}, "403": {"$ref": "#/components/responses/ExportAdminForbidden"}, "404": {"description": "`APP_NOT_FOUND`: an id in `filters.app_uuids` is not one of the workspace's apps. No job is created.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "500": {"description": "`CREATE_FAILED`: the job could not be stored, or an app in `filters.app_uuids` could not be read to check it. Retry.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}}}, "get": {"tags": ["Exports"], "summary": "List export jobs.", "description": "Export jobs for the tenant, newest first, 20 per page. Follow `cursor`\nwhile `has_more` is true; a page that reports `has_more: false` is the\nend, and no cursor is issued for it. Requires an admin or owner.\n", "operationId": "listExports", "security": [{"BearerAuth": []}], "parameters": [{"name": "cursor", "in": "query", "description": "The opaque `cursor` returned by the previous page. Preserve it\nunchanged when requesting the next page. Malformed or foreign\ncursors are rejected with `INVALID_CURSOR`. Existing job ID cursors\nremain supported; a well-formed ID whose job was deleted ends\npagination with an empty page.\n", "schema": {"type": "string"}}], "responses": {"200": {"description": "Export jobs listed.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ExportJobList"}}}}, "400": {"description": "`INVALID_CURSOR`: the cursor is not one this list issued.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "401": {"$ref": "#/components/responses/Unauthorized"}, "403": {"$ref": "#/components/responses/ExportAdminForbidden"}, "500": {"description": "`LIST_FAILED`: the export history could not be read. Retry.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}}}}, "/api/v1/exports/{job_id}": {"parameters": [{"name": "job_id", "in": "path", "required": true, "description": "The job's `job_id`.", "schema": {"type": "string"}}], "get": {"tags": ["Exports"], "summary": "Get export job status and download URL.", "description": "Requires an admin or owner. A completed job whose file is still kept carries a `download_url`; a job whose file has gone is answered `expired`.", "operationId": "getExport", "security": [{"BearerAuth": []}], "responses": {"200": {"description": "Export job details.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ExportJobResponse"}}}}, "401": {"$ref": "#/components/responses/Unauthorized"}, "403": {"$ref": "#/components/responses/ExportAdminForbidden"}, "404": {"$ref": "#/components/responses/ExportNotFound"}, "500": {"description": "`GET_FAILED`: the job could not be read. Retry.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}}}, "delete": {"tags": ["Exports"], "summary": "Delete an export job.", "description": "Requires an admin or owner. The job is deleted and its file can no longer be downloaded, at once. The stored copy is removed from storage within about two days.", "operationId": "deleteExport", "security": [{"BearerAuth": []}], "responses": {"200": {"description": "Export job deleted.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ExportDeleted"}}}}, "401": {"$ref": "#/components/responses/Unauthorized"}, "403": {"$ref": "#/components/responses/ExportAdminForbidden"}, "404": {"$ref": "#/components/responses/ExportNotFound"}, "500": {"description": "`INTERNAL_ERROR`: the job could not be read or deleted. Retry.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}}}}, "/api/v1/reports/templates": {"post": {"tags": ["Reports"], "summary": "Create a report template.", "description": "Requires an admin or owner.", "operationId": "createReportTemplate", "security": [{"BearerAuth": []}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/CreateReportTemplateRequest"}}}}, "responses": {"201": {"description": "Template created.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ReportTemplateResponse"}}}}, "400": {"$ref": "#/components/responses/ReportTemplateRefused"}, "401": {"$ref": "#/components/responses/Unauthorized"}, "402": {"$ref": "#/components/responses/SubscriptionRequired"}, "403": {"$ref": "#/components/responses/ReportAdminForbidden"}, "500": {"$ref": "#/components/responses/ReportStoreFailed"}, "503": {"$ref": "#/components/responses/SubscriptionUnverified"}}}, "get": {"tags": ["Reports"], "summary": "List report templates.", "description": "Any member can list them, and so can an API key with the `analytics:read` scope.", "operationId": "listReportTemplates", "responses": {"200": {"description": "Templates listed.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ReportTemplateList"}}}}, "401": {"$ref": "#/components/responses/Unauthorized"}, "402": {"$ref": "#/components/responses/SubscriptionRequired"}, "403": {"$ref": "#/components/responses/ReportKeyForbidden"}, "500": {"$ref": "#/components/responses/ReportStoreFailed"}, "503": {"$ref": "#/components/responses/SubscriptionUnverified"}}}}, "/api/v1/reports/templates/{template_id}": {"parameters": [{"name": "template_id", "in": "path", "required": true, "description": "The template's `template_id`. An id that is not a UUID answers `404 NOT_FOUND`.", "schema": {"type": "string", "format": "uuid"}}], "get": {"tags": ["Reports"], "summary": "Get a report template.", "description": "Any member can read it, and so can an API key with the `analytics:read` scope.", "operationId": "getReportTemplate", "responses": {"200": {"description": "Template details.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ReportTemplateResponse"}}}}, "401": {"$ref": "#/components/responses/Unauthorized"}, "402": {"$ref": "#/components/responses/SubscriptionRequired"}, "403": {"$ref": "#/components/responses/ReportKeyForbidden"}, "404": {"$ref": "#/components/responses/ReportTemplateNotFound"}, "500": {"$ref": "#/components/responses/ReportStoreFailed"}, "503": {"$ref": "#/components/responses/SubscriptionUnverified"}}}, "put": {"tags": ["Reports"], "summary": "Update a report template.", "description": "Requires an admin or owner. Only the members sent are changed; `branding`, `sections` and `output_formats` are each replaced whole.", "operationId": "updateReportTemplate", "security": [{"BearerAuth": []}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/UpdateReportTemplateRequest"}}}}, "responses": {"200": {"description": "Template updated.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ReportTemplateResponse"}}}}, "400": {"$ref": "#/components/responses/ReportTemplateRefused"}, "401": {"$ref": "#/components/responses/Unauthorized"}, "402": {"$ref": "#/components/responses/SubscriptionRequired"}, "403": {"$ref": "#/components/responses/ReportAdminForbidden"}, "404": {"$ref": "#/components/responses/ReportTemplateNotFound"}, "409": {"$ref": "#/components/responses/ReportChanged"}, "500": {"$ref": "#/components/responses/ReportStoreFailed"}, "503": {"$ref": "#/components/responses/SubscriptionUnverified"}}}, "delete": {"tags": ["Reports"], "summary": "Delete a report template.", "description": "Requires an admin or owner. A schedule that uses the template is not deleted; its next delivery fails until it names another template.", "operationId": "deleteReportTemplate", "security": [{"BearerAuth": []}], "responses": {"200": {"description": "Template deleted.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ReportTemplateDeleted"}}}}, "401": {"$ref": "#/components/responses/Unauthorized"}, "402": {"$ref": "#/components/responses/SubscriptionRequired"}, "403": {"$ref": "#/components/responses/ReportAdminForbidden"}, "404": {"$ref": "#/components/responses/ReportTemplateNotFound"}, "409": {"$ref": "#/components/responses/ReportChanged"}, "500": {"$ref": "#/components/responses/ReportStoreFailed"}, "503": {"$ref": "#/components/responses/SubscriptionUnverified"}}}}, "/api/v1/reports/generate": {"post": {"tags": ["Reports"], "summary": "Queue an on-demand report.", "description": "Creates a tenant-owned asynchronous report job from a snapshot of the selected template and apps. Poll getReportJob until completed or failed. Files become available on completion and expire seven days after their artifact checkpoint, which is set before upload and preserved during delivery retries. Each status read issues a fresh download URL valid for at most one hour and bounded by the artifact deadline. Only output formats enabled in the template can be selected. An Idempotency-Key reuses the same job for an identical request in this workspace; reuse with different data returns 400. Any member can generate a report; no API key can.", "operationId": "generateReport", "security": [{"BearerAuth": []}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/GenerateReportRequest"}}}}, "responses": {"400": {"description": "`VALIDATION_ERROR`: the message names what is wrong: no `template_id`, a field outside the three accepted, an output format that is not offered or not enabled in the template, filters outside their set, a date that is not a calendar date or a start after the end, an app that is not one of the workspace's current apps, an `Idempotency-Key` longer than 128 characters or already used for a different request, or a body that is not a JSON object.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "401": {"$ref": "#/components/responses/Unauthorized"}, "402": {"$ref": "#/components/responses/SubscriptionRequired"}, "404": {"$ref": "#/components/responses/ReportTemplateNotFound"}, "403": {"description": "`FORBIDDEN`: an API key. Any member of the workspace can generate a report, but no API key can: it needs `analytics:write`, which no key can hold.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "202": {"description": "The persisted report job was accepted for asynchronous processing.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ReportJobResponse"}}}}, "503": {"$ref": "#/components/responses/ReportQueueUnavailable"}}, "parameters": [{"name": "Idempotency-Key", "in": "header", "required": false, "schema": {"type": "string", "minLength": 1, "maxLength": 128}}]}}, "/api/v1/reports/jobs/{job_id}": {"parameters": [{"name": "job_id", "in": "path", "required": true, "description": "The job's `job_id`. An id that is not a UUID answers `404 NOT_FOUND`.", "schema": {"type": "string", "format": "uuid"}}], "get": {"tags": ["Reports"], "summary": "Get report generation job status.", "operationId": "getReportJob", "responses": {"200": {"description": "Report job details.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ReportJobResponse"}}}}, "401": {"$ref": "#/components/responses/Unauthorized"}, "402": {"$ref": "#/components/responses/SubscriptionRequired"}, "404": {"description": "`NOT_FOUND`: no report job of this workspace has this id, or the id is not a UUID. A job is kept for seven days after it is created or finished.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "403": {"$ref": "#/components/responses/ReportKeyForbidden"}, "500": {"description": "`INTERNAL_ERROR`: the job could not be read, or the download link of a completed job could not be made. Retry.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "503": {"$ref": "#/components/responses/SubscriptionUnverified"}}, "description": "Reads only a job in the authenticated workspace. A completed file has a fresh download URL valid for at most one hour and bounded by expires_at. An expired file returns a failed status with an instruction to generate another report. For scheduled jobs, delivery_status accepted means the email provider accepted every recipient; it does not confirm inbox delivery. Any member can read a job, and so can an API key with the `analytics:read` scope."}}, "/api/v1/reports/sections": {"get": {"tags": ["Reports"], "summary": "Get available report sections.", "description": "The sections a template can include, with English names. The volume section is listed as `review_volume`, which a template answers as `review_volume_trends`.", "operationId": "listReportSections", "responses": {"200": {"description": "Available sections listed.", "content": {"application/json": {"schema": {"type": "object", "required": ["sections"], "properties": {"sections": {"type": "array", "items": {"type": "object", "required": ["id", "name", "description"], "properties": {"id": {"type": "string"}, "name": {"type": "string"}, "description": {"type": "string"}}}}}}}}}, "401": {"$ref": "#/components/responses/Unauthorized"}, "402": {"$ref": "#/components/responses/SubscriptionRequired"}, "403": {"$ref": "#/components/responses/ReportKeyForbidden"}, "503": {"$ref": "#/components/responses/SubscriptionUnverified"}}}}, "/api/v1/reports/scheduled": {"post": {"tags": ["Reports"], "summary": "Create a scheduled report.", "description": "Creates a workspace schedule with daily, weekly, biweekly, or monthly delivery in UTC. The report covers the configured number of completed UTC calendar days ending the day before its due date. Recipients receive attachments in all formats enabled by the template. Only owners and administrators may manage delivery.", "operationId": "createScheduledReport", "security": [{"BearerAuth": []}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/CreateScheduledReportRequest"}}}}, "responses": {"400": {"$ref": "#/components/responses/ScheduledReportRefused"}, "401": {"$ref": "#/components/responses/Unauthorized"}, "402": {"$ref": "#/components/responses/SubscriptionRequired"}, "403": {"$ref": "#/components/responses/ReportAdminForbidden"}, "404": {"$ref": "#/components/responses/ReportTemplateNotFound"}, "201": {"description": "Schedule created with its next UTC delivery time.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ScheduledReportResponse"}}}}, "500": {"$ref": "#/components/responses/ReportStoreFailed"}, "503": {"$ref": "#/components/responses/SubscriptionUnverified"}}}, "get": {"tags": ["Reports"], "summary": "List scheduled reports.", "description": "Any member can list them, and so can an API key with the `analytics:read` scope.", "operationId": "listScheduledReports", "responses": {"200": {"description": "Scheduled reports listed.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ScheduledReportList"}}}}, "401": {"$ref": "#/components/responses/Unauthorized"}, "402": {"$ref": "#/components/responses/SubscriptionRequired"}, "403": {"$ref": "#/components/responses/ReportKeyForbidden"}, "500": {"$ref": "#/components/responses/ReportStoreFailed"}, "503": {"$ref": "#/components/responses/SubscriptionUnverified"}}}}, "/api/v1/reports/scheduled/{schedule_id}": {"parameters": [{"name": "schedule_id", "in": "path", "required": true, "description": "The schedule's `schedule_id`. An id that is not a UUID answers `404 NOT_FOUND`.", "schema": {"type": "string", "format": "uuid"}}], "get": {"tags": ["Reports"], "summary": "Get a scheduled report.", "description": "Any member can read it, and so can an API key with the `analytics:read` scope.", "operationId": "getScheduledReport", "responses": {"200": {"description": "Scheduled report details.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ScheduledReportResponse"}}}}, "401": {"$ref": "#/components/responses/Unauthorized"}, "402": {"$ref": "#/components/responses/SubscriptionRequired"}, "404": {"$ref": "#/components/responses/ScheduledReportNotFound"}, "403": {"$ref": "#/components/responses/ReportKeyForbidden"}, "500": {"$ref": "#/components/responses/ReportStoreFailed"}, "503": {"$ref": "#/components/responses/SubscriptionUnverified"}}}, "put": {"tags": ["Reports"], "summary": "Update a scheduled report.", "description": "Updates the supplied fields and recalculates the next UTC delivery time. Changing or disabling a schedule prevents unfinished jobs for its previous version from sending further emails. A concurrent change returns 409; refresh the schedule before retrying. Requires an admin or owner.", "operationId": "updateScheduledReport", "security": [{"BearerAuth": []}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/UpdateScheduledReportRequest"}}}}, "responses": {"400": {"$ref": "#/components/responses/ScheduledReportRefused"}, "401": {"$ref": "#/components/responses/Unauthorized"}, "402": {"$ref": "#/components/responses/SubscriptionRequired"}, "403": {"$ref": "#/components/responses/ReportAdminForbidden"}, "404": {"$ref": "#/components/responses/ScheduledReportNotFound"}, "409": {"$ref": "#/components/responses/ReportChanged"}, "200": {"description": "Schedule updated.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ScheduledReportResponse"}}}}, "500": {"$ref": "#/components/responses/ReportStoreFailed"}, "503": {"$ref": "#/components/responses/SubscriptionUnverified"}}}, "delete": {"tags": ["Reports"], "summary": "Delete a scheduled report.", "description": "Requires an admin or owner. Unfinished deliveries of the schedule stop before sending further email.", "operationId": "deleteScheduledReport", "security": [{"BearerAuth": []}], "responses": {"200": {"description": "Scheduled report deleted.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ScheduledReportDeleted"}}}}, "401": {"$ref": "#/components/responses/Unauthorized"}, "402": {"$ref": "#/components/responses/SubscriptionRequired"}, "403": {"$ref": "#/components/responses/ReportAdminForbidden"}, "404": {"$ref": "#/components/responses/ScheduledReportNotFound"}, "409": {"$ref": "#/components/responses/ReportChanged"}, "500": {"$ref": "#/components/responses/ReportStoreFailed"}, "503": {"$ref": "#/components/responses/SubscriptionUnverified"}}}}, "/api/v1/reports/scheduled/{schedule_id}/send-now": {"parameters": [{"name": "schedule_id", "in": "path", "required": true, "description": "The schedule's `schedule_id`. An id that is not a UUID answers `404 NOT_FOUND`.", "schema": {"type": "string", "format": "uuid"}}], "post": {"tags": ["Reports"], "summary": "Queue immediate report delivery.", "description": "Creates a job for the schedule's current template, apps, and recipients, including when automatic delivery is disabled. Poll getReportJob for its result. Completion means that the email provider accepted all recipients. An uncertain email-send result fails safely without an automatic resend; check the recipient before requesting another delivery. After an uncertain HTTP response, reuse the original Idempotency-Key even if the schedule was edited. Recovery returns the original snapshot; unfinished delivery stops if the schedule changed. Use a new key only for a new explicit send after resolving the earlier occurrence. Requires an admin or owner.", "parameters": [{"name": "Idempotency-Key", "in": "header", "description": "Reuse the original key after an uncertain response to recover the same delivery job, even if the schedule was edited. Recovery returns the original snapshot and does not create another delivery. Unfinished delivery stops if the schedule changed. Use a new key only for a new explicit send after resolving the earlier occurrence.", "schema": {"type": "string", "minLength": 1, "maxLength": 128}}], "operationId": "sendScheduledReportNow", "security": [{"BearerAuth": []}], "responses": {"401": {"$ref": "#/components/responses/Unauthorized"}, "402": {"$ref": "#/components/responses/SubscriptionRequired"}, "403": {"$ref": "#/components/responses/ReportAdminForbidden"}, "404": {"$ref": "#/components/responses/ScheduledReportNotFound"}, "202": {"description": "Immediate generation and delivery accepted for asynchronous processing.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ReportJobResponse"}}}}, "400": {"description": "`VALIDATION_ERROR`: an `Idempotency-Key` longer than 128 characters, a template with no output formats, an app of the schedule that is no longer one of the workspace's current apps, an earlier delivery under this key that could not be confirmed, or a body that is not a JSON object (a body is not read).", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "409": {"description": "`CONFLICT`: the schedule changed while the delivery was being queued. Read it again, then retry.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "503": {"$ref": "#/components/responses/ReportQueueUnavailable"}}}}, "/api/v1/dashboards": {"get": {"tags": ["Dashboards"], "summary": "List all custom dashboards.", "description": "Returns all custom dashboards for the tenant, including widget configurations and layout settings, in no particular order and not paged.", "operationId": "listDashboards", "responses": {"200": {"description": "Dashboards retrieved.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/DashboardList"}}}}, "401": {"$ref": "#/components/responses/Unauthorized"}, "402": {"$ref": "#/components/responses/SubscriptionRequired"}, "403": {"$ref": "#/components/responses/DashboardKeyForbidden"}, "500": {"description": "`INTERNAL_ERROR`: the dashboards could not be read. Retry.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "503": {"$ref": "#/components/responses/SubscriptionUnverified"}}}, "post": {"tags": ["Dashboards"], "summary": "Create a custom dashboard.", "description": "Creates a new custom dashboard with the specified layout and widgets. Requires admin role; dashboards are shared by the whole tenant.", "operationId": "createDashboard", "security": [{"BearerAuth": []}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/CreateDashboardRequest"}}}}, "responses": {"201": {"description": "Dashboard created.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Dashboard"}}}}, "400": {"$ref": "#/components/responses/DashboardRefused"}, "401": {"$ref": "#/components/responses/Unauthorized"}, "402": {"$ref": "#/components/responses/SubscriptionRequired"}, "403": {"$ref": "#/components/responses/DashboardAdminForbidden"}, "500": {"$ref": "#/components/responses/DashboardStoreFailed"}, "503": {"$ref": "#/components/responses/SubscriptionUnverified"}}}}, "/api/v1/dashboards/widget-types": {"get": {"tags": ["Dashboards"], "summary": "Get available widget types.", "description": "Returns all available widget types that can be added to dashboards, including their configuration options and size constraints.", "operationId": "getWidgetTypes", "responses": {"200": {"description": "Widget types retrieved.", "content": {"application/json": {"schema": {"type": "object", "properties": {"widget_types": {"type": "array", "items": {"$ref": "#/components/schemas/WidgetType"}}}}}}}, "401": {"$ref": "#/components/responses/Unauthorized"}}}}, "/api/v1/dashboards/{dashboardId}": {"get": {"tags": ["Dashboards"], "summary": "Get a specific dashboard.", "description": "Returns the full dashboard configuration including all widgets.", "operationId": "getDashboard", "parameters": [{"name": "dashboardId", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid"}, "description": "Dashboard ID. An id that is not a UUID answers `404 NOT_FOUND`."}], "responses": {"200": {"description": "Dashboard retrieved.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Dashboard"}}}}, "401": {"$ref": "#/components/responses/Unauthorized"}, "402": {"$ref": "#/components/responses/SubscriptionRequired"}, "403": {"$ref": "#/components/responses/DashboardKeyForbidden"}, "404": {"description": "`NOT_FOUND`: no dashboard of this workspace has this id, or the id is not a UUID.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "503": {"$ref": "#/components/responses/ReadUnavailable"}}}, "put": {"tags": ["Dashboards"], "summary": "Update a dashboard.", "description": "Updates the dashboard name, description, layout, widgets or default flag. Only the members sent are changed; `widgets`, when sent, replaces the stored list. Requires admin role; dashboards are shared by the whole tenant.", "operationId": "updateDashboard", "security": [{"BearerAuth": []}], "parameters": [{"name": "dashboardId", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid"}, "description": "Dashboard ID. An id that is not a UUID answers `404 NOT_FOUND`."}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/UpdateDashboardRequest"}}}}, "responses": {"200": {"description": "Dashboard updated.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Dashboard"}}}}, "400": {"$ref": "#/components/responses/DashboardRefused"}, "401": {"$ref": "#/components/responses/Unauthorized"}, "402": {"$ref": "#/components/responses/SubscriptionRequired"}, "403": {"$ref": "#/components/responses/DashboardAdminForbidden"}, "404": {"$ref": "#/components/responses/DashboardNotFound"}, "500": {"$ref": "#/components/responses/DashboardStoreFailed"}, "503": {"$ref": "#/components/responses/SubscriptionUnverified"}}}, "delete": {"tags": ["Dashboards"], "summary": "Delete a dashboard.", "description": "Permanently deletes a custom dashboard. Requires admin role; dashboards are shared by the whole tenant.", "operationId": "deleteDashboard", "security": [{"BearerAuth": []}], "parameters": [{"name": "dashboardId", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid"}, "description": "Dashboard ID. An id that is not a UUID answers `404 NOT_FOUND`."}], "responses": {"200": {"description": "Dashboard deleted.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/DashboardDeleted"}}}}, "401": {"$ref": "#/components/responses/Unauthorized"}, "402": {"$ref": "#/components/responses/SubscriptionRequired"}, "403": {"$ref": "#/components/responses/DashboardAdminForbidden"}, "404": {"$ref": "#/components/responses/DashboardNotFound"}, "500": {"$ref": "#/components/responses/DashboardStoreFailed"}, "503": {"$ref": "#/components/responses/SubscriptionUnverified"}}}}, "/api/v1/dashboards/{dashboardId}/duplicate": {"post": {"tags": ["Dashboards"], "summary": "Duplicate a dashboard.", "description": "Creates a copy of an existing dashboard with a new ID: its description (cut to 500 characters), the widgets the builder can draw, and its layout. The copy is named \"<original> (copy)\", cut to fit 100 characters, unless the request body supplies a `name`. The copy is never the default dashboard. Requires admin role; dashboards are shared by the whole tenant.", "operationId": "duplicateDashboard", "security": [{"BearerAuth": []}], "parameters": [{"name": "dashboardId", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid"}, "description": "Dashboard ID to duplicate. An id that is not a UUID answers `404 NOT_FOUND`."}], "requestBody": {"required": false, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/DuplicateDashboardRequest"}}}}, "responses": {"201": {"description": "Dashboard duplicated.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Dashboard"}}}}, "400": {"description": "`VALIDATION_ERROR`: the `name` sent is not text, is empty or only spaces, or is over 100 characters; or the body is not a JSON object.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "401": {"$ref": "#/components/responses/Unauthorized"}, "402": {"$ref": "#/components/responses/SubscriptionRequired"}, "403": {"$ref": "#/components/responses/DashboardAdminForbidden"}, "404": {"$ref": "#/components/responses/DashboardNotFound"}, "500": {"$ref": "#/components/responses/DashboardStoreFailed"}, "503": {"$ref": "#/components/responses/SubscriptionUnverified"}}}}, "/api/v1/releases": {"get": {"tags": ["Releases"], "summary": "List all releases across apps.", "description": "The workspace's stored releases across every app, newest release date first. Release ingestion is not available yet, so nothing stores a release and the list is empty. Any member can list them, and so can an API key with the `analytics:read` scope.", "operationId": "listTenantReleases", "parameters": [{"name": "limit", "in": "query", "schema": {"type": "integer", "default": 50}, "description": "Maximum number of releases to return. A value above 100 is read as 100 and a value below 1 as 1; a value that is not a whole number answers `400 VALIDATION_ERROR`."}], "responses": {"200": {"description": "The workspace's releases.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/TenantReleaseList"}}}}, "400": {"$ref": "#/components/responses/ReleaseLimitRefused"}, "401": {"$ref": "#/components/responses/Unauthorized"}, "402": {"$ref": "#/components/responses/SubscriptionRequired"}, "403": {"$ref": "#/components/responses/ReleaseKeyForbidden"}, "500": {"$ref": "#/components/responses/ReleaseUnread"}, "503": {"$ref": "#/components/responses/SubscriptionUnverified"}}}}, "/api/v1/apps/{appUuid}/releases": {"get": {"tags": ["Releases"], "summary": "List releases for an app.", "description": "The app's stored releases, newest first. Release ingestion is not available yet, so nothing stores a release and the list is empty. Any member can list them, and so can an API key with the `analytics:read` scope.", "operationId": "listAppReleases", "parameters": [{"name": "appUuid", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid"}, "description": "App UUID. Anything that is not a UUID answers `404 NOT_FOUND`."}, {"name": "limit", "in": "query", "schema": {"type": "integer", "default": 50}, "description": "Maximum number of releases to return. A value above 100 is read as 100 and a value below 1 as 1; a value that is not a whole number answers `400 VALIDATION_ERROR`."}, {"name": "platform", "in": "query", "schema": {"type": "string"}, "description": "Only the releases stored with exactly this `platform`."}], "responses": {"200": {"description": "The app's releases.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ReleaseList"}}}}, "400": {"$ref": "#/components/responses/ReleaseLimitRefused"}, "401": {"$ref": "#/components/responses/Unauthorized"}, "402": {"$ref": "#/components/responses/SubscriptionRequired"}, "403": {"$ref": "#/components/responses/ReleaseKeyForbidden"}, "404": {"$ref": "#/components/responses/ReleaseAppNotFound"}, "500": {"$ref": "#/components/responses/ReleaseUnread"}, "503": {"$ref": "#/components/responses/ReadUnavailable"}}}}, "/api/v1/apps/{appUuid}/releases/sync": {"post": {"tags": ["Releases"], "summary": "Trigger manual release sync (not implemented).", "deprecated": true, "description": "Not implemented. Release ingestion is unavailable, so this answers `501 NOT_IMPLEMENTED` for an admin or owner of a workspace that has the app. Members and API keys are refused first.", "operationId": "syncReleases", "security": [{"BearerAuth": []}], "parameters": [{"name": "appUuid", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid"}, "description": "App UUID. Anything that is not a UUID answers `404 NOT_FOUND`."}], "requestBody": {"required": false, "content": {"application/json": {"schema": {"type": "object", "description": "Not read. The answer is the same whatever is sent."}}}}, "responses": {"400": {"description": "`VALIDATION_ERROR`: the body is not a JSON object.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "401": {"$ref": "#/components/responses/Unauthorized"}, "402": {"$ref": "#/components/responses/SubscriptionRequired"}, "403": {"$ref": "#/components/responses/ReleaseAdminForbidden"}, "404": {"$ref": "#/components/responses/ReleaseAppNotFound"}, "501": {"description": "`NOT_IMPLEMENTED`: release sync is not available yet.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "503": {"$ref": "#/components/responses/ReadUnavailable"}}}}, "/api/v1/apps/{appUuid}/releases/report": {"get": {"tags": ["Releases"], "summary": "Get multi-release comparison report.", "description": "The app's newest stored releases, as the list shows them. No trend is measured across them. Release ingestion is not available yet, so the report lists no release.", "operationId": "getReleaseReport", "parameters": [{"name": "appUuid", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid"}, "description": "App UUID. Anything that is not a UUID answers `404 NOT_FOUND`."}, {"name": "limit", "in": "query", "schema": {"type": "integer", "default": 10}, "description": "Number of recent releases to include. A value above 20 is read as 20 and a value below 1 as 1; a value that is not a whole number answers `400 VALIDATION_ERROR`."}], "responses": {"200": {"description": "The report.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ReleaseReport"}}}}, "400": {"$ref": "#/components/responses/ReleaseLimitRefused"}, "401": {"$ref": "#/components/responses/Unauthorized"}, "402": {"$ref": "#/components/responses/SubscriptionRequired"}, "403": {"$ref": "#/components/responses/ReleaseKeyForbidden"}, "404": {"$ref": "#/components/responses/ReleaseAppNotFound"}, "500": {"$ref": "#/components/responses/ReleaseUnread"}, "503": {"$ref": "#/components/responses/ReadUnavailable"}}}}, "/api/v1/apps/{appUuid}/releases/compare": {"get": {"tags": ["Releases"], "summary": "Compare two releases (not implemented).", "deprecated": true, "description": "Not implemented. Release comparison is unavailable, so this answers `501 NOT_IMPLEMENTED` for a workspace that has the app.", "operationId": "compareReleases", "parameters": [{"name": "appUuid", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid"}, "description": "App UUID. Anything that is not a UUID answers `404 NOT_FOUND`."}, {"name": "version1", "in": "query", "required": true, "schema": {"type": "string"}, "description": "First version to compare."}, {"name": "version2", "in": "query", "required": true, "schema": {"type": "string"}, "description": "Second version to compare."}], "responses": {"401": {"$ref": "#/components/responses/Unauthorized"}, "402": {"$ref": "#/components/responses/SubscriptionRequired"}, "403": {"$ref": "#/components/responses/ReleaseKeyForbidden"}, "404": {"$ref": "#/components/responses/ReleaseAppNotFound"}, "501": {"description": "`NOT_IMPLEMENTED`: release comparison is not available yet.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "503": {"$ref": "#/components/responses/ReadUnavailable"}}}}, "/api/v1/apps/{appUuid}/releases/{releaseId}": {"get": {"tags": ["Releases"], "summary": "Get a specific release.", "description": "One stored release of the app, as the list shows it.", "operationId": "getRelease", "parameters": [{"name": "appUuid", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid"}, "description": "App UUID. Anything that is not a UUID answers `404 NOT_FOUND`."}, {"name": "releaseId", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid"}, "description": "The release's `release_id`. Anything that is not a UUID answers `404 NOT_FOUND`; a version string is not looked up."}], "responses": {"200": {"description": "The release.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Release"}}}}, "401": {"$ref": "#/components/responses/Unauthorized"}, "402": {"$ref": "#/components/responses/SubscriptionRequired"}, "403": {"$ref": "#/components/responses/ReleaseKeyForbidden"}, "404": {"$ref": "#/components/responses/ReleaseNotFound"}, "500": {"$ref": "#/components/responses/ReleaseUnread"}, "503": {"$ref": "#/components/responses/ReadUnavailable"}}}}, "/api/v1/apps/{appUuid}/releases/{releaseId}/impact": {"get": {"tags": ["Releases"], "summary": "Get release impact analysis.", "description": "The figures stored with one release, before and after it. Nothing is measured on read: the change in rating is worked out from the two stored ratings when both are known.", "operationId": "getReleaseImpact", "parameters": [{"name": "appUuid", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid"}, "description": "App UUID. Anything that is not a UUID answers `404 NOT_FOUND`."}, {"name": "releaseId", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid"}, "description": "The release's `release_id`. Anything that is not a UUID answers `404 NOT_FOUND`; a version string is not looked up."}], "responses": {"200": {"description": "The release's stored impact figures.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ReleaseImpact"}}}}, "401": {"$ref": "#/components/responses/Unauthorized"}, "402": {"$ref": "#/components/responses/SubscriptionRequired"}, "403": {"$ref": "#/components/responses/ReleaseKeyForbidden"}, "404": {"$ref": "#/components/responses/ReleaseNotFound"}, "500": {"$ref": "#/components/responses/ReleaseUnread"}, "503": {"$ref": "#/components/responses/ReadUnavailable"}}}}, "/api/v1/push/vapid-key": {"get": {"tags": ["Push Notifications"], "summary": "Get VAPID public key.", "description": "Returns the VAPID public key needed to subscribe to push notifications. This public key does not require authentication. Use it with the browser's PushManager.subscribe() method.", "operationId": "getVapidKey", "security": [], "responses": {"200": {"description": "VAPID key returned.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/VapidKeyResponse"}}}}, "503": {"$ref": "#/components/responses/ServiceUnavailable"}}}}, "/api/v1/push/subscribe": {"post": {"tags": ["Push Notifications"], "summary": "Register push subscription.", "description": "Registers a Web Push subscription for the authenticated user. The subscription object should be obtained from the browser's PushManager.subscribe() method. The endpoint must be an HTTPS URL on a browser push service (Firebase Cloud Messaging, Mozilla autopush, Apple Web Push or Windows Push Notification Services); anything else is refused with 400. A person keeps their 20 most recent subscriptions.", "operationId": "subscribePush", "security": [{"BearerAuth": []}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/PushSubscribeRequest"}}}}, "responses": {"200": {"description": "Subscription registered.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/PushSubscribeResponse"}}}}, "201": {"description": "Subscription registered.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/PushSubscribeResponse"}}}}, "400": {"$ref": "#/components/responses/ValidationError"}, "401": {"$ref": "#/components/responses/Unauthorized"}, "500": {"description": "Could not register the subscription.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}}}}, "/api/v1/push/unsubscribe": {"post": {"tags": ["Push Notifications"], "summary": "Remove push subscription.", "description": "Removes a Web Push subscription for the authenticated user. Accepts POST for browsers that don't support DELETE with body.", "operationId": "unsubscribePush", "security": [{"BearerAuth": []}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/PushUnsubscribeRequest"}}}}, "responses": {"200": {"description": "Subscription removed.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/GenericSuccess"}}}}, "400": {"$ref": "#/components/responses/ValidationError"}, "401": {"$ref": "#/components/responses/Unauthorized"}, "500": {"description": "Could not remove the subscription. Retrying the same endpoint is safe.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}}}, "delete": {"tags": ["Push Notifications"], "summary": "Remove push subscription.", "description": "Removes a Web Push subscription for the authenticated user. Alternative to POST for RESTful clients.", "operationId": "deletePushSubscription", "security": [{"BearerAuth": []}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/PushUnsubscribeRequest"}}}}, "responses": {"200": {"description": "Subscription removed.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/GenericSuccess"}}}}, "400": {"$ref": "#/components/responses/ValidationError"}, "401": {"$ref": "#/components/responses/Unauthorized"}, "500": {"description": "Could not remove the subscription. Retrying the same endpoint is safe.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}}}}, "/api/v1/push/test": {"post": {"tags": ["Push Notifications"], "summary": "Send a test push notification.", "description": "Sends a real VAPID-signed Web Push notification to the authenticated user's own subscribed devices. Used by the dashboard \"Send test notification\" action to verify end-to-end delivery. Returns how many of the user's subscriptions the notification was delivered to.", "operationId": "sendTestPush", "security": [{"BearerAuth": []}], "responses": {"200": {"description": "Test notification dispatched (see sent/total counts).", "content": {"application/json": {"schema": {"type": "object", "properties": {"message": {"type": "string"}, "sent": {"type": "integer", "description": "Subscriptions delivered to."}, "total": {"type": "integer", "description": "Total subscriptions for the user."}, "removed": {"type": "integer", "description": "Expired subscriptions removed during this attempt."}}}}}}, "401": {"$ref": "#/components/responses/Unauthorized"}, "500": {"description": "Could not read the user's subscriptions.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "404": {"description": "The user has no push subscriptions yet.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "503": {"$ref": "#/components/responses/ServiceUnavailable"}}}}, "/api/v1/admin/email-analytics/templates": {"get": {"tags": ["Email Analytics"], "summary": "Get metrics for all email templates.", "description": "Counts of every email event over the last `days` days, by template, most sent first, with the rates worked out from them. The figures are platform-wide, so only a Tritonium platform operator may read them; every other caller, workspace admins and owners included, is refused with `403`.", "operationId": "getAllEmailTemplateMetrics", "security": [{"BearerAuth": []}], "parameters": [{"name": "days", "in": "query", "schema": {"type": "integer", "default": 30, "minimum": 1, "maximum": 90}, "description": "Number of days to look back. Anything else answers `400 VALIDATION_ERROR`."}], "responses": {"200": {"description": "Template metrics returned.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/EmailTemplateMetricsList"}}}}, "400": {"$ref": "#/components/responses/EmailAnalyticsRefused"}, "401": {"$ref": "#/components/responses/Unauthorized"}, "403": {"$ref": "#/components/responses/EmailAnalyticsForbidden"}, "500": {"$ref": "#/components/responses/EmailAnalyticsUnread"}}}}, "/api/v1/admin/email-analytics/templates/{templateName}": {"get": {"tags": ["Email Analytics"], "summary": "Get metrics for a specific email template.", "description": "One template's event counts and rates over the last `days` days, and its counts per day. A template with no events answers zeros. Only a Tritonium platform operator may read them.", "operationId": "getEmailTemplateMetrics", "security": [{"BearerAuth": []}], "parameters": [{"name": "templateName", "in": "path", "required": true, "schema": {"type": "string", "pattern": "^[a-zA-Z0-9_-]+$"}, "description": "The template name, as the template list names it. Letters, digits, underscores and hyphens only; anything else answers `404 NOT_FOUND`."}, {"name": "days", "in": "query", "schema": {"type": "integer", "default": 30, "minimum": 1, "maximum": 90}, "description": "Number of days to look back. Anything else answers `400 VALIDATION_ERROR`."}], "responses": {"200": {"description": "Template metrics returned.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/EmailTemplateMetrics"}}}}, "400": {"$ref": "#/components/responses/EmailAnalyticsRefused"}, "401": {"$ref": "#/components/responses/Unauthorized"}, "403": {"$ref": "#/components/responses/EmailAnalyticsForbidden"}, "404": {"description": "`NOT_FOUND`: the template name is not in the form given above.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "500": {"$ref": "#/components/responses/EmailAnalyticsUnread"}}}}, "/api/v1/admin/email-analytics/daily": {"get": {"tags": ["Email Analytics"], "summary": "Get daily email metrics.", "description": "Email event counts per day over the last `days` days, across every template, oldest day first. Days with no event are left out. Only a Tritonium platform operator may read them.", "operationId": "getDailyEmailMetrics", "security": [{"BearerAuth": []}], "parameters": [{"name": "days", "in": "query", "schema": {"type": "integer", "default": 30, "minimum": 1, "maximum": 90}, "description": "Number of days to retrieve. Anything else answers `400 VALIDATION_ERROR`."}], "responses": {"200": {"description": "Daily metrics returned.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/DailyEmailMetricsList"}}}}, "400": {"$ref": "#/components/responses/EmailAnalyticsRefused"}, "401": {"$ref": "#/components/responses/Unauthorized"}, "403": {"$ref": "#/components/responses/EmailAnalyticsForbidden"}, "500": {"$ref": "#/components/responses/EmailAnalyticsUnread"}}}}, "/api/v1/admin/email-analytics/events": {"get": {"tags": ["Email Analytics"], "summary": "Get email event counts by type.", "description": "How many events of each type (Send, Delivery, Open, Click, Bounce, Complaint) there were over the last `days` days. Only a Tritonium platform operator may read them.", "operationId": "getEmailEventCounts", "security": [{"BearerAuth": []}], "parameters": [{"name": "days", "in": "query", "schema": {"type": "integer", "default": 7, "minimum": 1, "maximum": 30}, "description": "Number of days to look back. Anything else answers `400 VALIDATION_ERROR`."}], "responses": {"200": {"description": "Event counts returned.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/EmailEventCounts"}}}}, "400": {"$ref": "#/components/responses/EmailAnalyticsRefused"}, "401": {"$ref": "#/components/responses/Unauthorized"}, "403": {"$ref": "#/components/responses/EmailAnalyticsForbidden"}, "500": {"$ref": "#/components/responses/EmailAnalyticsUnread"}}}}, "/api/v1/admin/email-analytics/recipients/{email}": {"get": {"tags": ["Email Analytics"], "summary": "Get email history for a recipient.", "description": "The newest email events recorded for one recipient address, newest first. Events are kept for 90 days. Only a Tritonium platform operator may read them.", "operationId": "getRecipientEmailHistory", "security": [{"BearerAuth": []}], "parameters": [{"name": "email", "in": "path", "required": true, "schema": {"type": "string"}, "description": "Recipient email address, compared in lowercase. Any text is looked up; one with no events answers an empty history."}, {"name": "limit", "in": "query", "schema": {"type": "integer", "default": 50, "minimum": 1, "maximum": 100}, "description": "Maximum number of events to return. Anything else answers `400 VALIDATION_ERROR`."}], "responses": {"200": {"description": "Recipient history returned.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/RecipientEmailHistory"}}}}, "400": {"$ref": "#/components/responses/EmailAnalyticsRefused"}, "401": {"$ref": "#/components/responses/Unauthorized"}, "403": {"$ref": "#/components/responses/EmailAnalyticsForbidden"}, "500": {"$ref": "#/components/responses/EmailAnalyticsUnread"}}}}, "/api/v1/apps/{appUuid}/auto-response-rules": {"get": {"tags": ["Auto-Response Rules"], "summary": "List auto-response rules.", "description": "Returns all auto-response rules configured for the app.", "operationId": "listAutoResponseRules", "parameters": [{"name": "appUuid", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid"}, "description": "App UUID."}, {"name": "enabled_only", "in": "query", "required": false, "schema": {"type": "boolean", "default": false}, "description": "If true, return only enabled rules."}], "responses": {"200": {"description": "Rules retrieved.", "content": {"application/json": {"schema": {"type": "object", "properties": {"rules": {"type": "array", "items": {"$ref": "#/components/schemas/AutoResponseRule"}}, "count": {"type": "integer", "minimum": 0, "description": "Number of rules returned in this response, rather than the workspace total."}}}}}}, "401": {"$ref": "#/components/responses/Unauthorized"}, "404": {"$ref": "#/components/responses/NotFound"}, "503": {"description": "Auto-response is unavailable for this workspace or its storage is temporarily unavailable. Retry later or contact support.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}}}, "post": {"tags": ["Auto-Response Rules"], "summary": "Create an auto-response rule.", "description": "Creates a new auto-response rule. Rules are disabled by default for safety and must be explicitly enabled.", "operationId": "createAutoResponseRule", "security": [{"BearerAuth": []}], "parameters": [{"name": "appUuid", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid"}, "description": "App UUID."}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/CreateAutoResponseRuleRequest"}}}}, "responses": {"201": {"description": "Rule created.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/AutoResponseRule"}}}}, "400": {"$ref": "#/components/responses/ValidationError"}, "401": {"$ref": "#/components/responses/Unauthorized"}, "404": {"$ref": "#/components/responses/NotFound"}, "409": {"description": "The selected template changed during activation. Refresh and review it before enabling.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "503": {"description": "Auto-response is unavailable for this workspace or its storage is temporarily unavailable. Retry later or contact support.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}}}}, "/api/v1/apps/{appUuid}/auto-response-rules/analytics": {"get": {"tags": ["Auto-Response Rules"], "summary": "Get auto-response analytics.", "description": "Returns aggregate analytics for all auto-response rules.", "operationId": "getAutoResponseAnalytics", "parameters": [{"name": "appUuid", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid"}, "description": "App UUID."}, {"name": "days", "in": "query", "required": false, "deprecated": true, "schema": {"type": "integer", "minimum": 1, "maximum": 365, "default": 30}, "description": "Retained for compatibility and ignored. These counters cover the lifetime of current rules, not a rolling date window."}], "responses": {"200": {"description": "Analytics retrieved.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/AutoResponseAnalytics"}}}}, "401": {"$ref": "#/components/responses/Unauthorized"}, "404": {"$ref": "#/components/responses/NotFound"}, "503": {"description": "Auto-response is unavailable for this workspace or its storage is temporarily unavailable. Retry later or contact support.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}}}}, "/api/v1/apps/{appUuid}/auto-response-rules/{ruleId}": {"get": {"tags": ["Auto-Response Rules"], "summary": "Get a specific auto-response rule.", "description": "Returns the full configuration for an auto-response rule.", "operationId": "getAutoResponseRule", "parameters": [{"name": "appUuid", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid"}, "description": "App UUID."}, {"name": "ruleId", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid"}, "description": "Rule ID."}], "responses": {"200": {"description": "Rule retrieved.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/AutoResponseRule"}}}}, "401": {"$ref": "#/components/responses/Unauthorized"}, "404": {"$ref": "#/components/responses/NotFound"}, "503": {"description": "Auto-response is unavailable for this workspace or its storage is temporarily unavailable. Retry later or contact support.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}}}, "put": {"tags": ["Auto-Response Rules"], "summary": "Update an auto-response rule.", "description": "Updates the configuration for an auto-response rule.", "operationId": "updateAutoResponseRule", "security": [{"BearerAuth": []}], "parameters": [{"name": "appUuid", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid"}, "description": "App UUID."}, {"name": "ruleId", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid"}, "description": "Rule ID."}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/UpdateAutoResponseRuleRequest"}}}}, "responses": {"200": {"description": "Rule updated.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/AutoResponseRule"}}}}, "400": {"$ref": "#/components/responses/ValidationError"}, "401": {"$ref": "#/components/responses/Unauthorized"}, "404": {"$ref": "#/components/responses/NotFound"}, "409": {"description": "The rule changed while this request was in progress. Refresh and retry.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "503": {"description": "Auto-response is unavailable for this workspace or its storage is temporarily unavailable. Retry later or contact support.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}}}, "delete": {"tags": ["Auto-Response Rules"], "summary": "Delete an auto-response rule.", "description": "Permanently deletes an auto-response rule.", "operationId": "deleteAutoResponseRule", "security": [{"BearerAuth": []}], "parameters": [{"name": "appUuid", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid"}, "description": "App UUID."}, {"name": "ruleId", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid"}, "description": "Rule ID."}, {"name": "expected_updated_at", "in": "query", "required": false, "schema": {"type": "string", "minLength": 1, "maxLength": 128}, "description": "The displayed rule revision. A stale revision returns 409 RULE_CONFLICT."}], "responses": {"200": {"description": "Rule deleted.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/AutoResponseRuleDeleteResponse"}}}}, "204": {"description": "Rule deleted without a response body (legacy compatibility)."}, "401": {"$ref": "#/components/responses/Unauthorized"}, "404": {"$ref": "#/components/responses/NotFound"}, "409": {"description": "The rule changed while this request was in progress. Refresh and retry.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "503": {"description": "Auto-response is unavailable for this workspace or its storage is temporarily unavailable. Retry later or contact support.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}}}}, "/api/v1/apps/{appUuid}/auto-response-rules/{ruleId}/toggle": {"post": {"tags": ["Auto-Response Rules"], "summary": "Toggle rule enabled state.", "description": "Enables or disables an auto-response rule.", "operationId": "toggleAutoResponseRule", "security": [{"BearerAuth": []}], "parameters": [{"name": "appUuid", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid"}, "description": "App UUID."}, {"name": "ruleId", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid"}, "description": "Rule ID."}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"type": "object", "required": ["enabled"], "properties": {"expected_template": {"$ref": "#/components/schemas/AutoResponseTemplateRevision"}, "enabled": {"type": "boolean", "description": "New enabled state."}, "expected_updated_at": {"type": "string", "minLength": 1, "maxLength": 128, "description": "The displayed rule revision. A stale revision returns 409 RULE_CONFLICT."}}}}}}, "responses": {"200": {"description": "Rule toggled.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/AutoResponseRule"}}}}, "400": {"$ref": "#/components/responses/ValidationError"}, "401": {"$ref": "#/components/responses/Unauthorized"}, "404": {"$ref": "#/components/responses/NotFound"}, "409": {"description": "The rule changed while this request was in progress. Refresh and retry.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "503": {"description": "Auto-response is unavailable for this workspace or its storage is temporarily unavailable. Retry later or contact support.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}}}}, "/api/v1/apps/{appUuid}/auto-response-rules/{ruleId}/test": {"post": {"tags": ["Auto-Response Rules"], "summary": "Test a rule against sample review.", "description": "Tests whether a rule would match a sample review and shows what response would be generated.", "operationId": "testAutoResponseRule", "security": [{"BearerAuth": []}], "parameters": [{"name": "appUuid", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid"}, "description": "App UUID."}, {"name": "ruleId", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid"}, "description": "Rule ID."}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/TestAutoResponseRuleRequest"}}}}, "responses": {"200": {"description": "Test results returned.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/TestAutoResponseRuleResponse"}}}}, "400": {"$ref": "#/components/responses/ValidationError"}, "401": {"$ref": "#/components/responses/Unauthorized"}, "404": {"$ref": "#/components/responses/NotFound"}, "409": {"description": "The rule changed. Refresh before testing it again.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "503": {"description": "Auto-response is unavailable for this workspace or its storage is temporarily unavailable. Retry later or contact support.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}}}}, "/api/v1/apps/{appUuid}/segments": {"get": {"tags": ["Segments"], "summary": "List user segments for an app.", "description": "Retrieve reviewer segments classified from review data. Tritonium\nreads public reviews only: it has no sign-up dates and cannot see app\nusage, so a segment describes what a reviewer wrote and when, not what\nthey do in the app. Segments include:\n- **influencer**: The classifier assigns no reviewer to this segment\n  today, because the App Store and Google Play give no audience\n  figures. Only a row stored earlier can be listed or counted\n- **power_user**: Reviewers with five or more reviews, or one long,\n  detailed review (200 characters or more). A review whose platform\n  is `steam` also qualifies with 100 or more hours of play time\n  (Tritonium does not sync Steam reviews today)\n- **early_adopter**: Reviewers who reviewed within 7 days of launch\n- **at_risk**: Reviewers who gave 1 or 2 stars, scored by how\n  strongly the review suggests they may stop using the app\n- **churned**: Reviewers who gave 1 or 2 stars and either wrote that\n  they are leaving or comparing alternatives, or have not reviewed\n  for 90 days or more. Every churned reviewer is also `at_risk`\n- **competitor_mentioner**: Reviewers who mentioned a competitor the\n  workspace tracks\n- **casual**: Single-review reviewers with no other segment\n\nA reviewer is in every segment their reviews match, one row each, so\nthe rows outnumber the reviewers: `summary.total_segments` counts\nrows and `summary.total_reviewers` counts people.\n\nSegments are listed highest `sort` value first, then by segment type\nand `author_id`, one page at a time. The order is the same on every\npage. `next_cursor` is set only when more segments exist: pass it back\nas `cursor`, with the same `segment_type` and `sort`, for the next\npage. `summary` counts every segment of the selection on every page.\nA segment that changes while you page (a new review or a new score)\nand moves ahead of your cursor is sent on a later page, before the\nrest, so none is left out. It may be one you already have:\ndeduplicate by `segment_type` and `author_id`.\n", "operationId": "listUserSegments", "parameters": [{"$ref": "#/components/parameters/AppUuidPath"}, {"name": "segment_type", "in": "query", "description": "Filter by segment type.", "schema": {"type": "string", "enum": ["influencer", "power_user", "early_adopter", "at_risk", "churned", "competitor_mentioner", "casual"]}}, {"name": "sort", "in": "query", "description": "Sort field for results.", "schema": {"type": "string", "enum": ["score", "review_count", "sentiment"], "default": "score"}}, {"name": "limit", "in": "query", "description": "Maximum number of segments to return.", "schema": {"type": "integer", "minimum": 1, "maximum": 200, "default": 50}}, {"$ref": "#/components/parameters/ListCursor"}], "responses": {"200": {"description": "User segments retrieved.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/UserSegmentsResponse"}}}}, "400": {"$ref": "#/components/responses/ValidationError"}, "401": {"$ref": "#/components/responses/Unauthorized"}, "404": {"$ref": "#/components/responses/NotFound"}, "503": {"$ref": "#/components/responses/ServiceUnavailable"}}}}, "/api/v1/apps/{appUuid}/segments/backfill": {"post": {"tags": ["Segments"], "summary": "Backfill segments for existing reviews.", "description": "Re-enrich existing reviews with segment data. Useful for apps that were\nsynced before segmentation was enabled. Processes reviews in batches\nand may return partial results if timeout is reached.\n\nOne call processes at most `limit` reviews. When `partial` is `true`,\npass the returned `next_cursor` back as the `cursor` parameter to\nprocess the following batch; repeat until `partial` is `false`.\nWithout that loop an app is only ever backfilled up to `limit` reviews.\n", "operationId": "backfillSegments", "security": [{"BearerAuth": []}], "parameters": [{"$ref": "#/components/parameters/AppUuidPath"}, {"name": "limit", "in": "query", "description": "Maximum number of reviews to process.", "schema": {"type": "integer", "minimum": 1, "maximum": 100, "default": 50}}, {"name": "cursor", "in": "query", "description": "Opaque pagination cursor: the `next_cursor` returned by the previous\ncall. Omit to start from the first review. A cursor that cannot be\ndecoded, or that belongs to a different app, is rejected with\n`400 INVALID_CURSOR` rather than silently restarting the backfill.\n", "schema": {"type": "string"}}], "responses": {"200": {"description": "Backfill completed or partially completed. `errors_count` reports\nreviews that failed; reviews matching no segment rule are counted\nin `processed` only, incrementing neither `enriched_count` nor\n`errors_count`.\n", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/BackfillSegmentsResponse"}}}}, "400": {"$ref": "#/components/responses/BadRequest"}, "401": {"$ref": "#/components/responses/Unauthorized"}, "404": {"$ref": "#/components/responses/NotFound"}, "500": {"description": "Every review processed failed to persist (`SEGMENT_PERSIST_FAILED`),\nwhich indicates the persistence path is unavailable rather than a\nbatch of bad reviews. Nothing was written; do not follow a cursor\npast this.\n", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "503": {"$ref": "#/components/responses/ServiceUnavailable"}}}}, "/api/v1/apps/{appUuid}/analytics/languages": {"get": {"tags": ["Language Analytics"], "summary": "Get comprehensive language analytics.", "description": "Returns language distribution, sentiment breakdown by language,\ntrends over time, and translation statistics.\n", "operationId": "getLanguageAnalytics", "parameters": [{"$ref": "#/components/parameters/AppUuidPath"}, {"name": "days", "in": "query", "description": "Size of the trailing window to analyse, in days, ending today.", "schema": {"type": "integer", "minimum": 1, "maximum": 90, "default": 30}}], "responses": {"200": {"description": "Language analytics retrieved.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/LanguageAnalyticsResponse"}}}}, "401": {"$ref": "#/components/responses/Unauthorized"}, "404": {"$ref": "#/components/responses/NotFound"}, "503": {"$ref": "#/components/responses/ServiceUnavailable"}}}}, "/api/v1/apps/{appUuid}/analytics/sentiment-by-language": {"get": {"tags": ["Language Analytics"], "summary": "Get sentiment analysis grouped by language.", "description": "Returns sentiment metrics (positive, negative, neutral rates) for each\nlanguage detected in reviews, along with trend indicators.\n", "operationId": "getSentimentByLanguage", "parameters": [{"$ref": "#/components/parameters/AppUuidPath"}, {"name": "days", "in": "query", "description": "Number of days to analyze. Both bounds match what the API enforces: a value outside them answers 400 `VALIDATION_ERROR` rather than being clamped.", "schema": {"type": "integer", "minimum": 1, "maximum": 90, "default": 30}}], "responses": {"200": {"description": "Sentiment by language retrieved.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/SentimentByLanguageResponse"}}}}, "400": {"$ref": "#/components/responses/ValidationError"}, "401": {"$ref": "#/components/responses/Unauthorized"}, "404": {"$ref": "#/components/responses/NotFound"}, "503": {"$ref": "#/components/responses/ServiceUnavailable"}}}}, "/api/v1/apps/{appUuid}/feature-suggestions": {"get": {"tags": ["Feature Suggestions"], "summary": "Get AI-extracted feature suggestions.", "description": "Retrieve feature requests extracted from reviews using AI, one page at\na time in storage-key order (the same order on every page; sort by\n`demand_score` client-side if you need it). `next_cursor` is set only\nwhen more suggestions exist.\n", "operationId": "getFeatureSuggestions", "parameters": [{"$ref": "#/components/parameters/AppUuidPath"}, {"name": "limit", "in": "query", "description": "Maximum number of suggestions to return.", "schema": {"type": "integer", "minimum": 1, "maximum": 200, "default": 50}}, {"name": "status", "in": "query", "description": "Filter by suggestion status.", "schema": {"type": "string", "enum": ["suggested", "planned", "in_progress", "shipped", "dismissed"]}}, {"$ref": "#/components/parameters/ListCursor"}], "responses": {"200": {"description": "Feature suggestions retrieved.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/FeatureSuggestionsResponse"}}}}, "400": {"description": "`VALIDATION_ERROR`: `limit` is not a whole number from 1 to 200. `INVALID_CURSOR`: `cursor` was not given by this list, or was given for another app or another `status`.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "401": {"$ref": "#/components/responses/Unauthorized"}, "402": {"$ref": "#/components/responses/SubscriptionRequired"}, "403": {"$ref": "#/components/responses/AnalyticsKeyForbidden"}, "404": {"$ref": "#/components/responses/AppNotFound"}, "429": {"$ref": "#/components/responses/CredentialRateLimited"}, "500": {"description": "`INTERNAL_ERROR`: the suggestions could not be read. Retry.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "503": {"$ref": "#/components/responses/AnalyticsAppUnverified"}}}}, "/api/v1/apps/{appUuid}/feature-suggestions/{featureId}": {"patch": {"tags": ["Feature Suggestions"], "summary": "Update feature suggestion status.", "description": "Update the status of a feature suggestion to track its lifecycle. Any workspace role (owner, admin or member) may triage a suggestion.", "operationId": "updateFeatureSuggestionStatus", "security": [{"BearerAuth": []}], "parameters": [{"$ref": "#/components/parameters/AppUuidPath"}, {"name": "featureId", "in": "path", "required": true, "description": "Feature suggestion identifier.", "schema": {"type": "string"}}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"type": "object", "description": "Omitted fields are unchanged. At least one field is required.", "properties": {"status": {"type": "string", "enum": ["suggested", "planned", "in_progress", "shipped", "dismissed"], "description": "New status for the feature suggestion."}, "notes": {"type": "string", "maxLength": 2000, "description": "Free-form tracking notes."}, "priority": {"type": "string", "enum": ["low", "medium", "high"], "description": "Team-assigned triage priority."}, "assignee": {"type": "string", "maxLength": 200, "description": "Team member the suggestion is assigned to."}}, "additionalProperties": false, "anyOf": [{"required": ["status"]}, {"required": ["notes"]}, {"required": ["priority"]}, {"required": ["assignee"]}]}}}}, "responses": {"200": {"description": "The updated feature suggestion.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/FeatureSuggestion"}}}}, "400": {"$ref": "#/components/responses/BadRequest"}, "401": {"$ref": "#/components/responses/Unauthorized"}, "403": {"$ref": "#/components/responses/Forbidden"}, "404": {"$ref": "#/components/responses/NotFound"}, "503": {"$ref": "#/components/responses/ServiceUnavailable"}}}}, "/api/v1/apps/{appUuid}/generate-presentation": {"post": {"tags": ["Presentations"], "summary": "Generate a presentation from the app's current review analysis.", "description": "Workspace members can start a durable generation job. Analyze\nthe app's reviews first. Poll the returned job identifier for real PDF\nand PowerPoint downloads. Files expire after seven days; download links\nexpire sooner and can be refreshed by reading the completed job again.\n", "operationId": "generatePresentation", "security": [{"BearerAuth": []}], "parameters": [{"$ref": "#/components/parameters/AppUuidPath"}, {"$ref": "#/components/parameters/PresentationIdempotencyKey"}], "requestBody": {"required": false, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/PresentationGenerationRequest"}}}}, "responses": {"202": {"description": "A persisted presentation job, or the same job for a repeated idempotency key.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/PresentationJob"}}}}, "400": {"$ref": "#/components/responses/BadRequest"}, "401": {"$ref": "#/components/responses/Unauthorized"}, "402": {"description": "An active workspace subscription is required.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "403": {"$ref": "#/components/responses/Forbidden"}, "404": {"$ref": "#/components/responses/NotFound"}, "409": {"description": "Analysis is unavailable or the idempotency key belongs to another request.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "429": {"$ref": "#/components/responses/RateLimited"}, "503": {"description": "Presentation generation is temporarily unavailable.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}}}}, "/api/v1/product-groups/{groupId}/generate-presentation": {"post": {"tags": ["Presentations"], "summary": "Generate a presentation from the product group's current app analyses.", "description": "Requires workspace access and current analysis for every app\nin the owned product group. Generation captures each app's analysis and\ncoverage. Changed group membership requires a new presentation.\n", "operationId": "generateGroupPresentation", "security": [{"BearerAuth": []}], "parameters": [{"name": "groupId", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid"}}, {"$ref": "#/components/parameters/PresentationIdempotencyKey"}], "requestBody": {"required": false, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/PresentationGenerationRequest"}}}}, "responses": {"202": {"description": "A persisted generation job.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/PresentationJob"}}}}, "400": {"$ref": "#/components/responses/BadRequest"}, "401": {"$ref": "#/components/responses/Unauthorized"}, "402": {"description": "An active workspace subscription is required.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "403": {"$ref": "#/components/responses/Forbidden"}, "404": {"$ref": "#/components/responses/NotFound"}, "409": {"description": "A group or analysis has changed, or the idempotency key conflicts.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "429": {"$ref": "#/components/responses/RateLimited"}, "503": {"description": "Presentations are temporarily unavailable.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}}}}, "/api/v1/presentation-jobs/{jobId}": {"get": {"tags": ["Presentations"], "summary": "Read an owned presentation job and refresh its download links.", "operationId": "getPresentationJob", "security": [{"BearerAuth": []}], "parameters": [{"name": "jobId", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid"}}], "responses": {"200": {"description": "Current generation state and, when completed, the generated sections and downloads.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/PresentationJob"}}}}, "401": {"$ref": "#/components/responses/Unauthorized"}, "402": {"description": "An active workspace subscription is required.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "403": {"$ref": "#/components/responses/Forbidden"}, "404": {"$ref": "#/components/responses/NotFound"}, "409": {"description": "The product group has changed and a new presentation is required.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "503": {"description": "Presentation files are temporarily unavailable.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}}}}, "/api/v1/integrations/{integrationType}/connect": {"post": {"tags": ["Integrations"], "summary": "Connect an integration of a specific type.", "description": "Requires the owner or admin role. Jira and Slack connect through their sign-in page: the answer is 200 with the page's address, and the connection is made when the person comes back (`GET /api/v1/integrations/{jira|slack}/callback`); the body is not read. Every other tool that can be connected is connected at once and answers 201 with the connection; its secret (`api_key`, `access_token`, `routing_key`, a token or a signed address) is kept in Secrets Manager and never returned. A Discord address is kept in the connection and never returned either. GitLab, Zendesk, Monday, Asana, ClickUp, Trello, SMS and Twilio cannot be connected yet (400 `INTEGRATION_NOT_IMPLEMENTED`). Microsoft Teams Workflows connections require an explicitly enabled workspace and a signed Workflows URL; retired Office connector URLs are rejected.", "operationId": "connectIntegrationByType", "security": [{"BearerAuth": []}], "parameters": [{"name": "integrationType", "in": "path", "required": true, "schema": {"type": "string", "enum": ["jira", "slack", "linear", "discord", "teams", "asana", "clickup", "trello", "zapier", "sms", "twilio", "gitlab", "github", "zendesk", "helpscout", "salesforce", "freshdesk", "hubspot", "intercom", "monday", "pagerduty", "opsgenie", "victorops", "statuspage"]}}], "responses": {"200": {"$ref": "#/components/responses/IntegrationOAuthStarted"}, "201": {"$ref": "#/components/responses/IntegrationConnected"}, "400": {"$ref": "#/components/responses/IntegrationConnectRefused"}, "401": {"$ref": "#/components/responses/Unauthorized"}, "402": {"$ref": "#/components/responses/SubscriptionRequired"}, "403": {"$ref": "#/components/responses/IntegrationAdminForbidden"}, "500": {"$ref": "#/components/responses/IntegrationConnectFailed"}, "503": {"$ref": "#/components/responses/IntegrationConnectUnavailable"}}, "requestBody": {"required": false, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/IntegrationConnectFields"}}}}}}, "/api/v1/integrations/{integrationType}/status": {"get": {"tags": ["Integrations"], "summary": "Get connection status for a specific integration type.", "description": "Requires the owner or admin role. Whether the workspace has an active connection of this tool, and that connection. For Slack and Microsoft Teams, the newest connection decides: a disconnected or disabled newest Slack connection answers `connected` false, and Teams is connected only through a Workflows connection.", "operationId": "getIntegrationStatusByType", "security": [{"BearerAuth": []}], "parameters": [{"name": "integrationType", "in": "path", "required": true, "schema": {"type": "string", "enum": ["jira", "slack", "linear", "discord", "teams", "asana", "clickup", "trello", "zapier", "sms", "twilio", "gitlab", "github", "zendesk", "helpscout", "salesforce", "freshdesk", "hubspot", "intercom", "monday", "pagerduty", "opsgenie", "victorops", "statuspage"]}}], "responses": {"200": {"description": "The tool's connection status.", "content": {"application/json": {"schema": {"type": "object", "required": ["connected", "type", "integration"], "properties": {"connected": {"type": "boolean"}, "type": {"type": "string", "description": "The tool, as in the path."}, "integration": {"description": "The active connection; null when `connected` is false.", "oneOf": [{"$ref": "#/components/schemas/Integration"}, {"type": "null"}]}, "oauth_configured": {"type": "boolean", "description": "Jira and Slack only: whether Tritonium can start their sign-in. False means a connection cannot be made now."}, "available": {"type": "boolean", "description": "Microsoft Teams only: whether Teams can be used by this workspace, as `availability.teams` in the list."}}}}}}, "401": {"$ref": "#/components/responses/Unauthorized"}, "402": {"$ref": "#/components/responses/SubscriptionRequired"}, "403": {"$ref": "#/components/responses/IntegrationAdminForbidden"}, "500": {"description": "`QUERY_FAILED` or `STATUS_CHECK_FAILED`: the connections could not be read, so no status is answered. `STATUS_CHECK_FAILED` also names the tool in `type`.", "content": {"application/json": {"schema": {"allOf": [{"$ref": "#/components/schemas/ErrorResponse"}, {"type": "object", "properties": {"type": {"type": "string", "description": "The tool, as in the path. Only with `STATUS_CHECK_FAILED`."}}}]}}}}, "503": {"description": "`LOOKUP_FAILED`: the Microsoft Teams connection could not be read; try again. `SERVICE_UNAVAILABLE`: the workspace's subscription could not be verified.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}}}}, "/api/v1/integrations/{integrationId}/sync": {"post": {"tags": ["Integrations"], "summary": "Sync one integration (retired; not available).", "deprecated": true, "description": "Retired: syncing a connection is not available. An owner or admin of the workspace that has the connection gets `410 OPERATION_RETIRED`, and nothing is queued or changed. This route used to answer 202 although no sync ever ran. At most 20 an hour per workspace. The body is not read.", "operationId": "syncIntegrationByType", "security": [{"BearerAuth": []}], "parameters": [{"$ref": "#/components/parameters/IntegrationId"}], "responses": {"400": {"description": "`VALIDATION_ERROR`: the body is not a JSON object.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "401": {"$ref": "#/components/responses/Unauthorized"}, "402": {"$ref": "#/components/responses/SubscriptionRequired"}, "403": {"$ref": "#/components/responses/IntegrationAdminForbidden"}, "404": {"$ref": "#/components/responses/IntegrationNotFound"}, "410": {"description": "`OPERATION_RETIRED`, with `retryable` false: syncing a connection is not available. Nothing was queued or changed.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "429": {"$ref": "#/components/responses/IntegrationRateLimited"}, "500": {"description": "`INTERNAL_ERROR`: the connection could not be read. Retry.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "503": {"$ref": "#/components/responses/SubscriptionUnverified"}}}}, "/api/v1/apps/{appUuid}": {"delete": {"tags": ["Apps"], "summary": "Delete app.", "operationId": "deleteApp", "security": [{"BearerAuth": []}], "parameters": [{"name": "appUuid", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid"}}], "responses": {"200": {"description": "App deleted and workspace capacity released.", "content": {"application/json": {"schema": {"type": "object", "properties": {"success": {"type": "boolean"}, "message": {"type": "string"}, "app_uuid": {"type": "string", "format": "uuid"}}}}}}, "400": {"$ref": "#/components/responses/BadRequest"}, "401": {"$ref": "#/components/responses/Unauthorized"}, "403": {"$ref": "#/components/responses/Forbidden"}, "404": {"$ref": "#/components/responses/NotFound"}, "503": {"$ref": "#/components/responses/AppChangeUnavailable"}}}, "get": {"tags": ["Apps"], "summary": "Get app.", "operationId": "getApp", "parameters": [{"name": "appUuid", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid"}}], "responses": {"200": {"description": "The app, under `app`. It has the same fields as each entry of `GET /api/v1/apps`.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/AppResponse"}}}}, "400": {"$ref": "#/components/responses/BadRequest"}, "401": {"$ref": "#/components/responses/Unauthorized"}, "403": {"$ref": "#/components/responses/Forbidden"}, "404": {"$ref": "#/components/responses/NotFound"}, "503": {"$ref": "#/components/responses/ReadUnavailable"}}}, "patch": {"tags": ["Apps"], "summary": "Update app.", "description": "Changes only the fields sent; the others keep their values.", "operationId": "updateApp", "security": [{"BearerAuth": []}], "parameters": [{"name": "appUuid", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid"}}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/UpdateAppRequest"}}}}, "responses": {"200": {"description": "The app as stored after the change.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/AppUpdateResponse"}}}}, "400": {"$ref": "#/components/responses/BadRequest"}, "401": {"$ref": "#/components/responses/Unauthorized"}, "403": {"$ref": "#/components/responses/Forbidden"}, "404": {"$ref": "#/components/responses/NotFound"}, "503": {"$ref": "#/components/responses/ReadUnavailable"}}}, "put": {"tags": ["Apps"], "summary": "Replace app (full update).", "description": "Behaves exactly like `PATCH`: only the fields sent change, and a field left out keeps its value.", "operationId": "replaceApp", "security": [{"BearerAuth": []}], "parameters": [{"name": "appUuid", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid"}}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/UpdateAppRequest"}}}}, "responses": {"200": {"description": "The app as stored after the change.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/AppUpdateResponse"}}}}, "400": {"$ref": "#/components/responses/BadRequest"}, "401": {"$ref": "#/components/responses/Unauthorized"}, "403": {"$ref": "#/components/responses/Forbidden"}, "404": {"$ref": "#/components/responses/NotFound"}, "503": {"$ref": "#/components/responses/ReadUnavailable"}}}}, "/api/v1/apps/{appUuid}/analytics/anomalies": {"get": {"tags": ["Analytics"], "summary": "Get anomalies.", "description": "UTC days in the window whose review volume was two or more spreads above (`volume_spike`) or below (`volume_drop`) the days before it. An empty `anomalies` list is an all-clear only for the days `coverage` says each check evaluated: when an app's recent daily counts are incomplete (a store feed that returns only the newest reviews, a sync cut short) a check evaluates no day, and `coverage` says why. For an app read from such a feed, a spike is compared with the days the feed was read whole and is listed only when it is also two or more spreads above the stored counts of the 30 days before it.", "operationId": "getAnomalies", "parameters": [{"name": "appUuid", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid"}}, {"$ref": "#/components/parameters/AnalyticsWindowDays"}, {"$ref": "#/components/parameters/AnalyticsPeriod"}], "responses": {"200": {"description": "Successful response.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/AnomaliesResponse"}}}}, "400": {"$ref": "#/components/responses/BadRequest"}, "401": {"$ref": "#/components/responses/Unauthorized"}, "403": {"$ref": "#/components/responses/Forbidden"}, "404": {"$ref": "#/components/responses/NotFound"}, "503": {"$ref": "#/components/responses/ServiceUnavailable"}}}}, "/api/v1/apps/{appUuid}/analytics/bug-matrix": {"get": {"tags": ["Analytics"], "summary": "Get bug matrix.", "operationId": "getBugMatrix", "parameters": [{"name": "appUuid", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid"}}, {"$ref": "#/components/parameters/AnalyticsWindowDays90"}, {"$ref": "#/components/parameters/AnalyticsPeriod"}], "responses": {"200": {"description": "Successful response.", "content": {"application/json": {"schema": {"type": "object", "additionalProperties": true, "properties": {"unavailable": {"$ref": "#/components/schemas/AnalyticsUnavailableFlag"}}}}}}, "400": {"$ref": "#/components/responses/BadRequest"}, "401": {"$ref": "#/components/responses/Unauthorized"}, "403": {"$ref": "#/components/responses/Forbidden"}, "404": {"$ref": "#/components/responses/NotFound"}, "503": {"$ref": "#/components/responses/ServiceUnavailable"}}}}, "/api/v1/apps/{appUuid}/analytics/competitive": {"get": {"tags": ["Analytics"], "summary": "Get competitive intelligence.", "operationId": "getCompetitiveIntelligence", "parameters": [{"name": "appUuid", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid"}}, {"$ref": "#/components/parameters/AnalyticsWindowDays90"}, {"$ref": "#/components/parameters/AnalyticsPeriod"}], "responses": {"200": {"description": "Successful response.", "content": {"application/json": {"schema": {"type": "object", "additionalProperties": true, "properties": {"unavailable": {"$ref": "#/components/schemas/AnalyticsUnavailableFlag"}}}}}}, "400": {"$ref": "#/components/responses/BadRequest"}, "401": {"$ref": "#/components/responses/Unauthorized"}, "403": {"$ref": "#/components/responses/Forbidden"}, "404": {"$ref": "#/components/responses/NotFound"}, "503": {"$ref": "#/components/responses/ServiceUnavailable"}}}}, "/api/v1/apps/{appUuid}/analytics/geographic": {"get": {"tags": ["Analytics"], "summary": "Get geographic breakdown.", "operationId": "getGeographicBreakdown", "parameters": [{"name": "appUuid", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid"}}, {"$ref": "#/components/parameters/AnalyticsWindowDays"}, {"$ref": "#/components/parameters/AnalyticsPeriod"}], "responses": {"200": {"description": "Successful response.", "content": {"application/json": {"schema": {"type": "object", "additionalProperties": true, "properties": {"insights": {"type": "array", "description": "Findings composed from fixed templates. Each carries a stable `code` and the `params` its English text was built from, so a client can render it in its own language.", "items": {"$ref": "#/components/schemas/AnalyticsTemplateInsight"}}, "unavailable": {"$ref": "#/components/schemas/AnalyticsUnavailableFlag"}}}}}}, "400": {"$ref": "#/components/responses/BadRequest"}, "401": {"$ref": "#/components/responses/Unauthorized"}, "403": {"$ref": "#/components/responses/Forbidden"}, "404": {"$ref": "#/components/responses/NotFound"}, "503": {"$ref": "#/components/responses/ServiceUnavailable"}}}}, "/api/v1/apps/{appUuid}/analytics/language-quality": {"get": {"tags": ["Analytics"], "summary": "Get language quality.", "operationId": "getLanguageQuality", "parameters": [{"name": "appUuid", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid"}}, {"$ref": "#/components/parameters/AnalyticsWindowDays"}, {"$ref": "#/components/parameters/AnalyticsPeriod"}], "responses": {"200": {"description": "Successful response.", "content": {"application/json": {"schema": {"type": "object", "additionalProperties": true, "properties": {"unavailable": {"$ref": "#/components/schemas/AnalyticsUnavailableFlag"}}}}}}, "400": {"$ref": "#/components/responses/BadRequest"}, "401": {"$ref": "#/components/responses/Unauthorized"}, "403": {"$ref": "#/components/responses/Forbidden"}, "404": {"$ref": "#/components/responses/NotFound"}, "503": {"$ref": "#/components/responses/ServiceUnavailable"}}}}, "/api/v1/apps/{appUuid}/analytics/lifecycle": {"get": {"tags": ["Analytics"], "summary": "Get reviewers grouped by review recency and rating.", "description": "Groups the app's reviewers by how recently they reviewed and what they\nrated. Tritonium reads public reviews only: it has no sign-up dates\nand cannot see app usage, so despite the field names these are not\nuser lifecycle stages, and `churned` does not mean a user left the app.\n\nA reviewer is one author name on one store, over the last\n`history_days` (365) of reviews; `total_users` is the number of such\nnames. Each is placed in the first group that matches, in this order:\n\n- `advocate`: two or more reviews averaging 4.5 stars or more, at\n  least one with a recommendation phrase (\"recommend\", \"best app\").\n- `churned`: the latest review gave 1 or 2 stars and is 90 or more\n  days old.\n- `at_risk`: ratings fell by half a star or more between the first\n  two and the latest two reviews, or the latest review is more than\n  60 days old and the average is under 3.5 stars.\n- `new_user`: one review only, written in the last 7 days.\n- `active_user`: every other reviewer.\n\n`insights` are English summaries of the `at_risk` and `churned`\nshares. The response field names are kept for compatibility.\n", "operationId": "getLifecycleDistribution", "parameters": [{"name": "appUuid", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid"}}], "responses": {"200": {"description": "Successful response.", "content": {"application/json": {"schema": {"type": "object", "additionalProperties": true, "properties": {"unavailable": {"$ref": "#/components/schemas/AnalyticsUnavailableFlag"}}}}}}, "400": {"$ref": "#/components/responses/BadRequest"}, "401": {"$ref": "#/components/responses/Unauthorized"}, "403": {"$ref": "#/components/responses/Forbidden"}, "404": {"$ref": "#/components/responses/NotFound"}, "503": {"$ref": "#/components/responses/ServiceUnavailable"}}}}, "/api/v1/apps/{appUuid}/analytics/onboarding": {"get": {"tags": ["Analytics"], "summary": "Get this week's reviews from first-time reviewers.", "description": "The reviews of reviewers whose only review in `history_days` was\nwritten within `new_user_window_days` of now. Tritonium reads public\nreviews only: it has no sign-up dates and cannot see app usage, so\ndespite the field names this is not an onboarding or first-week\nmeasurement. `new_users` is the number of those reviews (one per\nreviewer), `positive_onboarding` and `negative_onboarding` their\nsentiment, and `top_onboarding_issues` how many of them contain each\nof six keyword groups (`signup`, `login`, `onboarding`, `tutorial`,\n`first time`, `account`). A keyword match says the word was used, not\nwhy: \"account\" in a review of a social app is as likely to be about a\nsuspension as about signing up. The response field names are kept for\ncompatibility.\n\n**This operation takes no period.** A reviewer counts as new when their\nfirst review landed within `new_user_window_days` of now, folded over\n`history_days` of corpus - both are returned in the response. The\nclassifier needs a reviewer's full history to tell a first-timer from a\nreturning one, so there is no caller window for it to honour. A `days`\nquery parameter was previously accepted and silently ignored: every\nvalue from 1 to 365 returned a byte-identical payload.\n", "operationId": "getOnboardingAnalysis", "parameters": [{"name": "appUuid", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid"}}], "responses": {"200": {"description": "Successful response.", "content": {"application/json": {"schema": {"type": "object", "additionalProperties": true, "properties": {"unavailable": {"$ref": "#/components/schemas/AnalyticsUnavailableFlag"}}}}}}, "400": {"$ref": "#/components/responses/BadRequest"}, "401": {"$ref": "#/components/responses/Unauthorized"}, "403": {"$ref": "#/components/responses/Forbidden"}, "404": {"$ref": "#/components/responses/NotFound"}, "503": {"$ref": "#/components/responses/ServiceUnavailable"}}}}, "/api/v1/apps/{appUuid}/analytics/platform-comparison": {"get": {"tags": ["Analytics"], "summary": "Get platform comparison.", "operationId": "getPlatformComparison", "parameters": [{"name": "appUuid", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid"}}, {"$ref": "#/components/parameters/AnalyticsWindowDays"}, {"$ref": "#/components/parameters/AnalyticsPeriod"}], "responses": {"200": {"description": "Successful response.", "content": {"application/json": {"schema": {"type": "object", "additionalProperties": true, "properties": {"insights": {"type": "array", "description": "Findings composed from fixed templates. Each carries a stable `code` and the `params` its English text was built from, so a client can render it in its own language.", "items": {"$ref": "#/components/schemas/AnalyticsTemplateInsight"}}, "unavailable": {"$ref": "#/components/schemas/AnalyticsUnavailableFlag"}}}}}}, "400": {"$ref": "#/components/responses/BadRequest"}, "401": {"$ref": "#/components/responses/Unauthorized"}, "403": {"$ref": "#/components/responses/Forbidden"}, "404": {"$ref": "#/components/responses/NotFound"}, "503": {"$ref": "#/components/responses/ServiceUnavailable"}}}}, "/api/v1/apps/{appUuid}/analytics/release-impact": {"get": {"tags": ["Analytics"], "summary": "Get release impact.", "description": "Each detected release's reviews in the `window_days` before and after\nit, and the change between the two windows.\n\nThe windows hold the stored reviews of whichever storefronts were\nread for their days, and a store's capped feed serves each\nstorefront's newest reviews. So the windows are compared only when\nevery day they touch is whole and settled, as the rating change of\n`GET /api/v1/apps/{appUuid}/ratings/summary` requires\n(`RatingChange`). Otherwise `impact_calculated` is false,\n`impact_summary.rating_delta`, `sentiment_delta` and\n`volume_change_pct` are null, and `impact_summary.reason` says why:\n`not_settled` (the days after the release have not all settled),\n`feed_limited` (the coverage does not reach back to the days before\nthe release), `sync_limited`, `insufficient_history`,\n`no_recent_sync` or `unknown`. With whole days but fewer than 30\nreviews in a window, `reason` is `too_few_ratings`: the volume change\nis given, the rating and sentiment changes are not.\n`impact_summary.reason` is null when the windows were compared.\n`pre_release_metrics` and `post_release_metrics` always report the\nstored reviews of each window as they are.\n\n`unavailable` is the genuine-empty-versus-backend-failure signal: it\nis true when detection or a window read failed, and also when the\nstored analyses behind `impact_summary.top_new_issues` could not be\nread (`issues_source.reason` is `insights_read_failed`). An empty\nissue list with `unavailable: true` is a degraded response, never a\nmeasured absence of issues.\n", "operationId": "getReleaseImpactAnalytics", "parameters": [{"name": "appUuid", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid"}}, {"$ref": "#/components/parameters/AnalyticsWindowDays90"}, {"$ref": "#/components/parameters/AnalyticsPeriod"}, {"name": "version", "in": "query", "schema": {"type": "string"}, "description": "Label the impact window is reported under. Supplied together with `release_date` it names the release being measured; on its own it is only a label, because `release_date` is what positions the window."}, {"name": "release_date", "in": "query", "schema": {"type": "string", "format": "date-time"}, "description": "Measure the window around this instant instead of letting the API detect releases from the corpus. Required to pin the window: `version` alone does not move it."}, {"name": "window_days", "in": "query", "schema": {"type": "integer", "minimum": 1, "maximum": 180, "default": 14}, "description": "Half-width of the before/after comparison around each release, in days. Distinct from `days`, which bounds how far back releases are looked for."}], "responses": {"200": {"description": "Successful response.", "content": {"application/json": {"schema": {"type": "object", "additionalProperties": true, "properties": {"releases": {"type": "array", "description": "Up to six detected releases, newest first.", "items": {"$ref": "#/components/schemas/ReleaseImpactAnalyticsRelease"}}, "reviews_scanned": {"type": "integer", "description": "Reviews read across every release's before/after windows. Each window is cut from this one whole read, so `review_count` in `pre_release_metrics` / `post_release_metrics` is the window's real count."}, "truncated": {"type": "boolean", "description": "True only when release detection or the window read stopped at its runaway bound, so versions may be missing or window counts partial."}, "issues_source": {"type": "object", "additionalProperties": true, "description": "Where the per-issue outcome entries in `impact_summary.top_new_issues` come from, once for the response. `available` is false, with `reason` (`no_release_boundaries`, `no_insights_document`, `insights_read_failed`), when no stored analysis could be read, in which case every release's `top_new_issues` is empty (an absent measurement, never a measured zero). `reason` is also `insights_read_failed`, with `unavailable` true, when SOME boundaries' analyses could be read and others failed: a partial failure is a degraded response, not a healthy one, and a release whose read failed presents an empty issue list. `reason` is null when every read succeeded. When true, `cohort_definition` names the before/after population rule, `topics_measured` the number of issues measured per boundary, and `thresholds` the disclosed floors and change rule every entry was classified under.", "properties": {"available": {"type": "boolean"}, "reason": {"oneOf": [{"type": "string", "enum": ["no_release_boundaries", "no_insights_document", "insights_read_failed"]}, {"type": "null"}]}, "cohort_definition": {"type": "string", "enum": ["event_time_window_boundary_in_after"], "description": "The before/after population rule every entry was classified under: event-time windows of stored reviews around the boundary, with the boundary instant counted in the after cohort only (before < boundary <= after), so one review never sits in both cohorts."}, "topics_measured": {"type": "integer"}, "thresholds": {"type": "object", "additionalProperties": true}}}, "unavailable": {"$ref": "#/components/schemas/AnalyticsUnavailableFlag"}}}}}}, "400": {"$ref": "#/components/responses/BadRequest"}, "401": {"$ref": "#/components/responses/Unauthorized"}, "403": {"$ref": "#/components/responses/Forbidden"}, "404": {"$ref": "#/components/responses/NotFound"}, "503": {"$ref": "#/components/responses/ServiceUnavailable"}}}}, "/api/v1/apps/{appUuid}/analytics/response-effectiveness": {"get": {"tags": ["Analytics"], "summary": "Get response effectiveness.", "operationId": "getResponseEffectiveness", "parameters": [{"name": "appUuid", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid"}}, {"$ref": "#/components/parameters/AnalyticsWindowDays90"}, {"$ref": "#/components/parameters/AnalyticsPeriod"}], "responses": {"200": {"description": "Successful response.", "content": {"application/json": {"schema": {"type": "object", "additionalProperties": true, "properties": {"unavailable": {"$ref": "#/components/schemas/AnalyticsUnavailableFlag"}}}}}}, "400": {"$ref": "#/components/responses/BadRequest"}, "401": {"$ref": "#/components/responses/Unauthorized"}, "403": {"$ref": "#/components/responses/Forbidden"}, "404": {"$ref": "#/components/responses/NotFound"}, "503": {"$ref": "#/components/responses/ServiceUnavailable"}}}}, "/api/v1/apps/{appUuid}/analysis-runs": {"get": {"tags": ["Apps"], "summary": "List an app's analysis runs, newest first.", "operationId": "listAnalysisRuns", "description": "Every accepted AI analysis of the app, requested through `POST /api/v1/apps/analyze` or queued automatically after a review sync, newest first. Reads are consistent, so a run accepted moments ago is listed: after a start whose outcome is uncertain (a timeout or a lost response), find the newest run created after the attempt instead of starting another. Read-only; available to every member and to API keys with `apps:read`.", "parameters": [{"$ref": "#/components/parameters/AppUuidPath"}, {"name": "limit", "in": "query", "schema": {"type": "integer", "minimum": 1, "maximum": 50, "default": 20}, "description": "Page size (1-50). Out-of-range or non-integer values are rejected with 400."}, {"name": "cursor", "in": "query", "schema": {"type": "string"}, "description": "Opaque `next_cursor` from the previous page, valid for 24 hours and only for this app's list. Any other value is rejected with 400."}], "responses": {"200": {"description": "One page of runs.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/AnalysisRunList"}}}}, "400": {"$ref": "#/components/responses/ValidationError"}, "401": {"$ref": "#/components/responses/Unauthorized"}, "403": {"$ref": "#/components/responses/Forbidden"}, "404": {"$ref": "#/components/responses/NotFound"}, "429": {"$ref": "#/components/responses/CredentialRateLimited"}, "503": {"$ref": "#/components/responses/ServiceUnavailable"}}}}, "/api/v1/apps/{appUuid}/analysis-runs/{runId}": {"get": {"tags": ["Apps"], "summary": "Get one analysis run.", "operationId": "getAnalysisRun", "description": "The lifecycle of one analysis: `queued` \u2192 `running` \u2192 `succeeded`, `failed` or `cancelled`. `stage` refines an open run (`queued`, `analyzing`, or `waiting_to_resume` when a long analysis continues on the next scheduled pass, within about 15 minutes). Progress is reported at checkpoints between analysis passes, so a short run can finish without any. A run the pipeline stopped without recording a result (for example a worker that timed out) is reported as `failed` with the retryable code `ANALYSIS_INTERRUPTED`. Terminal states never change. On success read the results from `links.insights`. The read never changes app or run records.\n\nTo be told when a run ends instead of polling, subscribe a webhook (`POST /api/v1/webhooks`) to `analysis.completed` (the run reached `succeeded`), `analysis.failed` and `analysis.cancelled`. One event is sent per run, when its terminal state is recorded on the run. For a run that stopped without recording a result, or was cancelled while waiting to resume, that can be later than the moment this read first reports the terminal status. The event's `data` holds `app_uuid`, `run_id`, `status` and `completed_at`, plus `error.code` and `error.retryable` for a failure, and never the analysis itself: treat it as a hint and read the run here. The same event can be delivered more than once, and events of different runs can arrive in any order, so deduplicate on `run_id` together with the envelope's `delivery_id`; a late duplicate is harmless because a terminal state never changes. An event that could not be delivered after its retries is not sent again, so keep this read as the fallback for a run you must not miss.", "parameters": [{"$ref": "#/components/parameters/AppUuidPath"}, {"name": "runId", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid"}, "description": "The `run_id` returned when the analysis was queued, or from the run list."}], "responses": {"200": {"description": "The run.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/AnalysisRunResponse"}}}}, "400": {"$ref": "#/components/responses/ValidationError"}, "401": {"$ref": "#/components/responses/Unauthorized"}, "403": {"$ref": "#/components/responses/Forbidden"}, "404": {"$ref": "#/components/responses/NotFound"}, "429": {"$ref": "#/components/responses/CredentialRateLimited"}, "503": {"$ref": "#/components/responses/ServiceUnavailable"}}}}, "/api/v1/apps/{appUuid}/analysis-runs/{runId}/cancel": {"post": {"tags": ["Apps"], "summary": "Cancel an analysis run that no worker is processing (admin, or an API key with apps:write).", "operationId": "cancelAnalysisRun", "description": "Cancels a run only while no worker holds it: still `queued` (not yet picked up) or `running` with stage `waiting_to_resume` (between passes of a long analysis). The decision is a single conditional write against the worker and the scheduled resume, so a cancel either wins while no pass is running or is refused; a pass in progress is never interrupted. After a cancel no further analysis runs for it: a queued run used no AI capacity, and passes a parked run already completed stay counted. The app's analysis status returns to what it was before the run was queued (a parked cycle settles to completed or pending instead of resuming). Cancelling an already cancelled run returns it again (200). A run that is being processed or has finished answers 409 `ANALYSIS_RUN_NOT_CANCELLABLE`; it finishes or stops on its own. Check `cancellable` on the run first.", "security": [{"BearerAuth": []}, {"ApiKeyAuth": []}], "parameters": [{"$ref": "#/components/parameters/AppUuidPath"}, {"name": "runId", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid"}, "description": "The run to cancel."}], "responses": {"200": {"description": "The run is cancelled.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/AnalysisRunResponse"}}}}, "400": {"$ref": "#/components/responses/ValidationError"}, "401": {"$ref": "#/components/responses/Unauthorized"}, "403": {"$ref": "#/components/responses/Forbidden"}, "404": {"$ref": "#/components/responses/NotFound"}, "409": {"$ref": "#/components/responses/Conflict"}, "429": {"$ref": "#/components/responses/CredentialRateLimited"}, "503": {"$ref": "#/components/responses/ServiceUnavailable"}}}}, "/api/v1/apps/{appUuid}/analyze": {"post": {"tags": ["Insights"], "summary": "Trigger analysis (admin only).", "description": "The same start as `POST /api/v1/apps/analyze` for the app in the path. The body is optional; an `app_uuid` in it is ignored.", "operationId": "triggerAnalysis", "security": [{"BearerAuth": []}], "parameters": [{"name": "appUuid", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid"}}], "requestBody": {"required": false, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/AppAnalysisRequest"}}}}, "responses": {"200": {"description": "Nothing was queued; see POST /api/v1/apps/analyze.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/AnalysisStartResponse"}}}}, "202": {"description": "Analysis accepted and queued, or already in progress; both name the run to follow in `run_id` when it is tracked. Same contract as POST /api/v1/apps/analyze.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/AnalysisStartResponse"}}}}, "400": {"$ref": "#/components/responses/BadRequest"}, "401": {"$ref": "#/components/responses/Unauthorized"}, "402": {"$ref": "#/components/responses/SubscriptionRequired"}, "403": {"$ref": "#/components/responses/Forbidden"}, "404": {"$ref": "#/components/responses/NotFound"}, "503": {"$ref": "#/components/responses/ServiceUnavailable"}}}}, "/api/v1/apps/{appUuid}/benchmark": {"get": {"tags": ["Competitive"], "summary": "Get benchmark.", "description": "The app beside each competitor the workspace tracks for it, over the period. `your_app` carries the app's name, store, icon and stored review count; its rating and its changes are always null here, so read them from `GET /api/v1/apps/{appUuid}/ratings/summary`. Any member can read it, and so can an API key with the `analytics:read` scope.\n\nEach entry of `competitors` includes independent nullable `rating_source` and `review_count_source` fields. `store` identifies the value published by the listing; `fetched_reviews` identifies the mean or count of the fetched review set, not a store-wide total. Null means the corresponding metric is unavailable or its source was not explicitly recorded, including legacy snapshots. The source of a count must not be inferred from the source of a rating. `rating_change` is null unless the first and last snapshots have the same known rating source. `review_count_change` is null unless both endpoints use store counts; changing fetched-set sizes are not total-count growth. Apple aggregates remain unavailable pending source permission.", "operationId": "getBenchmark", "parameters": [{"$ref": "#/components/parameters/CompetitorAppUuid"}, {"$ref": "#/components/parameters/BenchmarkPeriod"}], "responses": {"200": {"description": "The app and its competitors over the period.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/CompetitorBenchmark"}}}}, "400": {"$ref": "#/components/responses/BenchmarkPeriodRefused"}, "401": {"$ref": "#/components/responses/Unauthorized"}, "402": {"$ref": "#/components/responses/SubscriptionRequired"}, "403": {"$ref": "#/components/responses/CompetitorKeyForbidden"}, "404": {"$ref": "#/components/responses/CompetitorAppNotFound"}, "500": {"$ref": "#/components/responses/CompetitorUnread"}, "503": {"$ref": "#/components/responses/SubscriptionUnverified"}}}}, "/api/v1/apps/{appUuid}/benchmark/features": {"get": {"tags": ["Competitive"], "summary": "Get benchmark features.", "description": "A comparison of the app and its competitors by feature, which is not measured yet: every answer has `available` false, no features and no categories, and says so in `reason`.", "operationId": "getBenchmarkFeatures", "parameters": [{"$ref": "#/components/parameters/CompetitorAppUuid"}], "responses": {"200": {"description": "The feature comparison, not measured yet.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/BenchmarkFeatures"}}}}, "401": {"$ref": "#/components/responses/Unauthorized"}, "402": {"$ref": "#/components/responses/SubscriptionRequired"}, "403": {"$ref": "#/components/responses/CompetitorKeyForbidden"}, "404": {"$ref": "#/components/responses/CompetitorAppNotFound"}, "500": {"$ref": "#/components/responses/CompetitorUnread"}, "503": {"$ref": "#/components/responses/SubscriptionUnverified"}}}}, "/api/v1/apps/{appUuid}/benchmark/market-position": {"get": {"tags": ["Competitive"], "summary": "Get benchmark market position.", "description": "The app's rank in its store category, which is not measured yet: ranking needs data on the whole category. Every answer has `available` false and `market_position` null, and says so in `reason`.", "operationId": "getBenchmarkMarketPosition", "parameters": [{"$ref": "#/components/parameters/CompetitorAppUuid"}], "responses": {"200": {"description": "The market position, not measured yet.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/BenchmarkMarketPosition"}}}}, "401": {"$ref": "#/components/responses/Unauthorized"}, "402": {"$ref": "#/components/responses/SubscriptionRequired"}, "403": {"$ref": "#/components/responses/CompetitorKeyForbidden"}, "404": {"$ref": "#/components/responses/CompetitorAppNotFound"}, "500": {"$ref": "#/components/responses/CompetitorUnread"}, "503": {"$ref": "#/components/responses/SubscriptionUnverified"}}}}, "/api/v1/apps/{appUuid}/benchmark/trends": {"get": {"tags": ["Competitive"], "summary": "Get benchmark trends.", "description": "Each tracked competitor's rating snapshots inside the period, oldest first. `your_app.data` is always empty: read the app's own line from `GET /api/v1/apps/{appUuid}/ratings/history`. Any member can read it, and so can an API key with the `analytics:read` scope.\n\nEach point in `competitors[].data` includes independent nullable `rating_source` and `review_count_source` fields, each `store`, `fetched_reviews` or null. Sources belong to that exact point, not the competitor's latest fetch. A fetched-review mean or set size is not a store-wide rating or total. Missing or unrecognized legacy provenance remains null; do not infer it from values or platform. Apple aggregate series remain empty pending source permission.", "operationId": "getBenchmarkTrends", "parameters": [{"$ref": "#/components/parameters/CompetitorAppUuid"}, {"$ref": "#/components/parameters/BenchmarkPeriod"}], "responses": {"200": {"description": "The competitors' rating lines over the period.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/BenchmarkTrends"}}}}, "400": {"$ref": "#/components/responses/BenchmarkPeriodRefused"}, "401": {"$ref": "#/components/responses/Unauthorized"}, "402": {"$ref": "#/components/responses/SubscriptionRequired"}, "403": {"$ref": "#/components/responses/CompetitorKeyForbidden"}, "404": {"$ref": "#/components/responses/CompetitorAppNotFound"}, "500": {"$ref": "#/components/responses/CompetitorUnread"}, "503": {"$ref": "#/components/responses/SubscriptionUnverified"}}}}, "/api/v1/apps/{appUuid}/competitors/suggestions": {"get": {"tags": ["Competitive"], "summary": "Suggest competitors.", "description": "Other apps in the same store category as the app, for the connect wizard's optional competitor step. Apps already tracked for this app are left out. Only App Store and Google Play apps get suggestions; an app on another store, or without a store id, answers an empty list.\n\nAn empty `suggestions` array means the store answered and has nothing to suggest. A store that could not be reached is a `503 STORE_UNAVAILABLE`, an app that could not be read a `500`, and a list of tracked apps that could not be read a `503 SERVICE_UNAVAILABLE`, so an outage is never presented as \"no competitor suggestions found\", nor offers an app already tracked; retry the request.", "operationId": "suggestCompetitors", "parameters": [{"$ref": "#/components/parameters/CompetitorAppUuid"}, {"name": "limit", "in": "query", "schema": {"type": "integer", "minimum": 1, "maximum": 20, "default": 8}, "description": "Maximum suggestions to return."}], "responses": {"200": {"description": "Apps like this one, at most `limit` of them.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/CompetitorSuggestions"}}}}, "400": {"description": "`VALIDATION_ERROR`: `limit` is not a whole number from 1 to 20.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "401": {"$ref": "#/components/responses/Unauthorized"}, "402": {"$ref": "#/components/responses/SubscriptionRequired"}, "403": {"$ref": "#/components/responses/CompetitorKeyForbidden"}, "404": {"$ref": "#/components/responses/CompetitorAppNotFound"}, "500": {"description": "`INTERNAL_ERROR`: the app could not be read. Retry.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "503": {"description": "`STORE_UNAVAILABLE`: the store could not be reached; retry the request. `SERVICE_UNAVAILABLE`: the workspace's subscription could not be verified, or the apps it already tracks could not be read; retry shortly. The second also sends `retryable`, `retry_after_seconds` and `Retry-After`.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}}}}, "/api/v1/apps/{appUuid}/competitors/sync": {"post": {"tags": ["Competitive"], "summary": "Sync competitors.", "description": "Requires an admin or owner. Fetches the store listing and the newest reviews of the app's tracked competitors, those synced longest ago first, at most `batch_limit` of them a call. When the app tracks more, `status` is `sync_partial` and the rest wait for a later call or the daily refresh. A competitor that could not be synced is counted in `errors` and keeps what it had, except when saving failed part way.", "operationId": "syncCompetitors", "security": [{"BearerAuth": []}], "parameters": [{"$ref": "#/components/parameters/CompetitorAppUuid"}], "responses": {"200": {"description": "What this call synced.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/CompetitorSyncSummary"}}}}, "401": {"$ref": "#/components/responses/Unauthorized"}, "402": {"$ref": "#/components/responses/SubscriptionRequired"}, "403": {"$ref": "#/components/responses/CompetitorAdminForbidden"}, "404": {"$ref": "#/components/responses/CompetitorAppNotFound"}, "500": {"$ref": "#/components/responses/CompetitorUnread"}, "503": {"$ref": "#/components/responses/SubscriptionUnverified"}}}}, "/api/v1/apps/{appUuid}/credentials": {"get": {"tags": ["Apps"], "summary": "Get app credentials.", "operationId": "getAppCredentials", "parameters": [{"name": "appUuid", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid"}}], "responses": {"200": {"description": "Successful response.", "content": {"application/json": {"schema": {"type": "object", "additionalProperties": true}}}}, "400": {"$ref": "#/components/responses/BadRequest"}, "401": {"$ref": "#/components/responses/Unauthorized"}, "403": {"$ref": "#/components/responses/Forbidden"}, "404": {"$ref": "#/components/responses/NotFound"}, "503": {"$ref": "#/components/responses/ReadUnavailable"}}}}, "/api/v1/apps/{appUuid}/executive-spotlight": {"get": {"tags": ["Analytics"], "summary": "Get executive spotlight.", "description": "An app's headline figures for the period, its previous period and its\nalerts.\n\n`trends.rating_delta` and `trends.rating_trend` come from\n`trends.rating_change`, the change `GET\n/api/v1/apps/{appUuid}/ratings/summary` reports (`RatingChange`):\ntwo windows of whole, settled days, not the two periods. Both are\nnull when that change cannot be measured.\n\nThe volume and sentiment changes compare the same two windows.\n`trends.volume_change` has the shape of `RatingChange` with\n`reviews` in place of `ratings` and `average`, and `change` is the\nnewer window's reviews less the older one's.\n`trends.sentiment_change` gives each window's reviews with a\nsentiment and their `positive_share` (percent), and `change` is the\nmove in that share in percentage points; it needs 30 such reviews in\neach window (`too_few_ratings`). `trends.volume_delta`,\n`volume_trend`, `sentiment_delta` and `sentiment_trend` come from\nthem and are null when they cannot be measured, with the reason in\neach object's `reason`. The two rolling periods held the reviews of\nwhichever storefronts were read that far back, so they read a change\nin coverage as more reviews or as a change in sentiment.\n\n`response_effectiveness.reply_rate` is null, with\n`reply_rate_reason` `store_replies_not_observable`: the App Store\nreports no developer replies, and Google Play shows a reply only on\nthe reviews it serves when they are read, so the share of reviews\nwith a reply cannot be measured. `tritonium_reply_count` counts the\nperiod's reviews answered through Tritonium from this workspace, and\n`tritonium_reply_share` is their percentage of the period's reviews\n(null when the period has none).\n\n`unavailable_sections` names the blocks that could not be loaded for\nthis response. Each named block still carries its empty default (for\nexample `alerts` with `risk_level` `low` and no anomalies), which is\nnot a measurement: show it as not loaded, and request again. A\nresponse that names a block is never cached, so the next request\nreads it again.\n", "operationId": "getExecutiveSpotlight", "parameters": [{"name": "appUuid", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid"}}, {"$ref": "#/components/parameters/AnalyticsWindowDays"}, {"$ref": "#/components/parameters/AnalyticsPeriod"}], "responses": {"200": {"description": "The app's spotlight. A response that names no block in `unavailable_sections` is kept for a few minutes and served again (`metadata.cache_hit`).", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/AppExecutiveSpotlight"}}}}, "400": {"description": "`VALIDATION_ERROR`: `days` (or the number in `period`) is not a whole number from 1 to 365.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "401": {"$ref": "#/components/responses/Unauthorized"}, "402": {"$ref": "#/components/responses/SubscriptionRequired"}, "403": {"$ref": "#/components/responses/AnalyticsKeyForbidden"}, "404": {"$ref": "#/components/responses/AppNotFound"}, "503": {"$ref": "#/components/responses/AnalyticsAppUnverified"}}}}, "/api/v1/apps/{appUuid}/financial-config": {"get": {"tags": ["Apps"], "summary": "Get financial config.", "description": "The figures the Spotlight deck turns review counts into money with. Each value is the app's own when it was saved (`PUT /api/v1/apps/{appUuid}`), otherwise the default, and its `*_source` member says which.", "operationId": "getFinancialConfig", "parameters": [{"name": "appUuid", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid"}}], "responses": {"200": {"description": "The app's financial configuration.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/FinancialConfigAnswer"}}}}, "401": {"$ref": "#/components/responses/Unauthorized"}, "402": {"$ref": "#/components/responses/SubscriptionRequired"}, "403": {"$ref": "#/components/responses/AnalyticsKeyForbidden"}, "404": {"$ref": "#/components/responses/AppNotFound"}, "503": {"$ref": "#/components/responses/ReadUnavailable"}}}}, "/api/v1/apps/{appUuid}/insights/actions": {"get": {"tags": ["Insights"], "summary": "Get actions taken on app insights or available delivery capabilities.", "operationId": "getActions", "description": "Without `capabilities_only`, every action recorded on the app's insights (`InsightActionHistory`). With `capabilities_only=true`, only which delivery integrations the workspace can use for insight actions (`InsightActionCapabilities`), without reading the history.", "parameters": [{"name": "appUuid", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid"}}, {"name": "capabilities_only", "in": "query", "description": "`true` returns only tenant delivery capabilities, without reading action history. Available to any member who can access the app. Any other value returns the history.", "schema": {"type": "boolean", "default": false}}], "responses": {"200": {"description": "The action history, or with `capabilities_only=true` the delivery capabilities.", "content": {"application/json": {"schema": {"oneOf": [{"$ref": "#/components/schemas/InsightActionHistory"}, {"$ref": "#/components/schemas/InsightActionCapabilities"}]}}}}, "400": {"$ref": "#/components/responses/BadRequest"}, "401": {"$ref": "#/components/responses/Unauthorized"}, "402": {"$ref": "#/components/responses/SubscriptionRequired"}, "403": {"$ref": "#/components/responses/Forbidden"}, "404": {"$ref": "#/components/responses/NotFound"}, "503": {"description": "`CAPABILITIES_UNAVAILABLE`: with `capabilities_only=true`, the workspace's integrations could not be read; retry. `SERVICE_UNAVAILABLE`: the workspace subscription could not be verified; retry.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}}}}, "/api/v1/apps/{appUuid}/insights/compare": {"get": {"tags": ["Insights"], "summary": "Compare insights.", "operationId": "compareInsights", "parameters": [{"name": "appUuid", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid"}}, {"name": "from", "in": "query", "required": true, "schema": {"type": "string", "format": "date-time"}, "description": "Start of the earlier period being compared. Required: omitting either bound answers 400 `VALIDATION_ERROR` (\"Both 'from' and 'to' date parameters are required\"); there is no default that spans the corpus."}, {"name": "to", "in": "query", "required": true, "schema": {"type": "string", "format": "date-time"}, "description": "End of the later period being compared. Required, as `from` is."}], "responses": {"200": {"description": "Successful response.", "content": {"application/json": {"schema": {"type": "object", "additionalProperties": true}}}}, "400": {"$ref": "#/components/responses/BadRequest"}, "401": {"$ref": "#/components/responses/Unauthorized"}, "403": {"$ref": "#/components/responses/Forbidden"}, "404": {"$ref": "#/components/responses/NotFound"}, "503": {"$ref": "#/components/responses/ServiceUnavailable"}}}}, "/api/v1/apps/{appUuid}/insights/conversion-metrics": {"get": {"tags": ["Insights"], "summary": "Get insight -> action conversion rate metrics.", "operationId": "getConversionMetrics", "parameters": [{"name": "appUuid", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid"}}, {"name": "days", "in": "query", "description": "The period, in days back from now, whose feedback and actions are counted. Outside 1 to 365 answers 400 `VALIDATION_ERROR`.", "schema": {"type": "integer", "minimum": 1, "maximum": 365, "default": 30}}], "responses": {"200": {"description": "Successful response.", "content": {"application/json": {"schema": {"type": "object", "required": ["metrics"], "properties": {"metrics": {"$ref": "#/components/schemas/InsightConversionMetrics"}}}}}}, "400": {"$ref": "#/components/responses/BadRequest"}, "401": {"$ref": "#/components/responses/Unauthorized"}, "402": {"$ref": "#/components/responses/SubscriptionRequired"}, "403": {"$ref": "#/components/responses/Forbidden"}, "404": {"$ref": "#/components/responses/NotFound"}, "503": {"$ref": "#/components/responses/SubscriptionUnverified"}}}}, "/api/v1/apps/{appUuid}/insights/feedback": {"get": {"tags": ["Insights"], "summary": "Get all feedback for an app's insights.", "operationId": "getFeedback", "parameters": [{"name": "appUuid", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid"}}], "responses": {"200": {"description": "Successful response.", "content": {"application/json": {"schema": {"type": "object", "required": ["feedback", "count"], "properties": {"feedback": {"type": "object", "description": "The workspace's feedback on the app's insights, keyed by insight id: one entry per insight, the latest one given.", "additionalProperties": {"$ref": "#/components/schemas/InsightFeedbackEntry"}}, "count": {"type": "integer", "minimum": 0, "description": "The number of entries in `feedback`."}}}}}}, "400": {"$ref": "#/components/responses/BadRequest"}, "401": {"$ref": "#/components/responses/Unauthorized"}, "402": {"$ref": "#/components/responses/SubscriptionRequired"}, "403": {"$ref": "#/components/responses/Forbidden"}, "404": {"$ref": "#/components/responses/NotFound"}, "503": {"$ref": "#/components/responses/SubscriptionUnverified"}}}}, "/api/v1/apps/{appUuid}/insights/investigations": {"get": {"tags": ["Insights"], "summary": "List the app's investigation tasks.", "operationId": "listInvestigationTasks", "description": "The investigation tasks recorded when someone chose `investigate` on an insight (POST .../insights/{insightId}/actions), newest first within a page. Pass `next_cursor` back as `cursor` for the next page. A cursor is bound to the workspace, the app and the filters it was issued with, and replaying it under another filter answers 400 `INVALID_CURSOR`.", "parameters": [{"name": "appUuid", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid"}}, {"name": "status", "in": "query", "description": "Only tasks in this status. Another value answers 400.", "schema": {"type": "string", "enum": ["open", "in_progress", "closed"]}}, {"name": "insight_id", "in": "query", "description": "Only tasks opened on this insight.", "schema": {"type": "string"}}, {"name": "limit", "in": "query", "description": "Page size. Outside 1 to 100 answers 400.", "schema": {"type": "integer", "minimum": 1, "maximum": 100, "default": 50}}, {"name": "cursor", "in": "query", "description": "The `next_cursor` of the previous page.", "schema": {"type": "string"}}], "responses": {"200": {"description": "A page of the app's investigation tasks.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/InvestigationTaskList"}}}}, "400": {"description": "`VALIDATION_ERROR`: `status` outside the enum or an empty `insight_id` (`details.field` names it), or `limit` not an integer or outside 1 to 100 (`details.parameter` is `limit`). `INVALID_CURSOR`: `cursor` was not issued by this list with these filters.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "401": {"$ref": "#/components/responses/Unauthorized"}, "402": {"$ref": "#/components/responses/SubscriptionRequired"}, "403": {"$ref": "#/components/responses/Forbidden"}, "404": {"$ref": "#/components/responses/NotFound"}, "429": {"$ref": "#/components/responses/CredentialRateLimited"}, "500": {"description": "`QUERY_FAILED`: the tasks could not be read; retry.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "503": {"$ref": "#/components/responses/SubscriptionUnverified"}}}}, "/api/v1/apps/{appUuid}/insights/investigations/{taskId}": {"get": {"tags": ["Insights"], "summary": "Read one investigation task.", "operationId": "getInvestigationTask", "description": "A task of another app in the workspace answers 404, as a missing one does: the id alone never reveals the task exists.", "parameters": [{"name": "appUuid", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid"}}, {"name": "taskId", "in": "path", "required": true, "description": "The `task_id` the task was created with.", "schema": {"type": "string", "format": "uuid"}}], "responses": {"200": {"description": "The investigation task.", "content": {"application/json": {"schema": {"type": "object", "required": ["task"], "properties": {"task": {"$ref": "#/components/schemas/InvestigationTask"}}}}}}, "400": {"$ref": "#/components/responses/BadRequest"}, "401": {"$ref": "#/components/responses/Unauthorized"}, "402": {"$ref": "#/components/responses/SubscriptionRequired"}, "403": {"$ref": "#/components/responses/Forbidden"}, "404": {"description": "`APP_NOT_FOUND`: the app is not one of the workspace's. `INVESTIGATION_TASK_NOT_FOUND`: no such task on this app.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "429": {"$ref": "#/components/responses/CredentialRateLimited"}, "500": {"description": "`QUERY_FAILED`: the task could not be read; retry.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "503": {"$ref": "#/components/responses/SubscriptionUnverified"}}}, "patch": {"tags": ["Insights"], "summary": "Update an investigation task's status or assignee.", "operationId": "updateInvestigationTask", "security": [{"BearerAuth": []}], "description": "Updates the task. Omitted fields are unchanged; at least one is required. Closing a task sets `closed_at` the first time; closing it again keeps that first time. Reopening one (`open` or `in_progress`) keeps `closed_at` as the record of when it was last closed. Honours `Idempotency-Key`: a retry with the same key replays the first outcome instead of writing again.", "parameters": [{"name": "appUuid", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid"}}, {"name": "taskId", "in": "path", "required": true, "description": "The `task_id` the task was created with.", "schema": {"type": "string", "format": "uuid"}}, {"$ref": "#/components/parameters/IdempotencyKey"}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/InvestigationTaskPatch"}}}}, "responses": {"200": {"description": "The task after the update.", "content": {"application/json": {"schema": {"type": "object", "required": ["success", "task"], "properties": {"success": {"type": "boolean", "description": "Always true."}, "task": {"$ref": "#/components/schemas/InvestigationTask"}}}}}}, "400": {"description": "`VALIDATION_ERROR`: the body is not an object, `status` is outside the enum or `assigned_to` is not an email address (`details.field` names it). `UNKNOWN_FIELD`: a field other than `status` or `assigned_to` was sent. `EMPTY_UPDATE`: neither field was sent. Nothing was written.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "401": {"$ref": "#/components/responses/Unauthorized"}, "402": {"$ref": "#/components/responses/SubscriptionRequired"}, "403": {"$ref": "#/components/responses/Forbidden"}, "404": {"description": "`APP_NOT_FOUND`: the app is not one of the workspace's. `INVESTIGATION_TASK_NOT_FOUND`: no such task on this app; nothing was written.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "409": {"$ref": "#/components/responses/IdempotencyInProgress"}, "422": {"$ref": "#/components/responses/IdempotencyKeyReused"}, "500": {"description": "`UPDATE_FAILED`: the task could not be updated; retry.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "503": {"description": "`IDEMPOTENCY_UNAVAILABLE`: the `Idempotency-Key` could not be recorded and nothing ran; retry with the same key after `Retry-After`. `SERVICE_UNAVAILABLE`: the workspace's subscription could not be verified; retry shortly.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}}}}, "/api/v1/apps/{appUuid}/insights/trends": {"get": {"tags": ["Insights"], "summary": "Get topic trends.", "operationId": "getTopicTrends", "parameters": [{"name": "appUuid", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid"}}, {"name": "limit", "in": "query", "schema": {"type": "integer"}, "description": "Maximum topics to return in the trend series."}], "responses": {"200": {"description": "Successful response.", "content": {"application/json": {"schema": {"type": "object", "additionalProperties": true}}}}, "400": {"$ref": "#/components/responses/BadRequest"}, "401": {"$ref": "#/components/responses/Unauthorized"}, "403": {"$ref": "#/components/responses/Forbidden"}, "404": {"$ref": "#/components/responses/NotFound"}, "503": {"$ref": "#/components/responses/ServiceUnavailable"}}}}, "/api/v1/apps/{appUuid}/insights/{insightId}/actions": {"post": {"tags": ["Insights"], "summary": "Record when a user takes action on an insight.", "operationId": "recordAction", "security": [{"BearerAuth": []}], "parameters": [{"name": "appUuid", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid"}}, {"name": "insightId", "in": "path", "required": true, "schema": {"type": "string"}}], "description": "Records the action. `create_jira_ticket`, `share_to_slack`, `share_to_teams`, `share_to_discord`, `create_github_issue`, `add_to_roadmap`, `escalate`, `investigate` and `draft_response` then run their integration with the insight stored in the analysis named by `analysis_version`, and the answer carries `integration_result`. `mark_resolved` and `ignore` only record. The body is checked before anything is read, recorded or sent.", "requestBody": {"required": true, "content": {"application/json": {"schema": {"type": "object", "additionalProperties": true, "description": "Other keys are ignored, including `insight`: integrations use the stored insight, never one sent with the request.", "required": ["action_type"], "properties": {"action_type": {"type": "string", "description": "Action to record or execute.", "enum": ["create_jira_ticket", "draft_response", "add_to_roadmap", "share_to_slack", "share_to_teams", "mark_resolved", "ignore", "escalate", "investigate", "create_github_issue", "share_to_discord"]}, "analysis_version": {"type": "string", "description": "Required for integration actions. Use the analysis_version returned inside insights by GET /api/v1/apps/{appUuid}/insights. A stale version is rejected before any external send. Optional for `mark_resolved` and `ignore`, but required there whenever `cluster_id` is sent: the server persists the verified cluster identity only when the version lets it resolve the card, and refuses the action otherwise."}, "cluster_id": {"type": "string", "description": "The insight card's stable issue identity (R2R-10), from the analysis being viewed. When the content-hash id rewords later, the action bridge resolves the same issue through this id, and it is recorded on the action row for later continuity. Persisted only as verified by the server (see analysis_version). A value sent without analysis_version answers 400; one that names no card in the current analysis, or comes with a stale version, answers 404 (refresh insights). Nothing is recorded."}, "status": {"type": "string", "enum": ["initiated", "completed"], "description": "The status to record the action with; `initiated` when absent. `completed` also sets `completed_at`."}, "metadata": {"type": "object", "additionalProperties": true, "description": "Stored with the action and not served back. Absent stores an empty object."}}}}}}, "responses": {"200": {"description": "The action was recorded. `integration_result` is present for the action types that run an integration; a recorded action whose integration failed still answers 200, with `integration_result.success` false.", "content": {"application/json": {"schema": {"type": "object", "required": ["success", "action"], "properties": {"success": {"type": "boolean", "description": "Always true."}, "duplicate": {"type": "boolean", "description": "Present and true when an identical action (same insight and type) was already recorded inside the ten-minute duplicate window: the response then carries the earlier action and no integration ran again."}, "action": {"type": "object", "description": "The record `GET .../insights/actions` serves for the action from now on, with its insight's id.", "required": ["action_id", "insight_id", "action_type", "action_status", "created_at", "completed_at"], "properties": {"action_id": {"type": "string", "format": "uuid"}, "insight_id": {"type": "string"}, "action_type": {"type": "string", "enum": ["create_jira_ticket", "draft_response", "add_to_roadmap", "share_to_slack", "share_to_teams", "mark_resolved", "ignore", "escalate", "investigate", "create_github_issue", "share_to_discord"]}, "action_status": {"type": "string", "enum": ["initiated", "completed"]}, "created_at": {"type": "string", "format": "date-time"}, "completed_at": {"type": ["string", "null"], "format": "date-time", "description": "Set when the action was recorded as `completed`; otherwise null."}, "cluster_id": {"type": "string", "description": "The issue's stable identity (R2R-10) as recorded: the RESOLVED card's cluster_id. Absent when the insight has none."}}}, "integration_result": {"$ref": "#/components/schemas/InsightActionIntegrationResult"}}}}}}, "400": {"description": "`VALIDATION_ERROR`, and nothing was recorded or sent. A field of the body has the wrong type or value (`details.field` names it: `action_type`, `status`, `metadata` or `analysis_version`), a `metadata` value cannot be stored (for example a number that is not finite), or an integration action was sent without `analysis_version`.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "401": {"$ref": "#/components/responses/Unauthorized"}, "402": {"$ref": "#/components/responses/SubscriptionRequired"}, "403": {"$ref": "#/components/responses/Forbidden"}, "404": {"description": "`APP_NOT_FOUND`: the app is not one of the workspace's. `NOT_FOUND`: for an integration action, the insight is not in the analysis named by `analysis_version` (refresh insights and try again). Nothing was recorded or sent.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "409": {"description": "`DUPLICATE_IN_PROGRESS`: an identical action is being recorded right now by a concurrent request; retry in a moment and it folds onto that action. `retryable` is true.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "500": {"description": "`SAVE_FAILED`: the action could not be recorded, and no integration ran. `QUERY_FAILED`: the stored insight could not be read; nothing was recorded or sent.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "503": {"description": "`DEDUPE_UNAVAILABLE`: the duplicate-exclusion claim could not be acquired, so the action was not recorded safely; `retryable` is true. `SERVICE_UNAVAILABLE` (`SubscriptionUnverified`): the workspace subscription could not be verified.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}}}}, "/api/v1/apps/{appUuid}/insights/{insightId}/actions/{actionId}": {"put": {"tags": ["Insights"], "summary": "Mark an action as completed.", "operationId": "completeAction", "security": [{"BearerAuth": []}], "parameters": [{"name": "appUuid", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid"}}, {"name": "insightId", "in": "path", "required": true, "schema": {"type": "string"}}, {"name": "actionId", "in": "path", "required": true, "description": "The `action_id` the action was recorded with.", "schema": {"type": "string", "format": "uuid"}}], "description": "Completes an action recorded on this insight. Completing it again answers 200 and keeps the first `completed_at`. The request takes no body.", "responses": {"200": {"description": "The action is completed.", "content": {"application/json": {"schema": {"type": "object", "required": ["success", "action_id", "action_status", "completed_at"], "properties": {"success": {"type": "boolean", "description": "Always true."}, "action_id": {"type": "string", "format": "uuid"}, "action_status": {"type": "string", "enum": ["completed"]}, "completed_at": {"type": "string", "format": "date-time", "description": "When the action was first completed."}}}}}}, "400": {"$ref": "#/components/responses/BadRequest"}, "401": {"$ref": "#/components/responses/Unauthorized"}, "402": {"$ref": "#/components/responses/SubscriptionRequired"}, "403": {"$ref": "#/components/responses/Forbidden"}, "404": {"description": "`APP_NOT_FOUND`: the app is not one of the workspace's. `ACTION_NOT_FOUND`: no action with this id was recorded on this insight. Nothing was written.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "500": {"description": "`UPDATE_FAILED`: the action could not be updated; retry.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "503": {"$ref": "#/components/responses/SubscriptionUnverified"}}}}, "/api/v1/apps/{appUuid}/insights/{insightId}/feedback": {"post": {"tags": ["Insights"], "summary": "Submit feedback on an insight (useful/not useful).", "operationId": "submitFeedback", "security": [{"BearerAuth": []}], "parameters": [{"name": "appUuid", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid"}}, {"name": "insightId", "in": "path", "required": true, "schema": {"type": "string"}}], "description": "Stores the workspace's feedback on the insight, replacing any given before.", "requestBody": {"required": true, "content": {"application/json": {"schema": {"type": "object", "required": ["feedback_type"], "properties": {"feedback_type": {"type": "string", "enum": ["useful", "not_useful"]}, "reason": {"type": ["string", "null"], "maxLength": 2000, "description": "Why. Null or absent stores no reason."}}}}}}, "responses": {"200": {"description": "The feedback was stored.", "content": {"application/json": {"schema": {"type": "object", "required": ["success", "feedback"], "properties": {"success": {"type": "boolean", "description": "Always true."}, "feedback": {"type": "object", "description": "The entry `GET .../insights/feedback` serves for the insight from now on, with the insight's id.", "required": ["insight_id", "feedback_type", "feedback_reason", "created_at"], "properties": {"insight_id": {"type": "string"}, "feedback_type": {"type": "string", "enum": ["useful", "not_useful"]}, "feedback_reason": {"type": ["string", "null"], "description": "The `reason` sent, or null when none was sent."}, "created_at": {"type": "string", "format": "date-time"}}}}}}}}, "400": {"description": "`VALIDATION_ERROR`, and nothing was stored: `feedback_type` is missing or not one of the two values, or `reason` is not text or is longer than 2,000 characters. `details.field` names the field.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "401": {"$ref": "#/components/responses/Unauthorized"}, "402": {"$ref": "#/components/responses/SubscriptionRequired"}, "403": {"$ref": "#/components/responses/Forbidden"}, "404": {"$ref": "#/components/responses/NotFound"}, "500": {"description": "`SAVE_FAILED`: the feedback could not be stored; retry.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "503": {"$ref": "#/components/responses/SubscriptionUnverified"}}}}, "/api/v1/apps/{appUuid}/replies/performance": {"get": {"tags": ["Analytics"], "summary": "Get reply performance.", "operationId": "getReplyPerformance", "parameters": [{"name": "appUuid", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid"}}, {"name": "days", "in": "query", "required": false, "schema": {"type": "integer", "default": 30, "minimum": 1, "maximum": 365}}, {"$ref": "#/components/parameters/AnalyticsPeriod"}], "responses": {"200": {"description": "Successful response.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ReplyPerformance"}}}}, "400": {"$ref": "#/components/responses/BadRequest"}, "401": {"$ref": "#/components/responses/Unauthorized"}, "403": {"$ref": "#/components/responses/Forbidden"}, "404": {"$ref": "#/components/responses/NotFound"}, "503": {"$ref": "#/components/responses/ServiceUnavailable"}}}}, "/api/v1/apps/{appUuid}/reviews/high-quality": {"get": {"tags": ["Analytics"], "summary": "Get high quality reviews.", "description": "Returns the app's reviews whose stored `quality_score` is at least `min_score`. The score is written during AI enrichment, so a review that has not been analysed yet has none and is never returned.\n\nThe score is not indexed, so the app's reviews are walked and the floor applied after the read. A single call reads at most 5,000 stored reviews: `scanned_count` reports how many it looked at, `scored_count` how many of those carried a score at all, and `search_truncated` is `true` when it stopped at that ceiling. Follow `cursor` to sweep the rest.\n\n`scored_count: 0` means no review in the sweep has been analysed yet, which is a different answer to `count: 0` with a non-zero `scored_count`, where reviews were scored and none cleared the floor.\n\nReviews are returned newest-first, like every other review listing. They are not ranked by score, since only part of the corpus is read and a per-page ranking would misrepresent itself as the app's best overall.", "operationId": "getHighQualityReviews", "parameters": [{"name": "appUuid", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid"}}, {"name": "limit", "in": "query", "required": false, "description": "Maximum reviews to return in one page.", "schema": {"type": "integer", "minimum": 1, "maximum": 1000, "default": 50}}, {"name": "cursor", "in": "query", "required": false, "description": "Opaque cursor from a previous response's `cursor` field.", "schema": {"type": "string"}}, {"name": "min_score", "in": "query", "required": false, "description": "Lowest `quality_score` to include, inclusive.", "schema": {"type": "integer", "minimum": 0, "maximum": 100, "default": 70}}, {"name": "exclude_flags", "in": "query", "required": false, "description": "Comma-separated quality flags to exclude. A review carrying any of them is omitted. An unrecognised flag is rejected with `400`.", "schema": {"type": "string", "example": "potential_spam,generic"}}], "responses": {"200": {"description": "Successful response.", "content": {"application/json": {"schema": {"type": "object", "additionalProperties": true, "properties": {"reviews": {"type": "array", "items": {"type": "object", "additionalProperties": true}}, "count": {"type": "integer", "description": "Reviews in this page."}, "app_uuid": {"type": "string", "format": "uuid"}, "min_score": {"type": "integer", "description": "The floor applied to this request."}, "has_more": {"type": "boolean"}, "cursor": {"type": ["string", "null"]}, "scanned_count": {"type": "integer", "description": "Stored reviews examined while assembling this page."}, "scored_count": {"type": "integer", "description": "Of those examined, how many carry a `quality_score` at all. Distinguishes \"not analysed yet\" from \"analysed and below the floor\"."}, "search_truncated": {"type": "boolean", "description": "`true` when the sweep stopped at the 5,000-review ceiling rather than reaching the end of the app's reviews."}}}}}}, "400": {"$ref": "#/components/responses/BadRequest"}, "401": {"$ref": "#/components/responses/Unauthorized"}, "403": {"$ref": "#/components/responses/Forbidden"}, "404": {"$ref": "#/components/responses/NotFound"}, "503": {"$ref": "#/components/responses/ReadUnavailable"}}}}, "/api/v1/apps/{appUuid}/reviews/segments/{segmentType}": {"get": {"tags": ["Analytics"], "summary": "Get reviews for a review-content segment.", "description": "Returns reviews bucketed by a **review-content label** (sentiment / intent of the review text). This is distinct from `GET /api/v1/apps/{appUuid}/segments`, which lists **user** segments (influencer, power_user, \u2026). Passing a user-segment value here returns `400 Bad Request`.\n\n`positive` / `negative` / `neutral` are the review's sentiment: the stored label when it has one, otherwise the star rating in three buckets (4 to 5 positive, 3 neutral, 1 to 2 negative), which is the same rule `GET /reviews?sentiment=` applies. Each returned review says which applied in `sentiment_source`. `bug_report`, `feature_request` and `critical` come from the AI review-type and priority analysis written during enrichment, so a review that has not been analysed yet carries none of them.\n\nNeither family is an indexed field, so the app's reviews are read and the label is applied afterwards. A single call reads at most 5,000 stored reviews: `scanned_count` reports how many it looked at and `search_truncated` is `true` when it stopped at that ceiling. Follow `cursor` to sweep the rest.", "operationId": "getSegmentReviews", "parameters": [{"name": "appUuid", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid"}}, {"name": "segmentType", "in": "path", "required": true, "description": "Review-content label to filter by.", "schema": {"type": "string", "enum": ["positive", "negative", "neutral", "critical", "feature_request", "bug_report"]}}, {"name": "limit", "in": "query", "required": false, "description": "Maximum reviews to return in one page.", "schema": {"type": "integer", "minimum": 1, "maximum": 1000, "default": 50}}, {"name": "cursor", "in": "query", "required": false, "description": "Opaque cursor from a previous response's `cursor` field.", "schema": {"type": "string"}}], "responses": {"200": {"description": "Successful response.", "content": {"application/json": {"schema": {"type": "object", "additionalProperties": true, "properties": {"reviews": {"type": "array", "items": {"type": "object", "additionalProperties": true}}, "count": {"type": "integer", "description": "Reviews in this page."}, "segment": {"type": "string"}, "app_uuid": {"type": "string", "format": "uuid"}, "has_more": {"type": "boolean"}, "cursor": {"type": ["string", "null"]}, "scanned_count": {"type": "integer", "description": "Stored reviews examined while assembling this page."}, "search_truncated": {"type": "boolean", "description": "`true` when the sweep stopped at the 5,000-review ceiling rather than reaching the end of the app's reviews."}}}}}}, "400": {"$ref": "#/components/responses/BadRequest"}, "401": {"$ref": "#/components/responses/Unauthorized"}, "403": {"$ref": "#/components/responses/Forbidden"}, "404": {"$ref": "#/components/responses/NotFound"}, "503": {"$ref": "#/components/responses/ReadUnavailable"}}}}, "/api/v1/apps/{appUuid}/reviews/sync": {"post": {"tags": ["Reviews"], "summary": "Sync reviews (retired; use the app sync route).", "deprecated": true, "description": "Retired: use `POST /api/v1/apps/{appUuid}/sync` to sync an app's reviews. An owner or admin of the workspace that has the app gets `410 OPERATION_RETIRED`, and no sync starts. This route used to answer 202 although it never started one. The body is not read.", "operationId": "syncReviews", "security": [{"BearerAuth": []}], "parameters": [{"name": "appUuid", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid"}}], "requestBody": {"required": false, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ReviewSyncRequest"}}}}, "responses": {"400": {"description": "`VALIDATION_ERROR`: the body is not a JSON object.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "401": {"$ref": "#/components/responses/Unauthorized"}, "402": {"$ref": "#/components/responses/SubscriptionRequired"}, "403": {"$ref": "#/components/responses/SyncAdminForbidden"}, "404": {"$ref": "#/components/responses/AppNotFound"}, "410": {"description": "`OPERATION_RETIRED`, with `retryable` false: this route starts no sync. Use `POST /api/v1/apps/{appUuid}/sync`.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "503": {"$ref": "#/components/responses/ReadUnavailable"}}}}, "/api/v1/apps/{appUuid}/reviews/viral": {"get": {"tags": ["Analytics"], "summary": "Get viral reviews.", "operationId": "getViralReviews", "parameters": [{"name": "appUuid", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid"}}], "responses": {"200": {"description": "Successful response.", "content": {"application/json": {"schema": {"type": "object", "additionalProperties": true}}}}, "400": {"$ref": "#/components/responses/BadRequest"}, "401": {"$ref": "#/components/responses/Unauthorized"}, "403": {"$ref": "#/components/responses/Forbidden"}, "404": {"$ref": "#/components/responses/NotFound"}, "503": {"$ref": "#/components/responses/ReadUnavailable"}}}}, "/api/v1/apps/{appUuid}/reviews/{reviewId}/generate-reply": {"post": {"tags": ["Reviews"], "summary": "Generate reply.", "operationId": "generateReply", "parameters": [{"name": "appUuid", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid"}}, {"name": "reviewId", "in": "path", "required": true, "schema": {"type": "string"}}], "responses": {"200": {"description": "Successful response.", "content": {"application/json": {"schema": {"type": "object", "additionalProperties": true}}}}, "400": {"$ref": "#/components/responses/BadRequest"}, "401": {"$ref": "#/components/responses/Unauthorized"}, "403": {"$ref": "#/components/responses/Forbidden"}, "404": {"$ref": "#/components/responses/NotFound"}, "429": {"$ref": "#/components/responses/CredentialRateLimited"}, "500": {"description": "`INTERNAL_ERROR`: the review could not be read, so whether it is in this app is unknown. It is never answered as not found, and no reply is generated or billed. Retry after a short delay.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "503": {"$ref": "#/components/responses/ReadUnavailable"}}}}, "/api/v1/apps/{appUuid}/reviews/{reviewId}/reply/outcome": {"post": {"tags": ["Reviews"], "summary": "Track reply outcome.", "description": "Records what happened after a reply this workspace posted through Tritonium: the review's new rating or sentiment, and whether the reviewer updated it. Only the workspace that posted the reply may record its outcome. Any workspace role may; an API key needs the `reviews:write` scope.", "operationId": "trackReplyOutcome", "parameters": [{"name": "appUuid", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid"}}, {"$ref": "#/components/parameters/ReplyReviewId"}], "requestBody": {"required": false, "content": {"application/json": {"schema": {"type": "object", "properties": {"new_rating": {"type": "integer", "minimum": 1, "maximum": 5, "description": "The review's star rating now."}, "new_sentiment": {"type": "string", "enum": ["negative", "neutral", "mixed", "positive"], "description": "The review's sentiment now, in any letter case."}, "user_updated_review": {"type": "boolean", "default": false, "description": "Whether the reviewer edited the review after the reply."}}}}}}, "responses": {"200": {"description": "Outcome recorded.", "content": {"application/json": {"schema": {"type": "object", "required": ["success", "review_id", "outcome", "rating_change", "sentiment_improved"], "properties": {"success": {"type": "boolean", "const": true}, "review_id": {"type": "string"}, "outcome": {"type": "string", "enum": ["positive", "negative", "neutral"], "description": "`positive` when the rating went up, `negative` when it went down; with no rating change, the sentiment decides the same way; else `neutral`."}, "rating_change": {"type": ["integer", "null"], "description": "The new rating minus the rating at the reply; null without a new rating, or when the rating at the reply is unknown."}, "sentiment_improved": {"type": ["boolean", "null"], "description": "Null without a new sentiment, or when the sentiment at the reply is unknown."}}}}}}, "400": {"description": "`TRACKING_FAILED`: a new rating or sentiment that is not one of the allowed values, a review with no reply, a reply with no publication time, a reply that changed meanwhile, or the outcome could not be stored.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "401": {"$ref": "#/components/responses/Unauthorized"}, "402": {"$ref": "#/components/responses/SubscriptionRequired"}, "403": {"description": "`ACCESS_DENIED`: another workspace tracking the same store listing posted the reply. `FORBIDDEN`: an API key without the `reviews:write` scope.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "404": {"description": "`APP_NOT_FOUND`: the workspace has no such app. `NOT_FOUND`: no such review.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "500": {"$ref": "#/components/responses/InternalError"}, "503": {"$ref": "#/components/responses/SubscriptionUnverified"}}}}, "/api/v1/apps/{appUuid}/sentiment": {"get": {"tags": ["Insights"], "summary": "Get sentiment.", "operationId": "getSentiment", "parameters": [{"name": "appUuid", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid"}}], "responses": {"200": {"description": "Successful response.", "content": {"application/json": {"schema": {"type": "object", "additionalProperties": true}}}}, "400": {"$ref": "#/components/responses/BadRequest"}, "401": {"$ref": "#/components/responses/Unauthorized"}, "403": {"$ref": "#/components/responses/Forbidden"}, "404": {"$ref": "#/components/responses/NotFound"}, "503": {"$ref": "#/components/responses/ServiceUnavailable"}}}}, "/api/v1/apps/{appUuid}/status": {"get": {"tags": ["Apps"], "summary": "Get app status.", "operationId": "getAppStatus", "description": "Observe sync and analysis state without modifying app records or clearing analysis locks. Expired running locks are reported as completed when stored complete insights exist, otherwise pending. A concurrent worker's newer state takes precedence. API-key authentication records credential usage separately. `latest_analysis_run_id` names the app's most recent analysis run, if any; follow it with GET /api/v1/apps/{appUuid}/analysis-runs/{runId}.", "parameters": [{"name": "appUuid", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid"}}], "responses": {"200": {"description": "Successful response.", "content": {"application/json": {"schema": {"type": "object", "required": ["app_uuid", "sync_status", "sync_message", "sync_progress", "last_sync", "analysis_status", "last_analysis", "review_count", "latest_analysis_run_id"], "properties": {"app_uuid": {"type": "string", "format": "uuid"}, "sync_status": {"type": "string", "description": "The app's sync state as stored, for example `pending`, `syncing`, `completed` or `failed`; `pending` when the app has none."}, "sync_message": {"type": "string", "description": "The latest sync progress or result message; empty when the app has none."}, "sync_progress": {"type": "number", "description": "The latest sync progress percentage; 0 when the app has none."}, "last_sync": {"type": ["string", "null"], "format": "date-time", "description": "When the app last synced; null before its first sync."}, "analysis_status": {"type": "string", "description": "The app's analysis state, for example `pending`, `running`, `completed` or `error`; `pending` when the app has none. An expired running lock reads as `completed` or `pending` (see above)."}, "last_analysis": {"type": ["string", "null"], "format": "date-time", "description": "When the app's analysis last completed; null before the first."}, "review_count": {"type": "integer", "minimum": 0}, "latest_analysis_run_id": {"type": ["string", "null"], "description": "The app's most recent analysis run, or null when it has none."}}}}}}, "400": {"$ref": "#/components/responses/BadRequest"}, "401": {"$ref": "#/components/responses/Unauthorized"}, "402": {"$ref": "#/components/responses/SubscriptionRequired"}, "403": {"$ref": "#/components/responses/Forbidden"}, "404": {"$ref": "#/components/responses/NotFound"}, "503": {"$ref": "#/components/responses/SubscriptionUnverified"}}}}, "/api/v1/apps/{appUuid}/sync-schedule": {"get": {"tags": ["Apps"], "summary": "Get sync schedule.", "operationId": "getSyncSchedule", "parameters": [{"name": "appUuid", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid"}}], "responses": {"200": {"description": "The app's schedule, in `schedule`.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/SyncScheduleAnswer"}}}}, "401": {"$ref": "#/components/responses/Unauthorized"}, "402": {"$ref": "#/components/responses/SubscriptionRequired"}, "403": {"$ref": "#/components/responses/AppsKeyForbidden"}, "404": {"$ref": "#/components/responses/SyncScheduleAppNotFound"}, "500": {"$ref": "#/components/responses/SyncScheduleFailed"}, "503": {"$ref": "#/components/responses/ReadUnavailable"}}}, "put": {"tags": ["Apps"], "summary": "Update sync schedule.", "operationId": "updateSyncSchedule", "security": [{"BearerAuth": []}], "parameters": [{"name": "appUuid", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid"}}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/SyncScheduleUpdateRequest"}}}}, "responses": {"200": {"description": "The schedule as saved, read again, in `schedule`.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/SyncScheduleAnswer"}}}}, "400": {"description": "Nothing was saved. `VALIDATION_ERROR`: `sync_times` is not a list, a time is not `HH:MM`, the list is longer than the plan allows (the message names the limit), `auto_sync_enabled` or `version_sync_enabled` is not a boolean, or the body is not a JSON object.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "401": {"$ref": "#/components/responses/Unauthorized"}, "402": {"$ref": "#/components/responses/SubscriptionRequired"}, "403": {"$ref": "#/components/responses/SyncAdminForbidden"}, "404": {"$ref": "#/components/responses/SyncScheduleAppNotFound"}, "500": {"$ref": "#/components/responses/SyncScheduleFailed"}, "503": {"$ref": "#/components/responses/ReadUnavailable"}}}}, "/api/v1/apps/{appUuid}/topics": {"get": {"tags": ["Insights"], "summary": "Get topics.", "operationId": "getTopics", "parameters": [{"name": "appUuid", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid"}}], "responses": {"200": {"description": "Successful response.", "content": {"application/json": {"schema": {"type": "object", "additionalProperties": true}}}}, "400": {"$ref": "#/components/responses/BadRequest"}, "401": {"$ref": "#/components/responses/Unauthorized"}, "403": {"$ref": "#/components/responses/Forbidden"}, "404": {"$ref": "#/components/responses/NotFound"}, "503": {"$ref": "#/components/responses/ServiceUnavailable"}}}}, "/api/v1/billing/cancel": {"post": {"tags": ["Billing"], "summary": "Cancel subscription.", "operationId": "cancelSubscription", "security": [{"BearerAuth": []}], "responses": {"200": {"description": "Successful response.", "content": {"application/json": {"schema": {"type": "object", "additionalProperties": true}}}}, "400": {"$ref": "#/components/responses/BadRequest"}, "401": {"$ref": "#/components/responses/Unauthorized"}, "403": {"$ref": "#/components/responses/Forbidden"}, "404": {"$ref": "#/components/responses/NotFound"}}}}, "/api/v1/billing/health": {"get": {"tags": ["Billing"], "summary": "Health check endpoint.", "operationId": "healthCheck", "responses": {"200": {"description": "Successful response.", "content": {"application/json": {"schema": {"type": "object", "additionalProperties": true}}}}, "400": {"$ref": "#/components/responses/BadRequest"}, "401": {"$ref": "#/components/responses/Unauthorized"}, "403": {"$ref": "#/components/responses/Forbidden"}, "404": {"$ref": "#/components/responses/NotFound"}}}}, "/api/v1/billing/reactivate": {"post": {"tags": ["Billing"], "summary": "Reactivate subscription.", "operationId": "reactivateSubscription", "security": [{"BearerAuth": []}], "responses": {"200": {"description": "Successful response.", "content": {"application/json": {"schema": {"type": "object", "additionalProperties": true}}}}, "400": {"$ref": "#/components/responses/BadRequest"}, "401": {"$ref": "#/components/responses/Unauthorized"}, "403": {"$ref": "#/components/responses/Forbidden"}, "404": {"$ref": "#/components/responses/NotFound"}}}}, "/api/v1/docs": {"get": {"tags": ["Public"], "summary": "Serve the ReDoc API documentation page.", "operationId": "docsPage", "responses": {"200": {"description": "Successful response.", "content": {"application/json": {"schema": {"type": "object", "additionalProperties": true}}}}, "400": {"$ref": "#/components/responses/BadRequest"}, "401": {"$ref": "#/components/responses/Unauthorized"}, "403": {"$ref": "#/components/responses/Forbidden"}, "404": {"$ref": "#/components/responses/NotFound"}}}}, "/api/v1/docs/openapi.json": {"get": {"tags": ["Public"], "summary": "Serve the OpenAPI specification JSON.", "operationId": "openapiSpec", "responses": {"200": {"description": "Successful response.", "content": {"application/json": {"schema": {"type": "object", "additionalProperties": true}}}}, "400": {"$ref": "#/components/responses/BadRequest"}, "401": {"$ref": "#/components/responses/Unauthorized"}, "403": {"$ref": "#/components/responses/Forbidden"}, "404": {"$ref": "#/components/responses/NotFound"}}}}, "/api/v1/docs/swagger": {"get": {"tags": ["Public"], "summary": "Serve the Swagger UI documentation page.", "operationId": "swaggerPage", "responses": {"200": {"description": "Successful response.", "content": {"application/json": {"schema": {"type": "object", "additionalProperties": true}}}}, "400": {"$ref": "#/components/responses/BadRequest"}, "401": {"$ref": "#/components/responses/Unauthorized"}, "403": {"$ref": "#/components/responses/Forbidden"}, "404": {"$ref": "#/components/responses/NotFound"}}}}, "/api/v1/integrations/linear/post": {"post": {"tags": ["Integrations"], "summary": "Create a Linear issue.", "description": "Requires the owner or admin role. Creates one issue through the workspace's Linear connection. At most 30 issues a minute per workspace, shared with the other create operations of this tag.", "operationId": "postToLinear", "security": [{"BearerAuth": []}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"type": "object", "required": ["title"], "properties": {"title": {"type": "string", "minLength": 1, "description": "Cut to 500 characters."}, "description": {"type": "string", "description": "Cut to 10000 characters."}, "team_id": {"type": "string", "description": "The Linear team to create the issue in. Linear refuses an issue without a team, which answers 502; the connection's `config.team_id` is not used here."}, "project_id": {"type": "string"}, "priority": {"type": "integer", "description": "Linear's priority: 0 none, 1 urgent, 2 high, 3 medium, 4 low. Sent to Linear as given."}}}}}}, "responses": {"201": {"description": "Issue created.", "content": {"application/json": {"schema": {"type": "object", "required": ["issue_id", "identifier", "url"], "properties": {"issue_id": {"type": "string"}, "identifier": {"type": "string", "description": "The issue's identifier, for example `APP-12`."}, "url": {"type": "string"}}}}}}, "400": {"description": "`NO_INTEGRATION`: Linear is not connected. `NO_CREDENTIALS` or `INVALID_CREDENTIALS`: the connection's credential is missing; reconnect Linear. `VALIDATION_ERROR`: no title.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "401": {"$ref": "#/components/responses/Unauthorized"}, "402": {"$ref": "#/components/responses/SubscriptionRequired"}, "403": {"$ref": "#/components/responses/IntegrationAdminForbidden"}, "429": {"$ref": "#/components/responses/IntegrationRateLimited"}, "500": {"description": "`CREDENTIALS_ERROR`: the Linear credential could not be read.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "502": {"description": "`LINEAR_API_ERROR`: Linear could not be reached or refused the issue.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "503": {"$ref": "#/components/responses/SubscriptionUnverified"}}}}, "/api/v1/integrations/pagerduty/post": {"post": {"tags": ["Integrations"], "summary": "Create a PagerDuty incident.", "description": "Requires the owner or admin role. Sends one trigger event through the workspace's PagerDuty connection (Events API v2). Shares the 30 a minute limit of the create operations.", "operationId": "postToPagerDuty", "security": [{"BearerAuth": []}], "requestBody": {"required": false, "content": {"application/json": {"schema": {"type": "object", "properties": {"summary": {"type": "string", "description": "Cut to 1024 characters. Left out, `Alert from Tritonium`."}, "source": {"type": "string", "description": "Left out, `tritonium`."}, "severity": {"type": "string", "description": "PagerDuty's `critical`, `error`, `warning` or `info`. Left out, `critical`."}, "custom_details": {"type": "object", "additionalProperties": true}}}}}}, "responses": {"200": {"$ref": "#/components/responses/IncidentCreated"}, "400": {"description": "`NO_CREDENTIALS`: PagerDuty is not connected, or its routing key is missing.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "401": {"$ref": "#/components/responses/Unauthorized"}, "402": {"$ref": "#/components/responses/SubscriptionRequired"}, "403": {"$ref": "#/components/responses/IntegrationAdminForbidden"}, "429": {"$ref": "#/components/responses/IntegrationRateLimited"}, "500": {"$ref": "#/components/responses/InternalError"}, "502": {"description": "`PAGERDUTY_API_ERROR`: PagerDuty could not be reached or did not accept the event.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "503": {"$ref": "#/components/responses/SubscriptionUnverified"}}}}, "/api/v1/integrations/opsgenie/post": {"post": {"tags": ["Integrations"], "summary": "Create an Opsgenie alert.", "description": "Requires the owner or admin role. Creates one alert through the workspace's Opsgenie connection. Shares the 30 a minute limit of the create operations.", "operationId": "postToOpsgenie", "security": [{"BearerAuth": []}], "requestBody": {"required": false, "content": {"application/json": {"schema": {"type": "object", "properties": {"message": {"type": "string", "description": "Cut to 130 characters. Left out, `Alert from Tritonium`."}, "description": {"type": "string"}, "priority": {"type": "string", "description": "Opsgenie's `P1` to `P5`. Left out, `P3`."}}}}}}, "responses": {"200": {"$ref": "#/components/responses/IncidentCreated"}, "400": {"description": "`NO_CREDENTIALS`: Opsgenie is not connected, or its API key is missing.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "401": {"$ref": "#/components/responses/Unauthorized"}, "402": {"$ref": "#/components/responses/SubscriptionRequired"}, "403": {"$ref": "#/components/responses/IntegrationAdminForbidden"}, "429": {"$ref": "#/components/responses/IntegrationRateLimited"}, "500": {"$ref": "#/components/responses/InternalError"}, "502": {"description": "`OPSGENIE_API_ERROR`: Opsgenie could not be reached or did not accept the alert.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "503": {"$ref": "#/components/responses/SubscriptionUnverified"}}}}, "/api/v1/integrations/victorops/post": {"post": {"tags": ["Integrations"], "summary": "Create a VictorOps incident.", "description": "Requires the owner or admin role. Sends one alert to the workspace's VictorOps REST endpoint. Shares the 30 a minute limit of the create operations.", "operationId": "postToVictorOps", "security": [{"BearerAuth": []}], "requestBody": {"required": false, "content": {"application/json": {"schema": {"type": "object", "properties": {"message_type": {"type": "string", "description": "VictorOps' message type, for example `CRITICAL` or `RECOVERY`. Left out, `CRITICAL`."}, "entity_id": {"type": "string", "description": "Left out, `tritonium_alert`."}, "entity_display_name": {"type": "string", "description": "Left out, `Alert from Tritonium`."}, "state_message": {"type": "string"}}}}}}, "responses": {"200": {"$ref": "#/components/responses/IncidentCreated"}, "400": {"description": "`NO_CREDENTIALS`: VictorOps is not connected, or its key is missing. `INVALID_CREDENTIALS`: the stored key is not a VictorOps REST API key; reconnect VictorOps.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "401": {"$ref": "#/components/responses/Unauthorized"}, "402": {"$ref": "#/components/responses/SubscriptionRequired"}, "403": {"$ref": "#/components/responses/IntegrationAdminForbidden"}, "429": {"$ref": "#/components/responses/IntegrationRateLimited"}, "500": {"$ref": "#/components/responses/InternalError"}, "502": {"description": "`VICTOROPS_API_ERROR`: VictorOps could not be reached or did not accept the alert.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "503": {"$ref": "#/components/responses/SubscriptionUnverified"}}}}, "/api/v1/integrations/statuspage/post": {"post": {"tags": ["Integrations"], "summary": "Create a Statuspage incident.", "description": "Requires the owner or admin role. Creates one incident on the workspace's Statuspage page. Shares the 30 a minute limit of the create operations.", "operationId": "postToStatuspage", "security": [{"BearerAuth": []}], "requestBody": {"required": false, "content": {"application/json": {"schema": {"type": "object", "properties": {"page_id": {"type": "string", "description": "Used only when the connection has no `config.page_id`."}, "name": {"type": "string", "description": "Left out, `Alert from Tritonium`."}, "status": {"type": "string", "description": "Statuspage's incident status. Left out, `investigating`."}, "body": {"type": "string", "description": "Left out, `We are investigating an issue.`"}, "impact_override": {"type": "string", "description": "Left out, `minor`."}}}}}}, "responses": {"200": {"$ref": "#/components/responses/IncidentCreated"}, "400": {"description": "`NO_CREDENTIALS`: Statuspage is not connected, or its API key is missing. `MISSING_CONFIG`: no page id, in the connection or the body.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "401": {"$ref": "#/components/responses/Unauthorized"}, "402": {"$ref": "#/components/responses/SubscriptionRequired"}, "403": {"$ref": "#/components/responses/IntegrationAdminForbidden"}, "429": {"$ref": "#/components/responses/IntegrationRateLimited"}, "500": {"$ref": "#/components/responses/InternalError"}, "502": {"description": "`STATUSPAGE_API_ERROR`: Statuspage could not be reached or did not accept the incident.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "503": {"$ref": "#/components/responses/SubscriptionUnverified"}}}}, "/api/v1/integrations/linear/projects": {"get": {"tags": ["Integrations"], "summary": "Get Linear projects for the tenant.", "description": "Requires the owner or admin role. The projects Linear lists for the connection's API key, in Linear's first page.", "operationId": "getLinearProjects", "security": [{"BearerAuth": []}], "responses": {"200": {"description": "Linear projects.", "content": {"application/json": {"schema": {"type": "object", "required": ["projects"], "properties": {"projects": {"type": "array", "items": {"$ref": "#/components/schemas/LinearItem"}}}}}}}, "400": {"$ref": "#/components/responses/LinearNotConnected"}, "401": {"$ref": "#/components/responses/Unauthorized"}, "402": {"$ref": "#/components/responses/SubscriptionRequired"}, "403": {"$ref": "#/components/responses/IntegrationAdminForbidden"}, "500": {"$ref": "#/components/responses/LinearCredentialsUnreadable"}, "502": {"$ref": "#/components/responses/LinearUnreachable"}, "503": {"$ref": "#/components/responses/SubscriptionUnverified"}}}}, "/api/v1/integrations/linear/teams": {"get": {"tags": ["Integrations"], "summary": "Get Linear teams for the tenant.", "description": "Requires the owner or admin role. The teams Linear lists for the connection's API key, in Linear's first page.", "operationId": "getLinearTeams", "security": [{"BearerAuth": []}], "responses": {"200": {"description": "Linear teams.", "content": {"application/json": {"schema": {"type": "object", "required": ["teams"], "properties": {"teams": {"type": "array", "items": {"$ref": "#/components/schemas/LinearItem"}}}}}}}, "400": {"$ref": "#/components/responses/LinearNotConnected"}, "401": {"$ref": "#/components/responses/Unauthorized"}, "402": {"$ref": "#/components/responses/SubscriptionRequired"}, "403": {"$ref": "#/components/responses/IntegrationAdminForbidden"}, "500": {"$ref": "#/components/responses/LinearCredentialsUnreadable"}, "502": {"$ref": "#/components/responses/LinearUnreachable"}, "503": {"$ref": "#/components/responses/SubscriptionUnverified"}}}}, "/api/v1/integrations/share-chart": {"post": {"tags": ["Integrations"], "summary": "Share a chart image to a connected channel.", "description": "Requires the owner or admin role. Posts a PNG image of a chart in a Slack channel, or in the Microsoft Teams channel of a Workflows connection. Teams requires an explicitly enabled workspace, and its complete message including the inline PNG must fit within 28 KB. Provider acceptance does not prove channel receipt. At most 20 shares a minute per workspace.", "operationId": "shareChart", "security": [{"BearerAuth": []}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ShareChartRequest"}}}}, "responses": {"200": {"description": "Shared. Slack answers `success` alone. Teams answers the delivery as the Teams test does.", "content": {"application/json": {"schema": {"type": "object", "required": ["success"], "properties": {"success": {"type": "boolean"}, "integration_id": {"type": "string", "description": "Teams only."}, "result": {"$ref": "#/components/schemas/IntegrationDeliveryResult"}}}}}}, "400": {"description": "Nothing was posted. `VALIDATION_ERROR`: the target, image, size or Slack channel is missing or invalid. `NO_INTEGRATION`: the target is not connected. `NO_CREDENTIALS` or `INVALID_CREDENTIALS`: the Slack connection's credential is missing; reconnect Slack. `INTEGRATION_NOT_IMPLEMENTED`: Microsoft Teams is unavailable for this workspace. `INVALID_WEBHOOK_URL`: reconnect Teams with a signed Workflows URL.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "401": {"$ref": "#/components/responses/Unauthorized"}, "402": {"$ref": "#/components/responses/SubscriptionRequired"}, "403": {"$ref": "#/components/responses/IntegrationAdminForbidden"}, "429": {"$ref": "#/components/responses/IntegrationRateLimited"}, "500": {"description": "`CREDENTIALS_ERROR`: the Slack credential could not be read.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "502": {"description": "For Slack, `SLACK_API_ERROR`: Slack could not be reached or refused the upload. For Teams, the delivery answer with `success` false.", "content": {"application/json": {"schema": {"anyOf": [{"$ref": "#/components/schemas/ErrorResponse"}, {"$ref": "#/components/schemas/IntegrationTestResponse"}]}}}}, "503": {"$ref": "#/components/responses/IntegrationTeamsUnavailable"}}}}, "/api/v1/product-groups": {"get": {"tags": ["Admin"], "summary": "List product groups for the tenant.", "description": "Every product group of the workspace, by name with case ignored, in one answer (not paged). Any member can list them; a deleted group is not listed.", "operationId": "listProductGroups", "security": [{"BearerAuth": []}], "responses": {"200": {"description": "The workspace's product groups.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ProductGroupList"}}}}, "401": {"$ref": "#/components/responses/Unauthorized"}, "500": {"description": "`INTERNAL_ERROR`: the groups could not be read. Retry.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}}}, "post": {"tags": ["Admin"], "summary": "Create a product group.", "description": "Requires an admin or owner, and 2 to 5 app UUID entries owned by the caller's tenant, with at least 2 distinct apps. Duplicate entries are ignored when storing membership; a list containing only one distinct app is rejected with 400.", "operationId": "createProductGroup", "security": [{"BearerAuth": []}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ProductGroupCreateRequest"}}}}, "responses": {"201": {"description": "The group created.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ProductGroupAnswer"}}}}, "400": {"$ref": "#/components/responses/ProductGroupRefused"}, "401": {"$ref": "#/components/responses/Unauthorized"}, "403": {"$ref": "#/components/responses/ProductGroupAdminForbidden"}, "500": {"description": "`CREATE_FAILED`: the group was not created, or a member app could not be read to check it. Retry.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}}}}, "/api/v1/product-groups/{groupId}": {"delete": {"tags": ["Admin"], "summary": "Delete a product group.", "description": "Requires an admin or owner. The group is no longer listed or read; its member apps and their data are not changed.", "operationId": "deleteProductGroup", "security": [{"BearerAuth": []}], "parameters": [{"$ref": "#/components/parameters/ProductGroupId"}], "responses": {"200": {"description": "The group was deleted.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ProductGroupDeleted"}}}}, "401": {"$ref": "#/components/responses/Unauthorized"}, "403": {"$ref": "#/components/responses/ProductGroupAdminForbidden"}, "404": {"$ref": "#/components/responses/ProductGroupNotFound"}, "500": {"description": "`INTERNAL_ERROR`: the group could not be read or was not deleted. Retry.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}}}, "get": {"tags": ["Apps"], "summary": "Get product group.", "description": "One product group, as it is listed. Any member can read it, and so can an API key with the `analytics:read` scope.", "operationId": "getProductGroup", "parameters": [{"$ref": "#/components/parameters/ProductGroupId"}], "responses": {"200": {"description": "The group.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ProductGroup"}}}}, "401": {"$ref": "#/components/responses/Unauthorized"}, "402": {"$ref": "#/components/responses/SubscriptionRequired"}, "403": {"$ref": "#/components/responses/ProductGroupKeyForbidden"}, "404": {"$ref": "#/components/responses/ProductGroupNotFound"}, "500": {"$ref": "#/components/responses/ProductGroupUnread"}, "503": {"$ref": "#/components/responses/SubscriptionUnverified"}}}, "put": {"tags": ["Admin"], "summary": "Update a product group.", "description": "Requires an admin or owner. Only the members sent are changed.", "operationId": "updateProductGroup", "security": [{"BearerAuth": []}], "parameters": [{"$ref": "#/components/parameters/ProductGroupId"}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ProductGroupUpdateRequest"}}}}, "responses": {"200": {"description": "The group as it now is.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ProductGroupAnswer"}}}}, "400": {"$ref": "#/components/responses/ProductGroupRefused"}, "401": {"$ref": "#/components/responses/Unauthorized"}, "403": {"$ref": "#/components/responses/ProductGroupAdminForbidden"}, "404": {"$ref": "#/components/responses/ProductGroupNotFound"}, "500": {"description": "`UPDATE_FAILED`: the group or a member app could not be read, or the change was not written. Retry.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}}}}, "/api/v1/product-groups/{groupId}/executive-spotlight": {"get": {"tags": ["Apps"], "summary": "Get group executive spotlight.", "description": "Aggregated executive spotlight for a product group.\n\n`trends.rating_change` (shape `RatingChange`) compares two windows\nof whole, settled days that every member's coverage vouches for, and\n`trends.rating_delta` and `trends.rating_trend` come from it; all\nthree say no change (null) when that cannot be measured, with the\nreason in `trends.rating_change.reason`. `trends.volume_change` and\n`trends.sentiment_change` compare the same windows across member\napps, from the underlying review counts, in the shapes the app's\nexecutive spotlight gives them, and `volume_delta`, `volume_trend`,\n`sentiment_delta` and `sentiment_trend` come from them (null when\nnot measured). `current_period` names the 30 days the reply figures\ncover. `response_effectiveness.reply_rate`\nis null with `reply_rate_reason` `store_replies_not_observable`: the\nApp Store reports no developer replies and Google Play shows a reply\nonly on the reviews it serves when they are read.\n`tritonium_reply_count` counts the current window's reviews answered\nthrough Tritonium from this workspace, and `tritonium_reply_share` is\ntheir percentage of the window's reviews. Segment counts sum\napp-scoped memberships and do not identify the same person across\nplatforms.\n\nA section whose member data cannot be read completely is returned as\n`null` and listed in `executive_spotlight.unavailable_metrics`. Treat\n`null` as \"not measured\", rather than zero. Measurement shares a\n50,000 evaluated-row and 10-second read budget across the group,\nincluding filtered-out history and segments. Sections whose complete\nread exceeds that budget remain unavailable instead of reporting\ntruncated counts as complete.\n\n`alerts` and `summary_metrics` come from the member apps' latest\nanalyses (as `GET /api/v1/product-groups/{groupId}/insights` merges\nthem), not from the measured sections.\n", "operationId": "getGroupExecutiveSpotlight", "parameters": [{"$ref": "#/components/parameters/ProductGroupId"}], "responses": {"200": {"description": "The group's executive spotlight.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/GroupExecutiveSpotlightResponse"}}}}, "401": {"$ref": "#/components/responses/Unauthorized"}, "402": {"$ref": "#/components/responses/SubscriptionRequired"}, "403": {"$ref": "#/components/responses/ProductGroupKeyForbidden"}, "404": {"$ref": "#/components/responses/ProductGroupNotFound"}, "500": {"$ref": "#/components/responses/ProductGroupUnread"}, "503": {"$ref": "#/components/responses/ReadUnavailable"}}}}, "/api/v1/product-groups/{groupId}/insights": {"get": {"tags": ["Apps"], "summary": "Get group insights.", "description": "The member apps' latest analyses, each as its own Insights tab serves it, merged into one: the sentiment shares and the average rating are weighted by each app's reviews, themes are summed by name, and the actionable insights of every app are listed together, strongest evidence first, each naming the app it came from. A member that has never been analysed, or is no longer one of the workspace's apps, is in `apps_missing_data` and adds nothing.", "operationId": "getGroupInsights", "parameters": [{"$ref": "#/components/parameters/ProductGroupId"}], "responses": {"200": {"description": "The group's merged insights.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/GroupInsightsResponse"}}}}, "401": {"$ref": "#/components/responses/Unauthorized"}, "402": {"$ref": "#/components/responses/SubscriptionRequired"}, "403": {"$ref": "#/components/responses/ProductGroupKeyForbidden"}, "404": {"$ref": "#/components/responses/ProductGroupNotFound"}, "500": {"$ref": "#/components/responses/ProductGroupUnread"}, "503": {"$ref": "#/components/responses/ReadUnavailable"}}}}, "/api/v1/product-groups/{groupId}/platform-comparison": {"get": {"tags": ["Apps"], "summary": "Get group platform comparison.", "description": "The group's analysed member apps by store, and what differs between the stores. A store whose apps have no analysis is left out.", "operationId": "getGroupPlatformComparison", "parameters": [{"$ref": "#/components/parameters/ProductGroupId"}], "responses": {"200": {"description": "The group's stores compared.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/GroupPlatformComparisonResponse"}}}}, "401": {"$ref": "#/components/responses/Unauthorized"}, "402": {"$ref": "#/components/responses/SubscriptionRequired"}, "403": {"$ref": "#/components/responses/ProductGroupKeyForbidden"}, "404": {"$ref": "#/components/responses/ProductGroupNotFound"}, "500": {"$ref": "#/components/responses/ProductGroupUnread"}, "503": {"$ref": "#/components/responses/ReadUnavailable"}}}}, "/api/v1/reply-drafts/{draftId}": {"delete": {"tags": ["Reviews"], "summary": "Delete a rejected reply draft.", "operationId": "deleteReplyDraft", "security": [{"BearerAuth": []}], "description": "Permanently remove a rejected draft in the current workspace. Requires an admin or owner. Pending, approved, posting and posted drafts cannot be deleted. Publication history or an unfinished posting claim also prevents deletion. Reject an unpublished draft before deleting it. A retry after a completed deletion returns 404. A storage failure returns a retryable 503; it does not establish that the draft was removed.", "parameters": [{"$ref": "#/components/parameters/ReplyDraftId"}], "responses": {"200": {"description": "The rejected draft was deleted.", "content": {"application/json": {"schema": {"type": "object", "required": ["draft_id", "deleted"], "properties": {"draft_id": {"type": "string", "format": "uuid"}, "deleted": {"type": "boolean", "enum": [true]}}}}}}, "401": {"$ref": "#/components/responses/Unauthorized"}, "402": {"$ref": "#/components/responses/SubscriptionRequired"}, "403": {"$ref": "#/components/responses/ReplyDraftAdminForbidden"}, "404": {"$ref": "#/components/responses/ReplyDraftNotFound"}, "409": {"description": "`DRAFT_STATUS_CONFLICT`: the draft is not rejected, has publication history, or has an unfinished posting claim.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "500": {"$ref": "#/components/responses/InternalError"}, "503": {"$ref": "#/components/responses/ReplyDraftStorageUnavailable"}}}, "get": {"tags": ["Reviews"], "summary": "Get a single reply draft.", "operationId": "getReplyDraft", "description": "Read a workspace draft consistently, including immediately after it was saved, in any status. A storage failure returns a retryable 503 with Retry-After rather than reporting a draft as missing. A 404 means the authoritative read found no draft in this workspace. Any workspace role may read; an API key needs the `reviews:read` scope.", "parameters": [{"$ref": "#/components/parameters/ReplyDraftId"}], "responses": {"200": {"description": "The draft.", "content": {"application/json": {"schema": {"type": "object", "required": ["draft_id", "review_id", "app_uuid", "platform", "reply_text", "reply_language", "status", "created_at", "updated_at", "review_snapshot", "generation_method", "template_id"], "properties": {"draft_id": {"type": "string", "format": "uuid"}, "review_id": {"type": "string"}, "app_uuid": {"type": "string", "format": "uuid"}, "platform": {"type": ["string", "null"], "description": "The store the reply is for (`app_store_connect` or `google_play`)."}, "reply_text": {"type": "string"}, "reply_language": {"type": ["string", "null"]}, "status": {"$ref": "#/components/schemas/ReplyDraftStatus"}, "created_at": {"type": "string", "format": "date-time"}, "updated_at": {"type": ["string", "null"], "format": "date-time"}, "review_snapshot": {"$ref": "#/components/schemas/ReplyDraftReviewSnapshot"}, "generation_method": {"type": "string", "description": "`ai` unless an older draft stored another method."}, "template_id": {"type": ["string", "null"]}}}}}}, "401": {"$ref": "#/components/responses/Unauthorized"}, "402": {"$ref": "#/components/responses/SubscriptionRequired"}, "403": {"description": "`FORBIDDEN`: an API key without the `reviews:read` scope.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "404": {"$ref": "#/components/responses/ReplyDraftNotFound"}, "500": {"$ref": "#/components/responses/InternalError"}, "503": {"$ref": "#/components/responses/ReplyDraftStorageUnavailable"}}}}, "/api/v1/reply-drafts/{draftId}/post": {"post": {"tags": ["Reviews"], "summary": "Publish an approved reply draft to the app store.", "description": "Publishes the draft's reply to App Store Connect or Google Play. The draft must already be in `approved` status. A 200 means the store accepted the reply; failures are reported rather than recorded as a successful post. A 500 with code `REPLY_POSTED_NOT_RECORDED` means the reply IS live on the store but Tritonium could not finish recording it. Do not retry: a retry publishes a duplicate reply. Admin or owner only.", "operationId": "postReplyDraft", "security": [{"BearerAuth": []}], "parameters": [{"$ref": "#/components/parameters/ReplyDraftId"}], "requestBody": {"required": false, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/DraftPostRequest"}}}}, "responses": {"200": {"description": "The store accepted the reply. `draft_status` is `posted`, or `posting` with `draft_status_write_failed` when the reply is live but the draft could not be marked posted yet (it will be).", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ReplyPublication"}}}}, "400": {"description": "Nothing was published. `VALIDATION_ERROR`: the edited text is not text, empty or too long, or a template was chosen without text. `REPLY_HAS_PLACEHOLDER`: the reply still has an unfilled `{placeholder}`. `INVALID_REPLY_TEMPLATE`: the template could not be resolved. `REPLY_POST_FAILED`: the store refused the reply.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "401": {"$ref": "#/components/responses/Unauthorized"}, "402": {"$ref": "#/components/responses/SubscriptionRequired"}, "403": {"description": "Nothing was published. `FORBIDDEN`: the caller is not an owner or admin, or is an API key. `CREDENTIALS_REQUIRED`: no store credentials of the workspace can publish it. `ACCESS_DENIED`: the store refused the credential's permission.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "404": {"$ref": "#/components/responses/ReplyDraftNotFound"}, "409": {"$ref": "#/components/responses/ReplyDraftConflict"}, "500": {"description": "`REPLY_POSTED_NOT_RECORDED`: the reply was published to the store but recording it failed; it is live, do not retry. `DRAFT_PUBLISH_CLAIM_STUCK`: nothing was published, but the draft is left in `posting` and cannot be posted or rejected until the claim is cleared. `DRAFT_UPDATE_FAILED`: the edited text could not be saved; nothing was published.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "503": {"$ref": "#/components/responses/ReadUnavailable"}}}}, "/api/v1/reviews/analytics": {"get": {"tags": ["Reviews"], "summary": "Get analytics.", "operationId": "getAnalytics", "parameters": [{"name": "app_uuid", "in": "query", "required": true, "schema": {"type": "string", "format": "uuid"}, "description": "App the analytics are computed for. Required: this route has no tenant-wide mode, and omitting it answers 400 `VALIDATION_ERROR` (\"app_uuid query parameter is required\") rather than aggregating."}, {"name": "days", "in": "query", "required": false, "schema": {"type": "integer", "minimum": 1, "maximum": 365, "default": 365}, "description": "Window, in days back from now, the reviews are drawn from. The response echoes the window it used as `days`."}, {"name": "platform", "in": "query", "required": false, "schema": {"type": "string"}, "description": "Restrict the aggregation to a single store. `app_store` and `ios` are accepted as aliases of `app_store_connect`; `play_store` and `android` as aliases of `google_play`. A platform the app has no reviews on returns zeroed distributions rather than an error."}], "responses": {"200": {"description": "Successful response.", "content": {"application/json": {"schema": {"type": "object", "additionalProperties": true}}}}, "400": {"$ref": "#/components/responses/BadRequest"}, "401": {"$ref": "#/components/responses/Unauthorized"}, "403": {"$ref": "#/components/responses/Forbidden"}, "404": {"$ref": "#/components/responses/NotFound"}, "503": {"$ref": "#/components/responses/ReadUnavailable"}}}}, "/api/v1/reviews/segments": {"get": {"tags": ["Reviews"], "summary": "Get segments.", "operationId": "getSegments", "parameters": [{"name": "app_uuid", "in": "query", "required": true, "schema": {"type": "string", "format": "uuid"}, "description": "App the segment breakdown is computed for. Required: this route has no tenant-wide mode, and omitting it answers 400 `VALIDATION_ERROR` (\"app_uuid query parameter is required\") rather than aggregating."}], "responses": {"200": {"description": "Successful response.", "content": {"application/json": {"schema": {"type": "object", "additionalProperties": true}}}}, "400": {"$ref": "#/components/responses/BadRequest"}, "401": {"$ref": "#/components/responses/Unauthorized"}, "403": {"$ref": "#/components/responses/Forbidden"}, "404": {"$ref": "#/components/responses/NotFound"}, "503": {"$ref": "#/components/responses/ReadUnavailable"}}}}, "/api/v1/reviews/themes": {"get": {"tags": ["Reviews"], "summary": "Get themes.", "operationId": "getThemes", "parameters": [{"name": "app_uuid", "in": "query", "required": true, "schema": {"type": "string", "format": "uuid"}, "description": "App the theme breakdown is computed for. Required: this route has no tenant-wide mode, and omitting it answers 400 `VALIDATION_ERROR` (\"app_uuid query parameter is required\") rather than aggregating."}, {"name": "days", "in": "query", "schema": {"type": "integer", "minimum": 1, "maximum": 365, "default": 365}, "description": "Trailing window, in days, themes are extracted over."}, {"name": "platform", "in": "query", "schema": {"type": "string", "enum": ["app_store", "google_play", "slack", "whatsapp"]}, "description": "Restrict to reviews from one store. Matched after normalisation, so `ios` / `App Store` resolve to `app_store`. Omitted, every platform the app has reviews on is included."}], "responses": {"200": {"description": "Successful response.", "content": {"application/json": {"schema": {"type": "object", "additionalProperties": true}}}}, "400": {"$ref": "#/components/responses/BadRequest"}, "401": {"$ref": "#/components/responses/Unauthorized"}, "403": {"$ref": "#/components/responses/Forbidden"}, "404": {"$ref": "#/components/responses/NotFound"}, "503": {"$ref": "#/components/responses/ReadUnavailable"}}}}, "/api/v1/reviews/trends": {"get": {"tags": ["Reviews"], "summary": "Get trends.", "operationId": "getTrends", "parameters": [{"name": "app_uuid", "in": "query", "required": true, "schema": {"type": "string", "format": "uuid"}, "description": "App the trend series is computed for. Required: this route has no tenant-wide mode, and omitting it answers 400 `VALIDATION_ERROR` (\"app_uuid query parameter is required\") rather than aggregating."}, {"name": "days", "in": "query", "schema": {"type": "integer", "minimum": 1, "maximum": 365, "default": 30}, "description": "Trailing window, in days, the trend series covers."}, {"name": "platform", "in": "query", "schema": {"type": "string", "enum": ["app_store", "google_play", "slack", "whatsapp"]}, "description": "Restrict to reviews from one store. Matched after normalisation, so `ios` / `App Store` resolve to `app_store`. Omitted, every platform the app has reviews on is included."}], "responses": {"200": {"description": "Successful response.", "content": {"application/json": {"schema": {"type": "object", "additionalProperties": true}}}}, "400": {"$ref": "#/components/responses/BadRequest"}, "401": {"$ref": "#/components/responses/Unauthorized"}, "403": {"$ref": "#/components/responses/Forbidden"}, "404": {"$ref": "#/components/responses/NotFound"}, "503": {"$ref": "#/components/responses/ReadUnavailable"}}}}, "/api/v1/reviews/{reviewId}": {"get": {"tags": ["Reviews"], "summary": "Get review.", "operationId": "getReview", "parameters": [{"name": "reviewId", "in": "path", "required": true, "schema": {"type": "string"}}], "responses": {"200": {"description": "The review under `review`, with the same fields and the same workspace assignment and tags as in the review listings.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ReviewResponse"}}}}, "400": {"$ref": "#/components/responses/BadRequest"}, "401": {"$ref": "#/components/responses/Unauthorized"}, "403": {"$ref": "#/components/responses/Forbidden"}, "404": {"$ref": "#/components/responses/NotFound"}, "503": {"$ref": "#/components/responses/ReadUnavailable"}}}}, "/api/v1/settings/sentiment-alerts": {"get": {"tags": ["Alerts"], "summary": "Get sentiment config.", "description": "Legacy preference storage. These settings do not currently trigger automatic alerts; use alert rules instead. A stored `webhook_url` is returned in full only to a signed-in admin or owner; a member and an API key receive its scheme and host. With an address stored, `webhook_url_masked` says which of the two `webhook_url` holds.", "operationId": "getSentimentConfig", "responses": {"200": {"description": "The configuration under `config`. A workspace that has never saved one gets the defaults (off, in-app only).", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/SentimentAlertConfigAnswer"}}}}, "401": {"$ref": "#/components/responses/Unauthorized"}, "402": {"$ref": "#/components/responses/SubscriptionRequired"}, "403": {"description": "`FORBIDDEN`: an API key without the `alerts:read` scope.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "500": {"description": "`QUERY_FAILED`: the configuration could not be read. Retry.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "503": {"$ref": "#/components/responses/SubscriptionUnverified"}}}, "put": {"tags": ["Alerts"], "summary": "Update sentiment config.", "description": "Legacy preference storage; these settings do not currently trigger automatic alerts. Partial update. Only the fields present in the request body are changed; any field that is omitted keeps its currently stored value.", "operationId": "updateSentimentConfig", "security": [{"BearerAuth": []}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/UpdateSentimentConfigRequest"}}}}, "responses": {"200": {"description": "The whole configuration after the change, under `config`, with a stored `webhook_url` in full (only an admin or owner can change it). `webhook_url_masked` is not sent.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/SentimentAlertConfigAnswer"}}}}, "400": {"description": "`VALIDATION_ERROR`, and the message names the member that is wrong: no body or an empty one, a body that is not a JSON object, a number that is not a whole number or is outside its range, a `comparison_window` or `severity` that is not one of its values, a `destinations` that is not a list of `email`, `slack`, `in_app` and `webhook`, an `enabled` or `include_sample_reviews` that is not true or false, or a `webhook_url` that is not an http or https address or names a local, private or reserved host.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "401": {"$ref": "#/components/responses/Unauthorized"}, "402": {"$ref": "#/components/responses/SubscriptionRequired"}, "403": {"description": "`FORBIDDEN`: a member, or an API key. Only an admin or owner can change these settings, and no API key can: changing alerts is not available to API keys.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "500": {"description": "`UPDATE_FAILED`: the configuration could not be read or saved, and nothing was changed; or `INTERNAL_ERROR` when the save failed in an unexpected way. Retry.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "503": {"$ref": "#/components/responses/SubscriptionUnverified"}}}}, "/api/v1/settings/sentiment-alerts/test": {"post": {"tags": ["Alerts"], "summary": "Test sentiment alert.", "description": "Tests delivery only. Success does not verify automatic sentiment monitoring; use alert rules for automatic alerts.", "operationId": "testSentimentAlert", "security": [{"BearerAuth": []}], "responses": {"200": {"description": "One test was tried at each stored destination. `success` is false when any of them failed; a skipped one does not count as failed.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/SentimentAlertTestResult"}}}}, "400": {"description": "`ALERTS_DISABLED`: sentiment alerts are off, so nothing is sent. `NO_DESTINATIONS`: no destination is stored.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "401": {"$ref": "#/components/responses/Unauthorized"}, "402": {"$ref": "#/components/responses/SubscriptionRequired"}, "403": {"description": "`FORBIDDEN`: a member, or an API key. Only an admin or owner can send a test, and no API key can.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "500": {"description": "`QUERY_FAILED`: the configuration could not be read, and nothing was sent. `INTERNAL_ERROR`: a test failed in an unexpected way.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "503": {"$ref": "#/components/responses/SubscriptionUnverified"}}}}, "/api/v1/support/knowledge-base": {"get": {"tags": ["Public"], "summary": "Search knowledge base articles.", "description": "The help articles whose title, summary or keywords match the search, best match first, or every article in its order when there is no search. No sign-in is needed.", "operationId": "searchKnowledgeBase", "security": [], "parameters": [{"name": "category", "in": "query", "required": false, "description": "Only articles of this category, for example `gettingStarted`, `integrations`, `alerts`, `analytics`, `troubleshooting` or `billing`. At most 50 characters.", "schema": {"type": "string", "maxLength": 50}}, {"name": "limit", "in": "query", "required": false, "description": "Articles to return, from 1 to 50. Anything else answers `400 VALIDATION_ERROR`.", "schema": {"type": "integer", "default": 10, "minimum": 1, "maximum": 50}}, {"name": "q", "in": "query", "required": false, "description": "The search, at most 500 characters.", "schema": {"type": "string", "maxLength": 500}}, {"name": "search", "in": "query", "required": false, "description": "The search, when `q` is not sent.", "schema": {"type": "string", "maxLength": 500}}], "responses": {"200": {"description": "The matching articles.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/KnowledgeBaseSearchResult"}}}}, "400": {"description": "`VALIDATION_ERROR`: the search is longer than 500 characters, the category longer than 50, or `limit` is not a whole number from 1 to 50.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}}}}, "/api/v1/sync/plan-limits": {"get": {"tags": ["Apps"], "summary": "Get sync plan limits.", "description": "How many sync times a day each of the workspace's apps may have. That number is the only limit on a sync schedule: the times themselves may be any distinct `HH:MM` in UTC.", "operationId": "getSyncPlanLimits", "security": [{"BearerAuth": []}], "responses": {"200": {"description": "The workspace's tier and its limit.", "content": {"application/json": {"schema": {"type": "object", "required": ["plan_tier", "limits", "all_tiers"], "properties": {"plan_tier": {"type": "string", "enum": ["trial", "paid"], "description": "The workspace's sync tier: `paid` with a live subscription, otherwise `trial`."}, "limits": {"$ref": "#/components/schemas/SyncPlanLimits"}, "all_tiers": {"type": "object", "description": "The limit of every tier, by tier name, always `trial` and `paid`.", "additionalProperties": {"$ref": "#/components/schemas/SyncPlanLimits"}}}}}}}, "401": {"$ref": "#/components/responses/Unauthorized"}, "402": {"$ref": "#/components/responses/SubscriptionRequired"}, "403": {"description": "`FORBIDDEN`: an API key. This route is not available to API keys; sign in to use it.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "503": {"$ref": "#/components/responses/ReadUnavailable"}}}}}, "components": {"securitySchemes": {"BearerAuth": {"type": "http", "scheme": "bearer", "bearerFormat": "JWT", "description": "Cognito JWT access token"}, "ApiKeyAuth": {"type": "apiKey", "in": "header", "name": "X-API-Key", "description": "API key for programmatic access, for reading your tenant's data and\ndriving the review/reply workflow from your own scripts.\n\n**Every operation below states which credentials it accepts.** An\noperation that lists only `BearerAuth` needs a signed-in session; an API\nkey will be refused there. Three separate rules decide that, and it is\nworth knowing which one you have hit if a call fails:\n\n- **Scope.** A key is limited to the data domains named by its scopes:\n  `apps`, `reviews`, `alerts`, `integrations`, `workflows`, `analytics`\n  (read only) and `webhooks`. A `:write` scope also grants its domain's\n  `:read`, so you never need both. An **authenticated** endpoint outside\n  those domains (account, billing, credentials, team, security, most of\n  support) answers `403 FORBIDDEN` with \"This endpoint is not available\n  to API keys\". Endpoints that require no sign-in at all, such as\n  `GET /api/v1/support/knowledge-base`, accept a key exactly as they\n  accept an anonymous request. Note there is no `analytics:write` scope,\n  so report generation and insight actions are session-only even though\n  they sit under a domain keys can read. The one analysis trigger a key\n  can use is `POST /api/v1/apps/analyze` with `apps:write` (see below);\n  the runs it queues are read under `/api/v1/apps/{appUuid}/analysis-runs`\n  with `apps:read`.\n- **Role.** A key always acts as a **member** of its tenant, so an\n  endpoint that requires an admin or owner (connecting an integration,\n  creating or changing an alert, editing an app, posting a reply,\n  changing a subscription) answers `403 FORBIDDEN` with \"Admin access\n  required\", whatever scopes the key holds. The exceptions are\n  `POST /api/v1/apps/analyze` and cancelling the run it queued\n  (`POST /api/v1/apps/{appUuid}/analysis-runs/{runId}/cancel`): keys are\n  issued only by admins and owners, so a key holding `apps:write`\n  passes those routes' admin gate.\n  A key also needs `apps:write` and `analytics:read` before starting a\n  rating or sentiment forecast on a cache miss. `alerts`, `integrations`\n  and `workflows` offer read scopes only: their changes require an admin\n  or owner to sign in, and a key that tries one is answered\n  `403 FORBIDDEN` with \"Changing workflows is not available to API\n  keys; sign in to do it\" (likewise for alerts and integrations, and\n  \"This action is not available to API keys; sign in to do it\" for an\n  analytics write). The refusal names no scope, because there is none\n  to grant. Older keys with `alerts:write`,\n  `integrations:write` or `workflows:write` keep the same read access,\n  but those scopes cannot be selected for new keys. `webhooks:manage`\n  likewise reaches the webhook list and its delivery logs, not creation,\n  and is shown each destination's scheme and host, not the full address\n  (`url_masked`).\n- **Session-only routes.** A few endpoints are served behind the Cognito\n  authorizer and reject a key at the edge with\n  `401 {\"message\":\"Unauthorized\"}` rather than the usual error envelope:\n  data **exports** (`/api/v1/exports`), **alert-rule management**\n  (`/api/v1/alert-rules`, distinct from `/api/v1/alerts`, which a key\n  can read), the **product-group collection and its mutations**\n  (reading one group and its analytics *does* accept an `analytics:read`\n  key), **API keys**, **credentials**, **team**, **billing**, **PAM**,\n  and the **push subscription mutations** (`GET /api/v1/push/vapid-key`\n  is public and accepts anything). These are also admin/owner-gated, so\n  a key would be refused even without that.\n\nAn operation lists this scheme only where a key is accepted.\n\n**A key acts for the person who created it**, or, once rotated, for\nwhoever rotated it (`POST /api/v1/api-keys/{keyId}/rotate` hands them\nthe new secret). It stops working (`401 UNAUTHORIZED`) as soon as that\nperson leaves the workspace or their account is deactivated, and while\nthey are no longer an admin or owner it keeps only its `:read` scopes (a\nkey left with none stops working). Leaving ends it for good: when the\nperson is removed, the keys they hold become `revoked`, and they stay\nrefused if the person is invited to the workspace again. Such a key\ncannot be re-activated (`400`): make a new key instead. Create and\nrotate keys that must outlive a person's role from an account that will\nkeep it. A key made with an expiry (`expires_in_days`) is refused\n(`401 UNAUTHORIZED`) from its `expires_at` on, and the key list still\nshows it as `active`.\n\n**Rate limits.** Each key has two budgets, in fixed windows, counted\nacross this API and the MCP server (signed-in sessions are not\ncounted):\n\n- **Requests:** 600 per minute, every authenticated request.\n- **AI generation:** 60 per 10 minutes, counted only when a request\n  starts new AI work: a reply is generated (`.../reply/generate`,\n  `.../generate-reply`, `POST /api/v1/reviews/predict-replies`), an\n  analysis is queued (`POST /api/v1/apps/analyze`), or a rating or\n  sentiment forecast is generated (`GET /api/v1/predictions/rating-forecast`,\n  `GET /api/v1/predictions/sentiment-trends`). Returning an existing\n  pending draft, a running or recently completed analysis, a cached or\n  in-progress forecast, or a replayed `Idempotency-Key` outcome is\n  free. A charge\n  is given back only when the request provably started no AI work\n  (an analysis that could not be recorded, so nothing was queued); a\n  model call that may have run is never refunded, even if it produced\n  no usable reply. Other analytics reads count only toward the\n  request budget.\n\nResponses to key requests carry `RateLimit-Limit`,\n`RateLimit-Remaining` and `RateLimit-Reset` (seconds) for the request\nbudget. A spent budget answers `429 RATE_LIMITED` before anything runs,\nwith `Retry-After`, the same `RateLimit-*` headers for that budget, and\n`error.retryable: true`, `error.retry_after_seconds` and\n`error.details.budget` (`requests` or `ai_generation`) in the body. The\nedge also limits each client IP to 4,000 requests per 5 minutes.\n"}}, "parameters": {"ProductGroupId": {"name": "groupId", "in": "path", "required": true, "description": "The product group's `group_uuid`.", "schema": {"type": "string", "format": "uuid"}}, "ReplyAppUuid": {"name": "appUuid", "in": "path", "required": true, "schema": {"type": "string"}, "description": "The app's `app_uuid`. An app of another workspace answers 404 `APP_NOT_FOUND`."}, "ReplyReviewId": {"name": "reviewId", "in": "path", "required": true, "schema": {"type": "string"}, "description": "The review's id in its store."}, "ReplyDraftId": {"name": "draftId", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid"}, "description": "The draft's `draft_id`. A draft of another workspace answers 404."}, "IntegrationId": {"name": "integrationId", "in": "path", "required": true, "schema": {"type": "string"}, "description": "The connection's `integration_id`. An id the workspace has no connection with, a disconnected connection, or a tool name such as `slack` answers 404."}, "PresentationIdempotencyKey": {"name": "Idempotency-Key", "in": "header", "required": false, "description": "Reuse this key to recover the same request. A different target or language conflicts.", "schema": {"type": "string", "minLength": 1, "maxLength": 128, "pattern": "^[A-Za-z0-9._:-]+$"}}, "AppUuidPath": {"name": "appUuid", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid"}, "description": "Application UUID."}, "ApiKeyId": {"name": "keyId", "in": "path", "required": true, "schema": {"type": "string"}, "description": "The key's `key_id`. An id the workspace has no API key with, such as the id of another workspace's key or of an agent connected by signing in, answers 404."}, "ReviewLimit": {"name": "limit", "in": "query", "schema": {"type": "integer", "minimum": 1, "maximum": 1000, "default": 50}, "description": "Page size. A non-integer, zero or negative value is rejected with 400; a value above the maximum is capped at it rather than rejected."}, "ListCursor": {"name": "cursor", "in": "query", "required": false, "schema": {"type": "string", "maxLength": 4096}, "description": "Opaque `next_cursor` from the previous page. Pass it back unmodified, with the same filters. It is bound to your workspace, this list and its filters, and expires after 24 hours; any other cursor is rejected with 400 `INVALID_CURSOR` rather than silently restarting at page 1."}, "ReviewCursor": {"name": "cursor", "in": "query", "schema": {"type": "string"}, "description": "Opaque cursor from a previous page's `cursor`. Pass it back unmodified. A cursor this endpoint did not issue is rejected with 400 `INVALID_CURSOR` rather than silently restarting at page 1. That includes a cursor issued before the list was ordered by review date: resuming it would repeat some reviews and skip others, so start again from the first page."}, "ReviewRating": {"name": "rating", "in": "query", "schema": {"type": "integer", "minimum": 1, "maximum": 5}, "description": "Exact star rating to filter by."}, "ReviewMinRating": {"name": "min_rating", "in": "query", "schema": {"type": "integer", "minimum": 1, "maximum": 5}, "description": "Lowest star rating to include."}, "ReviewMaxRating": {"name": "max_rating", "in": "query", "schema": {"type": "integer", "minimum": 1, "maximum": 5}, "description": "Highest star rating to include."}, "ReviewSearch": {"name": "search", "in": "query", "schema": {"type": "string"}, "description": "Case-insensitive substring matched against review content, title and author (including their translated forms). Applied server-side across the app's whole review corpus, bounded by `scanned_count`. The edge firewall refuses a request whose query string looks like an attack with a 403 that carries no JSON body, and ordinary review words can look like one (`select photos from gallery`, `../`, `<script>`). Send such a term as `search_hex` instead."}, "ReviewSearchHex": {"name": "search_hex", "in": "query", "schema": {"type": "string", "pattern": "^h([0-9a-f]{2})+$", "maxLength": 1500}, "description": "The search term of `search`, encoded so the edge firewall does not read it as an attack: the letter `h` followed by the term's UTF-8 bytes in lowercase hexadecimal (`crash` is `h6372617368`). The term is decoded and applied exactly as `search` is, and the response then carries `search_hex_applied: true`. Send `search` or `search_hex`, not both. A value that is not of this form, is longer than 1,500 characters or does not decode to UTF-8 text is rejected with 400 `INVALID_SEARCH`."}, "ReviewSentiment": {"name": "sentiment", "in": "query", "schema": {"type": "string", "enum": ["positive", "negative", "neutral", "mixed"]}, "description": "Filter by the review's `sentiment`: the stored label where there is one, otherwise the star rating in three buckets (4 to 5 positive, 3 neutral, 1 to 2 negative). Almost every review has no stored label, so `positive` selects 4 and 5 star reviews, `neutral` 3 star reviews and `negative` 1 and 2 star reviews; each returned review says which applied in `sentiment_source`. An unrecognised value is rejected with 400 `INVALID_SENTIMENT` rather than ignored."}, "ReviewLanguage": {"name": "language", "in": "query", "schema": {"type": "string"}, "description": "`english`, `non-english`, `unknown`, or an explicit language code such as `de`. Every review is in exactly one of the three states. `english` and `non-english` use the detected language code where one is stored. A review without one is `non-english` when it carries a translation or its letters are mostly in a non-Latin script (kana, Hangul, Cyrillic, Arabic, Devanagari and others). Latin-script text is then matched against short per-language word lists, and a language counts only when it clearly outnumbers the others. A review read from Google Play's public feed, which is fetched in English, also counts as English when it has an English word and nothing pointing to another language. Everything else is `unknown` (short, emoji-only or mixed-language text), which is never counted as English. A language code matches the stored code, or the code the script or the words imply (`ko` for Hangul, `ja` for kana, `es` for Spanish words). A code that names no language (`und`, `mixed`) selects `unknown`."}, "ReviewAssignee": {"name": "assignee", "in": "query", "schema": {"type": "string", "maxLength": 128, "pattern": "^[A-Za-z0-9._@:-]+$"}, "description": "`assigned`, `unassigned`, or a team member's user id. Applied across the whole corpus like the other filters (assignments are a tenant-side annotation, so the API resolves them before paging). `assigned` and a member id are answered from the assigned set itself rather than a sweep of the corpus, so the answer does not depend on how large the app is: the set is read whole, listed newest first like the rest of the list, `scanned_count` counts the assigned reviews examined for the page, and `search_truncated` is `false`. An assigned set of more than 5,000 reviews is swept like the other filters instead, with their coverage fields. `unassigned` sweeps the corpus like the other filters. A malformed value is rejected with 400 `INVALID_ASSIGNEE`; an unknown member id matches nothing."}, "ReviewTagFilter": {"name": "tag", "in": "query", "schema": {"type": "string", "format": "uuid"}, "description": "One of the workspace's tag ids (`GET /api/v1/review-tags`). Returns only reviews carrying that tag. Tags are a workspace-side annotation, so the API resolves the tagged reviews first and, for a tag on up to 250 reviews, reads those directly instead of scanning the corpus, listing them newest first like the rest of the list; `search_truncated` is then `false`. A tag on more than 250 reviews is applied during the sweep instead, with the sweep's coverage fields. A malformed id is rejected with 400 `INVALID_TAG`; an id that is not one of the workspace's tags matches nothing."}, "ReviewStartDate": {"name": "start_date", "in": "query", "schema": {"type": "string"}, "description": "Only reviews at or after this instant. Accepts `YYYY-MM-DD` or a full ISO 8601 timestamp. Unparseable values are rejected with 400 `INVALID_DATE`."}, "ReviewEndDate": {"name": "end_date", "in": "query", "schema": {"type": "string"}, "description": "Only reviews at or before this instant. A bare `YYYY-MM-DD` covers the whole of that day. A range whose start is after its end is rejected with 400 `INVALID_DATE_RANGE`."}, "AnalyticsWindowDays": {"name": "days", "in": "query", "schema": {"type": "integer", "minimum": 1, "maximum": 365, "default": 30}, "description": "Size of the trailing window, in days, the analysis is computed over. Omitting it does not return the whole corpus: the API applies the default, so a caller who does not send it receives a 30-day slice. Values outside 1-365 are rejected with 400."}, "AnalyticsWindowDays90": {"name": "days", "in": "query", "schema": {"type": "integer", "minimum": 1, "maximum": 365, "default": 90}, "description": "Size of the trailing window, in days, the analysis is computed over. This surface needs a longer lookback than the 30-day default used elsewhere, so omitting it yields a 90-day slice rather than the whole corpus. Values outside 1-365 are rejected with 400."}, "AnalyticsPeriod": {"name": "period", "in": "query", "schema": {"type": "string", "pattern": "^[0-9]+d?$"}, "description": "Alias for `days`, accepted because the dashboard sends its period selector verbatim (`30d`, `7d`). Digits are extracted and the rest ignored, so `30d` and `30` are the same request. `days` takes precedence when both are sent; prefer `days` in new integrations."}, "BenchmarkPeriod": {"name": "period", "in": "query", "schema": {"type": "string", "enum": ["7d", "30d", "90d", "180d", "365d"], "default": "30d"}, "description": "Comparison window. Unlike the `period` alias on the advanced-analytics surfaces this one is a closed set: a value outside it is rejected with 400 rather than coerced to a day count."}, "CompetitorAppUuid": {"name": "appUuid", "in": "path", "required": true, "description": "The app's `app_uuid`. Another workspace's app answers `404 APP_NOT_FOUND`, and an id that is not a UUID answers `404 NOT_FOUND`.", "schema": {"type": "string", "format": "uuid"}}, "MfaSetupToken": {"name": "X-MFA-Setup-Token", "in": "header", "required": false, "description": "The `mfa_setup_token` a sign-in answered for an account that must enroll MFA first. Send it instead of an access token. It authorizes only the enrollment.", "schema": {"type": "string"}}, "IdempotencyKey": {"name": "Idempotency-Key", "in": "header", "required": false, "schema": {"type": "string", "minLength": 1, "maxLength": 255, "pattern": "^[!-~]+$"}, "example": "5b0c8f1e-2f6a-4c3d-9e7b-8a1d2c3b4e5f", "description": "Makes a retry after a timeout or a network failure safe. Send a unique\nvalue per action (a new UUID is ideal; 1-255 visible ASCII characters)\nand repeat it, with the same request, when you retry because the\nresponse was lost.\n\n- **Scope.** A key belongs to one workspace, one credential (the API\n  key, or the signed-in user) and this operation. The same key sent by\n  another credential or to another operation is a different key.\n- **Replay.** The first request stores its outcome. A retry with the\n  same key and the same request (method, path, query and JSON body,\n  compared after parsing) returns the stored status and body with\n  `Idempotent-Replayed: true`, without running the action again.\n- **Reuse.** The same key with a different request answers `422\n  IDEMPOTENCY_KEY_REUSED`.\n- **In flight.** A retry while the first request is still running\n  answers `409 IDEMPOTENCY_IN_PROGRESS` with `retryable: true` and\n  `Retry-After`; retry with the same key after that delay.\n- **What is stored.** 2xx outcomes and validation failures (400, 422)\n  are stored and replayed. Refusals (401, 402, 403, 404, 409, 429) are\n  released: nothing ran, so the same key can be retried once the\n  cause is fixed.\n- **Unknown outcome.** A server error (5xx) is not stored, and it\n  spends the key, because it can follow a partial action (for example\n  an analysis queued before its confirmation failed). The same holds\n  when the first request ended without recording any outcome (it\n  crashed, or the outcome could not be written). A retry then answers\n  `409 IDEMPOTENCY_OUTCOME_UNKNOWN` with `retryable: false` until the\n  key expires; the key never runs the action again. Read the\n  resource's state and use a new key to try again.\n- **Expiry.** Keys are remembered for 24 hours after the request that\n  stored them. After that the same key starts a new request.\n- **Store unavailable.** If the key cannot be recorded the request is\n  refused with `503 IDEMPOTENCY_UNAVAILABLE` (`retryable: true`) and\n  nothing runs.\n\nEvery request is authenticated and authorized again before a stored\noutcome is returned. Operations that do not list this parameter ignore\nthe header. Server-side, agent and browser clients may send this header.\nBrowser requests must come from an allowed production origin and pass\nthe API's CORS preflight; authentication and authorization still apply.\n"}}, "headers": {"IdempotentReplayed": {"description": "`true` when this response is the stored outcome of an earlier request with the same `Idempotency-Key`; absent otherwise.", "schema": {"type": "string", "enum": ["true"]}}}, "responses": {"ProductGroupNotFound": {"description": "`NOT_FOUND`: no product group of this workspace has this id, or it was deleted.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "ProductGroupRefused": {"description": "`INVALID_REQUEST`: no body, or an empty one. `VALIDATION_ERROR`: the message names what is wrong: a name that is missing, empty or too long, a text member that is not text, fewer than 2 distinct apps or more than 5, or an app that is not one of the workspace's apps.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "ProductGroupAdminForbidden": {"description": "`FORBIDDEN`: only an admin or owner can create, change or delete a product group.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "ProductGroupKeyForbidden": {"description": "`FORBIDDEN`: an API key without the `analytics:read` scope.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "ProductGroupUnread": {"description": "`INTERNAL_ERROR`: the group could not be read. Retry.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "ReplyContextNotFound": {"description": "`APP_NOT_FOUND`: the workspace has no such app (another workspace's app answers the same). `REVIEW_CONTEXT_NOT_FOUND`: the app has no such review.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "ReplyDraftNotFound": {"description": "`NOT_FOUND`: the workspace has no such draft (another workspace's draft answers the same).", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "ReplyDraftAdminForbidden": {"description": "`FORBIDDEN`: the caller is not an owner or admin, or is an API key. Every API key acts as a member.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "ReplyDraftConflict": {"description": "Nothing changed. `DRAFT_STATUS_CONFLICT`: the draft is not in a status this action takes (its status is in the message), or it changed meanwhile. `REPLY_CONFLICT`: the edited text conflicts with a change made meanwhile.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "ReplyDraftEditRefused": {"description": "Nothing changed. `VALIDATION_ERROR`: `auto_post` is not a boolean, the edited text is not text, empty or too long, or a template was chosen without text. `REPLY_HAS_PLACEHOLDER`: the reply still has an unfilled `{placeholder}`. `INVALID_REPLY_TEMPLATE`: the template could not be resolved.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "ReplyDraftStorageUnavailable": {"description": "`SERVICE_UNAVAILABLE`: the draft could not be read or written, so nothing is answered about it; `retryable` is true, retry after `Retry-After`. The subscription check answers the same code when the workspace's subscription could not be verified.", "headers": {"Retry-After": {"description": "Seconds to wait before retrying.", "schema": {"type": "integer", "minimum": 1}}}, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "IntegrationAdminForbidden": {"description": "`FORBIDDEN`: the caller is not an owner or admin, or is an API key. Every API key acts as a member, and changing integrations needs `integrations:write`, a scope no API key can be given.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "IntegrationNotFound": {"description": "`INTEGRATION_NOT_FOUND`: the workspace has no such connection, it was disconnected, or the caller may not read it. `NOT_FOUND`: the id is a tool name, such as `slack`.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "IntegrationRateLimited": {"description": "`RATE_LIMIT_EXCEEDED`: the workspace made too many of these requests; try again later.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "IntegrationOAuthStarted": {"description": "Jira and Slack: send the person to `auth_url`. Nothing is connected until they come back and the callback operation finishes.", "content": {"application/json": {"schema": {"type": "object", "required": ["auth_url", "state"], "properties": {"auth_url": {"type": "string", "description": "The provider's sign-in page (Atlassian or Slack)."}, "state": {"type": "string", "description": "The signed state in `auth_url`. It names the workspace and the person, works once and expires after 10 minutes."}}}}}}, "IntegrationConnected": {"description": "Connected.", "content": {"application/json": {"schema": {"type": "object", "required": ["integration"], "properties": {"integration": {"$ref": "#/components/schemas/Integration"}}}}}}, "IntegrationConnectRefused": {"description": "Nothing was connected. `VALIDATION_ERROR`: no or an unknown `integration_type`, a required field is missing, or a field fails its check (for example an address that is not the tool's). `INTEGRATION_NOT_IMPLEMENTED`: the tool cannot be connected yet, or Microsoft Teams is unavailable for this workspace.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "IntegrationConnectFailed": {"description": "`SECRET_FAILED`: the credential could not be stored; nothing is connected. `CREATE_FAILED`: the connection could not be stored.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "IntegrationConnectUnavailable": {"description": "`CONFIGURATION_ERROR`: Tritonium cannot start the Jira or Slack sign-in now. `CREDENTIAL_CLEANUP_PENDING` or `CONNECTION_UNCONFIRMED`: a Microsoft Teams connection could not be saved or confirmed. With `CREDENTIAL_CLEANUP_PENDING` the error envelope also carries `integration`, the inactive connection (an `Integration`) to disconnect before connecting again. `SERVICE_UNAVAILABLE`: the workspace's subscription could not be verified.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "IntegrationUpdated": {"description": "The connection after the change.", "content": {"application/json": {"schema": {"type": "object", "required": ["integration"], "properties": {"integration": {"$ref": "#/components/schemas/Integration"}}}}}}, "IntegrationUpdateRefused": {"description": "Nothing was changed. `VALIDATION_ERROR`: nothing to change, an invalid status, a Slack channel that is not an available public channel, an address or Freshdesk or Salesforce address that fails its check, a value that cannot be stored, or, on Microsoft Teams, a credential or signed address outside the Workflows URL. `NO_CREDENTIALS`: the Slack connection has no credential of this workspace; reconnect Slack. `INTEGRATION_NOT_IMPLEMENTED`: a Microsoft Teams connection cannot be activated while Teams is unavailable for this workspace.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "IntegrationUpdateFailed": {"description": "`UPDATE_FAILED`: the change could not be stored. `INTERNAL_ERROR`: the connection could not be read.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "IntegrationSlackCheckFailed": {"description": "Nothing was changed. Choosing a Slack channel asks Slack about it: `CREDENTIALS_UNAVAILABLE`, the Slack credential could not be read, or `SLACK_API_ERROR`, Slack could not be reached or did not confirm the channel.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "IntegrationTeamsUnavailable": {"description": "`TEAMS_CREDENTIALS_UNAVAILABLE`: the Microsoft Teams credential could not be read; try again. `SERVICE_UNAVAILABLE`: the workspace's subscription could not be verified.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "IntegrationOAuthRefused": {"description": "Nothing was connected. `OAUTH_ERROR`: the person did not allow the connection (`error` in the query). `VALIDATION_ERROR`: no `code` or `state`. `INVALID_STATE`: the state is unknown, used, expired, or for the other provider. `TOKEN_EXCHANGE_FAILED`: the provider refused the code or answered no token.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "IntegrationOAuthForbidden": {"description": "Nothing was connected. `FORBIDDEN`: the caller is not an owner or admin, or is an API key. `STATE_MISMATCH`: the connection was started from another workspace; start it again while signed in to this one.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "IntegrationOAuthUnreachable": {"description": "`TOKEN_EXCHANGE_FAILED`: the provider could not be reached. Nothing was connected.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "IntegrationOAuthUnavailable": {"description": "`CONFIGURATION_ERROR`: Tritonium's sign-in with the provider is not configured. `SERVICE_UNAVAILABLE`: the workspace's subscription could not be verified.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "IncidentCreated": {"description": "The tool accepted it.", "content": {"application/json": {"schema": {"type": "object", "required": ["success", "message", "data"], "properties": {"success": {"type": "boolean", "const": true}, "message": {"type": "string"}, "data": {"type": "object", "additionalProperties": true, "description": "The tool's own answer, as it sent it."}}}}}}, "LinearNotConnected": {"description": "`NO_INTEGRATION`: Linear is not connected.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "LinearCredentialsUnreadable": {"description": "`CREDENTIALS_ERROR`: the Linear API key could not be read.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "LinearUnreachable": {"description": "`LINEAR_API_ERROR`: Linear could not be reached or refused the request.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "BadRequest": {"description": "Bad request - invalid input or missing parameters.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "ValidationError": {"description": "Request validation failed.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "Unauthorized": {"description": "Authentication required or token invalid.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "Forbidden": {"description": "Request not permitted for this user.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "NotFound": {"description": "Resource not found.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "ApiKeyManagementForbidden": {"description": "`FORBIDDEN`: the caller is not an owner or admin. An API key is refused as well, whatever its scopes: keys are managed by a signed-in person, and a request made with a key is refused before it reaches this check (401, see `ApiKeyAuth`).", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "ApiKeyNotFound": {"description": "`NOT_FOUND`: the workspace has no API key with this id. Nothing is changed.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "SubscriptionRequired": {"description": "The workspace's subscription does not allow this request. `SUBSCRIPTION_CANCELED`, `SUBSCRIPTION_UNPAID` or `TRIAL_EXPIRED`: the subscription or the trial has ended; resubscribe in Billing. `SUBSCRIPTION_REQUIRED`: the workspace has no subscription that allows access yet, for example one awaiting its first payment.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "SubscriptionUnverified": {"description": "`SERVICE_UNAVAILABLE`: the workspace's subscription could not be verified; retry shortly.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "ReadUnavailable": {"description": "`SERVICE_UNAVAILABLE`: the workspace's subscription could not be verified, or something this answer needs could not be read. It says nothing about whether the record exists. Retry shortly; the second also sends `retryable`, `retry_after_seconds` and `Retry-After`.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "SessionOnlyForbidden": {"description": "`FORBIDDEN`: an API key. This acts for the signed-in person, so only their session can call it.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "MfaSessionInvalid": {"description": "`AUTH_REQUIRED` (no bearer token), `INVALID_TOKEN` (not a valid access token, or one that names no user) or `USER_NOT_FOUND` (the token's user has no account).", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "MfaSetupSessionInvalid": {"description": "`AUTH_REQUIRED` (neither a bearer token nor `X-MFA-Setup-Token`), `INVALID_TOKEN` (the token is not valid, names no user, or the setup session it names is gone), `USER_NOT_FOUND` (a bearer token whose user has no account) or `TOKEN_EXPIRED` (the setup session from sign-in has expired, or has no expiry that can be read; sign in again).", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "AuthRateLimited": {"description": "`RATE_LIMITED`: too many attempts from this network or for this account. Nothing was done. Wait before sending the request again.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "MfaCodeRateLimited": {"description": "`RATE_LIMITED`: too many wrong codes in five minutes, or the limit could not be checked. Nothing was done. A correct code and a failure on our side do not count against the limit. Wait before sending the request again.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "AccountUnavailable": {"description": "`SERVICE_UNAVAILABLE`: an account or workspace record could not be read, or the identity provider failed. This says nothing about the account: it is not the answer for a record that does not exist, and the session stays valid. Nothing was changed. The same request can succeed in a moment.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "ProfileChangeRefused": {"description": "Nothing was changed. `INVALID_REQUEST` (an empty body, or no member this route changes), `INVALID_TYPE` (a member of the wrong type, or a `name` over 200 characters), `INVALID_COMPANY_NAME` (not 2 to 100 characters), `INVALID_SUPPORT_EMAIL`, `INVALID_PICTURE_URL` (an uploaded picture that is not this person's own), `INVALID_LOCALE` or `VALIDATION_ERROR` (a body that is not a JSON object).", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "ProfileChangeForbidden": {"description": "`FORBIDDEN`: a member changing `company_name` or `support_email`, which only an admin or owner may change, or an API key.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "ProfileUserNotFound": {"description": "`USER_NOT_FOUND`: the signed-in user has no user record.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "ProfileNotJson": {"description": "`UNSUPPORTED_MEDIA_TYPE`: the request was not sent as `application/json`.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "ProfileChangeFailed": {"description": "`UPDATE_FAILED`: the change could not be saved. When the company name was part of it and could not be saved, nothing was changed.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "CompetitorAppNotFound": {"description": "`APP_NOT_FOUND`: the workspace has no such app (another workspace's app answers the same). `NOT_FOUND`: the app id is not a UUID.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "CompetitorKeyForbidden": {"description": "`FORBIDDEN`: an API key without the `analytics:read` scope.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "CompetitorAdminForbidden": {"description": "`FORBIDDEN`: only an admin or owner can add, remove or sync an app's competitors, and never with an API key: these are analytics changes, which no key scope allows.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "CompetitorUnread": {"description": "`INTERNAL_ERROR`: the app or its competitors could not be read, or a change could not be saved. Retry.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "CompetitorAddRefused": {"description": "`VALIDATION_ERROR`, and the message says what is wrong: the body is not a JSON object; `platform` or `external_app_id` is missing or contains `#`; the platform is not an App Store or Google Play one; `display_name` is longer than 200 characters; the store answered that it has no app with this id in the storefront; or `metadata` holds a value that cannot be stored.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "BenchmarkPeriodRefused": {"description": "`VALIDATION_ERROR`: `period` is not one of `7d`, `30d`, `90d`, `180d` or `365d`. `error.details` names the parameter (`parameter`) and the value sent (`value`).", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "Conflict": {"description": "Resource already exists or is in conflict.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "ReleaseLimitRefused": {"description": "`VALIDATION_ERROR`: `limit` is not a whole number.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "ReleaseKeyForbidden": {"description": "`FORBIDDEN`: an API key without the `analytics:read` scope.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "ReleaseAdminForbidden": {"description": "`FORBIDDEN`: only an admin or owner can start a release sync, and never with an API key: it is an analytics change, which no key scope allows.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "ReleaseAppNotFound": {"description": "`APP_NOT_FOUND`: the workspace has no such app (another workspace's app answers the same). `NOT_FOUND`: the app id is not a UUID.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "ReleaseNotFound": {"description": "`APP_NOT_FOUND`: the workspace has no such app (another workspace's app answers the same). `NOT_FOUND`: the app has no release with this id, or an id in the path is not a UUID.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "ReleaseUnread": {"description": "`INTERNAL_ERROR`: the releases could not be read. Retry.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "ActivityKeyForbidden": {"description": "`FORBIDDEN`: an API key without the `analytics:read` scope.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "EmailAnalyticsForbidden": {"description": "`FORBIDDEN`: the caller is not a Tritonium platform operator. Members, workspace admins and owners, and API keys are all refused.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "EmailAnalyticsRefused": {"description": "`VALIDATION_ERROR`: `days` or `limit` is not a whole number in its range. `error.details` names the parameter (`parameter`) and the value sent (`value`).", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "EmailAnalyticsUnread": {"description": "`INTERNAL_ERROR`: the email events could not be read. Retry.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "SupportKeyForbidden": {"description": "`FORBIDDEN`: an API key. The support chat is for signed-in people only.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "SupportUnread": {"description": "`INTERNAL_ERROR`: the conversations could not be read. Retry.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "OAuthPersonUnauthorized": {"description": "`UNAUTHORIZED`: no signed-in session was sent, it has ended, or the caller used an API key or an agent's token. Only a signed-in person can approve or manage agent connections.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "OAuthPersonForbidden": {"description": "`FORBIDDEN`: the signed-in account is not an active member of a Tritonium workspace.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "OAuthUnavailable": {"description": "`SERVICE_UNAVAILABLE`: the sign-in could not be checked, or the connections could not be read or saved. Retry.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "OAuthUnexpected": {"description": "`INTERNAL_ERROR`: the request could not be completed. Retry.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "AutoTagRuleInvalid": {"description": "`VALIDATION_ERROR`: the rule cannot be stored. When a condition is at fault, `details` says which: `field` is `conditions`, `index` is the condition's position in the request (from 0), `condition_field` is the review field it compares, `reason` is one of `VALUE_REQUIRED`, `VALUE_TOO_LONG`, `VALUE_NOT_FINITE`, `VALUE_OUT_OF_RANGE`, `RATING_NOT_A_NUMBER`, `RATING_OUT_OF_RANGE`, `INVALID_SENTIMENT`, `INVALID_PLATFORM`, `INVALID_FIELD`, `INVALID_OPERATOR`, `INVALID_CONDITION`, `CONDITIONS_REQUIRED` or `TOO_MANY_CONDITIONS`, and `allowed` lists the accepted values for `INVALID_SENTIMENT` and `INVALID_PLATFORM`. Other faults carry no `details`: `MISSING_BODY` (no body or an empty one), `MISSING_NAME` (no name, or only spaces), or `VALIDATION_ERROR` for a name over 200 characters, a `match_all` or `enabled` that is not true or false, a `tag_id` that is not text, an unknown `tag_id`, or a body that is not a JSON object.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}, "example": {"error": {"code": "VALIDATION_ERROR", "message": "A sentiment condition's value must be one of: mixed, negative, neutral, positive", "details": {"field": "conditions", "index": 0, "condition_field": "sentiment", "reason": "INVALID_SENTIMENT", "allowed": ["mixed", "negative", "neutral", "positive"]}}}}}}, "ReviewTagRefused": {"description": "The tag cannot be stored: `MISSING_BODY` (no body or an empty one), `MISSING_NAME` (a create without a name, or with only spaces), or `VALIDATION_ERROR` for a name over 100 characters, a description over 500, or a body that is not a JSON object. A member of the wrong type is read as absent.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "ReviewTagKeyForbidden": {"description": "`FORBIDDEN`: an API key without the `reviews:read` scope.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "ReviewTagAdminForbidden": {"description": "`FORBIDDEN`: a member, or an API key. Only an admin or owner can create, change or delete tags and auto-tagging rules; an API key acts as a member, so no key can, whatever its scopes.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "ReviewTagNotFound": {"description": "`NOT_FOUND`: no tag of this workspace has this id, or the id is not a UUID.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "AutoTagRuleNotFound": {"description": "`NOT_FOUND`: no auto-tagging rule of this workspace has this id, or the id is not a UUID.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "ReviewTagStoreFailed": {"description": "`INTERNAL_ERROR`: the tags or rules could not be read or saved. Retry.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "SlaKeyForbidden": {"description": "`FORBIDDEN`: an API key without the `analytics:read` scope.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "DashboardRefused": {"description": "`VALIDATION_ERROR`, and the message names what is wrong: a missing, empty or over 100 character `name` (on a create, or when an update sends one), a `description` that is not text or is over 500 characters, an `is_default` that is not true or false, `widgets` that is not a list, a widget that is not an object or lacks a `widget_id` or a `position` with numeric `x`, `y`, `w` and `h`, a number that cannot be stored, or a body that is not a JSON object.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "DashboardKeyForbidden": {"description": "`FORBIDDEN`: an API key without the `analytics:read` scope.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "DashboardAdminForbidden": {"description": "`FORBIDDEN`: a member, or an API key. Only an admin or owner can create, change, duplicate or delete dashboards, and no API key can: these need `analytics:write`, which no key can hold.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "DashboardNotFound": {"description": "`NOT_FOUND`: no dashboard of this workspace has this id, or the id is not a UUID.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "DashboardStoreFailed": {"description": "`INTERNAL_ERROR`: the dashboard could not be read or saved. Retry.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "TagNameTaken": {"description": "`TAG_NAME_TAKEN`: another tag of the workspace already has this name (compared without regard to letter case or surrounding spaces). Nothing was written. `details.field` is `name`.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}, "example": {"error": {"code": "TAG_NAME_TAKEN", "message": "A tag with this name already exists", "details": {"field": "name"}}}}}}, "ExportRefused": {"description": "`VALIDATION_ERROR`: the message names what is wrong: a field or a filter outside the accepted set, a data set or format that is not offered, a filter value of the wrong type, a review list filter on an export that does not read reviews, or a body that is not a JSON object. `INVALID_SENTIMENT`: a `filters.sentiments` value outside its four. No job is created.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "ExportAdminForbidden": {"description": "`FORBIDDEN`: only an admin or owner can create, list, read or delete exports.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "ExportNotFound": {"description": "`NOT_FOUND`: no export job of this workspace has this id.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "ReportKeyForbidden": {"description": "`FORBIDDEN`: an API key without the `analytics:read` scope.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "ReportAdminForbidden": {"description": "`FORBIDDEN`: a member, or an API key. Only an admin or owner can create, change or delete report templates and schedules or send a schedule now. No API key can: these need `analytics:write`, which no key can hold.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "ReportTemplateNotFound": {"description": "`NOT_FOUND`: no report template of this workspace has this id, or the id is not a UUID.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "ScheduledReportNotFound": {"description": "`NOT_FOUND`: no scheduled report of this workspace has this id, or the id is not a UUID. A change or a send also answers it when the template the schedule names does not exist (it was deleted, or the change names an unknown one).", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "ReportChanged": {"description": "`CONFLICT`: it changed, or was deleted, after it was read for this request. Read it again, then retry.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "ReportStoreFailed": {"description": "`INTERNAL_ERROR`: it could not be read or saved. Retry.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "ReportTemplateRefused": {"description": "`VALIDATION_ERROR`: the message names what is wrong: a name that is missing, empty or longer than 200 characters, a `template_type` or `type` that is not one of the five (or the two disagree), a section or output format that is not offered, an empty `output_formats`, branding with another member, a value that is not text or is too long, a color that is not six-digit hex, a logo address that is refused, or a body that is not a JSON object.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "ScheduledReportRefused": {"description": "`VALIDATION_ERROR`: the message names what is wrong: a field outside the accepted set, no `template_id`, a name, frequency, day, hour, recipient list, `enabled` or filter outside its bounds, an app that is not one of the workspace's current apps, or a body that is not a JSON object.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "ReportQueueUnavailable": {"description": "`SERVICE_UNAVAILABLE`: the report could not be queued, or the workspace's subscription could not be verified. Retry shortly; a retry with the same `Idempotency-Key` recovers a job that was accepted.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "AppCapacityRefused": {"description": "APP_QUOTA_EXCEEDED: the workspace has reached its established app limit. Optional current and limit values describe usage.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "AppChangeUnavailable": {"description": "The app change is unavailable or its result could not be confirmed. For APP_CHANGE_UNCONFIRMED, refresh the app list before trying again. `SERVICE_UNAVAILABLE`, with `retryable`, `retry_after_seconds` and `Retry-After`: a record the change depends on, or the workspace's usage, could not be read, so nothing was changed. `QUOTA_CHECK_FAILED`: the app capacity change could not be made, so nothing was changed.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "AppNotFound": {"description": "`APP_NOT_FOUND`: the workspace has no app with this id (an app of another workspace, a deleted app and a product group answer the same). `NOT_FOUND`: the id is not a UUID.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "AppsKeyForbidden": {"description": "`FORBIDDEN`: an API key without the `apps:read` scope.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "AnalyticsKeyForbidden": {"description": "`FORBIDDEN`: an API key without the `analytics:read` scope.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "AnalyticsAppUnverified": {"description": "`SERVICE_UNAVAILABLE`: the workspace's subscription, or whether the app is the workspace's, could not be checked. Retry shortly; the second also sends `retryable`, `retry_after_seconds` and `Retry-After`.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "SyncAdminForbidden": {"description": "`FORBIDDEN`: the caller is not an owner or admin, or is an API key. Every API key acts as a member.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "AppSyncRefused": {"description": "Nothing was started. `VALIDATION_ERROR`: `force` is not a boolean (`true`, `false`, `0`, `1`, or the text `true`, `false`, `1`, `0`, `yes` or `no`), or the body is not a JSON object.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "SyncScheduleAppNotFound": {"description": "`APP_NOT_FOUND`: the workspace has no app with this id (an app of another workspace answers the same). A deleted app's schedule is still answered. `NOT_FOUND`: the id is not a UUID.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "SyncScheduleFailed": {"description": "`INTERNAL_ERROR`: the app or its schedule could not be read or saved. Retry.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "InternalError": {"description": "Unexpected server error.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "BadGateway": {"description": "An upstream service failed to accept the request.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "ServiceUnavailable": {"description": "A required upstream service is temporarily unavailable.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, "RateLimited": {"description": "Too many requests. Rate limit exceeded.", "content": {"application/json": {"schema": {"type": "object", "properties": {"error": {"type": "object", "properties": {"code": {"type": "string", "example": "RATE_LIMITED"}, "message": {"type": "string", "example": "Too many requests. Please try again later."}}}}}}}}, "CredentialRateLimited": {"description": "`RATE_LIMITED`: this API key spent its request budget or its AI generation budget (see `ApiKeyAuth`). Nothing ran. Retry after `Retry-After` seconds.", "headers": {"Retry-After": {"description": "Seconds until the spent budget resets.", "schema": {"type": "integer", "minimum": 1}}, "RateLimit-Limit": {"description": "Requests allowed in the spent budget's window.", "schema": {"type": "integer"}}, "RateLimit-Remaining": {"description": "Requests left in that window (0).", "schema": {"type": "integer"}}, "RateLimit-Reset": {"description": "Seconds until that window resets.", "schema": {"type": "integer"}}}, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}, "example": {"error": {"code": "RATE_LIMITED", "message": "This API key has started 60 AI generations in the last 10 minutes, its limit. You can try again in about 4 minutes.", "retryable": true, "retry_after_seconds": 212, "details": {"retry_after": 212, "budget": "ai_generation", "limit": 60, "window_seconds": 600}}}}}}, "IdempotencyInProgress": {"description": "`IDEMPOTENCY_IN_PROGRESS`: the first request with this `Idempotency-Key` is still running. Nothing new was started. Retry with the same key after `Retry-After` seconds to receive its outcome. `IDEMPOTENCY_OUTCOME_UNKNOWN` (`retryable: false`): the first request failed with a server error or ended without a recorded outcome, and may have partly completed; nothing new was started. Read the resource's state and use a new key to try again.", "headers": {"Retry-After": {"description": "Seconds to wait before retrying with the same key.", "schema": {"type": "integer", "minimum": 1}}}, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}, "example": {"error": {"code": "IDEMPOTENCY_IN_PROGRESS", "message": "The first request with this Idempotency-Key is still running. Retry with the same key to receive its result.", "retryable": true, "retry_after_seconds": 5}}}}}, "IdempotencyKeyReused": {"description": "`IDEMPOTENCY_KEY_REUSED`: this `Idempotency-Key` was already used for a different request (another path, query or body). Nothing ran. Use a new key for a new request.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}, "example": {"error": {"code": "IDEMPOTENCY_KEY_REUSED", "message": "This Idempotency-Key was already used for a different request. Use a new key for a new request.", "retryable": false, "details": {"field": "Idempotency-Key"}}}}}}}, "schemas": {"ActivityEvent": {"type": "object", "description": "One activity event. `app_uuid`, `user_id` and `metadata` are sent only when the event was recorded with them.", "required": ["event_id", "event_type", "created_at"], "properties": {"event_id": {"type": "string"}, "event_type": {"type": "string", "description": "What happened, for example `app.connected`, `app.removed`, `reviews.synced`, `reply.sent` or `outcome.earned`."}, "created_at": {"type": "string", "format": "date-time"}, "app_uuid": {"type": "string"}, "user_id": {"type": "string"}, "metadata": {"type": "object", "additionalProperties": true, "description": "What the event recorded about itself; the members depend on `event_type`."}}}, "ActivityFeed": {"type": "object", "required": ["events", "next_cursor", "count"], "properties": {"events": {"type": "array", "items": {"$ref": "#/components/schemas/ActivityEvent"}}, "next_cursor": {"type": ["string", "null"], "description": "The `cursor` for the next page; null on the last page."}, "count": {"type": "integer", "minimum": 0, "description": "Number of events in this page."}}}, "ActivityStats": {"type": "object", "description": "Event counts over the last `period_days` days. A window that could not be read completely is counted as far as it was read.", "required": ["total_events", "by_type", "by_day", "period_days"], "properties": {"total_events": {"type": "integer", "minimum": 0}, "by_type": {"type": "object", "description": "Events per `event_type`.", "additionalProperties": {"type": "integer"}}, "by_day": {"type": "object", "description": "Events per day, keyed `YYYY-MM-DD` (UTC).", "additionalProperties": {"type": "integer"}}, "period_days": {"type": "integer", "minimum": 1, "maximum": 365}}}, "MilestoneProgress": {"type": "object", "required": ["counts", "milestones", "total_achieved", "total_milestones"], "properties": {"counts": {"type": "object", "description": "The counts the milestones are worked out from.", "required": ["reviews_synced", "replies_sent", "apps_connected"], "properties": {"reviews_synced": {"type": "integer", "minimum": 0}, "replies_sent": {"type": "integer", "minimum": 0}, "apps_connected": {"type": "integer", "minimum": 0}}}, "milestones": {"type": "array", "description": "Every tier of every count, lowest first within each count.", "items": {"type": "object", "required": ["milestone_id", "description", "threshold", "current", "achieved", "progress_percent"], "properties": {"milestone_id": {"type": "string", "description": "The count and the tier, for example `reviews_synced_1000`."}, "description": {"type": "string", "description": "An English label, for example \"Reviews synced: 1,000\"."}, "threshold": {"type": "integer", "minimum": 1}, "current": {"type": "integer", "minimum": 0}, "achieved": {"type": "boolean"}, "progress_percent": {"type": "number", "minimum": 0, "maximum": 100, "description": "`current` as a share of `threshold`, at most 100, to one decimal place."}}}}, "total_achieved": {"type": "integer", "minimum": 0}, "total_milestones": {"type": "integer", "minimum": 0}}}, "OutcomeSummary": {"type": "object", "required": ["milestones", "evaluated_at", "evaluation_stale"], "properties": {"milestones": {"type": "array", "items": {"$ref": "#/components/schemas/OutcomeMilestone"}}, "evaluated_at": {"type": ["string", "null"], "format": "date-time", "description": "When the milestones were last evaluated; null when no evaluation could be run or read."}, "evaluation_stale": {"type": "boolean", "description": "True when the served snapshot could not be refreshed."}, "evaluation_errors": {"type": "boolean", "description": "Sent only when this read evaluated the milestones: true when a change of state could not be stored, so the answer is not cached as fresh."}}}, "OutcomeHistoryEntry": {"type": "object", "required": ["from_state", "to_state", "reason", "actor", "rule_version", "created_at"], "properties": {"from_state": {"type": ["string", "null"]}, "to_state": {"type": "string"}, "reason": {"type": "string", "description": "An English reason for the change, as recorded."}, "actor": {"type": "string", "description": "`evaluator`, or the user id of the reviewer who confirmed or rejected."}, "rule_version": {"type": "integer"}, "created_at": {"type": "string", "format": "date-time"}}}, "OutcomeDetail": {"description": "One milestone with its audit history.", "allOf": [{"$ref": "#/components/schemas/OutcomeMilestone"}, {"type": "object", "required": ["history", "evaluation_stale"], "properties": {"history": {"type": "array", "items": {"$ref": "#/components/schemas/OutcomeHistoryEntry"}}, "history_unavailable": {"type": "boolean", "description": "Sent, as true, only when the audit trail could not be read; `history` is then empty. The achievement record itself is unaffected."}, "verification_details": {"type": "object", "description": "Sent only for a milestone a reviewer confirmed: the reviewer's user id, when, and their note.", "required": ["method", "verified_by", "verified_at", "note"], "properties": {"method": {"type": "string"}, "verified_by": {"type": "string"}, "verified_at": {"type": "string", "format": "date-time"}, "note": {"type": ["string", "null"]}}}, "evaluation_stale": {"type": "boolean"}}}]}, "OutcomeVerifyResult": {"description": "The milestone's new state.", "allOf": [{"$ref": "#/components/schemas/OutcomeMilestone"}, {"type": "object", "required": ["snapshot_refreshed"], "properties": {"snapshot_refreshed": {"type": "boolean", "description": "False when the post-verify checkpoint write lost its race: the transition itself is stored, but the cached outcomes summary still shows the pre-verify state until the next evaluation. An immediate summary reload can therefore be the stale snapshot the one-a-minute forced refresh does not replace; clients should keep the returned record and re-pull the summary once that window has passed."}}}]}, "OutcomeMilestone": {"type": "object", "required": ["milestone_id", "rule_version", "state", "title_key", "verification"], "properties": {"milestone_id": {"type": "string", "enum": ["first_recurring_issue", "first_issue_action", "first_cross_app_issue", "first_reply_followup", "first_verified_improvement"]}, "rule_version": {"type": "integer"}, "state": {"type": "string", "enum": ["not_eligible", "candidate", "awaiting_verification", "earned", "evidence_unavailable", "withdrawn"]}, "title_key": {"type": "string", "description": "i18n key under `activity.outcomes`, never composed text."}, "verification": {"type": "string", "enum": ["automatic", "human_confirm"]}, "subject": {"type": "object", "additionalProperties": true, "description": "What the milestone is about (insight, action, app or review identifiers)."}, "evidence": {"type": "object", "additionalProperties": true, "description": "Supporting references only: app ids, review ids, action ids, analysis versions, timestamps, coverage. Never review text."}, "progress": {"type": "object", "description": "How far the milestone is toward its target, in `unit`.", "required": ["current", "target", "unit"], "properties": {"current": {"type": "integer", "minimum": 0}, "target": {"type": "integer", "minimum": 1}, "unit": {"type": "string", "enum": ["distinct_reviews", "verified_actions", "listings", "verified_updates", "stages"]}, "actions_recorded": {"type": "integer", "minimum": 0, "description": "`first_issue_action` before it is earned: the insight actions recorded so far, none of them with a verified result."}}}, "limitations": {"type": "array", "items": {"type": "string"}, "description": "Machine-readable evidence-gap codes."}, "next_action": {"type": "object", "description": "The next useful step. `kind` names it; the identifiers say where, when the step is about one app, insight, review or milestone.", "required": ["kind"], "properties": {"kind": {"type": "string", "enum": ["verify", "open_insights", "connect_or_analyze", "act_on_insight", "track_action", "connect_another_app", "compare_apps", "review_insights", "reply_to_reviews", "open_review", "complete_engineering_action"]}, "milestone_id": {"type": "string"}, "app_uuid": {"type": "string"}, "insight_id": {"type": ["string", "null"]}, "review_id": {"type": ["string", "null"]}}}, "achieved_at": {"type": "string", "format": "date-time"}, "updated_at": {"type": "string", "format": "date-time"}, "withdrawal": {"type": "object", "description": "Who rejected the evidence, when, and their note.", "required": ["withdrawn_by", "withdrawn_at", "note", "previous_state"], "properties": {"withdrawn_by": {"type": "string", "description": "The reviewer's user id."}, "withdrawn_at": {"type": "string", "format": "date-time"}, "note": {"type": ["string", "null"]}, "previous_state": {"type": "string", "enum": ["awaiting_verification"]}}}}}, "PresentationGenerationRequest": {"type": "object", "additionalProperties": false, "properties": {"language": {"type": "string", "enum": ["en", "de", "fr", "es", "it", "pt", "nl", "ja", "ko", "zh"], "default": "en"}}}, "PresentationSection": {"type": "object", "additionalProperties": false, "required": ["title", "lines"], "properties": {"title": {"type": "string", "maxLength": 120}, "lines": {"type": "array", "minItems": 1, "maxItems": 6, "items": {"type": "string", "maxLength": 600}}}}, "PresentationJobState": {"type": "string", "enum": ["pending", "processing", "completed", "failed", "expired"]}, "PresentationJob": {"type": "object", "required": ["job_id", "status", "created_at"], "properties": {"job_id": {"type": "string", "format": "uuid"}, "app_uuid": {"type": "string", "format": "uuid"}, "group_uuid": {"type": "string", "format": "uuid"}, "status": {"$ref": "#/components/schemas/PresentationJobState"}, "created_at": {"type": "string", "format": "date-time"}, "completed_at": {"type": "string", "format": "date-time"}, "expires_at": {"type": "string", "format": "date-time"}, "error_message": {"type": "string"}, "download_url": {"type": "string", "format": "uri", "description": "PowerPoint download, available only on a completed, unexpired job."}, "downloads": {"type": "object", "required": ["pdf", "pptx"], "properties": {"pdf": {"type": "string", "format": "uri"}, "pptx": {"type": "string", "format": "uri"}}}, "sections": {"type": "array", "items": {"$ref": "#/components/schemas/PresentationSection"}}}}, "GenericSuccess": {"type": "object", "description": "Generic success envelope.", "additionalProperties": true}, "ContactAccepted": {"type": "object", "description": "The submission was saved and the Tritonium team is notified.", "required": ["success"], "properties": {"success": {"type": "boolean", "enum": [true]}}}, "ErrorResponse": {"type": "object", "required": ["error"], "properties": {"error": {"type": "object", "required": ["code", "message"], "properties": {"code": {"type": "string", "description": "Machine readable error code."}, "message": {"type": "string", "description": "Human readable error description."}, "retryable": {"type": "boolean", "description": "Present on errors built for automated callers. `true` means the same request may succeed if retried after `retry_after_seconds`; `false` means change the request first."}, "retry_after_seconds": {"type": "integer", "minimum": 0, "description": "Seconds to wait before retrying; mirrors the `Retry-After` header for clients that cannot read it."}, "current": {"type": "integer", "minimum": 0, "description": "Current usage when a capacity limit is reached."}, "limit": {"type": "integer", "minimum": 0, "description": "Established capacity limit when a limit is reached."}, "details": {"type": "object", "additionalProperties": true, "description": "Optional structured error details."}}}}}, "ResponseMetadata": {"type": "object", "description": "Standard response metadata.", "properties": {"generated_at": {"type": "string", "format": "date-time", "description": "When the response was generated."}, "cache_hit": {"type": "boolean", "description": "True when this payload was served from the analytics cache rather than recomputed. Returned by every cached analytics endpoint."}, "degraded": {"type": "boolean", "description": "Present and `true` only when a source query failed and the section it feeds fell back to an empty or zeroed default. The response is still a 200 so the surface degrades rather than erroring, but the numbers are not a measurement; treat them as unavailable, not as a real zero. A degraded payload is never written to the cache, so the next request retries."}, "request_id": {"type": "string", "description": "Unique request identifier."}, "duration_ms": {"type": "integer", "description": "Processing time in milliseconds."}}}, "ResolvedStoreApp": {"type": "object", "description": "One app resolved from a store URL. Mirrors an entry in `AppSearchResponse.results`, so a pasted link and a search hit are interchangeable to the caller.", "properties": {"app_id": {"type": "string", "description": "Platform-specific app ID: the numeric track id for the App Store, the package name for Google Play."}, "name": {"type": "string"}, "developer": {"type": "string"}, "icon_url": {"type": "string", "format": "uri"}, "bundle_id": {"type": "string", "description": "App Store only."}, "rating": {"type": ["number", "null"]}, "rating_count": {"type": ["integer", "null"]}, "price": {"type": "number", "description": "App Store only."}, "category": {"type": "string"}, "platform": {"type": "string", "enum": ["app_store", "google_play"]}, "store_url": {"type": "string", "format": "uri"}}}, "AppSearchResponse": {"type": "object", "properties": {"results": {"type": "array", "items": {"type": "object", "properties": {"app_id": {"type": "string", "description": "Platform-specific app ID."}, "name": {"type": "string"}, "developer": {"type": "string"}, "icon_url": {"type": "string", "format": "uri"}, "rating": {"type": ["number", "null"]}, "rating_count": {"type": ["integer", "null"], "description": "Total ratings, or null when aggregate data is unavailable."}, "review_count": {"type": "integer"}, "platform": {"type": "string", "enum": ["app_store", "google_play"]}}}}, "query": {"type": "string"}, "platform": {"type": "string"}, "error": {"type": "string", "description": "Error message if search failed."}}}, "ChurnRiskResponse": {"type": "object", "properties": {"risk_score": {"type": "integer", "minimum": 0, "maximum": 100, "description": "Churn risk score (0-100, higher = more risk)."}, "risk_level": {"type": "string", "enum": ["minimal", "low", "medium", "high", "critical"], "description": "The score's band: `minimal` below 15, `low` from 15, `medium` from 30, `high` from 50, `critical` from 70."}, "confidence": {"type": "object", "description": "How many of the score's signals were counted for this app. The score reads up to `max_data_points` (5) signals: the average rating, the sentiment split, the review count, the rating trend and the age of the last sync. A signal with no value is left out, not counted against the app. So are an average rating of 0 and a review count of 0: the route does not tell those from a value that was never written. A rating trend of 0 is a stable rating and counts; a trend that could not be measured is left out.", "properties": {"score": {"type": "integer", "minimum": 0, "maximum": 100, "description": "The share of the signals that were counted, as a percentage."}, "level": {"type": "string", "enum": ["low", "medium", "high"], "description": "`high` from 70, `medium` from 40, otherwise `low`."}, "data_points_analyzed": {"type": "integer"}, "max_data_points": {"type": "integer"}}}, "factors": {"type": "array", "description": "Factors that raise the risk score.", "items": {"$ref": "#/components/schemas/ChurnRiskFactor"}}, "positive_factors": {"type": "array", "description": "Favourable signals found in the same inputs (no impact on the score).", "items": {"$ref": "#/components/schemas/ChurnRiskFactor"}}, "recommendations": {"type": "array", "items": {"type": "object", "properties": {"priority": {"type": "integer"}, "action": {"type": "string", "description": "English action label; render `code` for other locales."}, "code": {"type": "string", "description": "Stable identifier for the recommendation (address_complaints, improve_sentiment, investigate_decline, sync_reviews, maintain)."}, "description": {"type": "string"}}}}, "app_uuid": {"type": "string"}, "app_name": {"type": "string", "description": "The app's name as stored; empty when it has none."}, "calculated_at": {"type": "string", "format": "date-time"}, "methodology": {"type": "string", "description": "The version of the scoring rules (`heuristic_v3`). It is a rule-based score, not a model's. `heuristic_v3` measures the rating trend from whole days and no longer reads a reply rate."}}}, "ChurnRiskFactor": {"type": "object", "properties": {"factor": {"type": "string", "description": "English factor label; render `code` for other locales."}, "code": {"type": "string", "description": "Stable identifier for the factor (rating_critical, rating_low, rating_moderate, rating_excellent, sentiment_negative_critical, sentiment_negative_high, sentiment_negative_elevated, sentiment_positive_strong, volume_very_low, volume_low, volume_high, trend_declining, trend_slightly_declining, trend_improving, data_stale, data_aging)."}, "category": {"type": "string"}, "severity": {"type": "string"}, "impact": {"type": "integer", "description": "Impact score (positive = increases risk)."}, "value": {"type": "number", "description": "The number the description quotes: average rating, percentage of reviews, review count, trend in rating points, or days since sync. The trend is the newer window's average star rating minus the older one's: four weeks each when every sync read the store in full, and as many whole weeks each as the coverage holds, at least one, for an app read from a store's capped feed. Both end on the last day whose reviews are complete and settled, three days or more before the answer, and each must hold at least 30 ratings. There is no trend when the last sync is more than 14 days old, the point at which the score reports stale data."}, "threshold": {"type": "number"}, "description": {"type": "string", "description": "English explanation; render `code` with `value` for other locales."}}}, "ReviewVolumePredictionResponse": {"type": "object", "properties": {"predictions": {"type": "array", "items": {"type": "object", "properties": {"date": {"type": "string", "format": "date"}, "predicted_count": {"type": "integer"}, "confidence_low": {"type": "integer"}, "confidence_high": {"type": "integer"}}}}, "average_daily": {"type": "number", "description": "Average daily reviews over the last `observed_days` settled days (28 once the app has eight weeks of history; the newest is three days back, `coverage.last_day`). The projection continues this rate, weekdays and weekends separately, so over whole weeks `total_predicted` is about `average_daily` times the horizon (each day's bar is rounded to a whole review)."}, "previous_average_daily": {"type": "number", "description": "Average daily reviews over the equal-length window just before the one `average_daily` covers: what the trend compares the projection with. Absent when `insufficient_data` is true."}, "trend": {"type": "string", "enum": ["increasing", "stable", "decreasing"], "description": "Compare total_predicted / horizon_days with previous_average_daily: increasing above +15%, decreasing below -15%, otherwise stable."}, "app_uuid": {"type": "string"}, "horizon_days": {"type": "integer", "description": "Number of days projected (capped server-side)."}, "total_predicted": {"type": "integer", "description": "Sum of `predicted_count` across the horizon: the total of the bars as returned."}, "insufficient_data": {"type": "boolean", "description": "True when no forecast was made: there is too little recent review activity, or the app's daily counts are not whole for enough days to compare (`coverage.reason` says which). `predictions` is then empty, the numeric fields are zero and `trend` carries no meaning."}, "reason": {"type": "string", "description": "Why no forecast was produced, in English. Present when insufficient_data is true; `coverage.reason` is the code for it."}, "coverage": {"$ref": "#/components/schemas/VolumeForecastCoverage"}, "reviews_observed": {"type": "integer", "description": "Number of reviews the forecast was computed from: every review posted in the lookback window on a complete day (the rate window and the window the trend compares it with). Zero when the app's counts were not whole enough to read."}, "observed_days": {"type": "integer", "description": "Number of complete days the rate, the projection and the confidence band were averaged over: the most recent 28, or the newer half of a younger app's history. For an app read from a store's capped public feed, as many whole weeks (one to four) as fit twice into the days its latest sync vouches for. They are the days from `observed_first_day` to `observed_last_day`, which end at least three days before the forecast was made: not \"the last 28 days\"."}, "observed_first_day": {"type": "string", "format": "date", "description": "The first of the `observed_days` days `average_daily` is read from. Absent when `insufficient_data` is true."}, "observed_last_day": {"type": "string", "format": "date", "description": "The last of the `observed_days` days `average_daily` is read from: the newest day whose count is whole and settled, which is three days before the forecast was made, or earlier when the app's latest sync is older (the same day as `coverage.last_day`). A label for the average names this day, for example \"28 days to 28 September\". Absent when `insufficient_data` is true."}, "lookback_days": {"type": "integer", "description": "Size of the recent window the forecast is derived from: the rate window plus the equal-length window before it (56 days)."}, "truncated": {"type": "boolean", "description": "True only when the window read stopped at its runaway bound, so the observed history is partial."}, "calculated_at": {"type": "string", "format": "date-time"}}}, "ContactRequest": {"type": "object", "required": ["name", "email", "company", "message"], "properties": {"name": {"type": "string", "description": "Contact's full name.", "maxLength": 120, "example": "Jane Doe"}, "email": {"type": "string", "format": "email", "description": "Contact's email address.", "maxLength": 320, "example": "jane@company.com"}, "company": {"type": "string", "description": "Company or organization name.", "minLength": 2, "maxLength": 160, "example": "Acme Inc"}, "message": {"type": "string", "description": "The contact message or inquiry.", "minLength": 6, "maxLength": 4000}, "title": {"type": "string", "description": "Contact's job title.", "maxLength": 160}, "role": {"type": "string", "description": "Contact's role in the organization.", "maxLength": 160}, "company_size": {"type": "string", "description": "Size of the company.", "maxLength": 160}, "utm_source": {"type": "string", "description": "UTM source tracking parameter.", "maxLength": 160}, "utm_medium": {"type": "string", "description": "UTM medium tracking parameter.", "maxLength": 160}, "utm_campaign": {"type": "string", "description": "UTM campaign tracking parameter.", "maxLength": 160}}}, "SignupRequest": {"type": "object", "required": ["email", "company"], "properties": {"email": {"type": "string", "format": "email", "description": "Email address for waitlist signup.", "maxLength": 320, "example": "user@company.com"}, "company": {"type": "string", "description": "Company or organization name.", "minLength": 2, "maxLength": 160, "example": "Acme Inc"}, "app": {"type": "string", "description": "Name of the app they want to track.", "maxLength": 160}, "app_name": {"type": "string", "description": "Alias for app field.", "maxLength": 160}, "name": {"type": "string", "description": "Contact person's name.", "maxLength": 120}}}, "NewsletterRequest": {"type": "object", "required": ["email"], "properties": {"email": {"type": "string", "format": "email", "description": "Email address to subscribe.", "maxLength": 320, "example": "subscriber@email.com"}, "website": {"type": "string", "description": "Honeypot field rendered visually hidden by the marketing-site footer form. People never fill it; a non-empty value marks the request as automated and the subscription is silently dropped with the generic success response.", "maxLength": 320, "example": ""}, "turnstile_token": {"type": "string", "maxLength": 2048, "description": "Cloudflare Turnstile token minted in the browser for the `newsletter_subscribe` action. Required only while the API enforces Turnstile; otherwise it is ignored or recorded without effect."}}}, "RegionalPricingRequest": {"type": "object", "properties": {"country_code": {"type": "string", "description": "ISO 3166-1 alpha-2 country code.", "pattern": "^[A-Z]{2}$", "default": "US", "example": "BR"}, "app_count": {"type": "integer", "minimum": 1, "default": 1, "description": "Number of apps to price."}, "member_count": {"type": "integer", "minimum": 1, "default": 1, "description": "Number of team members."}, "billing_cycle": {"type": "string", "enum": ["monthly", "annual"], "default": "monthly", "description": "Billing frequency."}, "payment_method_id": {"type": "string", "description": "A Stripe payment method (card) to quote for checkout. Its issuing country picks the tier and `country_code` is ignored. Workspace owner only."}}}, "RegionalPricingResponse": {"type": "object", "properties": {"source": {"type": "string", "enum": ["locked", "card", "country"], "description": "Where the tier came from: the workspace's locked tier, the card's issuing country, or `country_code`."}, "country_code": {"type": ["string", "null"], "example": "BR"}, "tier": {"type": "string", "enum": ["full", "tier20", "tier40", "tier60"], "description": "Pricing tier based on purchasing power parity."}, "discount_rate": {"type": "number", "format": "float", "description": "Regional discount on the per-app price (0.0 to 0.6).", "example": 0.4}, "discount_percentage": {"type": "integer", "description": "Regional discount as a percentage (0 to 60).", "example": 40}, "currency": {"type": "string", "description": "The billing currency. Every tier is billed in USD.", "example": "usd"}, "billing_cycle": {"type": "string", "enum": ["monthly", "annual"]}, "cycle_label": {"type": "string", "enum": ["month", "year"]}, "per_app_price": {"type": "number", "format": "float", "description": "Price of one app per billing period on this tier.", "example": 11.99}, "per_member_price": {"type": "number", "format": "float", "description": "Price of one additional member per billing period (the same on every tier)."}, "app_count": {"type": "integer"}, "member_count": {"type": "integer"}, "additional_members": {"type": "integer", "description": "Members beyond the one included with the workspace."}, "member_subtotal": {"type": "number", "format": "float"}, "total_price": {"type": "number", "format": "float", "description": "Total charged per billing period on this tier."}, "original_price": {"type": "number", "format": "float", "description": "The same plan at the full price."}, "savings": {"type": "number", "format": "float", "description": "original_price minus total_price."}, "stripe_price_id": {"type": ["string", "null"], "description": "The Stripe price the per-app line is billed on."}, "annual_discount_rate": {"type": "number", "format": "float", "description": "Annual billing discount rate. Present for annual quotes only."}, "annual_discount_amount": {"type": "number", "format": "float", "description": "What annual billing saves against twelve monthly payments on this tier. Present for annual quotes only."}}}, "ApiKeyStatus": {"type": "string", "enum": ["active", "revoked"], "description": "`active` until the key is revoked: by an owner or admin, or because the person who held it was removed from the workspace. A key past its `expires_at` stays `active` and is refused on every request."}, "ApiKey": {"type": "object", "description": "An API key as the workspace's key list shows it. It never carries the key's secret or a hash of it: the secret is returned once, by the create or the rotation that made it (`ApiKeyWithSecret`). Every key has every member but `updated_at`.", "required": ["key_id", "key_prefix", "name", "scopes", "status", "created_at", "created_by", "expires_at", "last_used_at", "usage_count"], "properties": {"key_id": {"type": "string", "description": "Unique identifier for the API key."}, "key_prefix": {"type": "string", "description": "The first 16 characters of the secret, to tell keys apart. It is not enough to use the key. It changes when the key is rotated."}, "name": {"type": "string", "description": "Friendly name for the API key, at most 100 characters."}, "scopes": {"type": "array", "items": {"type": "string"}, "description": "The scopes the key was given. A key made before `alerts:write`, `integrations:write` and `workflows:write` were retired can still hold them; each grants only its domain's read. What a request may do is also capped by the role of the person the key acts for (see `ApiKeyAuth`)."}, "status": {"$ref": "#/components/schemas/ApiKeyStatus"}, "created_at": {"type": "string", "format": "date-time"}, "created_by": {"type": "string", "description": "The user id of the person who made the key. A rotation hands the key to whoever rotated it (see `ApiKeyAuth`) and does not change this."}, "expires_at": {"type": ["string", "null"], "format": "date-time", "description": "When the key stops working, or null for a key that does not expire."}, "last_used_at": {"type": ["string", "null"], "format": "date-time", "description": "When a request last used the key, or null if none has."}, "usage_count": {"type": "integer", "minimum": 0, "description": "How many requests have used the key."}, "updated_at": {"type": "string", "format": "date-time", "description": "Returned only by `PATCH /api/v1/api-keys/{keyId}`: when that change was stored."}}}, "ApiKeyWithSecret": {"type": "object", "description": "The key a create or a rotation answers, with its secret. This is the only time the secret is returned; the API keeps only a hash of it. `key` and `expires_at` are in every such answer, and `rotated_at` in a rotation's.", "required": ["key_id", "key_prefix", "name", "scopes", "status", "created_at"], "properties": {"key_id": {"type": "string", "description": "Unique identifier for the API key."}, "key_prefix": {"type": "string", "description": "The first 16 characters of `key`, as the key list shows them."}, "name": {"type": "string", "description": "Friendly name for the API key."}, "scopes": {"type": "array", "items": {"type": "string"}, "description": "The scopes the key holds."}, "status": {"$ref": "#/components/schemas/ApiKeyStatus"}, "created_at": {"type": "string", "format": "date-time", "description": "When the key was made. A rotation does not change it."}, "created_by": {"type": "string", "deprecated": true, "description": "Not returned. The key list (`ApiKey`) has it."}, "expires_at": {"type": ["string", "null"], "format": "date-time", "description": "When the key stops working, or null for a key that does not expire."}, "last_used_at": {"type": ["string", "null"], "format": "date-time", "deprecated": true, "description": "Not returned. The key list (`ApiKey`) has it."}, "usage_count": {"type": "integer", "deprecated": true, "description": "Not returned. The key list (`ApiKey`) has it."}, "key": {"type": "string", "description": "The secret, in full. Returned once, by the create or the rotation that made it; no other answer carries it, and it cannot be read again.", "example": "trtn_live_abc123def456ghi789jkl012mno345pqr678"}, "rotated_at": {"type": "string", "format": "date-time", "description": "Returned only by a rotation, when it happened."}}}, "CreateApiKeyRequest": {"type": "object", "required": ["name"], "description": "`name` is stored without its leading and trailing spaces, and the length limit applies to what is stored: a name of spaces alone is refused.", "properties": {"name": {"type": "string", "description": "Friendly name for the API key.", "minLength": 1, "maxLength": 100}, "scopes": {"type": "array", "items": {"type": "string"}, "description": "The scopes the key holds, from `GET /api/v1/api-keys/scopes`. Left out or empty, the key gets the `read_only` group. A scope a key cannot be given is dropped, and a list left with none is refused (400). The retired write scopes `alerts:write`, `integrations:write` and `workflows:write` are refused (400)."}, "expires_in_days": {"type": "integer", "minimum": 1, "maximum": 365, "description": "Days until the key stops working, as a whole number. Left out, the key does not expire."}}}, "AppReplyTemplate": {"type": "object", "properties": {"template_id": {"type": "string"}, "title": {"type": "string"}, "content": {"type": "string"}, "tone": {"type": "string"}, "category": {"type": "string"}, "tags": {"type": "array", "items": {"type": "string"}}, "variables": {"type": "array", "items": {"type": "string"}}, "is_default": {"type": "boolean", "description": "True for seeded default templates."}, "performance_metrics": {"type": "object", "properties": {"usage_count": {"type": "integer"}, "satisfaction_score": {"type": "number"}}}, "created_at": {"type": "string", "format": "date-time"}, "updated_at": {"type": "string", "format": "date-time"}}}, "AppReplyTemplateRequest": {"type": "object", "required": ["title", "content"], "properties": {"title": {"type": "string", "maxLength": 200}, "content": {"type": "string", "maxLength": 5970, "description": "The reply body. Bounded by the App Store's 5,970-character reply limit, which the publish path also enforces."}, "tone": {"type": "string", "maxLength": 100}, "category": {"type": "string", "maxLength": 100}, "tags": {"type": "array", "items": {"type": "string"}}, "variables": {"type": "array", "items": {"type": "string"}}}}, "ApiKeyScopes": {"type": "object", "required": ["scopes", "scope_groups"], "properties": {"scopes": {"type": "array", "items": {"type": "string"}, "description": "Every scope a new key can be given. The retired write scopes are not among them."}, "scope_groups": {"type": "object", "additionalProperties": {"type": "array", "items": {"type": "string"}}, "description": "Two named groups of scopes: `read_only`, the six `:read` scopes, which a new key gets when no scope is asked for; and `full_access`, every scope in `scopes`."}}}, "UpdateApiKeyRequest": {"type": "object", "description": "Only the fields sent change, and at least one of them must be sent. `name` is stored without its leading and trailing spaces; a name of spaces alone is refused.", "properties": {"name": {"type": "string", "description": "New friendly name for the API key.", "minLength": 1, "maxLength": 100}, "scopes": {"type": "array", "items": {"type": "string"}, "description": "The key's scopes from now on, in place of the old ones. A scope a key cannot be given is dropped, and a list left with none is refused (400). A retired write scope (`alerts:write`, `integrations:write`, `workflows:write`) is refused unless the key already holds it, so sending a key's scopes back unchanged keeps them."}, "status": {"type": "string", "enum": ["active", "revoked"], "description": "`revoked` stops the key at once; `active` starts it again. A key whose holder was removed from the workspace cannot be made `active` again (400)."}}}, "UserSummary": {"type": "object", "description": "The user object returned inside the `user` envelope by `GET /api/v1/me` and the profile update endpoints, which send every member. No answer carries a password, a token or a multi-factor secret.", "required": ["user_id", "email", "name", "tenant_id", "company_name", "support_email", "role", "plan"], "properties": {"user_id": {"type": "string"}, "email": {"type": "string", "format": "email"}, "name": {"type": ["string", "null"], "description": "The display name, or null when none was given."}, "tenant_id": {"type": "string"}, "company_name": {"type": ["string", "null"], "description": "The workspace's company name, or null when the workspace could not be found."}, "support_email": {"type": ["string", "null"], "description": "Workspace support contact for reply templates and AI suggestions; empty or null when not configured."}, "role": {"type": "string", "description": "`owner`, `admin` or `member`."}, "plan": {"type": ["string", "null"], "description": "`trial` while the workspace is trialing (including a trial cancelled before its end date), `per_app` while its subscription is active or past due, and null when it has no access."}, "created_at": {"type": ["string", "null"], "format": "date-time", "description": "When the account was created."}, "email_verified": {"type": "boolean", "description": "Whether the email address was confirmed. False when not recorded."}, "onboarding_tour_completed": {"type": "boolean", "description": "Whether the person finished the onboarding tour. False when not recorded."}, "avatar_preset_id": {"type": ["string", "null"]}, "profile_picture_url": {"type": ["string", "null"]}, "preferred_locale": {"type": ["string", "null"], "description": "Language this person chose in the product, when they have chosen one (en, es, fr, de, pt, ja, zh, ko, it, nl). Null when they have not. Digest links use it. A missing value leaves the language out of the link so the dashboard opens in the reader's own."}}}, "ApiKeyCallerContext": {"type": "object", "description": "Present on `GET /api/v1/me` only when the caller authenticated with an API key. Every member but `oauth_client_id` is always sent.", "required": ["key_id", "name", "scopes", "effective_role", "tenant_id", "created_by_user_id", "company_name", "plan", "credential_type"], "properties": {"key_id": {"type": "string"}, "name": {"type": ["string", "null"]}, "scopes": {"type": "array", "description": "The scopes granted to this key, sorted. A `:write` scope also grants its domain's `:read`.", "items": {"type": "string"}}, "effective_role": {"type": "string", "enum": ["member"], "description": "The role every API key acts with, whatever the role of the person who created it."}, "tenant_id": {"type": "string"}, "created_by_user_id": {"type": ["string", "null"], "description": "The person who created the key, or null when no creator is recorded."}, "company_name": {"type": ["string", "null"]}, "plan": {"type": ["string", "null"], "description": "The workspace's plan, as on the signed-in user: `trial`, `per_app` or null."}, "credential_type": {"type": "string", "enum": ["api_key", "oauth"], "description": "`oauth` when the caller is an MCP OAuth access token: then `key_id` is the grant, `name` the approved client, `created_by_user_id` the person who approved it, and `scopes` what they approved, capped by their current role. OAuth access tokens are only accepted by the MCP server (https://api.tritonium.com/mcp), never by this REST API."}, "oauth_client_id": {"type": ["string", "null"], "description": "The approved OAuth client's id, when `credential_type` is `oauth`."}}}, "OAuthConsentPreview": {"type": "object", "description": "What the consent screen shows before a person approves an AI agent's connection.", "required": ["client", "redirect", "resource", "requested_scopes", "grantable_scopes", "workspace", "user", "expires_at", "warnings"], "properties": {"client": {"type": "object", "required": ["client_id", "name", "type", "domain", "client_uri"], "properties": {"client_id": {"type": "string"}, "name": {"type": "string", "description": "For `dynamic_registration` clients this name is self-asserted and unverified."}, "type": {"type": "string", "enum": ["metadata_document", "dynamic_registration"]}, "domain": {"type": ["string", "null"], "description": "Host of a metadata-document client_id (fetched and verified); null otherwise."}, "client_uri": {"type": ["string", "null"]}}}, "redirect": {"type": "object", "required": ["uri", "host", "kind"], "properties": {"uri": {"type": "string"}, "host": {"type": "string"}, "kind": {"type": "string", "enum": ["loopback", "https", "app_scheme"]}}}, "resource": {"type": "string", "example": "https://api.tritonium.com/mcp"}, "requested_scopes": {"type": "array", "items": {"$ref": "#/components/schemas/OAuthScope"}}, "grantable_scopes": {"type": "array", "description": "Scopes this person's role may grant (members can grant read scopes only).", "items": {"$ref": "#/components/schemas/OAuthScope"}}, "workspace": {"type": "object", "required": ["tenant_id", "name"], "properties": {"tenant_id": {"type": "string"}, "name": {"type": ["string", "null"]}}}, "user": {"type": "object", "required": ["email", "name", "role"], "properties": {"email": {"type": ["string", "null"]}, "name": {"type": ["string", "null"]}, "role": {"type": "string"}}}, "expires_at": {"type": "string", "format": "date-time"}, "warnings": {"type": "array", "items": {"type": "string", "enum": ["loopback_redirect", "unverified_client", "app_scheme_redirect", "scopes_reduced"]}}}}, "OAuthScope": {"type": "string", "enum": ["apps:read", "apps:write", "reviews:read", "reviews:write", "analytics:read", "alerts:read", "webhooks:manage"]}, "OAuthConsentRequest": {"type": "object", "required": ["request"], "properties": {"request": {"type": "string", "description": "The opaque `request` value from the consent screen URL."}}}, "OAuthConsentDecisionRequest": {"type": "object", "required": ["request", "decision"], "properties": {"request": {"type": "string"}, "decision": {"type": "string", "enum": ["approve", "deny"]}, "scopes": {"type": "array", "description": "Required when approving; a subset of `grantable_scopes`.", "items": {"$ref": "#/components/schemas/OAuthScope"}}}}, "OAuthGrant": {"type": "object", "description": "An AI agent connected with OAuth sign-in.", "required": ["grant_id", "client_id", "client_name", "client_type", "scopes", "user_id", "created_at", "status"], "properties": {"grant_id": {"type": "string", "format": "uuid"}, "client_id": {"type": "string"}, "client_name": {"type": "string"}, "client_type": {"type": "string", "enum": ["metadata_document", "dynamic_registration"]}, "client_domain": {"type": ["string", "null"]}, "redirect_host": {"type": ["string", "null"]}, "scopes": {"type": "array", "items": {"$ref": "#/components/schemas/OAuthScope"}}, "user_id": {"type": "string", "description": "The person who approved the connection."}, "user_email": {"type": ["string", "null"]}, "created_at": {"type": "string", "format": "date-time"}, "last_used_at": {"type": ["string", "null"], "format": "date-time"}, "usage_count": {"type": "integer", "minimum": 0}, "expires_at": {"type": ["string", "null"], "format": "date-time", "description": "When the connection lapses if it is not used (its refresh token expires) before then."}, "status": {"type": "string", "enum": ["active", "expired"]}}}, "OAuthConsentDecision": {"type": "object", "required": ["redirect_to"], "properties": {"redirect_to": {"type": "string", "description": "Where to send the browser next: the client's redirect URI as shown on the consent screen, with `code`, `state` and `iss` added (approve), or with `error=access_denied`, `error_description`, `state` and `iss` (deny). The code is single-use, expires within minutes and works only with the client's proof key."}}}, "OAuthGrantList": {"type": "object", "required": ["grants", "can_manage_all"], "properties": {"grants": {"type": "array", "items": {"$ref": "#/components/schemas/OAuthGrant"}}, "can_manage_all": {"type": "boolean", "description": "True for an admin or owner, who sees and can disconnect every connection in the workspace."}}}, "OAuthGrantRevoked": {"type": "object", "required": ["revoked", "grant_id"], "properties": {"revoked": {"type": "boolean", "enum": [true]}, "grant_id": {"type": "string", "format": "uuid"}}}, "AuthTokenPair": {"type": "object", "description": "The tokens of a new session. `access_token` authorizes every other call; `refresh_token` renews it at `POST /api/v1/auth/refresh`.", "required": ["access_token", "refresh_token", "id_token", "token_type", "expires_in"], "properties": {"access_token": {"type": "string"}, "refresh_token": {"type": ["string", "null"], "description": "Null only when the identity provider issued none."}, "id_token": {"type": ["string", "null"]}, "token_type": {"type": ["string", "null"], "example": "Bearer"}, "expires_in": {"type": ["integer", "null"], "description": "Seconds until `access_token` expires.", "example": 3600}}}, "SignInUser": {"type": "object", "description": "The person a new session belongs to, as a sign-in answer describes them. `GET /api/v1/me` describes them in full.", "required": ["user_id", "email", "name", "tenant_id", "company_name", "role", "email_verified"], "properties": {"user_id": {"type": "string"}, "email": {"type": "string", "format": "email"}, "name": {"type": ["string", "null"]}, "tenant_id": {"type": "string"}, "company_name": {"type": ["string", "null"]}, "role": {"type": "string", "description": "`owner`, `admin` or `member`."}, "email_verified": {"type": "boolean", "description": "Always true; only a confirmed address can sign in."}, "mfa_enrolled_at": {"type": ["string", "null"], "format": "date-time", "description": "When the person enrolled an authenticator app, or null. Sent after an MFA challenge and after an email confirmation that signs in; not after an MFA enrollment."}}}, "SignInSession": {"type": "object", "description": "A new session, with the person it belongs to.", "required": ["user", "tokens"], "properties": {"user": {"$ref": "#/components/schemas/SignInUser"}, "tokens": {"$ref": "#/components/schemas/AuthTokenPair"}}}, "AuthNotice": {"type": "object", "description": "What a sign-in, password or MFA request did, in a sentence. No account details: a password reset or a new confirmation code answers the same for an address with no account.", "required": ["success", "message"], "properties": {"success": {"type": "boolean", "enum": [true]}, "message": {"type": "string"}}}, "PasswordResetDone": {"type": "object", "description": "The password was changed and every earlier session ended.", "required": ["message"], "properties": {"message": {"type": "string", "example": "Password reset successfully"}}}, "AuthRegisterRequest": {"type": "object", "description": "Register a new workspace owner.", "required": ["email", "password", "company_name", "terms_accepted", "privacy_accepted"], "not": {"anyOf": [{"required": ["invitation"]}, {"required": ["invitation_id"]}]}, "properties": {"email": {"type": "string", "format": "email"}, "password": {"type": "string", "format": "password", "minLength": 8}, "company_name": {"type": "string"}, "name": {"type": "string"}, "terms_accepted": {"type": "boolean", "description": "Explicit acceptance of the Tritonium Terms of Service."}, "privacy_accepted": {"type": "boolean", "description": "Explicit acceptance of the Tritonium Privacy Policy."}, "marketing_opt_in": {"type": "boolean", "default": false, "description": "Optional consent to receive Tritonium product tips and updates. Recorded with a timestamp and initializes the product-email preference. Omitting it disables optional product email. Account, security and billing notices are sent regardless of this choice."}, "locale": {"type": "string", "enum": ["en", "es", "fr", "de", "pt", "ja", "zh", "ko", "it", "nl"], "description": "The dashboard language the sign-up came from. The verification email links to the email verification page in this language. Any other value is ignored and the link opens the English page. It is not stored on the account."}}}, "AuthInvitedRegisterRequest": {"type": "object", "description": "Accept a team invitation without creating a workspace.", "required": ["email", "password", "invitation", "terms_accepted", "privacy_accepted"], "properties": {"email": {"type": "string", "format": "email"}, "password": {"type": "string", "format": "password"}, "invitation": {"type": "string", "description": "Pending team invitation ID."}, "name": {"type": "string"}, "terms_accepted": {"type": "boolean", "enum": [true]}, "privacy_accepted": {"type": "boolean", "enum": [true]}, "marketing_opt_in": {"type": "boolean", "default": false}, "locale": {"type": "string", "enum": ["en", "es", "fr", "de", "pt", "ja", "zh", "ko", "it", "nl"], "description": "The dashboard language the sign-up came from. The verification email links to the email verification page in this language. Any other value is ignored and the link opens the English page. It is not stored on the account."}}}, "AuthRegisterResponse": {"type": "object", "required": ["success", "message", "data"], "properties": {"success": {"type": "boolean"}, "message": {"type": "string"}, "reused_account": {"type": "boolean", "description": "True when an invited member reclaimed an existing login; sign in again instead of verifying email."}, "data": {"type": "object", "required": ["user", "verification_delivery"], "properties": {"user": {"type": "object", "required": ["user_id", "email", "tenant_id", "email_verified"], "properties": {"user_id": {"type": "string"}, "email": {"type": "string", "format": "email"}, "tenant_id": {"type": "string"}, "email_verified": {"type": "boolean"}, "company_name": {"type": "string", "description": "Present for a new workspace owner."}, "role": {"type": "string", "description": "Present for a team invitation."}}}, "verification_delivery": {"type": ["object", "null"], "description": "Cognito code delivery details, or null when an existing login is reused.", "additionalProperties": true}}}}}, "AuthLoginRequest": {"type": "object", "required": ["email", "password"], "properties": {"email": {"type": "string", "format": "email"}, "password": {"type": "string", "format": "password"}}}, "AuthLoginResponse": {"oneOf": [{"$ref": "#/components/schemas/AuthLoginSuccessResponse"}, {"$ref": "#/components/schemas/AuthLoginChallengeResponse"}]}, "AuthLoginSuccessResponse": {"type": "object", "required": ["success", "message", "data"], "properties": {"success": {"type": "boolean", "enum": [true]}, "message": {"type": "string"}, "data": {"type": "object", "required": ["user", "tokens"], "properties": {"user": {"type": "object", "required": ["user_id", "email", "tenant_id", "role", "email_verified"], "properties": {"user_id": {"type": "string"}, "email": {"type": "string", "format": "email"}, "name": {"type": ["string", "null"]}, "tenant_id": {"type": "string"}, "company_name": {"type": ["string", "null"]}, "role": {"type": "string"}, "email_verified": {"type": "boolean"}, "mfa_enrolled_at": {"type": ["string", "null"], "format": "date-time"}}}, "tokens": {"type": "object", "required": ["access_token"], "properties": {"access_token": {"type": "string"}, "id_token": {"type": ["string", "null"]}, "refresh_token": {"type": ["string", "null"]}, "token_type": {"type": ["string", "null"]}, "expires_in": {"type": ["integer", "null"]}}}}}}}, "AuthLoginChallengeResponse": {"type": "object", "required": ["success", "message", "data"], "properties": {"success": {"type": "boolean", "enum": [false]}, "message": {"type": "string"}, "data": {"type": "object", "required": ["challenge", "email"], "properties": {"challenge": {"type": "string", "enum": ["MFA_SETUP", "SMS_MFA", "SOFTWARE_TOKEN_MFA", "NEW_PASSWORD_REQUIRED"]}, "session": {"type": ["string", "null"]}, "challenge_parameters": {"type": "object", "additionalProperties": true}, "email": {"type": "string", "format": "email"}}}}}, "AuthLoginForbiddenResponse": {"oneOf": [{"$ref": "#/components/schemas/ErrorResponse"}, {"$ref": "#/components/schemas/AuthLoginMfaSetupResponse"}]}, "AuthLoginMfaSetupResponse": {"type": "object", "required": ["success", "message", "data"], "properties": {"success": {"type": "boolean", "enum": [false]}, "message": {"type": "string"}, "data": {"type": "object", "required": ["error", "mfa_setup_required", "user", "mfa_setup_token", "mfa_setup"], "properties": {"error": {"type": "object", "required": ["code", "message"], "properties": {"code": {"type": "string", "enum": ["MFA_REQUIRED"]}, "message": {"type": "string"}}}, "mfa_setup_required": {"type": "boolean", "enum": [true]}, "user": {"type": "object", "required": ["user_id", "email"], "properties": {"user_id": {"type": "string"}, "email": {"type": "string", "format": "email"}}}, "mfa_setup_token": {"type": "string", "description": "Limited token for MFA enrollment endpoints only; it cannot authorize general API access."}, "mfa_setup": {"type": "object", "required": ["secret_code", "qr_code", "issuer"], "properties": {"secret_code": {"type": "string"}, "qr_code": {"type": "string", "format": "uri"}, "issuer": {"type": "string"}}}}}}}, "EmailRequest": {"type": "object", "required": ["email"], "properties": {"email": {"type": "string", "format": "email"}, "locale": {"type": "string", "enum": ["en", "es", "fr", "de", "pt", "ja", "zh", "ko", "it", "nl"], "description": "The dashboard language the request came from. The verification email links to the email verification page in this language. Any other value is ignored and the link opens the English page. It is not stored on the account."}}}, "ForgotPasswordRequest": {"type": "object", "required": ["email"], "properties": {"email": {"type": "string", "format": "email"}, "locale": {"type": "string", "enum": ["en", "es", "fr", "de", "pt", "ja", "zh", "ko", "it", "nl"], "description": "The dashboard language the request came from. The reset email links to the reset form in this language. Any other value is ignored and the link opens the English form."}}}, "ResetPasswordRequest": {"type": "object", "required": ["email", "code", "new_password"], "properties": {"email": {"type": "string", "format": "email", "description": "Email address the reset code was sent to."}, "code": {"type": "string", "description": "Confirmation code from the password-reset email."}, "token": {"type": "string", "deprecated": true, "description": "Deprecated alias for `code`, still accepted by the API."}, "new_password": {"type": "string", "format": "password", "minLength": 8}}}, "SsoRequest": {"type": "object", "description": "Start an ordinary SSO sign-in.", "required": ["redirect_uri"], "not": {"required": ["invitation_id"]}, "properties": {"redirect_uri": {"type": "string", "format": "uri", "description": "Frontend callback URL that Cognito should redirect back to."}, "state": {"type": "string", "description": "Optional client state to carry through the signed OAuth state token."}}}, "SsoInvitationRequest": {"type": "object", "description": "Start SSO acceptance of a pending team invitation.", "required": ["redirect_uri", "invitation_id", "terms_accepted", "privacy_accepted"], "properties": {"redirect_uri": {"type": "string", "format": "uri"}, "state": {"type": "string"}, "invitation_id": {"type": "string", "description": "Pending invitation ID bound to signed OAuth state."}, "terms_accepted": {"type": "boolean", "enum": [true]}, "privacy_accepted": {"type": "boolean", "enum": [true]}, "marketing_opt_in": {"type": "boolean", "default": false, "description": "Optional product-email consent, bound to the signed invitation state."}}}, "SsoStartResponse": {"type": "object", "required": ["provider", "authorize_url", "redirect_uri", "state"], "properties": {"provider": {"type": "string", "description": "Cognito Hosted UI identity provider name.", "example": "Google"}, "authorize_url": {"type": "string", "format": "uri", "description": "URL the browser should navigate to for Cognito Hosted UI."}, "redirect_uri": {"type": "string", "format": "uri", "description": "Redirect URI accepted for this SSO flow."}, "state": {"type": "string", "description": "Signed state token that must be sent to the callback."}}}, "InsightBundle": {"type": "object", "description": "One stored AI analysis of an app's reviews, as `GET /api/v1/apps/{appUuid}/insights`, `GET /api/v1/insights` and the full view of the history serve it. Every key a stored analysis carries is declared here; which ones a record has depends on the analyzer that wrote it and on how far the run got, so check `status` and `analysis_degraded` before relying on the narrative fields. Clients should still tolerate keys added later. Mention counts on a record folded from more than one batch (`batches_processed` above 1) are floors.", "required": ["summary", "sentiment_summary", "swot", "key_topics", "user_loves", "user_frustrations", "feature_requests", "actionable_insights", "total_reviews", "analyzed_reviews"], "properties": {"status": {"type": "string", "description": "`analyzing` while a run that analyses its reviews in batches is still adding batches (the record is progressive), `completed` when it finished. Absent on records written in one pass."}, "summary": {"type": "string", "description": "The \"Users say\" summary in English. The API, exports, MCP and messages built from an analysis read this text; `summary_parts` holds the same sentences with message codes."}, "summary_parts": {"type": "array", "description": "The sentences of `summary` in order, each with the English `text` it reads as there; `summary` is these texts joined by \". \" and ended with \".\". A sentence written from a fixed template also has `code` and `params`, so a client can write it in its reader's language. A sentence without `code` (a theme the model wrote, or the coverage note) is shown as its `text`. Reads can correct a stored sentence and restate the coverage note, so use a code only while its `text` is still the sentence `summary` serves. Absent on analyses written before the codes.", "items": {"$ref": "#/components/schemas/AnalysisTextPart"}}, "generated_at": {"type": "string", "format": "date-time", "description": "When the analysis was written."}, "created_at": {"type": "string", "format": "date-time", "description": "Carried by a few older records only. `generated_at` is when the analysis was written."}, "computed_at": {"type": "string", "format": "date-time", "description": "When the shared analysis of a public store listing was stored. Only on the analysis an analyze answer returns from that shared store (`cached: true`); the insights reads never carry it."}, "sentiment_summary": {"type": "object", "required": ["positive_percent", "negative_percent", "neutral_percent", "mixed_percent", "average_rating"], "properties": {"positive_percent": {"type": "number"}, "negative_percent": {"type": "number"}, "neutral_percent": {"type": "number"}, "mixed_percent": {"type": "number"}, "average_rating": {"type": "number", "description": "Average star rating of the analysed reviews."}, "sentiment_details": {"type": "string", "description": "A sentence on the sentiment, on records written in one pass."}}}, "key_topics": {"type": "array", "description": "Themes the reviews discuss, most mentioned first.", "items": {"type": "object", "properties": {"topic": {"type": "string"}, "mentions": {"type": "integer", "minimum": 0, "description": "Reviews mentioning the theme."}, "sentiment": {"type": "string", "description": "`positive`, `negative`, `neutral` or `mixed`."}, "examples": {"type": "array", "items": {"type": "string"}}, "also_mentioned_as": {"type": "array", "description": "Other wordings of the same theme that were merged into it.", "items": {"type": "string"}}, "language_breakdown": {"type": "object", "description": "Mentions by review language (ISO 639-1 code).", "additionalProperties": {"type": "integer"}}, "custom_categories": {"type": "array", "description": "The workspace's custom categories the theme matched.", "items": {"type": "string"}}}}}, "user_loves": {"type": "array", "description": "What reviewers praise, most mentioned first.", "items": {"$ref": "#/components/schemas/InsightBundleUserLovesItem"}}, "user_frustrations": {"type": "array", "description": "What reviewers complain about, most mentioned first.", "items": {"type": "object", "properties": {"frustration": {"type": "string"}, "mention_count": {"type": "integer", "minimum": 0}, "quotes": {"type": "array", "items": {"$ref": "#/components/schemas/InsightQuote"}}, "quote": {"type": "string", "description": "One review quote, on records written in one pass."}, "original_quote": {"type": "string", "description": "`quote` in the review's own language, when it was translated."}, "source_language": {"type": "string"}, "custom_categories": {"type": "array", "items": {"type": "string"}}, "author": {"type": "string"}, "review_id": {"type": ["string", "null"]}, "review_date": {"type": ["string", "null"]}, "review_rating": {"type": ["integer", "null"]}}}}, "feature_requests": {"type": "array", "description": "Features reviewers ask for, most requested first.", "items": {"$ref": "#/components/schemas/InsightBundleFeatureRequestsItem"}}, "technical_issues": {"type": "array", "items": {"type": "object", "properties": {"issue": {"type": "string"}, "severity": {"type": "string", "description": "`critical`, `high`, `medium` or `low`."}, "frequency": {"type": "integer", "minimum": 0, "description": "Reviews reporting the issue."}, "details": {"type": "string"}}}}, "actionable_insights": {"type": "array", "description": "Recommended actions, strongest evidence first.", "items": {"$ref": "#/components/schemas/InsightBundleActionableInsightsItem"}}, "swot": {"type": "object", "properties": {"strengths": {"type": "array", "items": {"type": "string"}}, "weaknesses": {"type": "array", "items": {"type": "string"}}, "opportunities": {"type": "array", "items": {"type": "string"}}, "threats": {"type": "array", "items": {"type": "string"}}}}, "swot_codes": {"type": "object", "description": "One entry per `swot` line, quadrant by quadrant and in the same order, in the shape of `summary_parts`. A quadrant that `swot` holds as null has no entry. Use a code only while its `text` is still the line `swot` serves. Absent on analyses written before the codes.", "properties": {"strengths": {"type": "array", "items": {"$ref": "#/components/schemas/AnalysisTextPart"}}, "weaknesses": {"type": "array", "items": {"$ref": "#/components/schemas/AnalysisTextPart"}}, "opportunities": {"type": "array", "items": {"$ref": "#/components/schemas/AnalysisTextPart"}}, "threats": {"type": "array", "items": {"$ref": "#/components/schemas/AnalysisTextPart"}}}}, "competitive_insights": {"type": "string", "description": "A note on competitors, on records written in one pass. Often empty."}, "total_reviews": {"type": "integer", "minimum": 0, "description": "Reviews the analysis is measured against: the app's stored corpus when it was counted, otherwise the analysis window."}, "total_reviews_in_db": {"type": "integer", "minimum": 0, "description": "Carried by a few older records only."}, "reviews_analyzed": {"type": "integer", "minimum": 0}, "analyzed_reviews": {"type": "integer", "minimum": 0, "description": "Reviews the analysis read."}, "analysis_sampled_reviews": {"type": "integer", "minimum": 0, "description": "Reviews in the window the analysis sampled from."}, "analysis_corpus_reviews": {"type": ["integer", "null"], "minimum": 0, "description": "The app's stored review corpus. `null` when it could not be counted (`analysis_corpus_reviews_known` is then `false`)."}, "analysis_corpus_reviews_known": {"type": "boolean", "description": "Whether `analysis_corpus_reviews` is a count of the stored corpus."}, "analysis_coverage_percent": {"type": ["number", "null"], "description": "Percentage of the app's review corpus covered by the analysis. A value below 100 means the bundle is based on a sample. `null` when the corpus could not be counted or is empty."}, "analysis_sample_limited": {"type": "boolean"}, "analysis_sweep": {"type": "object", "description": "Progress of the corpus sweep behind this bundle. Each run analyses new reviews first, then older reviews newest first, within the app's monthly AI analysis allowance and a daily pace, so coverage grows across runs. Absent on bundles produced before the sweep.", "properties": {"state": {"type": "string", "enum": ["in_progress", "paused_allowance", "complete"], "description": "`in_progress`: older reviews are still to be analysed and will be in later runs. `paused_allowance`: this month's included AI analyses are used; older reviews continue next month (new reviews are still analysed). `complete`: every review was analysed or attempted."}, "remaining_reviews": {"type": "integer", "minimum": 0, "description": "Number of corpus reviews without a successful recorded analysis, excluding reviews whose retry limit has been reached. Stale previously successful batches awaiting re-analysis are not included in this count."}, "next_at": {"type": ["string", "null"], "format": "date-time", "description": "Earliest time more older reviews can be analysed; null when complete."}}}, "analysis_degraded": {"type": "boolean"}, "analysis_stopped_reason": {"type": ["string", "null"], "description": "Why the run stopped short, such as `sample_limit`, `analysis_incomplete`, `wall_clock_budget` or `corpus_count_unavailable`."}, "analysis_mode": {"type": "string", "description": "`bedrock_ai`, or `keyword_fallback` when the AI analysis was unavailable and the figures come from star ratings and keywords. A record is one or the other, never a mix: a run that analyses its reviews in batches leaves out a batch the AI analysis could not read (`batches_failed`) rather than analyse it by keywords, so every figure of a `bedrock_ai` record comes from the AI analysis of the reviews it counts in `analyzed_reviews`."}, "fallback_reason": {"type": "string", "description": "Why the keyword fallback ran, on `keyword_fallback` records."}, "analysis_by": {"type": "string", "description": "Which analyzer wrote the record, with its coverage when it was measured."}, "analysis_time_ms": {"type": "integer", "minimum": 0}, "batches_processed": {"type": "integer", "minimum": 0, "description": "Batches whose AI analysis is in the figures, on a record from a run that analyses its reviews in batches."}, "total_batches": {"type": "integer", "minimum": 0, "description": "Batches the run divided its reviews into."}, "batches_failed": {"type": "integer", "minimum": 0, "description": "Batches the run attempted whose AI analysis gave no usable answer, failed, or could not be saved. Their reviews are in no figure of the record and are not counted in `analyzed_reviews`, so when it is above 0 the reviews analysed are not simply the most recent ones. On a record still being written (`status: analyzing`) it counts the batches attempted so far. Absent on records written in one pass and on records written before it was recorded."}, "newest_review_id": {"type": ["string", "null"], "description": "The newest review the run saw, used to tell whether new reviews arrived since."}, "analysis_ledger_epoch": {"type": "string", "description": "Identifies the corpus sweep this record belongs to. Opaque."}, "analysis_ledger_seq": {"type": "integer", "minimum": 0, "description": "Order of this record within its sweep. Opaque."}, "insight_priority_rule": {"type": "string", "description": "Set when the analyzer already ranked `actionable_insights` by evidence (for example `evidence_v1`)."}, "auto_triggered": {"type": "boolean", "description": "True when the analysis was started by a review sync rather than a person."}, "analysis_version": {"type": "string", "description": "Identifies this record. Present on the latest analysis (`GET /api/v1/apps/{appUuid}/insights` and `GET /api/v1/insights`); send it with an insight action so the action applies to this analysis's insight. Opaque."}, "language_summary": {"type": "object", "description": "Review languages in the analysis window, on records written in one pass.", "properties": {"total_reviews": {"type": "integer", "minimum": 0}, "languages_detected": {"type": "object", "additionalProperties": {"type": "integer"}}, "translated_count": {"type": "integer", "minimum": 0}, "total": {"type": "integer", "minimum": 0}, "languages": {"type": "object", "additionalProperties": {"type": "integer"}}}}, "translations": {"type": "array", "description": "English translations of the analysed reviews, on records written in one pass.", "items": {"type": "object", "properties": {"review_id": {"type": "string"}, "detected_language": {"type": "string"}, "is_english": {"type": "boolean"}, "title_en": {"type": "string"}, "content_en": {"type": "string"}}}}}}, "InsightBundleFeatureRequestsItem": {"type": "object", "properties": {"feature": {"type": "string"}, "mention_count": {"type": "integer", "minimum": 0}, "mentions": {"type": "integer", "minimum": 0, "description": "The same count as `mention_count`, on records that carry both."}, "priority": {"type": "string", "description": "On records written in one pass."}, "priority_factors": {"$ref": "#/components/schemas/InsightPriorityFactors"}, "revenue_signal": {"type": "boolean"}, "user_quote": {"type": "string", "description": "One review quote, on records written in one pass."}, "quotes": {"type": "array", "items": {"$ref": "#/components/schemas/InsightQuote"}}, "author": {"type": "string"}, "review_id": {"type": ["string", "null"]}, "review_date": {"type": ["string", "null"]}, "review_rating": {"type": ["integer", "null"]}}}, "InsightBundleUserLovesItem": {"type": "object", "properties": {"aspect": {"type": "string"}, "mention_count": {"type": "integer", "minimum": 0}, "quotes": {"type": "array", "items": {"$ref": "#/components/schemas/InsightQuote"}}, "quote": {"type": "string", "description": "One review quote, on records written in one pass."}, "original_quote": {"type": "string", "description": "`quote` in the review's own language, when it was translated."}, "source_language": {"type": "string"}, "author": {"type": "string"}, "review_id": {"type": ["string", "null"]}, "review_date": {"type": ["string", "null"]}, "review_rating": {"type": ["integer", "null"]}}}, "InsightBundleActionableInsightsItem": {"type": "object", "required": ["id"], "properties": {"id": {"type": "string", "description": "Stable id of the insight within this analysis. Feedback, actions and resolved state are recorded against it."}, "cluster_id": {"type": "string", "description": "The insight's wording-independent issue identity (R2R-10), minted from deterministic evidence at analysis time. The content-hash `id` changes when the grouper re-words the cluster; `cluster_id` persists while the cited evidence persists. Absent when the evidence cannot be determined (identity is never fabricated)."}, "title": {"type": "string"}, "description": {"type": "string"}, "action": {"type": "string", "description": "What to do about it."}, "expected_impact": {"type": "string"}, "type": {"type": "string", "description": "The card's tone as the analyzer wrote it, such as `critical`, `warning`, `info` or `success`."}, "priority": {"type": "string", "description": "`critical`, `high`, `medium` or `low`, no stronger than the insight's mention count supports."}, "model_priority": {"type": "string", "description": "The priority the model gave, when it was lowered to what the evidence supports. Absent when `priority` is the model's own."}, "category": {"type": "string"}, "evidence_quotes": {"type": "array", "description": "Review quotes behind the insight.", "items": {"type": "string"}}, "evidence_quote_sources": {"type": "array", "description": "The review each `evidence_quotes` entry was taken from, one entry per quote in the same order. Present on analyses written since quote attribution was recorded at analysis time; absent on older records. Degraded keyword-fallback records (`analysis_mode: keyword_fallback`) carry the same array for their extractive quotes rather than omitting it. An entry whose `resolution` is not `resolved` carries no review reference rather than a guess.", "items": {"$ref": "#/components/schemas/InsightQuoteSource"}}, "priority_factors": {"$ref": "#/components/schemas/InsightPriorityFactors"}}}, "InsightBundleCompact": {"type": "object", "description": "One entry of `GET /api/v1/apps/{appUuid}/insights/history?view=compact`: the fields of the stored analysis a trend line reads, each with the value the full view serves for the same entry.", "required": ["sentiment_summary", "total_reviews", "analyzed_reviews"], "properties": {"generated_at": {"type": "string", "format": "date-time"}, "status": {"type": "string"}, "analysis_mode": {"type": "string"}, "analysis_degraded": {"type": "boolean"}, "analysis_stopped_reason": {"type": ["string", "null"]}, "analysis_sample_limited": {"type": "boolean"}, "total_reviews": {"type": "integer", "minimum": 0}, "reviews_analyzed": {"type": "integer", "minimum": 0}, "analyzed_reviews": {"type": "integer", "minimum": 0}, "analysis_sampled_reviews": {"type": "integer", "minimum": 0}, "analysis_corpus_reviews": {"type": ["integer", "null"], "minimum": 0}, "analysis_corpus_reviews_known": {"type": "boolean"}, "analysis_coverage_percent": {"type": ["number", "null"]}, "batches_processed": {"type": "integer", "minimum": 0}, "total_batches": {"type": "integer", "minimum": 0}, "sentiment_summary": {"type": "object", "description": "The numbers of the full view's `sentiment_summary`, without its prose.", "properties": {"positive_percent": {"type": "number"}, "negative_percent": {"type": "number"}, "neutral_percent": {"type": "number"}, "mixed_percent": {"type": "number"}, "average_rating": {"type": "number"}}}}}, "InsightQuote": {"type": "object", "description": "A review quote behind a theme.", "properties": {"text": {"type": "string"}, "author": {"type": "string"}, "review_id": {"type": ["string", "null"]}, "review_date": {"type": ["string", "null"]}}}, "InsightQuoteSource": {"type": "object", "description": "Where one evidence quote was found in the analysed reviews. Attribution is computed at analysis time by matching the quote against the reviews the analysis saw; it is never the model's own claim.", "required": ["text", "resolution"], "properties": {"text": {"type": "string", "description": "The quote this entry attributes; the same string as the aligned `evidence_quotes` entry."}, "review_id": {"type": ["string", "null"], "description": "The review the quote was taken from. Null unless `resolution` is `resolved`. A `generated-` value is synthetic, never a store id: the review had none, so the analysis derived one from the row's content and scoped it to the batch it was seen in (`generated-<hash>-b<N>`), so two identical rows in different batches can never collapse into one reference."}, "review_date": {"type": ["string", "null"], "description": "The review's date as the store reported it. Null when the review recorded none or the quote is unresolved."}, "review_rating": {"type": ["integer", "null"], "description": "The review's star rating. Null when the review recorded none or the quote is unresolved."}, "app_version": {"type": ["string", "null"], "description": "The app version the review was written against, when the store reported one."}, "resolution": {"type": "string", "enum": ["resolved", "ambiguous", "unresolved"], "description": "`resolved` when exactly one analysed review contained the quote; `ambiguous` when several did, so no arbitrary one is attached; `unresolved` when none did or the quote predates attribution."}, "candidate_count": {"type": ["integer", "null"], "minimum": 0, "description": "How many analysed reviews contained the quote: 1 for `resolved`, 2 or more for `ambiguous`, 0 for an unmatched one. Null on entries merged from records stored before attribution existed."}, "review_search_text": {"type": "string", "description": "The Reviews-tab search term for the resolved review, derived at analysis time from the stored review text: its content's (else title's) first line, no truncation ellipsis, at most 60 characters (the dashboard's `reviewSearchPath` rule). Build citation links from this, never from the quote: resolution matches on normalized text, while the reviews search matches literally. Absent on entries written before the key existed and on `ambiguous`/`unresolved` entries."}}}, "InsightPriorityFactors": {"type": "object", "properties": {"mention_count": {"type": "integer", "minimum": 0, "description": "Reviews mentioning the insight. A count summed across the whole analysis only when `mention_count_scope` is `analysis_minimum`; older records carry one batch's count here."}, "mention_count_scope": {"type": "string", "description": "`analysis_minimum` when `mention_count` was summed across every batch of the analysis (a floor for the analysis)."}, "trend": {"type": "string", "description": "`up`, `down` or `stable`."}, "revenue_signal": {"type": "boolean"}}}, "InsightFeedbackEntry": {"type": "object", "description": "The workspace's feedback on one insight (POST /api/v1/apps/{appUuid}/insights/{insightId}/feedback).", "required": ["feedback_type", "feedback_reason", "created_at"], "properties": {"feedback_type": {"type": "string", "enum": ["useful", "not_useful"]}, "feedback_reason": {"type": ["string", "null"], "description": "The `reason` sent with the feedback, or null when none was sent."}, "created_at": {"type": "string", "format": "date-time"}}}, "InsightActionRecord": {"type": "object", "description": "One action recorded on an insight (POST /api/v1/apps/{appUuid}/insights/{insightId}/actions).", "required": ["action_id", "action_type", "action_status", "created_at", "completed_at"], "properties": {"action_id": {"type": "string", "format": "uuid"}, "action_type": {"type": "string", "enum": ["create_jira_ticket", "draft_response", "add_to_roadmap", "share_to_slack", "share_to_teams", "mark_resolved", "ignore", "escalate", "investigate", "create_github_issue", "share_to_discord"]}, "action_status": {"type": "string", "enum": ["initiated", "completed"], "description": "`initiated` when the action is recorded (unless the request sent `status: completed`), `completed` once it is marked complete (PUT /api/v1/apps/{appUuid}/insights/{insightId}/actions/{actionId})."}, "created_at": {"type": "string", "format": "date-time"}, "completed_at": {"type": ["string", "null"], "format": "date-time", "description": "When the action was marked complete; null until then."}, "cluster_id": {"type": "string", "description": "The insight's stable issue identity (R2R-10), recorded when the action request carried it. A reworded insight keeps this action attachable through cluster_id; absent on actions recorded before the field existed or without one."}, "integration_outcome": {"type": "object", "description": "The integration execution outcome persisted on the record, when the action ran an integration: `success`, `error`, `retryable`, and on success the provider's reference (`issue_id`, `issue_url`, `identifier`, `task_id`, `channel`, `delivered_at`) plus `recorded_at`. Absent on actions recorded before outcomes were persisted, so a failed delivery is distinguishable from pending work. Keys beyond that list are never stored.", "additionalProperties": true}}}, "InsightActionIntegrationResult": {"type": "object", "description": "What running the action's integration did. Which keys are present depends on the action type and the outcome: `create_jira_ticket` sets `issue_key`, `issue_id` and `issue_url`; `share_to_slack` sets `channel`, `channel_name` and `ts`; `share_to_teams` sets `accepted` and `status_code`; `add_to_roadmap` (Linear) sets `issue_id`, `issue_identifier` and `issue_url`; `escalate` sets `message_id` and `recipients`; `investigate` sets `task_id` and `linear_issue`; `draft_response` sets `draft_id`, `content`, `subject` and `template_used`; `create_github_issue` sets `issue_id`, `issue_number` and `issue_url`; `share_to_discord` sets no keys beyond the outcome. A failure sets `error`, and sometimes `retryable` and `status_code`.", "required": ["success"], "properties": {"success": {"type": "boolean", "description": "True when the integration did what the action asks."}, "error": {"type": "string", "description": "Set when `success` is false: a code naming the failure, such as `jira_not_configured`, `slack_token_missing`, `teams_rate_limited`, `linear_team_not_configured`, `no_escalation_recipients` or `email_send_failed`. A provider rejection can carry the provider's status or message after the code."}, "retryable": {"type": "boolean", "description": "Set on some failures; true when the same action may succeed if taken again."}, "status_code": {"type": "integer", "description": "`share_to_teams`: the HTTP status Teams answered."}, "accepted": {"type": "boolean", "description": "`share_to_teams`: Teams accepted the card."}, "issue_key": {"type": ["string", "null"], "description": "`create_jira_ticket`: the new issue's key."}, "issue_id": {"type": ["string", "null"], "description": "`create_jira_ticket`, `add_to_roadmap` and `create_github_issue`: the new issue's id."}, "issue_url": {"type": ["string", "null"], "description": "`create_jira_ticket`, `add_to_roadmap` and `create_github_issue`: a link to the new issue."}, "issue_number": {"type": "integer", "description": "`create_github_issue`: the new GitHub issue's number."}, "issue_identifier": {"type": ["string", "null"], "description": "`add_to_roadmap`: the new Linear issue's identifier."}, "channel": {"type": "string", "description": "`share_to_slack`: the channel id posted to."}, "channel_name": {"type": ["string", "null"], "description": "`share_to_slack`: the channel's name, when known."}, "ts": {"type": ["string", "null"], "description": "`share_to_slack`: the posted message's timestamp."}, "message_id": {"type": ["string", "null"], "description": "`escalate`: the sent email's id."}, "recipients": {"type": "array", "items": {"type": "string"}, "description": "`escalate`: who the email was sent to."}, "task_id": {"type": "string", "format": "uuid", "description": "`investigate`: the investigation task created."}, "linear_issue": {"description": "`investigate`: the Linear issue created for the task, or null when none was (Linear not connected, or the create failed).", "oneOf": [{"type": "null"}, {"type": "object", "required": ["success"], "properties": {"success": {"type": "boolean"}, "issue_id": {"type": ["string", "null"]}, "issue_identifier": {"type": ["string", "null"]}, "issue_url": {"type": ["string", "null"]}}}]}, "draft_id": {"type": "string", "format": "uuid", "description": "`draft_response`: the draft created."}, "content": {"type": "string", "description": "`draft_response`: the draft text."}, "subject": {"type": "string", "description": "`draft_response`: the draft subject."}, "template_used": {"type": "string", "description": "`draft_response`: the template the draft was made from."}}}, "InsightActionHistory": {"type": "object", "description": "Every action recorded on the app's insights.", "required": ["actions", "count"], "properties": {"actions": {"type": "object", "description": "The actions, grouped by insight id. Within an insight they are in no particular order; sort by `created_at`.", "additionalProperties": {"type": "array", "items": {"$ref": "#/components/schemas/InsightActionRecord"}}}, "count": {"type": "integer", "minimum": 0, "description": "The number of actions across every insight."}}}, "InsightActionCapabilities": {"type": "object", "description": "Which delivery integrations the workspace can use for insight actions (`capabilities_only=true`).", "required": ["capabilities"], "properties": {"capabilities": {"type": "object", "required": ["jira_connected", "teams_connected", "github_connected", "discord_connected"], "properties": {"jira_connected": {"type": "boolean", "description": "The workspace has an active Jira connection."}, "teams_connected": {"type": "boolean", "description": "Microsoft Teams actions can be delivered: Teams is available to the workspace and connected through a Teams workflow."}, "github_connected": {"type": "boolean", "description": "The workspace has an active GitHub connection, so `create_github_issue` can run."}, "discord_connected": {"type": "boolean", "description": "The workspace has an active Discord connection, so `share_to_discord` can run."}}}}}, "InvestigationTask": {"type": "object", "description": "One investigation task, recorded when someone chose `investigate` on an insight (POST /api/v1/apps/{appUuid}/insights/{insightId}/actions).", "required": ["task_id", "insight_id", "app_uuid", "app_name", "title", "description", "priority", "category", "status", "assigned_to", "created_at", "updated_at", "closed_at", "evidence_quotes"], "properties": {"task_id": {"type": "string", "format": "uuid"}, "insight_id": {"type": "string", "description": "The insight the task was opened on."}, "app_uuid": {"type": "string", "format": "uuid"}, "app_name": {"type": ["string", "null"], "description": "The app's name when the task was recorded."}, "title": {"type": "string"}, "description": {"type": ["string", "null"], "description": "The insight's description when the task was recorded."}, "priority": {"type": "string", "description": "The insight's priority when the task was recorded."}, "category": {"type": ["string", "null"], "description": "The insight's category when the task was recorded."}, "status": {"type": "string", "enum": ["open", "in_progress", "closed"], "description": "`open` when the task is recorded; PATCH /api/v1/apps/{appUuid}/insights/investigations/{taskId} moves it."}, "assigned_to": {"type": ["string", "null"], "description": "The email of whoever opened the task, or of the assignee a PATCH set. Null on tasks recorded before assignees were kept."}, "created_at": {"type": "string", "format": "date-time"}, "updated_at": {"type": "string", "format": "date-time", "description": "When the task was last patched (created_at until then)."}, "closed_at": {"type": ["string", "null"], "format": "date-time", "description": "When the task was first closed; null until then. Kept when the task is reopened, as the record of when it was last closed."}, "evidence_quotes": {"type": "array", "items": {"type": "string"}, "description": "The insight's evidence quotes when the task was recorded."}}}, "InvestigationTaskList": {"type": "object", "description": "A page of the app's investigation tasks.", "required": ["tasks", "count", "next_cursor"], "properties": {"tasks": {"type": "array", "items": {"$ref": "#/components/schemas/InvestigationTask"}, "description": "The page, newest first. Pages are in no particular order against each other: tasks key on random ids and the filters are applied while paging the workspace's task partition."}, "count": {"type": "integer", "minimum": 0, "description": "The number of tasks in this page."}, "next_cursor": {"type": ["string", "null"], "description": "Pass back as `cursor` for the next page; null when this is the last one. Bound to the workspace, the app and the filters it was issued with."}}}, "InvestigationTaskPatch": {"type": "object", "description": "Omitted fields are unchanged. At least one field is required; an empty body answers 400 `EMPTY_UPDATE`. A top-level `anyOf` is intentionally avoided because it forces the Python SDK generator to emit an untyped `body: Any` (see CreateExportRequest): the at-least-one rule is `minProperties` plus the service-side check.", "minProperties": 1, "properties": {"status": {"type": "string", "enum": ["open", "in_progress", "closed"], "description": "New status for the task. `closed` sets `closed_at` the first time only."}, "assigned_to": {"type": "string", "format": "email", "maxLength": 320, "description": "Email of the person the task is assigned to."}}, "additionalProperties": false}, "InsightConversionMetrics": {"type": "object", "description": "How the app's insights turned into actions and feedback over the period. The rates are percentages rounded to one decimal, and 0 when their denominator is 0.", "required": ["total_insights", "insights_with_actions", "insights_with_feedback", "action_conversion_rate", "feedback_rate", "useful_rate", "actions_by_type", "period_days", "total_actions", "total_feedback"], "properties": {"total_insights": {"type": "integer", "minimum": 0, "description": "Actionable insights in the analysis the Insights tab shows (the latest one)."}, "insights_with_actions": {"type": "integer", "minimum": 0, "description": "Of `total_insights`, those with an action recorded in the period."}, "insights_with_feedback": {"type": "integer", "minimum": 0, "description": "Insights given feedback in the period, from any analysis."}, "action_conversion_rate": {"type": "number", "minimum": 0, "maximum": 100, "description": "`insights_with_actions` as a percentage of `total_insights`."}, "feedback_rate": {"type": "number", "minimum": 0, "maximum": 100, "description": "Insights of `total_insights` given feedback in the period, as a percentage of `total_insights`."}, "useful_rate": {"type": "number", "minimum": 0, "maximum": 100, "description": "Feedback in the period marked `useful`, as a percentage of `total_feedback`."}, "actions_by_type": {"type": "object", "description": "Actions recorded in the period, counted by action type. Only types with at least one action appear.", "additionalProperties": {"type": "integer", "minimum": 1}}, "period_days": {"type": "integer", "minimum": 1, "maximum": 365, "description": "The period, in days, that was counted (`days`)."}, "total_actions": {"type": "integer", "minimum": 0, "description": "Actions recorded in the period, on any analysis's insights."}, "total_feedback": {"type": "integer", "minimum": 0, "description": "Feedback given in the period, on any analysis's insights."}}}, "AnalysisTextPart": {"type": "object", "description": "One generated sentence of an analysis: its English text and, when it was written from a fixed template, the template's code and the values it was built from.", "required": ["text"], "properties": {"text": {"type": "string", "description": "The sentence in English, as it was written."}, "code": {"type": "string", "description": "Stable identifier of the template, for example `summary.praise_leads` or `swot.reported_in_reviews`. New codes may be added; show `text` for a code you do not know."}, "params": {"type": "object", "additionalProperties": true, "description": "The values the sentence was built from, as it uses them: `label` (a theme in the model's words), `competitor`, `quote` (a reviewer's words, attached after the statement), `count`, `at_least` (true when `count` is a floor folded across analysis batches), `share_percent` (the share of reviews analyzed that the text prints), `percent`, `positive_percent`, `negative_percent` and `rating`."}}}, "ProfileUpdateRequest": {"type": "object", "description": "All fields are optional. Omitted fields retain their current values. Role, timezone and notification preferences cannot be changed through this endpoint.", "properties": {"support_email": {"type": "string", "maxLength": 254, "description": "Workspace support email used in reply templates and AI reply suggestions. Only an admin or owner may change it. An empty string clears it; omitted values are preserved. Must be a valid email address when nonempty."}, "name": {"type": "string", "maxLength": 200, "description": "The signed-in user's display name."}, "company_name": {"type": "string", "minLength": 2, "maxLength": 100, "description": "Workspace company name. Stored on the tenant and shared by every member, so only an admin or owner may change it; a member receives 403."}, "avatar_preset_id": {"type": ["string", "null"], "description": "Preset avatar id, or null to clear it."}, "profile_picture_url": {"type": ["string", "null"], "description": "Uploaded profile picture URL, or null to clear it."}, "onboarding_tour_completed": {"type": "boolean", "description": "Whether the user has finished the onboarding tour."}, "preferred_locale": {"type": "string", "maxLength": 20, "description": "Language the signed-in user wants on their account. Stored on the user and used in links the product sends them. A regional tag such as de-DE is stored as its language (de). Any other value is rejected. One of en, es, fr, de, pt, ja, zh, ko, it, nl."}}}, "AppSummary": {"type": "object", "description": "An app as the apps endpoints return it: the stored app record without the sync worker's private bookkeeping, with defaults filled in for the required sync and analysis fields.", "required": ["app_uuid", "app_name", "platform", "app_id", "tenant_id", "review_count", "sync_status", "sync_progress", "sync_message", "last_sync", "auto_sync_enabled", "auto_sync_frequency", "auto_sync_next_attempt", "sync_times", "version_sync_enabled", "last_known_version", "analysis_status", "last_analysis"], "properties": {"app_uuid": {"type": "string"}, "app_name": {"type": "string"}, "platform": {"type": "string"}, "review_count": {"type": "integer"}, "auto_sync_enabled": {"type": "boolean"}, "auto_sync_frequency": {"type": "string"}, "sync_started_at": {"type": ["string", "null"], "format": "date-time", "description": "When the app's latest sync started. `feed_coverage` describes that sync only while its own `sync_started_at` is this same value."}, "next_sync_at": {"type": ["string", "null"], "format": "date-time", "description": "When the next automatic sync is due. After a store-feed read that some storefronts failed on, the sync that failed them brings this forward (2 hours, doubling per miss in a row, never past the app's cadence) so they are read again sooner."}, "feed_coverage": {"$ref": "#/components/schemas/AppFeedCoverage"}, "review_freshness": {"$ref": "#/components/schemas/AppReviewFreshness"}, "feed_partial_miss_streak": {"type": "integer", "minimum": 1, "description": "Store-feed syncs in a row on which some storefronts failed. Present only after such a sync; a sync that reads every storefront removes it. Together with a future `next_sync_at` it means a sooner retry of the failed storefronts is planned."}, "feed_unanswered_streak": {"type": "integer", "minimum": 1, "description": "Store-feed syncs in a row on which every storefront failed. Present only after such a sync (`sync_status` is then `limited`); an answered read removes it. Together with a future `next_sync_at` it means a sooner retry is planned."}, "app_id": {"type": "string"}, "tenant_id": {"type": "string"}, "analysis_run_id": {"type": "string", "description": "Identifier of the current or latest analysis run, when recorded."}, "analysis_completed_run_id": {"type": "string", "description": "Identifier of the last completed analysis run, when recorded."}, "status": {"type": "string"}, "sync_status": {"type": "string"}, "analysis_status": {"type": "string"}, "icon_url": {"type": ["string", "null"]}, "last_known_version": {"type": ["string", "null"]}, "credential_id": {"type": ["string", "null"], "description": "Credential identifier used by this app. Credentials themselves are never returned."}, "sync_message": {"type": ["string", "null"], "description": "Latest sync progress or recovery message."}, "sync_error": {"type": ["string", "null"], "description": "Why the latest sync failed, when it did. Null or absent after a sync that succeeded."}, "sync_failed_at": {"type": "string", "format": "date-time", "description": "When a sync last failed to start."}, "analysis_progress": {"type": "string", "description": "Batch progress of the latest analysis, as `<done>/<total> batches`. May be absent once an analysis has completed."}, "analysis_cancelled_run_id": {"type": "string", "description": "The analysis run that was last cancelled."}, "analysis_cancelled_at": {"type": "string", "format": "date-time", "description": "When that run was cancelled."}, "analysis_recovery_reason": {"type": "string", "description": "Why the scheduler reset an analysis that stopped responding, when it did. Removed when an analysis completes."}, "analysis_recovered_at": {"type": "string", "format": "date-time", "description": "When that analysis was reset."}, "platform_metadata_updated_at": {"type": "string", "format": "date-time", "description": "When a sync last refreshed `platform_metadata` from the store."}, "app_type": {"type": "string", "description": "Ownership marker that older app records can carry. Absent means `my_app`, the workspace's own app. Automatic replies are offered only for `my_app` apps."}, "category": {"type": "string", "description": "Benchmark category, when one is set. A category set through `PATCH /api/v1/apps/{appUuid}` is one of the `AppCategory` values; an app connected before categories were checked can hold the store's own genre name."}, "arpu_cents": {"type": "integer", "description": "Average revenue per user in cents, when the workspace set one for this app. Absent means the defaults in `GET /api/v1/apps/{appUuid}/financial-config` apply."}, "ltv_months": {"type": "integer", "description": "Customer lifetime in months, when set for this app."}, "power_user_multiplier": {"type": "number", "description": "Power-user revenue multiplier, when set for this app."}, "influencer_multiplier": {"type": "number", "description": "Influential-reviewer revenue multiplier, when set for this app. The dashboard no longer offers this setting, because the classifier assigns no reviewer to the `influencer` segment today."}, "created_at": {"type": ["string", "null"], "format": "date-time"}, "updated_at": {"type": ["string", "null"], "format": "date-time"}, "last_sync": {"type": ["string", "null"], "format": "date-time"}, "last_analysis": {"type": ["string", "null"], "format": "date-time"}, "auto_sync_next_attempt": {"type": ["string", "null"], "format": "date-time"}, "auto_sync_enabled_at": {"type": ["string", "null"], "format": "date-time"}, "first_analysis_completed_at": {"type": ["string", "null"], "format": "date-time"}, "last_auto_sync_at": {"type": ["string", "null"], "format": "date-time"}, "sync_requested_at": {"type": ["string", "null"], "format": "date-time"}, "sync_completed_at": {"type": ["string", "null"], "format": "date-time"}, "sync_updated_at": {"type": ["string", "null"], "format": "date-time"}, "sync_recovered_at": {"type": ["string", "null"], "format": "date-time"}, "reactivated_at": {"type": ["string", "null"], "format": "date-time"}, "icon_refreshed_at": {"type": ["string", "null"], "format": "date-time"}, "feed_complete_since": {"type": ["string", "null"], "format": "date-time"}, "deleted": {"type": "boolean"}, "version_sync_enabled": {"type": "boolean"}, "sync_progress": {"type": "number", "description": "Latest sync progress percentage."}, "data_completeness": {"type": "number", "description": "Stored estimate of review data completeness."}, "sync_times": {"type": "array", "items": {"type": "string"}, "description": "Configured automatic sync times, when present."}, "platform_metadata": {"type": "object", "additionalProperties": true, "description": "Store-specific metadata. Its optional keys vary by platform."}, "feed_sync_limited": {"type": ["integer", "boolean"], "description": "Stored sync coverage flag. Existing records may encode false and true as 0 and 1."}, "feed_sync_fetch_error": {"type": ["integer", "boolean"], "description": "Stored sync coverage flag. Existing records may encode false and true as 0 and 1."}, "full_sync_limited": {"type": ["integer", "boolean"], "description": "Stored sync coverage flag. Existing records may encode false and true as 0 and 1."}, "full_sync_fetch_failed": {"type": ["integer", "boolean"], "description": "Stored sync coverage flag. Existing records may encode false and true as 0 and 1."}, "quota_sync_limited": {"type": ["integer", "boolean"], "description": "Stored sync coverage flag. Existing records may encode false and true as 0 and 1."}}}, "CredentialLinkedApp": {"type": "object", "description": "An app that uses the credential. Only these three fields are returned.", "required": ["app_uuid", "app_name", "platform"], "properties": {"app_uuid": {"type": "string"}, "app_name": {"type": "string"}, "platform": {"type": "string"}}}, "AppResponse": {"type": "object", "description": "One app, as `GET /api/v1/apps/{appUuid}` returns it.", "required": ["app"], "properties": {"app": {"$ref": "#/components/schemas/AppSummary"}}}, "AppUpdateResponse": {"type": "object", "required": ["app", "updated"], "properties": {"app": {"$ref": "#/components/schemas/AppSummary"}, "updated": {"type": "boolean", "const": true}}}, "UpdateAppRequest": {"type": "object", "description": "Fields to change. At least one of them must be present; other keys are ignored, and a body with none of these fields is rejected with 400.", "properties": {"app_name": {"type": "string", "description": "Name shown for the app in this workspace."}, "category": {"$ref": "#/components/schemas/AppCategory"}, "arpu_cents": {"type": "integer", "minimum": 0, "description": "Average revenue per user in cents, used by the revenue-at-risk figures."}, "ltv_months": {"type": "integer", "minimum": 1, "maximum": 120, "description": "Customer lifetime in months, used by the revenue-at-risk figures."}, "power_user_multiplier": {"type": "number", "minimum": 1, "maximum": 10, "description": "How many times the average revenue a power user is worth."}, "influencer_multiplier": {"type": "number", "minimum": 1, "maximum": 10, "description": "How many times the average revenue an influential reviewer is worth. It is still accepted and stored, but the dashboard no longer offers it, because the classifier assigns no reviewer to the `influencer` segment today."}, "auto_sync_enabled": {"type": "boolean"}, "auto_sync_frequency": {"type": "string", "enum": ["hourly", "daily", "weekly"]}}}, "AppCategory": {"type": "string", "description": "Benchmark category of the app.", "enum": ["productivity", "social", "games", "finance", "health_fitness", "entertainment", "education", "shopping", "travel", "utilities", "other"]}, "AppFeedCoverage": {"type": "object", "description": "How the app's latest store-feed sync read the store, written when that sync completes. It describes the app's data only while its `sync_started_at` equals the app's `sync_started_at`: a newer sync has started otherwise, or ended without recording one. Google Play apps are read from a single feed and carry no `storefronts`.", "properties": {"sync_started_at": {"type": "string", "format": "date-time", "description": "The sync this record describes."}, "sync_completed_at": {"type": "string", "format": "date-time"}, "stored_whole": {"type": "boolean", "description": "Whether the sync stored every review it fetched (nothing cut by the plan's review allowance, nothing left unwritten)."}, "storefronts": {"type": "object", "description": "App Store only. How the read of each storefront, keyed by its lower-case two-letter country code, ended: `complete` (its feed ran out), `capped` (read to the feed's page limit, which returns only the newest reviews), `failed` (a request failed or the store answered with an empty page, so nothing new was read from that country) or `skipped` (not requested because the sync's review budget ran out).", "additionalProperties": {"type": "string", "enum": ["complete", "capped", "failed", "skipped"]}}, "accumulated": {"$ref": "#/components/schemas/AppFeedCoverageAccumulated"}}}, "AppFeedCoverageAccumulated": {"type": "object", "description": "How the start of the app's whole days was reached across consecutive store-feed syncs. One read of a store's public feed is whole only as far back as the feed serves, so a sync keeps the earlier start of the syncs before it when its own read joins theirs without a gap, and starts again from its own read otherwise. The days from `since` on are the ones the review-volume forecast and the Analytics volume checks take as whole. Present on a record written by a capped feed sync that could vouch for its own read; absent on older records, on syncs that could not, and on apps synced with store credentials.", "properties": {"since": {"type": "string", "format": "date-time", "description": "The instant after which the stored reviews are whole: the start carried over from the earlier syncs, or this sync's own when it started afresh."}, "sync_since": {"type": "string", "format": "date-time", "description": "The instant after which this sync's own read is whole. Equal to `since` on a sync that started afresh, later on one that kept an earlier start."}, "syncs": {"type": "integer", "minimum": 1, "description": "Consecutive syncs joined, this one included. 1 after a restart."}, "first_sync_started_at": {"type": "string", "format": "date-time", "description": "When the first of the joined syncs started."}, "scope": {"type": "object", "description": "What the joined syncs read. A sync that reads something else starts afresh (`restart: scope_changed`).", "properties": {"platform": {"type": "string", "description": "The store, as review rows record it (`app_store_connect`, `google_play`)."}, "app_id": {"type": "string", "description": "The app's identifier in the store."}, "storefronts": {"type": "array", "description": "App Store only. The storefronts read, as lower-case two-letter country codes, sorted.", "items": {"type": "string"}}, "feed": {"type": "string", "description": "Google Play only. The country and language read, as `country/language` (`us/en`)."}}}, "stocked": {"type": "array", "description": "App Store only. The storefronts read that hold stored reviews after this sync. A storefront that gains its first stored reviews restarts the coverage, because its history is missing from the days already counted as whole.", "items": {"type": "string"}}, "restart": {"type": "string", "enum": ["first_record", "previous_not_recorded", "previous_not_whole", "scope_changed", "stock_unknown", "storefront_newly_stocked", "gap", "vouched_day_changed", "own_read_deeper", "record_moved"], "description": "Why this sync started afresh; absent on a sync that kept an earlier start. `first_record`: no earlier record to join. `previous_not_recorded`: the record on the app is not from its latest completed sync. `previous_not_whole`: the preceding sync could not vouch for its own read (a failed request or storefront, the plan's review allowance, an uncapped read). `scope_changed`: the syncs read different things. `stock_unknown`: which storefronts hold stored reviews could not be established. `storefront_newly_stocked`: a storefront holds stored reviews it did not hold before. `gap`: this read is not whole from at least three days before the previous sync began, or the syncs are out of order. `vouched_day_changed`: a review new to this app is dated on a day already counted as whole that this read does not cover. `own_read_deeper`: this read alone reaches at least as far back. `record_moved`: another sync recorded coverage in between."}}}, "AppReviewFreshness": {"type": "object", "description": "Whether new reviews are still reaching the app, as the app's latest store-feed sync measured it. A sync can succeed and store nothing new because the store's public review feed has served nothing newer; this record says when that is unusual for the app. It is judged on every review stored for the app's listing, whichever sync stored it (another workspace's sync of the same listing with store credentials included), because those are the reviews the app shows. It says nothing of the store's own pages, which may show newer reviews. It describes the app only while its `sync_started_at` equals the app's `sync_started_at`. Absent on apps synced with store credentials, on apps whose latest feed sync could not read their stored reviews, and on records written before it existed.", "required": ["sync_started_at", "measured_at", "state", "reason"], "properties": {"sync_started_at": {"type": "string", "format": "date-time", "description": "The sync this record describes."}, "measured_at": {"type": "string", "format": "date-time", "description": "When the sync judged it."}, "state": {"type": "string", "enum": ["current", "behind", "unknown"], "description": "`current`: the newest stored review is as recent as the app's usual flow of reviews and the feed's usual delay allow. `behind`: no review newer than `newest_review_at` has reached the app although its usual flow would have brought one by `expected_newer_by`: the store's public review feed has served none, and no other sync of the listing has stored one. `unknown`: too few reviews to tell; nothing is claimed."}, "reason": {"type": "string", "enum": ["within_usual_gap", "no_newer_reviews_received", "too_few_reviews"], "description": "Why. `within_usual_gap` goes with `current`, `no_newer_reviews_received` with `behind`, `too_few_reviews` (fewer than 14 reviews in the 28 days up to the newest one) with `unknown`."}, "newest_review_at": {"type": "string", "format": "date-time", "description": "The review time of the newest review stored for the app's listing, from any storefront and any sync. Absent when no stored review has a readable time."}, "reviews_in_window": {"type": "integer", "minimum": 0, "description": "Stored reviews dated in the 28 days up to `newest_review_at`."}, "usual_gap_hours": {"type": "number", "minimum": 0, "description": "The mean time between the app's reviews over those 28 days, in hours. Absent with `too_few_reviews`."}, "expected_newer_by": {"type": "string", "format": "date-time", "description": "When the feed would almost certainly have served a newer review: `newest_review_at` plus the feed's usual delay (48 hours for the App Store, 36 for Google Play) plus four usual gaps. Absent with `too_few_reviews`."}}}, "AppConnectionRequest": {"type": "object", "description": "Connect an app through POST /api/v1/apps or POST /api/v1/apps/connect. Supply platform, app_id and app_name. credential_id is optional.", "required": ["platform", "app_id", "app_name"], "properties": {"platform": {"type": "string", "description": "External platform identifier (e.g. app_store_connect, google_play)."}, "app_id": {"type": "string", "description": "The app's identifier in the store (e.g. an App Store numeric id)."}, "app_name": {"type": "string", "description": "Display name for the app."}, "credential_id": {"type": "string", "description": "Optional stored credential to sync with. Without one the app is created and synced through the public store feed."}, "icon_url": {"type": "string", "description": "Optional app icon URL. Read by `POST /api/v1/apps/connect` only; `POST /api/v1/apps` ignores it."}, "platform_metadata": {"type": "object", "additionalProperties": true, "description": "Free-form platform-specific fields stored with the app."}, "auto_sync_enabled": {"type": "boolean", "default": true, "description": "Whether the app is synced on a schedule after connection."}, "auto_sync_frequency": {"type": "string", "enum": ["hourly", "daily", "weekly"], "default": "daily", "description": "How often the scheduled sync runs."}}}, "AnalysisStartResponse": {"type": "object", "description": "Answer to POST /api/v1/apps/analyze and POST /api/v1/apps/{appUuid}/analyze. Which fields are present depends on the outcome; see the 200 and 202 descriptions. A start never answers with the analysis it queues: follow `run_id` (or `poll_url`) and read the result from GET /api/v1/apps/{appUuid}/insights. Only the stale-lock answer (`cached: true`) carries an analysis, the latest stored one.", "required": ["success", "analysis_status", "message"], "properties": {"success": {"type": "boolean"}, "app_uuid": {"type": "string", "format": "uuid", "description": "The app, on a 202 for a queued or running analysis."}, "status": {"type": "string", "enum": ["analysis_queued", "analysis_running"], "description": "On a 202: `analysis_queued` when this request queued the analysis, `analysis_running` when one was already in progress."}, "analysis_status": {"type": "string", "enum": ["running", "pending", "completed"], "description": "The app's analysis state after this request. `running`: queued or in progress. `pending`: a stale lock was cleared and nothing was queued. `completed`: nothing was queued (a recent analysis, or the latest stored one is returned)."}, "queued": {"type": "boolean", "description": "`true` when this request queued the analysis."}, "message": {"type": "string", "description": "A short English description of the outcome."}, "poll_url": {"type": "string", "description": "Path of GET /api/v1/apps/{appUuid}/status, on a 202 for a queued or running analysis."}, "run_id": {"type": "string", "format": "uuid", "description": "The analysis run to follow (GET run_url). Present on a 202 when the analysis is tracked."}, "run_url": {"type": "string", "description": "Path of GET /api/v1/apps/{appUuid}/analysis-runs/{runId} for this run."}, "run_status": {"type": "string", "enum": ["queued", "running"]}, "stale_lock_cleared": {"type": "boolean", "description": "`true` when this request cleared an expired analysis lock."}, "last_analysis": {"type": ["string", "null"], "format": "date-time", "description": "When the app's last analysis completed, on the `Analysis completed recently` answer. Null when the app has no recorded completion time."}, "cached": {"type": "boolean", "description": "`true` when the answer carries the latest stored analysis in `insights`."}, "insights": {"$ref": "#/components/schemas/InsightBundle", "description": "Only with `cached: true` and `stale_lock_cleared: true`: the latest stored analysis, read with the same rules as GET /api/v1/apps/{appUuid}/insights (for an app tracked through its public store listing, the shared analysis of that listing, with `computed_at`)."}}}, "AnalysisRun": {"type": "object", "description": "One accepted AI analysis of an app. Additional fields may be added.", "required": ["run_id", "app_uuid", "status", "trigger", "requested_by", "force_refresh", "created_at", "updated_at", "cancellable", "links"], "properties": {"run_id": {"type": "string", "format": "uuid", "description": "Time-ordered (UUIDv7) identifier."}, "app_uuid": {"type": "string", "format": "uuid"}, "status": {"type": "string", "enum": ["queued", "running", "succeeded", "failed", "cancelled"], "description": "`succeeded`, `failed` and `cancelled` are terminal and never change."}, "stage": {"type": ["string", "null"], "enum": ["queued", "analyzing", "waiting_to_resume", null], "description": "Detail of an open run; null once terminal. `waiting_to_resume`: a long analysis paused between passes and continues on the next scheduled pass (about every 15 minutes)."}, "trigger": {"type": "string", "enum": ["request", "sync"], "description": "`request` for POST /api/v1/apps/analyze, `sync` when queued after a review sync."}, "requested_by": {"type": "string", "enum": ["user", "api_key", "system"]}, "force_refresh": {"type": "boolean"}, "created_at": {"type": "string", "format": "date-time"}, "started_at": {"type": ["string", "null"], "format": "date-time", "description": "When a worker first picked the run up."}, "updated_at": {"type": "string", "format": "date-time"}, "completed_at": {"type": ["string", "null"], "format": "date-time", "description": "When a terminal state was recorded. Null for an open run, and for a stopped run until the pipeline records it."}, "cancellable": {"type": "boolean", "description": "Whether POST .../cancel would currently succeed (no worker holds the run)."}, "progress": {"type": "object", "description": "Reported at checkpoints between analysis passes; absent until the first one.", "properties": {"reviews_processed": {"type": "integer", "minimum": 0}, "reviews_in_window": {"type": "integer", "minimum": 1}}}, "result": {"type": "object", "description": "Present when `status` is `succeeded`; each field only when it was recorded. Read the insights from `links.insights`.", "properties": {"cached": {"type": "boolean", "description": "Recent insights were reused instead of analyzing again."}, "no_new_reviews": {"type": "boolean"}, "reviews_analyzed": {"type": "integer", "minimum": 0}}}, "error": {"type": "object", "description": "Present when `status` is `failed`. Messages are safe to show to customers.", "required": ["code", "message", "retryable"], "properties": {"code": {"type": "string", "enum": ["ANALYSIS_INTERRUPTED", "ANALYSIS_QUEUE_FAILED", "ANALYSIS_FAILED", "AI_SERVICE_UNAVAILABLE", "INSIGHTS_PERSIST_FAILED", "NO_REVIEWS", "UNSUPPORTED_PLATFORM", "SUBSCRIPTION_REQUIRED", "APP_NOT_ACCESSIBLE"]}, "message": {"type": "string"}, "retryable": {"type": "boolean", "description": "Whether starting a new run can succeed without other changes."}}}, "superseded_by_run_id": {"type": "string", "format": "uuid", "description": "A newer run that took over the app's analysis from this one."}, "links": {"type": "object", "required": ["self", "status", "insights"], "properties": {"self": {"type": "string"}, "status": {"type": "string"}, "insights": {"type": "string"}}}}}, "AnalysisRunResponse": {"type": "object", "required": ["run"], "properties": {"run": {"$ref": "#/components/schemas/AnalysisRun"}}}, "AnalysisRunList": {"type": "object", "required": ["app_uuid", "runs", "count", "has_more"], "properties": {"app_uuid": {"type": "string", "format": "uuid"}, "runs": {"type": "array", "items": {"$ref": "#/components/schemas/AnalysisRun"}}, "count": {"type": "integer", "minimum": 0}, "has_more": {"type": "boolean"}, "next_cursor": {"type": ["string", "null"]}}}, "AnalysisRequest": {"type": "object", "required": ["app_uuid"], "properties": {"app_uuid": {"type": "string"}, "force_refresh": {"type": "boolean", "default": false, "description": "Analyze again even when an analysis completed in the last five minutes or a stored one can be answered, rather than answering that one. An analysis already running is answered either way. The text `true`, `1` or `yes` is read as true and any other text as false."}}}, "AppAnalysisRequest": {"type": "object", "description": "The start of an analysis for the app in the path.", "properties": {"force_refresh": {"type": "boolean", "default": false, "description": "As `force_refresh` of `POST /api/v1/apps/analyze`."}}}, "ReviewSummary": {"type": "object", "description": "A review as returned by the listing endpoints and by `GET /api/v1/reviews/{reviewId}`. Translation, quality, reply and assignment fields are optional: they depend on the review source, the enrichment that has run, whether the store shows a developer reply, and whether someone in the caller's workspace assigned the review.", "required": ["review_id"], "properties": {"review_id": {"type": "string"}, "title": {"type": "string"}, "content": {"type": "string", "description": "Review text. Named `content`, not `body`; `body` was documented but never returned by any endpoint."}, "rating": {"type": "integer"}, "sentiment": {"oneOf": [{"type": "string", "enum": ["positive", "negative", "neutral", "mixed"]}, {"type": "null"}], "description": "The label stored on the review where there is one, otherwise the star rating in three buckets (4 to 5 positive, 3 neutral, 1 to 2 negative). `sentiment_source` says which. It is the same value `?sentiment=` filters on, so a filtered listing returns rows carrying the sentiment that was matched. `null` when there is neither a label nor a usable rating, which distinguishes \"measured neutral\" from \"nothing to measure\"; such a review matches no sentiment filter."}, "sentiment_source": {"oneOf": [{"type": "string", "enum": ["rating", "analysis"]}, {"type": "null"}], "description": "Where `sentiment` came from. `rating`: the label is the review's star rating in three buckets and says nothing about its text, so a five-star review whose text is a complaint is `positive`. Most reviews carry no stored label, so this is the usual value. `analysis`: a label stored on the review by an analysis that read it. `null` exactly when `sentiment` is `null`. Show a `rating` label as the star rating it is, not as a reading of the review."}, "detected_language": {"type": "string", "description": "Detected language code. Named `detected_language`, not `locale`; `locale` was documented but never returned. Absent when the review was never analysed; `und` (or `unknown`, `mixed`) when analysis could not name one language. Clients should show both as unknown."}, "created_at": {"type": "string", "format": "date-time"}, "tag_ids": {"type": "array", "items": {"type": "string"}, "description": "The workspace's own tags on this review, ordered by tag name. Empty when the review is untagged. Absent only when the tags could not be read for this page (the reviews are still returned), so an absent field means \"unknown\", not \"untagged\"."}, "tags": {"type": "array", "items": {"$ref": "#/components/schemas/ReviewTagRef"}, "description": "The tags in `tag_ids`, with the name and colour to show."}, "app_id": {"type": ["string", "null"]}, "app_version": {"type": ["string", "null"]}, "author": {"type": ["string", "null"]}, "content_en": {"type": "string", "description": "English text of the review. The translation when there is one, otherwise the review text itself."}, "date": {"type": ["string", "null"]}, "original_review_id": {"type": ["string", "null"]}, "platform": {"type": ["string", "null"]}, "review_date": {"type": ["string", "null"]}, "source": {"type": ["string", "null"]}, "territory": {"type": ["string", "null"]}, "title_en": {"type": "string", "description": "English title. The translation when there is one, otherwise the title itself, and empty when the review has no title."}, "fetched_at": {"type": ["string", "null"], "format": "date-time"}, "translated_at": {"type": ["string", "null"], "format": "date-time"}, "is_translated": {"type": "boolean"}, "quality_score": {"type": "number"}, "quality_version": {"type": "integer"}, "quality_flags": {"type": "array", "items": {"type": "string"}, "description": "Quality flags attached to this review."}, "engagement_metrics": {"type": "object", "additionalProperties": true, "description": "Optional engagement metrics recorded for the review; keys vary by source and assessment version."}, "quality_signals": {"type": "object", "additionalProperties": true, "description": "Optional quality signals recorded for the review; keys vary by source and assessment version."}, "updated_at": {"type": "string", "format": "date-time", "description": "When a sync last saw the reviewer change the text or rating."}, "developer_responded": {"type": "boolean", "description": "True when Tritonium knows of a developer reply to this review: one posted through Tritonium, or one Google Play showed when the review was read. False does not mean unanswered: the App Store does not report replies written in App Store Connect, and Google Play shows a reply only on the reviews it serves at read time."}, "reply": {"$ref": "#/components/schemas/ReviewReply"}, "reply_text": {"type": ["string", "null"], "description": "The developer reply's text, the same as `reply.text`."}, "reply_posted_at": {"type": "string", "format": "date-time", "description": "When the developer reply was published."}, "reply_metrics": {"$ref": "#/components/schemas/ReviewReplyMetrics"}, "reply_outcome": {"$ref": "#/components/schemas/ReviewReplyOutcome"}, "helpful_count": {"type": "integer", "description": "Store helpfulness votes, where the store reports them."}, "reviewer_language": {"type": "string", "description": "Language the reviewer's store locale names (Google Play)."}, "device": {"type": "string", "description": "Device the review was written on, where the store reports it."}, "android_os_version": {"type": "string", "description": "Android version the review was written on, where the store reports it."}, "themes": {"type": "array", "items": {"type": "string"}, "description": "Themes a batch analysis found in this review."}, "translation": {"type": ["string", "null"], "description": "English translation from a batch analysis, when it made one."}, "ai_insight": {"type": ["string", "null"], "description": "One-line insight from a batch analysis, when it made one."}, "batch_analyzed": {"type": "boolean", "description": "True when a batch analysis has read this review."}, "analyzed_at": {"type": "string", "format": "date-time", "description": "When a batch analysis read this review."}, "assignee_id": {"type": "string", "description": "The team member in the caller's workspace this review is assigned to. Absent when it is not assigned. Assignment belongs to the workspace, not to the store review."}, "assignee_email": {"type": "string"}, "assignee_name": {"type": "string"}, "assigned_at": {"type": "string", "format": "date-time"}, "assigned_by": {"type": "string", "description": "Who made the assignment."}, "routing_rule_id": {"type": "string", "description": "The routing rule that assigned it, when a rule did."}}}, "ReviewReply": {"type": "object", "description": "The developer reply the store shows for this review. A reply read from the store has `source: store`; a reply posted through Tritonium also carries the store's `response_id` and the language it was written in.", "properties": {"text": {"type": "string"}, "posted_at": {"type": "string", "format": "date-time"}, "platform": {"type": "string"}, "source": {"type": "string", "description": "`store` when the reply was read from the store feed."}, "response_id": {"type": "string"}, "reply_language": {"type": "string"}, "reconciled_by": {"type": "string", "description": "Set when a reply posted earlier was confirmed against the store."}}}, "ReviewReplyMetrics": {"type": "object", "description": "Recorded when a reply is posted through Tritonium.", "properties": {"response_minutes": {"type": "number", "description": "Minutes from the review to the reply."}, "rating_at_reply": {"type": "number"}, "reported_sentiment": {"type": "string"}, "reply_count": {"type": "integer", "description": "Replies posted to this review through Tritonium, this one included."}}}, "ReviewReplyOutcome": {"type": "object", "description": "What happened after the reply, once someone recorded it.", "properties": {"outcome": {"type": "string", "enum": ["positive", "negative", "neutral"]}, "rating_change": {"type": ["integer", "null"]}, "sentiment_improved": {"type": ["boolean", "null"]}, "new_rating": {"type": ["integer", "null"]}, "new_sentiment": {"type": ["string", "null"]}, "user_updated_review": {"type": "boolean"}, "tracked_at": {"type": "string", "format": "date-time"}, "reply_posted_at": {"type": "string", "format": "date-time"}}}, "ReviewResponse": {"type": "object", "required": ["review"], "properties": {"review": {"$ref": "#/components/schemas/ReviewSummary"}}}, "ReviewListResponse": {"type": "object", "description": "Response shared by GET /api/v1/reviews and GET /api/v1/apps/{appUuid}/reviews.", "required": ["reviews"], "properties": {"reviews": {"type": "array", "items": {"$ref": "#/components/schemas/ReviewSummary"}}, "count": {"type": "integer", "description": "Number of reviews in this page."}, "app_uuid": {"type": "string", "format": "uuid", "description": "The app this page lists. The reviews themselves carry no app identifier of yours: a store review belongs to the store listing, which other workspaces can track too."}, "has_more": {"type": "boolean", "description": "True when further pages exist. Follow `cursor` to fetch them; stop when this is false."}, "cursor": {"type": ["string", "null"], "description": "Opaque cursor for the next page, or null on the last page. Pass it back unmodified as the `cursor` query parameter."}, "filtered": {"type": "boolean", "description": "Present and true only when a search or content filter was applied, meaning the three coverage fields below are meaningful. Absent on an unfiltered listing."}, "scanned_count": {"type": "integer", "description": "How many stored reviews were examined to build this page. Filters have no index behind them, so they are applied after the read; this is the honest coverage of the answer. Reviews are examined newest first, so a sweep that stopped early covered the newest `scanned_count` reviews after the cursor."}, "search_truncated": {"type": "boolean", "description": "True when the scan stopped at its per-request bound before reaching the end of the corpus. Page on with `cursor` to continue the sweep; false means the corpus was exhausted, so an empty `reviews` really does mean \"no matches exist\"."}, "search_hex_applied": {"type": "boolean", "description": "Present and true only when the request's `search_hex` was decoded and applied. Its absence from the answer to a `search_hex` request means the term was not applied."}}}, "ReviewReplyGenerateRequest": {"type": "object", "description": "Context for the reply. Every member is optional; an empty body or none generates from the review alone.", "properties": {"issue_type": {"type": "string", "description": "The kind of issue the review raises. Left out, the review's own stored issue type."}, "known_issues": {"type": "array", "items": {"type": "string"}}, "fix_eta": {"type": "string", "description": "When a fix is expected, in the team's words."}, "similar_review_count": {"type": "integer"}, "user_segment": {"type": "string"}, "language": {"type": "string", "description": "The language to reply in, as a language code such as `en` or `pt-BR`. A value that names no language (`und`, `unknown`, `mixed`) is ignored. Left out, the review's own language, else English."}, "reply_language": {"type": "string", "description": "The same as `language`, read when `language` is left out."}}}, "ReviewReplyPostRequest": {"type": "object", "required": ["reply_text"], "properties": {"reply_text": {"type": "string", "maxLength": 5970, "description": "The reply, published as given. `reply` is read when this is left out."}, "reply_language": {"type": "string", "maxLength": 35, "description": "The reply's language code. `language` is read when this is left out."}, "reply_template": {"$ref": "#/components/schemas/ReplyTemplateSelection"}}}, "ReviewReplyStatus": {"type": "object", "description": "The reply currently stored against a review. `reply` and `reply_text` are both null and `developer_responded` is false when no reply has been published yet.", "required": ["review_id", "app_uuid", "app_name", "developer_responded", "reply", "reply_text"], "properties": {"review_id": {"type": "string"}, "app_uuid": {"type": "string"}, "app_name": {"type": ["string", "null"]}, "developer_responded": {"type": "boolean"}, "reply": {"description": "The reply, read from the store (`source: store`) or posted through Tritonium. Who posted it is not answered.", "oneOf": [{"$ref": "#/components/schemas/ReviewReply"}, {"type": "null"}]}, "reply_text": {"type": ["string", "null"], "description": "The reply's text, as `reply.text`."}}}, "ReviewReplyResult": {"type": "object", "deprecated": true, "description": "Not the answer of any operation, and never was: publishing a reply answers `ReplyPublication`. Kept so that existing clients still compile.", "properties": {"review_id": {"type": "string"}, "status": {"type": "string"}, "posted_at": {"type": "string", "format": "date-time"}, "source_reference": {"type": "string"}}}, "ReviewReplyDraft": {"type": "object", "deprecated": true, "description": "Not the answer of any operation, and never was: generating a reply answers `ReplyDraftGenerated`. Kept so that existing clients still compile.", "properties": {"reply_text": {"type": "string"}, "tone": {"type": "string"}, "suggested_actions": {"type": "array", "items": {"type": "string"}}}}, "ReplyDraftGenerated": {"type": "object", "description": "The draft a Generate saved, or the draft that already awaits approval for the review (`existing` true). Nothing is published.", "required": ["draft_id", "reply", "status", "review_id", "app_uuid", "reply_language", "preview", "message", "next_steps"], "properties": {"draft_id": {"type": "string", "format": "uuid"}, "reply": {"type": "string", "description": "The draft's reply text."}, "status": {"type": "string", "enum": ["pending_approval"]}, "review_id": {"type": "string"}, "app_uuid": {"type": "string", "format": "uuid"}, "reply_language": {"type": "string", "description": "The language the reply is written in."}, "preview": {"$ref": "#/components/schemas/ReplyStorePreview"}, "message": {"type": "string"}, "next_steps": {"type": "object", "required": ["approve", "reject", "view"], "description": "The paths that approve, reject and read the draft.", "properties": {"approve": {"type": "string"}, "reject": {"type": "string"}, "view": {"type": "string"}}}, "existing": {"type": "boolean", "const": true, "description": "Present only when the draft already awaited approval; no reply was generated."}}}, "ReplyStorePreview": {"type": "object", "description": "How the reply will look on the store, with any problem found in it.", "required": ["platform", "review", "reply", "warnings", "platform_display"], "properties": {"platform": {"type": "string", "description": "The store, `app_store_connect` or `google_play`."}, "review": {"type": "object", "required": ["rating", "rating_display", "title", "content", "author", "date"], "properties": {"rating": {"type": ["integer", "null"]}, "rating_display": {"type": "string", "description": "The rating as five stars, filled and empty."}, "title": {"type": "string"}, "content": {"type": "string"}, "author": {"type": "string"}, "date": {"type": "string", "description": "The review's date as shown, for example `Oct 29, 2026`."}}}, "reply": {"type": "object", "required": ["text", "character_count", "developer_name", "date", "max_characters"], "properties": {"text": {"type": "string"}, "character_count": {"type": "integer", "minimum": 0}, "developer_name": {"type": "string"}, "date": {"type": "string"}, "max_characters": {"type": "integer", "description": "The store's limit, 350 on Google Play and 5970 on the App Store."}}}, "warnings": {"type": "array", "items": {"type": "object", "required": ["type", "message"], "properties": {"type": {"type": "string", "enum": ["error", "warning", "info"], "description": "`error` means the store will refuse the reply as it is."}, "code": {"type": "string", "description": "`REPLY_HAS_PLACEHOLDER` for an unfilled `{placeholder}`."}, "message": {"type": "string"}}}}, "platform_display": {"type": "string", "description": "The store's name, `Google Play Store` or `Apple App Store`."}}}, "ReplyPublication": {"type": "object", "description": "What publishing a reply did. `success` true means the store accepted it. `store_published` true means it is live on the store, even when `success` is false because recording it failed: do not publish it again. When nothing was published, `error` says why and `code` names it where it can.", "required": ["success"], "properties": {"success": {"type": "boolean"}, "store_published": {"type": "boolean"}, "error": {"type": "string"}, "code": {"type": "string", "description": "For example `CREDENTIALS_REQUIRED`, `REPLY_HAS_PLACEHOLDER`, `REPLY_TOO_LONG`, `REVIEW_ALREADY_REPLIED`, `REVIEW_CHANGED`, `REPLY_CONFLICT` or `REPLY_VERIFICATION_PENDING`."}, "blocked": {"type": "boolean", "description": "True when Tritonium refused to publish (nothing was sent)."}, "status_code": {"type": "integer", "description": "The store's HTTP status, when the store refused."}, "draft_id": {"type": "string", "format": "uuid"}, "draft_status": {"$ref": "#/components/schemas/ReplyDraftStatus"}, "claim_release_failed": {"type": "boolean", "description": "Nothing was published, and the draft is left in `posting`."}, "draft_status_write_failed": {"type": "boolean", "description": "The reply is live, and the draft is left in `posting` until it is marked posted."}, "review_record_write_failed": {"type": "boolean", "description": "The reply is live, and recording it on the review failed."}, "reply": {"type": "object", "description": "The reply as recorded on the review.", "properties": {"text": {"type": "string"}, "posted_at": {"type": "string", "format": "date-time"}, "posted_by": {"type": "string", "description": "The user id of whoever published it."}, "platform": {"type": "string"}, "tenant_id": {"type": "string"}, "response_id": {"type": "string"}, "reply_language": {"type": "string"}, "template_provenance": {"type": "object", "additionalProperties": true, "description": "The template the reply came from, when one was chosen."}}}, "platform": {"type": "string"}, "review_id": {"type": "string"}, "app_uuid": {"type": "string", "format": "uuid"}}}, "ReplyAnalytics": {"type": "object", "description": "Reply metrics for the selected period, measured over every review in the window. `reviews_scanned` is the number the counts and rates were computed from; only when `truncated` is true (the read hit its runaway bound) do the figures describe a partial window.", "properties": {"app_uuid": {"type": "string", "format": "uuid"}, "period_days": {"type": "integer", "description": "Reporting window the metrics were computed over."}, "reply_rate": {"type": ["number", "null"], "format": "float", "description": "Always null; see `reply_rate_reason`. The share of the period's reviews that have a developer reply cannot be measured: the App Store's feed and its App Store Connect read report no developer replies, and Google Play shows a reply only on the reviews it serves when they are read. Use `tritonium_reply_share` for the reviews answered through Tritonium."}, "reply_rate_reason": {"type": "string", "enum": ["store_replies_not_observable"], "description": "Why `reply_rate` is null: `store_replies_not_observable`, the stores do not report every developer reply (see `reply_rate`)."}, "tritonium_reply_count": {"type": "integer", "description": "Reviews of the period answered through Tritonium from this workspace. A reply the store showed when the review was read, and a reply another workspace posted, are not counted."}, "tritonium_reply_share": {"type": ["number", "null"], "format": "float", "description": "`tritonium_reply_count` as a percentage (0-100) of `total_reviews`. Null when the period has no review. This is not a reply rate: replies written in the store consoles are not in it."}, "replied_count": {"type": "integer", "description": "Alias of `tritonium_reply_count`."}, "total_reviews": {"type": "integer", "description": "Reviews in the period: the denominator of `tritonium_reply_share`. The whole window is read, so this is the app's review count for the period unless `truncated` is true."}, "reviews_scanned": {"type": "integer", "description": "Reviews the figures were measured over. Equals `total_reviews`; published under the name the other analytics surfaces use."}, "average_response_minutes": {"type": ["number", "null"], "format": "float", "description": "Average time from review to reply, in minutes, over the replies this workspace posted through Tritonium in the period."}, "median_response_minutes": {"type": ["number", "null"], "format": "float", "description": "Median of the same response times, in minutes."}, "recent_reply_counts": {"type": "object", "description": "Replies this workspace posted through Tritonium in the last 7 and 30 calendar days, whatever the period.", "properties": {"last_7_days": {"type": "integer"}, "last_30_days": {"type": "integer"}}}, "avg_response_time": {"type": ["number", "null"], "format": "float", "description": "Alias of `average_response_minutes`, in minutes."}, "median_response_time": {"type": ["number", "null"], "format": "float", "description": "Alias of `median_response_minutes`, in minutes."}, "response_rate": {"type": ["number", "null"], "format": "float", "description": "Alias of `reply_rate`, always null."}, "average_response_time_minutes": {"type": ["number", "null"], "format": "float", "description": "Alias of `average_response_minutes`."}, "escalations": {"type": "integer", "description": "Replies this workspace posted through Tritonium in the period whose tracked outcome is negative."}, "truncated": {"type": "boolean", "description": "True only when the window read stopped at its runaway bound before the period was complete. Treat every figure as measured over `reviews_scanned` rows rather than the whole period."}, "metrics": {"type": "object", "additionalProperties": true}}}, "ReplyPerformance": {"type": "object", "description": "Outcome analysis for the replies this workspace posted through Tritonium on the period's reviews, measured over every review in the window. A reply the store showed when the review was read, and a reply another workspace posted, are not counted: only Tritonium's own replies are known in full and have tracked outcomes. As with `ReplyAnalytics`, `truncated` marks a read stopped at its runaway bound.", "properties": {"app_uuid": {"type": "string", "format": "uuid"}, "period_days": {"type": "integer"}, "total_replies": {"type": "integer", "description": "Replies this workspace posted through Tritonium on the period's reviews."}, "replies_with_outcomes": {"type": "integer", "description": "Replies carrying a recognised outcome label; the denominator for `effectiveness_rate`."}, "outcome_breakdown": {"type": "object", "properties": {"positive": {"type": "integer"}, "neutral": {"type": "integer"}, "negative": {"type": "integer"}}}, "effectiveness_rate": {"type": "number", "format": "float", "description": "Percentage (0-100) of outcomes that were positive."}, "average_rating_change": {"type": ["number", "null"], "format": "float"}, "sentiment_metrics": {"type": "object", "properties": {"improvements": {"type": "integer"}, "declines": {"type": "integer"}, "improvement_rate": {"type": ["number", "null"], "format": "float"}}}, "alerts": {"type": "array", "items": {"type": "object", "properties": {"type": {"type": "string"}, "code": {"type": "string", "enum": ["no_replies", "low_effectiveness"], "description": "Stable identifier for the alert so clients can render it in the customer's language; `title`/`message` are the English rendering."}, "params": {"type": "object", "description": "Numbers the alert's sentence needs (`rate` for `low_effectiveness`).", "additionalProperties": {"type": "number"}}, "title": {"type": "string"}, "message": {"type": "string"}, "recommendation": {"type": "string"}}}}, "recommendation": {"type": "string"}, "recommendation_code": {"type": "string", "enum": ["no_replies", "outcomes_pending", "strong", "moderate", "low"], "description": "Stable identifier for `recommendation` so clients can render it in the customer's language."}, "reviews_scanned": {"type": "integer", "description": "Reviews in the period the replies were counted from."}, "truncated": {"type": "boolean", "description": "True only when the window read stopped at its runaway bound before the period was complete."}}}, "Insight": {"type": "object", "deprecated": true, "description": "Not returned by any operation. The analyze answers once named it for their `insights`; they carry an `InsightBundle`. Kept so existing SDK imports keep working.", "properties": {"insight_id": {"type": "string"}, "app_uuid": {"type": "string"}, "generated_at": {"type": "string", "format": "date-time"}, "summary": {"type": "string"}, "key_drivers": {"type": "array", "items": {"type": "object", "properties": {"driver": {"type": "string"}, "sentiment": {"type": "string"}, "change": {"type": "number"}}}}, "opportunities": {"type": "array", "items": {"type": "string"}}}}, "RatingHistoryPoint": {"type": "object", "description": "One calendar day of rating activity for an app.", "properties": {"date": {"type": "string", "format": "date"}, "count": {"type": "integer", "description": "Number of reviews received that day."}, "average": {"type": "number", "format": "float", "description": "Mean star rating for that day, rounded to 2dp."}, "distribution": {"type": "object", "description": "Star value (as a string, \"1\"..\"5\") to the number of reviews with that rating that day. Star values with no reviews are omitted.", "additionalProperties": {"type": "integer"}}}}, "RatingSummary": {"type": "object", "properties": {"app_uuid": {"type": "string"}, "days": {"type": "integer", "description": "Length of the summary window in days."}, "total_reviews": {"type": "integer", "description": "Rated reviews inside the window (after any platform filter). The window is read in full, so this is the period's count unless `truncated` is true."}, "average_rating": {"type": ["number", "null"], "format": "float", "description": "Mean star rating over the window. Null when the window contained no rated review: that is an absence of a measurement, not a zero-star app, and rendering it as 0 produces a full-scale fake drop against a populated previous window."}, "previous_average": {"type": ["number", "null"], "format": "float", "description": "Always null; kept so that older clients keep parsing the answer. It was the average of the stored reviews of the window of equal length before this one, and subtracting it from `average_rating` read a change in which storefronts were read as a change in the rating. Read the change from `rating_change`."}, "rating_change": {"$ref": "#/components/schemas/RatingChange"}, "reviews_scanned": {"type": "integer", "description": "Reviews the scan produced inside the current or preceding window, before the rating and platform filters. This is not `total_reviews`, which counts only rated current-window rows after any platform filter."}, "truncated": {"type": "boolean", "description": "True only when a runaway safety bound stopped the scan, in which case every figure here is measured over part of the window. False in normal operation: the window is read in full."}, "distribution": {"type": "object", "description": "Review counts keyed by star rating (\"1\"..\"5\").", "additionalProperties": {"type": "integer"}}, "by_platform": {"type": "object", "description": "Per-platform counts and averages inside the window.", "additionalProperties": {"type": "object", "properties": {"count": {"type": "integer"}, "average": {"type": "number", "format": "float"}}}}}}, "SpotlightTrends": {"type": "object", "required": ["rating_change", "rating_delta", "rating_trend", "volume_change", "volume_delta", "volume_trend", "sentiment_change", "sentiment_delta", "sentiment_trend", "current_period", "previous_period"], "description": "The rating, volume and sentiment changes of an executive spotlight, each between the same two windows of whole, settled days (null, with the reason in its `*_change` object, when they cannot be measured).", "properties": {"rating_change": {"$ref": "#/components/schemas/RatingChange"}, "rating_delta": {"type": ["number", "null"], "format": "float", "description": "`rating_change.change`."}, "rating_trend": {"oneOf": [{"type": "string", "enum": ["up", "down", "flat"]}, {"type": "null"}]}, "volume_change": {"$ref": "#/components/schemas/WholeDayCountChange"}, "volume_delta": {"type": ["number", "null"], "description": "`volume_change.change`: the newer window's reviews less the older one's."}, "volume_trend": {"oneOf": [{"type": "string", "enum": ["up", "down", "flat"]}, {"type": "null"}]}, "sentiment_change": {"$ref": "#/components/schemas/WholeDayCountChange"}, "sentiment_delta": {"type": ["number", "null"], "format": "float", "description": "`sentiment_change.change`: the move in the positive share, in percentage points."}, "sentiment_trend": {"oneOf": [{"type": "string", "enum": ["up", "down", "flat"]}, {"type": "null"}]}, "current_period": {"type": "string", "description": "The period the other figures cover, for example `last 30d`."}, "previous_period": {"type": "string"}}}, "WholeDayCountChange": {"type": "object", "description": "A volume or sentiment change between the two windows of whole days a rating change compares (`RatingChange`).", "required": ["change", "reason", "days_each", "current", "previous"], "properties": {"change": {"type": ["number", "null"], "format": "float", "description": "Volume: the newer window's reviews less the older one's. Sentiment: the move in the share of reviews with a positive sentiment, in percentage points to one decimal. Null when it cannot be measured."}, "reason": {"oneOf": [{"type": "string", "enum": ["no_recent_sync", "feed_limited", "sync_limited", "insufficient_history", "too_few_ratings", "unknown"]}, {"type": "null"}], "description": "Null when `change` is measured; otherwise the reason, as in `RatingChange`. `too_few_ratings` here means fewer than 30 reviews with a sentiment in a window."}, "days_each": {"type": ["integer", "null"], "description": "Days in each window."}, "current": {"oneOf": [{"$ref": "#/components/schemas/WholeDayCountWindow"}, {"type": "null"}]}, "previous": {"oneOf": [{"$ref": "#/components/schemas/WholeDayCountWindow"}, {"type": "null"}]}}}, "WholeDayCountWindow": {"type": "object", "properties": {"first_day": {"type": "string", "format": "date"}, "last_day": {"type": "string", "format": "date"}, "reviews": {"type": "integer", "description": "Reviews in the window (for sentiment, those with a sentiment)."}, "positive_share": {"type": ["number", "null"], "format": "float", "description": "Sentiment only. The percentage of the window's reviews with a positive sentiment."}}}, "RatingChange": {"type": "object", "description": "The change in the app's average star rating between two equal, back-to-back windows of whole, settled days. It does not depend on `days`. A window of stored reviews holds the reviews of whichever storefronts were read for those days: a store's capped feed serves each storefront's newest reviews, so a day counts as whole only once the latest sync read every storefront back past it, and a day is settled three days after it ends. The windows are four weeks each when the app's syncs read the store in full, and as many whole weeks each as a capped feed's coverage holds (at least one). The newer window ends on the last settled day, or where the last sync's days settled. Each window must hold at least 30 ratings. Otherwise `change` is null and `reason` says why: no change is reported rather than one a coverage change produced.", "required": ["change", "reason", "days_each", "current", "previous"], "properties": {"change": {"type": ["number", "null"], "format": "float", "description": "The newer window's average star rating minus the older window's, exact to two decimals. Null when it cannot be measured."}, "reason": {"oneOf": [{"type": "string", "enum": ["no_recent_sync", "feed_limited", "sync_limited", "insufficient_history", "too_few_ratings", "unknown"]}, {"type": "null"}], "description": "Null when `change` is measured. Otherwise: `no_recent_sync`, the app has no completed sync in the last 14 days; `feed_limited`, the store's capped feed has not yet been read whole for two weeks; `sync_limited`, the last sync was cut by the plan's review allowance or a failed request; `insufficient_history`, no settled day yet; `too_few_ratings`, a window holds fewer than 30 ratings; `unknown`, the app row or the ratings could not be read completely."}, "days_each": {"type": ["integer", "null"], "description": "Days in each window; null when the windows could not be set."}, "current": {"description": "The newer window; null until the windows are set and read.", "oneOf": [{"$ref": "#/components/schemas/RatingChangeWindow"}, {"type": "null"}]}, "previous": {"description": "The older window; null until the windows are set and read.", "oneOf": [{"$ref": "#/components/schemas/RatingChangeWindow"}, {"type": "null"}]}}}, "RatingChangeWindow": {"type": "object", "description": "One window of a rating change.", "required": ["first_day", "last_day", "ratings", "average"], "properties": {"first_day": {"type": "string", "format": "date", "description": "First UTC day of the window."}, "last_day": {"type": "string", "format": "date", "description": "Last UTC day of the window."}, "ratings": {"type": "integer", "description": "Rated reviews posted on the window's days."}, "average": {"type": ["number", "null"], "format": "float", "description": "Their average star rating; null when there are none."}}}, "RatingsComparison": {"type": "object", "properties": {"app_uuid": {"type": "string"}, "ratings": {"type": "array", "description": "Latest synced rating snapshot per competitor of the app.", "items": {"type": "object", "properties": {"platform": {"type": "string"}, "external_app_id": {"type": "string"}, "display_name": {"type": ["string", "null"]}, "average_rating": {"type": ["number", "null"], "format": "float"}, "total_reviews": {"type": ["integer", "null"]}, "rating_source": {"$ref": "#/components/schemas/CompetitorMetricSource"}, "review_count_source": {"$ref": "#/components/schemas/CompetitorMetricSource"}, "last_synced_at": {"type": ["string", "null"], "format": "date-time"}}}}}}, "CompetitorUpsertRequest": {"type": "object", "required": ["platform", "external_app_id"], "properties": {"platform": {"type": "string", "enum": ["ios", "app_store", "app_store_connect", "android", "google_play", "play_store"], "description": "Store on which the competitor is tracked. Only App Store and Google Play are supported. Other stores return 400. The name is stored and listed as it was sent, in lowercase; a sync treats the names of one store alike."}, "external_app_id": {"type": "string", "description": "The competitor's id in that store: a numeric track id for the App Store (for example `1113153706`), a package name for Google Play (for example `com.example.app`). Must not contain `#`. Looked up in the store before the competitor is created; an id the store has no app for is rejected with `400`."}, "display_name": {"type": "string", "maxLength": 200, "description": "The name to show. When it is missing or empty, the store's name is used."}, "country": {"type": "string", "minLength": 2, "maxLength": 2, "default": "us", "description": "Two-letter storefront to look `external_app_id` up in. Both stores scope existence by country, so an app published only outside the US is not found on the default `us` storefront. Anything that is not a two-letter code falls back to `us`."}, "metadata": {"type": "object", "additionalProperties": true, "description": "Stored with the competitor and not listed."}, "icon_url": {"type": "string", "description": "The address of the competitor's icon. When it is missing or empty, the store's icon is used. An address that is not http or https, is longer than 2,000 characters or points at a private or local host is dropped, and the competitor has no icon until its first sync."}}}, "FeatureImpactEntry": {"type": "object", "description": "One recorded feature launch. Every member is always sent.", "required": ["feature", "rating_delta", "sentiment", "notes", "recorded_at"], "properties": {"feature": {"type": "string", "description": "The `feature_name` it was recorded with."}, "rating_delta": {"type": "number", "minimum": -4, "maximum": 4, "description": "Change in the app star rating attributed to this feature launch, from -4 to +4 stars. This is the value recorded through POST /api/v1/apps/{uuid}/features/history, and 0 when none was recorded."}, "sentiment": {"type": "string", "enum": ["positive", "negative", "neutral"], "description": "Sign of `rating_delta`."}, "notes": {"type": ["string", "null"], "description": "The recorded `metadata.notes`; null when none was given."}, "recorded_at": {"type": "string", "format": "date-time", "description": "When the launch was recorded."}}}, "RatingForecastPoint": {"type": "object", "description": "One daily estimate of written-review stars, not the displayed store rating or the full population of ratings without written reviews. Future displayed store-rating accuracy has not been established. Bounds are uncalibrated model ranges, not validated confidence intervals.", "required": ["date", "predicted_rating", "confidence_lower", "confidence_upper", "delta_from_current"], "properties": {"date": {"type": "string", "format": "date"}, "predicted_rating": {"type": "number", "format": "float", "description": "Estimated written-review star rating for this date."}, "confidence_lower": {"type": "number", "format": "float", "description": "Lower bound of the uncalibrated model range; no empirical coverage is established."}, "confidence_upper": {"type": "number", "format": "float", "description": "Upper bound of the uncalibrated model range; no empirical coverage is established."}, "delta_from_current": {"type": "number", "format": "float"}}}, "RatingForecastAnomaly": {"type": "object", "description": "A day whose average rating differs from the other days in anomaly_sample. The sample uses all ratings from the last 30 UTC days, or the newest 30 days of loaded reviews when complete counts are unavailable. A day requires at least 10 ratings, at least 7 other days with at least 30 ratings in total, and a difference of at least 3 standard errors and 0.5 stars. Only days satisfying every condition are reported.", "properties": {"date": {"type": "string", "format": "date"}, "observed": {"type": "number", "description": "The day's average rating, to two decimals."}, "expected": {"type": "number", "description": "The average of every rating on the other days of the sample, to two decimals. Not the forecast's baseline (`current_rating`), which averages the newest 100 reviews."}, "delta": {"type": "number", "description": "`observed` minus `expected`, from the two figures as returned, so the three always add up."}, "direction": {"type": "string", "enum": ["below", "above"], "description": "Whether the day's average is below or above the other days'."}, "z_score": {"type": ["number", "null"], "description": "The gap in standard errors; negative below, positive above."}, "severity": {"type": "string", "enum": ["medium", "high", "critical"], "description": "How far past chance the gap is: medium from 3 standard errors, high from 4, critical from 5."}, "review_count": {"type": "integer", "description": "Ratings posted on the day."}, "baseline_days": {"type": "integer", "description": "The other days it was compared with."}, "baseline_reviews": {"type": "integer", "description": "Ratings on those days."}}}, "RatingForecastAnomalyAssessment": {"type": "object", "description": "Whether any day's rating could be compared with the others. An empty `anomalies` list is an all-clear only when `assessed` is true.", "required": ["assessed"], "properties": {"assessed": {"type": "boolean", "description": "True when at least one day was judged."}, "days_judged": {"type": "integer", "description": "Days that held enough ratings and had enough other days to be compared with."}, "days_available": {"type": "integer", "description": "Days with at least one rating in the sample."}, "reason": {"oneOf": [{"type": "string", "enum": ["insufficient_history", "sample_limited", "not_recorded", "no_forecast"]}, {"type": "null"}], "description": "Why no day was judged; null when some were. `insufficient_history`: too few days, or too few ratings a day, to compare one day with the others. `sample_limited`: the day counts could not be read and the reviews the forecast loaded, cut at the 500 it loads, cover too few days; the app may have the history, it was not read (`anomaly_sample.basis` is then `forecast_sample`). `not_recorded`: the forecast was cached before the assessment existed; its anomalies are not served. `no_forecast`: there is no forecast. A forecast cached before `sample_limited` existed gives `insufficient_history` for a busy app whose loaded reviews spanned two or three days. Treat a reason you do not know as \"not compared\"."}}}, "RatingForecastAnomalySample": {"type": "object", "description": "What the days of `anomaly_assessment` were read from. Absent on a forecast cached before this field existed, and when there is no forecast.", "required": ["basis", "first_day", "last_day", "ratings"], "properties": {"basis": {"type": "string", "enum": ["day_counts", "forecast_sample"], "description": "`day_counts`: every rating of the 30 UTC days ending on the day the forecast was made, counted per day, as `GET /api/v1/apps/{uuid}/ratings/history?days=30` counts them. A busy app's days are compared this way. `forecast_sample`: the newest 30 days of the reviews the forecast loaded (at most 500), used when the day counts could not be read whole. For a busy app those are two or three days and nothing can be judged (`anomaly_assessment.reason` is then `sample_limited`)."}, "first_day": {"type": ["string", "null"], "format": "date", "description": "The oldest day of the sample that holds a rating; null when none does."}, "last_day": {"type": ["string", "null"], "format": "date", "description": "The newest day of the sample that holds a rating; null when none does."}, "ratings": {"type": "integer", "description": "Ratings in the sample, over all its days."}}}, "RatingForecastTrendAnalysis": {"type": "object", "properties": {"direction": {"type": "string", "enum": ["increasing", "declining", "stable"], "description": "Unknown model direction values are returned as stable."}, "velocity": {"type": "number", "description": "Predicted rating change in stars per day."}, "velocity_category": {"type": "string", "enum": ["rapid", "moderate", "slow", "minimal"], "description": "An unknown value from the model becomes minimal."}, "rating_change": {"type": "number"}, "description": {"type": "string", "description": "English summary computed from direction and drivers. Each figure reflects a measured count. Clients can use direction and drivers to provide their own wording."}, "drivers": {"type": "array", "description": "The themes that move the rating over the horizon, and which way. Ordered by `review_count`, most first; at most six.", "items": {"$ref": "#/components/schemas/RatingForecastTrendDriver"}}}}, "ForecastThemeId": {"type": "string", "description": "A review theme from the list forecasts are written in: `stability` (crashes and bugs), `performance` (speed and performance), `login_account` (sign-in and account access), `account_restrictions` (account bans and suspensions), `moderation` (content moderation), `billing_pricing` (pricing and subscriptions), `ads`, `ui_design` (design and ease of use), `core_experience`, `feature_requests`, `recent_update` (changes in recent updates), `customer_support`, `notifications`, `data_sync` (sync and connectivity), `media` (photos, video and audio), `content` (the feed and content), `ai_features`, `privacy_security` and `device_compatibility` (device and platform support). A string, not a closed enum: the list may grow, and a client should skip an item whose id it does not know rather than reject the forecast."}, "ForecastThemeCount": {"type": "object", "additionalProperties": false, "required": ["theme_id", "review_count", "sample_size"], "properties": {"theme_id": {"$ref": "#/components/schemas/ForecastThemeId"}, "review_count": {"type": "integer", "minimum": 1, "description": "How many of the reviews the model read support this item: the reviews it cited that were among those it was sent, counted in code."}, "sample_size": {"type": "integer", "minimum": 1, "description": "How many reviews the model read (the same as `data_summary.model_input_reviews`)."}}}, "RatingForecastTrendDriver": {"type": "object", "additionalProperties": false, "required": ["theme_id", "effect", "review_count", "sample_size"], "properties": {"effect": {"type": "string", "enum": ["raises", "lowers"], "description": "Whether the theme lifts the rating or weighs on it."}, "theme_id": {"$ref": "#/components/schemas/ForecastThemeId"}, "review_count": {"type": "integer", "minimum": 1, "description": "How many of the reviews the model read support this item: the reviews it cited that were among those it was sent, counted in code."}, "sample_size": {"type": "integer", "minimum": 1, "description": "How many reviews the model read (the same as `data_summary.model_input_reviews`)."}}}, "RatingForecastKeyFactor": {"type": "object", "additionalProperties": false, "required": ["factor", "impact", "weight", "theme_id", "review_count", "sample_size"], "properties": {"factor": {"type": "string", "description": "The theme's English name, written by code."}, "impact": {"type": "string", "enum": ["positive", "negative", "neutral"]}, "weight": {"type": "string", "enum": ["high", "medium", "low"]}, "theme_id": {"$ref": "#/components/schemas/ForecastThemeId"}, "review_count": {"type": "integer", "minimum": 1, "description": "How many of the reviews the model read support this item: the reviews it cited that were among those it was sent, counted in code."}, "sample_size": {"type": "integer", "minimum": 1, "description": "How many reviews the model read (the same as `data_summary.model_input_reviews`)."}}}, "RatingForecastRecommendedAction": {"type": "object", "additionalProperties": false, "required": ["action", "priority", "theme_id", "review_count", "sample_size"], "properties": {"action": {"type": "string", "description": "Written by code from the theme: \"Act on feedback about crashes and bugs.\""}, "priority": {"type": "string", "enum": ["high", "medium", "low"]}, "expected_outcome": {"type": "string", "description": "The evidence, written by code: \"Based on 8 of the 50 reviews read.\""}, "theme_id": {"$ref": "#/components/schemas/ForecastThemeId"}, "review_count": {"type": "integer", "minimum": 1, "description": "How many of the reviews the model read support this item: the reviews it cited that were among those it was sent, counted in code."}, "sample_size": {"type": "integer", "minimum": 1, "description": "How many reviews the model read (the same as `data_summary.model_input_reviews`)."}}}, "RatingForecastRiskFactor": {"type": "object", "additionalProperties": false, "required": ["risk", "probability", "theme_id", "review_count", "sample_size"], "properties": {"risk": {"type": "string", "description": "The theme's English name, written by code."}, "probability": {"type": "string", "enum": ["high", "medium", "low"]}, "potential_impact": {"type": "string", "description": "The evidence, written by code: \"Based on 8 of the 50 reviews read.\""}, "theme_id": {"$ref": "#/components/schemas/ForecastThemeId"}, "review_count": {"type": "integer", "minimum": 1, "description": "How many of the reviews the model read support this item: the reviews it cited that were among those it was sent, counted in code."}, "sample_size": {"type": "integer", "minimum": 1, "description": "How many reviews the model read (the same as `data_summary.model_input_reviews`)."}}}, "RatingForecastOpportunity": {"type": "object", "additionalProperties": false, "required": ["opportunity", "theme_id", "review_count", "sample_size"], "properties": {"opportunity": {"type": "string", "description": "The theme's English name, written by code."}, "expected_impact": {"type": "string", "description": "The evidence, written by code: \"Based on 8 of the 50 reviews read.\""}, "theme_id": {"$ref": "#/components/schemas/ForecastThemeId"}, "review_count": {"type": "integer", "minimum": 1, "description": "How many of the reviews the model read support this item: the reviews it cited that were among those it was sent, counted in code."}, "sample_size": {"type": "integer", "minimum": 1, "description": "How many reviews the model read (the same as `data_summary.model_input_reviews`)."}}}, "SentimentForecastTheme": {"type": "object", "additionalProperties": false, "required": ["theme", "sentiment", "frequency", "theme_id", "review_count", "sample_size"], "properties": {"theme": {"type": "string", "description": "The theme's English name, written by code."}, "sentiment": {"type": "string", "enum": ["positive", "negative", "mixed"]}, "frequency": {"type": "string", "enum": ["high", "medium", "low"], "description": "Counted, not the model's word: high when at least a fifth of the reviews read support the theme, medium from a tenth, otherwise low."}, "theme_id": {"$ref": "#/components/schemas/ForecastThemeId"}, "review_count": {"type": "integer", "minimum": 1, "description": "How many of the reviews the model read support this item: the reviews it cited that were among those it was sent, counted in code."}, "sample_size": {"type": "integer", "minimum": 1, "description": "How many reviews the model read (the same as `data_summary.model_input_reviews`)."}}}, "RatingForecastRiskAssessment": {"type": "object", "properties": {"assessed": {"type": "boolean", "description": "False when the risks were not assessed: there is no forecast (`insufficient_data` or `generation_failed`), or the model reported risks and none of them had a known theme and valid evidence. `risk_factors` is then absent, and clients must not present the forecast as having no notable risk. True when `risk_factors` is the assessed list."}, "risk_factors": {"type": "array", "description": "The assessed risks. An empty list (with `assessed` true) means the model reported none. Absent when `assessed` is false.", "items": {"$ref": "#/components/schemas/RatingForecastRiskFactor"}}, "opportunities": {"type": "array", "items": {"$ref": "#/components/schemas/RatingForecastOpportunity"}}}}, "RatingForecastConfidence": {"type": "object", "description": "Heuristic input-quality indicators, not a calibrated probability of forecast correctness or evidence of future displayed store-rating accuracy.", "properties": {"overall_score": {"type": "number"}, "level": {"type": "string", "enum": ["high", "moderate", "low", "very_low", "insufficient"]}, "factors": {"type": "object", "additionalProperties": true}, "interpretation": {"type": "string"}}}, "RatingForecastDataSummary": {"type": "object", "properties": {"reviews_analyzed": {"type": "integer", "description": "Number of the newest reviews used to compute the forecast baseline. The model reads the newest model_input_reviews from this set."}, "model_input_reviews": {"type": "integer", "description": "How many of the newest reviews the model read. Absent on forecasts cached before this field existed."}, "model_input_rating_counts": {"type": "object", "description": "Star ratings of the reviews the model read, counted in code. The model is given them to ground its wording; a client that states the split renders it from these counts. Keys are \"1\" to \"5\".", "additionalProperties": {"type": "integer"}}, "time_span_days": {"type": "integer", "description": "Days between the oldest and the newest review the confidence score read (up to the newest 500)."}}}, "RatingForecastModelAccuracy": {"type": "object", "description": "Backtest accuracy, when enough forecasts have resolved against outcomes.", "properties": {"mape": {"type": "number"}, "description": {"type": "string"}, "sample_size": {"type": "integer"}}}, "RatingForecastMetadata": {"type": "object", "properties": {"data_quality_score": {"type": "number"}, "training_data_points": {"type": "integer", "description": "How many of the newest reviews the forecast is built from; the same figure as `data_summary.reviews_analyzed`."}, "model_input_reviews": {"type": "integer", "description": "How many of the newest reviews the model read; a subset of `training_data_points`. Absent on forecasts cached before this field existed."}, "last_sync": {"type": "string", "format": "date-time"}, "accuracy_status": {"type": "string"}, "cache_hit": {"type": "boolean"}}}, "RatingForecastResponse": {"type": "object", "description": "Body of GET /api/v1/predictions/rating-forecast. Estimates use star ratings attached to retrieved written reviews, excluding the rating-only universe; they do not establish displayed store-rating ground truth. Accuracy against future displayed store ratings has not been established. Bounds are uncalibrated model ranges, not validated confidence intervals. When `insufficient_data` is true the app has fewer than the minimum reviews required to forecast: `forecast` is empty and `current_rating`/`predicted_rating` are null. When `generation_failed` is true the same empty envelope is returned because generation kept failing on our side; the two are mutually exclusive and must not be shown to the user as the same condition.", "properties": {"app_uuid": {"type": "string"}, "platform": {"type": "string"}, "forecast_horizon_days": {"type": "integer"}, "generated_at": {"type": "string", "format": "date-time"}, "model_version": {"type": "string"}, "current_rating": {"type": ["number", "null"], "format": "float"}, "current_rating_basis_reviews": {"type": ["integer", "null"], "description": "How many of the newest reviews were averaged to produce `current_rating`. This is a much narrower sample than the window a customer selects on the Ratings tab, so the two figures legitimately differ and a client must label this one with its basis rather than present it as the app's overall rating. Null when no rating could be parsed (the baseline is then a neutral default, not an average) and on cached forecasts generated before this field existed."}, "predicted_rating": {"type": ["number", "null"], "format": "float", "description": "Estimated written-review star rating at the requested horizon, not a verified prediction of the displayed store rating."}, "forecast": {"type": "array", "items": {"$ref": "#/components/schemas/RatingForecastPoint"}}, "anomalies": {"type": "array", "description": "Days whose average rating is well below the other days' (every row has `direction: below`). Empty when none is, and when no day could be judged: `anomaly_assessment` says which.", "items": {"$ref": "#/components/schemas/RatingForecastAnomaly"}}, "anomalies_above": {"type": "array", "description": "Days whose average rating is well above the other days', by the same rule (`direction: above`). Kept out of `anomalies` because that list is what a client shows as issues to fix.", "items": {"$ref": "#/components/schemas/RatingForecastAnomaly"}}, "anomaly_assessment": {"$ref": "#/components/schemas/RatingForecastAnomalyAssessment"}, "anomaly_sample": {"$ref": "#/components/schemas/RatingForecastAnomalySample"}, "key_factors": {"type": "array", "items": {"$ref": "#/components/schemas/RatingForecastKeyFactor"}}, "recommended_actions": {"type": "array", "items": {"$ref": "#/components/schemas/RatingForecastRecommendedAction"}}, "risk_assessment": {"$ref": "#/components/schemas/RatingForecastRiskAssessment"}, "trend_analysis": {"$ref": "#/components/schemas/RatingForecastTrendAnalysis"}, "confidence": {"$ref": "#/components/schemas/RatingForecastConfidence"}, "data_summary": {"$ref": "#/components/schemas/RatingForecastDataSummary"}, "model_accuracy": {"oneOf": [{"$ref": "#/components/schemas/RatingForecastModelAccuracy"}, {"type": "null"}]}, "metadata": {"$ref": "#/components/schemas/RatingForecastMetadata"}, "insufficient_data": {"type": "boolean", "description": "True when the app has too few reviews to forecast."}, "generation_failed": {"type": "boolean", "description": "True when forecast generation itself failed repeatedly. `forecast` is empty and `insufficient_data` is false; the review count is not the problem. Clients should offer a retry (`force_refresh=true`) rather than telling the user to sync more reviews."}, "cache_hit": {"type": "boolean"}}}, "PredictionResponse": {"type": "object", "properties": {"request_id": {"type": "string"}, "generated_at": {"type": "string", "format": "date-time"}, "metrics": {"type": "object", "additionalProperties": true}, "series": {"type": "array", "items": {"type": "object", "properties": {"timestamp": {"type": "string", "format": "date-time"}, "value": {"type": "number", "format": "float"}}}}}}, "FeatureImpactPredictionRequest": {"type": "object", "description": "One proposed feature to score.", "required": ["app_uuid", "feature_description"], "properties": {"app_uuid": {"type": "string", "minLength": 1, "description": "App the feature is for. It must belong to the caller's workspace."}, "feature_description": {"type": "string", "minLength": 1, "maxLength": 2000, "description": "What the feature does. Whitespace-only text is rejected."}, "feature_category": {"type": "string", "minLength": 1, "maxLength": 100, "default": "general", "description": "Free-text category such as `performance`, `ux` or `feature`. Stored lower-cased; it keys the app's feature history used for comparison."}, "implementation_effort": {"type": "string", "enum": ["low", "medium", "high"], "default": "medium", "description": "Expected implementation effort."}, "current_mentions": {"type": "integer", "minimum": 0, "default": 0, "description": "How many reviews currently ask for this feature. Send a measured count only."}, "sentiment_of_mentions": {"type": "string", "enum": ["positive", "mixed", "negative"], "default": "mixed", "description": "Overall sentiment of the reviews that mention the feature."}}}, "IssueSeverityPredictionRequest": {"type": "object", "description": "One issue to score.", "required": ["app_uuid", "issue_description"], "properties": {"app_uuid": {"type": "string", "minLength": 1, "description": "App the issue affects. It must belong to the caller's workspace."}, "issue_description": {"type": "string", "minLength": 1, "maxLength": 2000, "description": "What users report. Whitespace-only text is rejected."}, "issue_category": {"type": "string", "minLength": 1, "maxLength": 100, "default": "general", "description": "Free-text category such as `crash`, `performance` or `billing`. Stored lower-cased; it keys the mention-velocity history and the similar issues returned."}, "current_mentions": {"type": "integer", "minimum": 0, "default": 0, "description": "How many reviews currently mention the issue. Send a measured count only."}, "negative_sentiment_ratio": {"type": "number", "format": "float", "minimum": 0, "maximum": 1, "default": 0.5, "description": "Share of those mentions that are negative, from 0 to 1."}, "platforms_affected": {"type": "array", "items": {"type": "string"}, "description": "Affected platforms, for example `ios` and `android`. Used to weight platform coverage."}}}, "Alert": {"type": "object", "description": "An alert as it is stored, without the storage keys and the dispatcher's own bookkeeping. Only `alert_id` is in every answer, because `view=compact` leaves out every member but seven. In a full answer, every alert the alert engine raises has `title`, `description`, `alert_code`, `trigger_type`, `source`, `app_uuid`, `app_name`, `triggered_at`, `context`, `severity`, `status`, `tenant_id`, `rule_id`, `dedup_key`, `ttl`, `destinations` and `delivery_status`; a sentiment test alert has all of them but `app_uuid` and `app_name`. The other members are present only when the alert has them.", "required": ["alert_id"], "properties": {"alert_id": {"type": "string"}, "title": {"type": "string", "description": "English text composed by the alert engine when the alert was written. Clients that localise render `alert_code` with `context` and use this as the fallback."}, "description": {"type": "string"}, "alert_code": {"type": ["string", "null"], "description": "Stable per-family code for a generated alert (`rating_drop`, `sync_failure`, `critical_technical_issues`, `crash_spike`, `volume_spike`, `volume_drop`, `low_rating_spike`, `custom_category_spike`, `custom_categories_spike`, `sla_breach`, `sla_critical`, `sla_warning`, `sla_breach_summary`, `sla_critical_summary`, `sla_warning_summary`, `sentiment_alert_test`). Absent on rows written before it existed. Alerts raised before the rule was retired can still carry `negative_sentiment_spike`: it counted the same 1 and 2 star reviews as `low_rating_spike`, which is the rule that raises them now. `sentiment_spike_negative` was never raised."}, "trigger_type": {"type": "string"}, "source": {"type": "string"}, "app_uuid": {"type": "string"}, "app_name": {"type": "string"}, "triggered_at": {"type": "string", "format": "date-time"}, "context": {"type": "object", "additionalProperties": true, "description": "The values the alert's sentence was composed from (for example `drop_amount` and `current_rating` for `rating_drop`), plus `alert_code` again. A `rating_drop` alert compares two equal, back-to-back windows of whole, settled days, the `rating_change` of `GET /api/v1/apps/{app_uuid}/ratings/summary`: `previous_rating` and `current_rating` are the two windows' average star ratings, `drop_amount` their difference, `days_each` each window's length, and `current_first_day`, `current_last_day`, `previous_first_day` and `previous_last_day` name the windows (UTC days), and `measured_at` says when the comparison was measured. It is raised when a sync's comparison first shows a drop of at least the rule's `threshold`, not again while later syncs show the same drop, and never when the app has no comparison (a store feed with less than two weeks of complete reviews from every storefront, fewer than 30 ratings in a window). An older alert compared the average of every stored review between two syncs and carries no windows."}, "severity": {"type": "string", "enum": ["critical", "high", "medium", "low"]}, "status": {"type": "string", "enum": ["unread", "read", "acknowledged", "snoozed"], "description": "The status as of now: a snoozed alert whose `snooze_until` has passed is reported as `unread`."}, "snooze_until": {"type": ["string", "null"], "format": "date-time", "description": "When the alert's snooze ends, in UTC. Absent on an alert that is not snoozed; a time already past means the snooze has ended."}, "created_at": {"type": "string", "format": "date-time", "deprecated": true, "description": "Not returned: no producer writes it. `triggered_at` is when the alert was raised."}, "updated_at": {"type": "string", "format": "date-time", "description": "When the alert last changed: a delivery result, a status change or a snooze. Absent until the first change."}, "metadata": {"type": "object", "additionalProperties": true, "deprecated": true, "description": "Not returned: no producer writes it."}, "tenant_id": {"type": "string", "description": "Informational alert identifier."}, "event_id": {"type": "string", "description": "Informational alert identifier."}, "rule_id": {"type": "string", "description": "Informational alert identifier."}, "dedup_key": {"type": "string", "description": "Informational alert identifier."}, "ttl": {"type": "integer", "description": "Storage expiry as Unix seconds, when recorded. Informational; not a delivery deadline."}, "destinations": {"type": "array", "items": {"type": "string"}, "description": "Notification destinations selected for this alert."}, "delivery_status": {"type": "object", "additionalProperties": true, "description": "Delivery outcome keyed by destination. Optional detail varies by provider."}, "acknowledged_at": {"type": "string", "format": "date-time", "description": "When the alert was last acknowledged (`acknowledge`, or `status` `acknowledged`)."}, "acknowledged_by": {"type": "string", "description": "The user who last acknowledged the alert."}, "rule_ids": {"type": "array", "items": {"type": "string"}, "description": "Present when several rules fired for one occurrence and raised this one alert: every one of them, `rule_id` first."}, "routing": {"type": "object", "additionalProperties": true, "description": "The `routing` of the rule that raised the alert, when it has one."}, "channel": {"description": "The rule's `routing.slack_channel`, copied as stored, when it names one."}, "assigned_to": {"description": "The rule's `routing.assigned_to`, copied as stored, when it names one."}, "assigned_team": {"description": "The rule's `routing.assigned_team`, copied as stored, when it names one."}, "priority": {"description": "The rule's `routing.priority`, copied as stored, when it names one."}, "escalation": {"description": "The rule's `routing.escalation`, copied as stored, when it names one."}, "labels": {"description": "The rule's `routing.labels`, copied as stored, when it names them."}, "insights_id": {"type": "string", "description": "The analysis the alert was raised from, when it was raised from one."}, "review_batch_id": {"type": "string", "description": "The batch of synced reviews the alert was raised from, when the event names one."}, "correlation_id": {"type": "string", "description": "Informational identifier of the event the alert was raised from."}, "arpu_cents": {"type": "integer", "description": "The app's revenue per user, in cents, when the event that raised the alert carried it."}, "slack_channel": {"type": "string", "description": "The Slack channel the alert was posted to, once Slack delivery succeeded."}, "slack_thread_ts": {"type": "string", "description": "The Slack message the alert was posted as, once Slack delivery succeeded."}, "is_test": {"type": "boolean", "description": "`true` on a test alert (`POST /api/v1/settings/sentiment-alerts/test`)."}, "triggered_by": {"type": "string", "description": "The user who sent the test that raised a test alert."}}}, "AlertUpdateRequest": {"description": "At least one recognised field must be present; a body with none of them is rejected with `400 NO_UPDATES`. `acknowledge` wins over `mark_read`, which wins over an explicit `status`.", "type": "object", "minProperties": 1, "properties": {"acknowledge": {"type": "boolean", "description": "Shorthand for setting `status` to `acknowledged`. Takes precedence over `mark_read` and `status`."}, "mark_read": {"type": "boolean", "description": "Shorthand for setting `status` to `read`. Takes precedence over `status`."}, "status": {"type": "string", "enum": ["unread", "read", "acknowledged", "snoozed"]}, "snooze_until": {"type": ["string", "null"], "format": "date-time", "description": "ISO 8601 datetime to snooze until (a time without an offset is UTC). Snoozing sets `status` to `snoozed`, with or without `status: snoozed` in the body; once the time passes the alert reads as `unread` again. It cannot be combined with `acknowledge`, `mark_read` or another `status` (`400 INVALID_SNOOZE`). Send `null` (or an empty value) to end a snooze early: a snoozed alert becomes `unread`; it cannot be combined with `status: snoozed` (`400 INVALID_SNOOZE`). Setting any other status also ends a snooze, and `status: snoozed` without `snooze_until` snoozes until the status changes."}}}, "AlertSummary": {"type": "object", "required": ["total", "by_status", "by_severity", "complete", "next_snooze_end"], "properties": {"total": {"type": "integer", "minimum": 0, "description": "Alerts counted."}, "by_status": {"type": "object", "description": "Alerts by their status as of now.", "required": ["unread", "read", "acknowledged", "snoozed"], "properties": {"unread": {"type": "integer", "minimum": 0}, "read": {"type": "integer", "minimum": 0}, "acknowledged": {"type": "integer", "minimum": 0}, "snoozed": {"type": "integer", "minimum": 0}}}, "by_severity": {"type": "object", "required": ["critical", "high", "medium", "low"], "properties": {"critical": {"type": "integer", "minimum": 0}, "high": {"type": "integer", "minimum": 0}, "medium": {"type": "integer", "minimum": 0}, "low": {"type": "integer", "minimum": 0}}}, "complete": {"type": "boolean", "description": "`true` when every alert was counted. `false` when the count stopped at its bound: each number is then at least that many."}, "next_snooze_end": {"type": ["string", "null"], "format": "date-time", "description": "When the earliest counted snooze ends (UTC). The `unread` and `snoozed` counts change at that time without any other change, so a client keeping them on screen recounts then. `null` when no counted alert has a snooze that ends."}}}, "MarkAllAlertsReadRequest": {"type": "object", "properties": {"cursor": {"type": "string", "description": "The `next_cursor` of the previous call, to continue with older alerts. Omit it to start from the newest."}}}, "MarkAllAlertsReadResponse": {"type": "object", "required": ["updated", "remaining", "next_cursor"], "properties": {"updated": {"type": "integer", "minimum": 0, "description": "Alerts this call marked read."}, "remaining": {"type": "boolean", "description": "`true` when the call stopped before the oldest alert; call again with `next_cursor` to continue."}, "next_cursor": {"type": ["string", "null"]}}}, "SyncPlanLimits": {"type": "object", "required": ["max_syncs_per_day"], "properties": {"max_syncs_per_day": {"type": "integer", "description": "The most sync times a day one app may have (1 on a trial, 4 with a subscription)."}}}, "SyncScheduleAnswer": {"type": "object", "description": "The answer of both the read and the change of an app's sync schedule.", "required": ["schedule"], "properties": {"schedule": {"$ref": "#/components/schemas/SyncSchedule"}}}, "SyncSchedule": {"type": "object", "description": "An app's sync schedule. Every member is always sent.", "required": ["app_uuid", "app_name", "auto_sync_enabled", "sync_times", "sync_frequency", "last_sync", "next_sync_at", "time_until_next_sync", "syncs_today", "max_syncs_per_day", "version_sync_enabled", "last_known_version", "plan_tier"], "properties": {"app_uuid": {"type": "string", "format": "uuid"}, "app_name": {"type": "string", "description": "Empty text when the app has no stored name."}, "auto_sync_enabled": {"type": "boolean", "description": "Whether the scheduled sync runs; false until it is turned on."}, "sync_times": {"type": "array", "description": "The daily sync times in UTC, as they were saved; empty when none is set.", "items": {"type": "string"}}, "sync_frequency": {"type": "string", "enum": ["hourly", "daily", "weekly"], "description": "`daily` when `sync_times` holds a time; otherwise the app's interval cadence (`daily` when none is stored)."}, "last_sync": {"type": ["string", "null"], "format": "date-time", "description": "When the last sync finished; null before the first."}, "next_sync_at": {"type": ["string", "null"], "format": "date-time", "description": "When the next automatic sync is due, as the dispatcher holds it (including any wait after a failure). Null when auto-sync is off or no time is due."}, "time_until_next_sync": {"type": ["string", "null"], "description": "`next_sync_at` from now, in English words (`in 3 hours`, `in 1 day`, `in less than a minute`, or `now` when it is past). Null when `next_sync_at` is."}, "syncs_today": {"type": "null", "deprecated": true, "description": "Always null. A daily manual-sync counter is not tracked."}, "max_syncs_per_day": {"type": "integer", "description": "The most sync times a day the workspace's plan allows (1 on a trial, 4 with a subscription)."}, "version_sync_enabled": {"type": "boolean", "description": "Whether app version metadata is synced alongside reviews; false until it is turned on."}, "last_known_version": {"type": ["string", "null"], "description": "The app version last recorded; null when none is."}, "plan_tier": {"type": "string", "enum": ["trial", "paid"], "description": "The workspace's sync tier, as `GET /api/v1/sync/plan-limits` names it."}}}, "AppSyncRequest": {"type": "object", "description": "Optional.", "properties": {"force": {"type": "boolean", "default": false, "description": "Start a sync even when one is running or has just completed. The text `true`, `false`, `yes`, `no`, `1` or `0` and the numbers 0 and 1 are read too."}}}, "AppSyncStarted": {"type": "object", "required": ["app_uuid", "status", "message"], "properties": {"app_uuid": {"type": "string", "format": "uuid"}, "status": {"type": "string", "enum": ["sync_queued", "sync_in_progress", "already_synced"], "description": "`sync_queued`: a sync was queued. `sync_in_progress`: one started in the last ten minutes and is still running, so none was started. `already_synced`: one completed in the last three minutes, so none was started."}, "message": {"type": "string", "description": "The same in English words."}, "sync_status": {"type": "string", "description": "The app's stored sync status (for example `syncing_full` or `completed`). Sent only when nothing was started."}, "sync_progress": {"type": "integer", "description": "The running sync's progress in percent, or 100 for a completed one when none is stored. Sent only when nothing was started."}}}, "ReviewSyncRequest": {"type": "object", "deprecated": true, "description": "The body of the retired `POST /api/v1/apps/{appUuid}/reviews/sync`. It is not read.", "properties": {"max_reviews": {"description": "Not read."}, "country": {"description": "Not read."}, "force_refresh": {"type": "boolean", "description": "Not read."}}}, "ReviewSyncAccepted": {"type": "object", "deprecated": true, "description": "No route answers this any more. `POST /api/v1/apps/{appUuid}/reviews/sync` answered it while starting no sync; that route now answers `410 OPERATION_RETIRED`.", "required": ["app_uuid", "status", "message", "max_reviews", "country"], "properties": {"app_uuid": {"type": "string", "format": "uuid"}, "status": {"type": "string", "enum": ["sync_queued"], "description": "Always `sync_queued`, although no sync was started."}, "message": {"type": "string"}, "max_reviews": {"description": "The body's `max_reviews` as sent, or 500."}, "country": {"description": "The body's `country` as sent, or `us`."}}}, "FinancialConfigAnswer": {"type": "object", "required": ["financial_config", "app_uuid", "app_name"], "properties": {"financial_config": {"$ref": "#/components/schemas/FinancialConfig"}, "app_uuid": {"type": "string", "format": "uuid"}, "app_name": {"type": ["string", "null"], "description": "Null when the app has no stored name."}}}, "FinancialConfig": {"type": "object", "description": "Each value with its source: `app` when it was saved on the app, `default` when it is the default (ARPU 12,000 cents, 24 months, power users 3 and influencers 5 times a reviewer's value, category `other`). Every member is always sent.", "required": ["arpu_cents", "arpu_cents_source", "ltv_months", "ltv_months_source", "power_user_multiplier", "power_user_multiplier_source", "influencer_multiplier", "influencer_multiplier_source", "category", "category_source", "arpu_dollars", "ltv_dollars", "is_configured"], "properties": {"arpu_cents": {"type": "integer", "minimum": 0, "description": "Average revenue per user, in cents."}, "arpu_cents_source": {"$ref": "#/components/schemas/FinancialConfigSource"}, "ltv_months": {"type": "integer", "minimum": 1, "maximum": 120, "description": "Months a user stays."}, "ltv_months_source": {"$ref": "#/components/schemas/FinancialConfigSource"}, "power_user_multiplier": {"type": "number", "minimum": 1, "maximum": 10}, "power_user_multiplier_source": {"$ref": "#/components/schemas/FinancialConfigSource"}, "influencer_multiplier": {"type": "number", "minimum": 1, "maximum": 10, "description": "Still stored and returned, but the dashboard no longer offers it, because the classifier assigns no reviewer to the `influencer` segment today."}, "influencer_multiplier_source": {"$ref": "#/components/schemas/FinancialConfigSource"}, "category": {"type": "string", "description": "The app's category as stored. An app connected before categories were checked can hold the store's own genre name, which is not one of `AppCategory`."}, "category_source": {"$ref": "#/components/schemas/FinancialConfigSource"}, "arpu_dollars": {"type": "number", "description": "`arpu_cents` in dollars."}, "ltv_dollars": {"type": "number", "description": "`arpu_dollars` times `ltv_months`."}, "is_configured": {"type": "boolean", "description": "True when any of the four figures (not the category) is the app's own. Only `arpu_cents_source` says whether the ARPU is."}}}, "FinancialConfigSource": {"type": "string", "enum": ["app", "default"]}, "SyncScheduleUpdateRequest": {"description": "A partial update: only the keys present are written, so an omitted key leaves the stored value alone. A body with none of them is accepted and only bumps `updated_at`.", "type": "object", "properties": {"sync_times": {"type": "array", "description": "Daily sync times in 24-hour `HH:MM`, UTC. Rejected with `400` if any entry is not `HH:MM`, or if the list is longer than the plan allows (`max_syncs_per_day` from `GET /api/v1/sync/plan-limits`: 1 on trial, 4 on paid). Daily UTC slots take precedence over the saved interval cadence, which is preserved for when slots are cleared. The dispatcher checks due slots hourly, so a sync can start after its configured time. Sending an empty list clears the clock-time schedule and returns to interval-based scheduling.", "items": {"type": "string", "pattern": "^([01][0-9]|2[0-3]):[0-5][0-9]$"}}, "version_sync_enabled": {"type": "boolean", "description": "Whether app version/release metadata is synced alongside reviews."}, "auto_sync_enabled": {"type": "boolean", "description": "Whether the scheduled sync runs at all."}}}, "ShareChartRequest": {"type": "object", "required": ["target", "image_base64"], "properties": {"target": {"type": "string", "enum": ["slack", "teams"]}, "image_base64": {"type": "string", "minLength": 1, "description": "Base64-encoded PNG image, without a `data:` prefix. At most 2,936,012 characters, and at most 2 MB once decoded; the complete Teams message must fit within its smaller delivery limit."}, "channel_id": {"type": "string", "description": "Required for a Slack chart share. Not read for Teams."}, "title": {"type": "string", "description": "Cut to 500 characters. Left out, `Shared chart`."}, "message": {"type": "string", "description": "Posted with the image. Cut to 10000 characters."}}}, "IntegrationUpdateRequest": {"description": "At least one of the four members must be present; a body with none of them, or whose only members are a `config` or `metadata` that is not an object, is rejected with `400`. `config` and `metadata` are **merged** into the stored objects rather than replacing them, so a partial `config` cannot erase sibling keys.", "type": "object", "minProperties": 1, "properties": {"name": {"type": "string", "maxLength": 255, "description": "Truncated to 255 characters rather than rejected."}, "status": {"type": "string", "enum": ["active", "disabled", "pending"], "description": "Any letter case. `disabled` stops delivery. Activating a Microsoft Teams connection needs Teams to be available for the workspace and a signed Workflows URL."}, "config": {"type": "object", "additionalProperties": true, "description": "Merged into the stored config, with the checks `IntegrationConfigUpdate` describes. A `webhook_url` here is re-validated against the integration type on every update, so an SSRF-unsafe host is rejected with `400` even if the integration already existed."}, "metadata": {"type": "object", "additionalProperties": true, "description": "Merged into the stored metadata."}}}, "ProductGroupCreateRequest": {"allOf": [{"$ref": "#/components/schemas/ProductGroupUpdateRequest"}, {"type": "object", "required": ["group_name", "app_uuids"], "properties": {"group_name": {"type": "string"}, "app_uuids": {"type": "array", "items": {"type": "string", "format": "uuid"}}}}]}, "ProductGroupUpdateRequest": {"description": "A partial update: only the keys present are written. An omitted key leaves the stored value alone.", "type": "object", "properties": {"group_name": {"type": "string", "minLength": 1, "maxLength": 200, "description": "Trimmed; rejected with `400` if it trims to empty."}, "description": {"type": "string", "maxLength": 2000, "description": "Trimmed."}, "icon_url": {"type": "string", "maxLength": 2000, "description": "Trimmed."}, "app_uuids": {"type": "array", "description": "Replaces the group's membership outright. Requires 2 to 5 UUID entries, all owned by the caller's tenant, with at least 2 distinct apps. Duplicate entries are ignored when storing membership; a list containing only one distinct app is rejected with 400.", "minItems": 2, "maxItems": 5, "items": {"type": "string", "format": "uuid"}}}}, "ProductGroup": {"type": "object", "description": "A product group: 2 to 5 of the workspace's apps, usually the same product's listing on each store, read together in Spotlight.", "required": ["group_uuid", "group_name", "description", "app_uuids", "icon_url", "created_by", "created_at", "updated_at"], "properties": {"group_uuid": {"type": "string", "format": "uuid"}, "group_name": {"type": "string"}, "description": {"type": "string", "description": "Empty when none was given."}, "app_uuids": {"type": "array", "description": "The member apps, in the order they were given, without repeats.", "items": {"type": "string", "format": "uuid"}}, "icon_url": {"type": "string", "description": "Empty when none was given."}, "created_by": {"type": ["string", "null"], "description": "The user id of the member who created the group. Null only on a group stored without it, as `created_at` and `updated_at` are; every group created through the API has all three."}, "created_at": {"type": ["string", "null"], "format": "date-time"}, "updated_at": {"type": ["string", "null"], "format": "date-time"}}}, "ProductGroupList": {"type": "object", "required": ["groups", "count"], "properties": {"groups": {"type": "array", "items": {"$ref": "#/components/schemas/ProductGroup"}}, "count": {"type": "integer", "minimum": 0, "description": "The number of `groups`."}}}, "ProductGroupAnswer": {"type": "object", "required": ["product_group"], "properties": {"product_group": {"$ref": "#/components/schemas/ProductGroup"}}}, "ProductGroupDeleted": {"type": "object", "required": ["message", "group_uuid"], "properties": {"message": {"type": "string", "description": "`Product group deleted`."}, "group_uuid": {"type": "string", "format": "uuid"}}}, "GroupInsightsResponse": {"type": "object", "required": ["insights", "platform_breakdown", "apps_with_data", "apps_missing_data", "group_name", "auto_title"], "properties": {"insights": {"$ref": "#/components/schemas/GroupInsights"}, "platform_breakdown": {"type": "object", "description": "One analysed app for each store the group's apps are on, keyed by store (`app_store`, `google_play`): the one with the most reviews. A store none of whose apps has an analysis is left out.", "additionalProperties": {"$ref": "#/components/schemas/GroupPlatformApp"}}, "apps_with_data": {"type": "array", "description": "The member apps whose analysis is in `insights`.", "items": {"type": "string", "format": "uuid"}}, "apps_missing_data": {"type": "array", "description": "The member apps with no analysis: never analysed, or no longer one of the workspace's apps.", "items": {"type": "string", "format": "uuid"}}, "group_name": {"type": "string"}, "auto_title": {"type": "string", "description": "The group's name followed by its stores, for example `Acme (iOS + Android)`. Without a name, the first member app's name."}}}, "GroupInsights": {"type": "object", "description": "The member apps' latest analyses merged. Counts and shares are of the analysed apps only.", "required": ["analyzed_reviews", "total_reviews", "average_rating", "sentiment_summary", "key_themes", "key_topics", "actionable_insights", "feature_requests", "user_loves", "analysis_mode"], "properties": {"analyzed_reviews": {"type": "integer", "minimum": 0, "description": "Reviews the analyses read, summed."}, "total_reviews": {"type": "integer", "minimum": 0, "description": "Reviews the analysed apps hold, summed."}, "average_rating": {"type": "number", "description": "The apps' average star ratings, weighted by their reviews."}, "sentiment_summary": {"type": "object", "description": "Each share is the apps' shares weighted by their reviews, to one decimal; the four sum to 100 give or take rounding.", "required": ["positive_percent", "negative_percent", "neutral_percent", "mixed_percent", "average_rating"], "properties": {"positive_percent": {"type": "number"}, "negative_percent": {"type": "number"}, "neutral_percent": {"type": "number"}, "mixed_percent": {"type": "number"}, "average_rating": {"type": "number", "description": "The same figure as `average_rating` above."}}}, "key_themes": {"type": "array", "description": "Always empty: no current analysis carries themes under this name. The group's themes are `key_topics`.", "items": {"$ref": "#/components/schemas/GroupInsightTopic"}}, "key_topics": {"type": "array", "description": "The apps' themes, summed by name (case ignored), most mentioned first; at most 15.", "items": {"$ref": "#/components/schemas/GroupInsightTopic"}}, "actionable_insights": {"type": "array", "description": "Every member app's actionable insights, each as that app's own Insights tab shows it, by priority and then by mentions; at most 25. A count on an insight is that app's, never the group's.", "items": {"$ref": "#/components/schemas/GroupActionableInsight"}}, "feature_requests": {"type": "array", "description": "The apps' feature requests, in member order; at most 15.", "items": {"$ref": "#/components/schemas/InsightBundleFeatureRequestsItem"}}, "user_loves": {"type": "array", "description": "What reviewers praise, in member order; at most 10.", "items": {"$ref": "#/components/schemas/InsightBundleUserLovesItem"}}, "analysis_mode": {"oneOf": [{"type": "string", "enum": ["bedrock_ai", "keyword_fallback", "mixed"]}, {"type": "null"}], "description": "How the analyses behind `sentiment_summary` ran: `bedrock_ai` or `keyword_fallback` when every analysed app's ran that way (`keyword_fallback` reads star ratings, not review text), `mixed` when a fallback sits beside other runs, and null when it cannot be said."}}}, "GroupInsightTopic": {"type": "object", "required": ["topic", "mentions", "sentiment", "trend"], "properties": {"topic": {"type": "string", "description": "The wording of the first app that named the theme."}, "mentions": {"type": "integer", "minimum": 0, "description": "The apps' mentions, summed."}, "sentiment": {"type": ["string", "null"], "description": "The sentiment most of the mentions have; null when no app gave one."}, "trend": {"type": ["string", "null"], "description": "The trend most of the mentions have; null when no app gave one."}}}, "GroupActionableInsight": {"description": "A member app's actionable insight, and the app it came from.", "allOf": [{"$ref": "#/components/schemas/InsightBundleActionableInsightsItem"}, {"type": "object", "required": ["app_uuid", "app_name", "platform", "app_label_ambiguous"], "properties": {"app_uuid": {"type": "string", "format": "uuid"}, "app_name": {"type": "string"}, "platform": {"type": "string", "description": "The app's store, `app_store` or `google_play`."}, "app_label_ambiguous": {"type": "boolean", "description": "True when more than one app of the group has this name on this store, so `app_uuid` has to be shown to say which."}}}]}, "GroupPlatformApp": {"type": "object", "required": ["app_uuid", "app_name", "sentiment_score", "negative_percent", "average_rating", "review_count"], "properties": {"app_uuid": {"type": "string", "format": "uuid"}, "app_name": {"type": "string"}, "sentiment_score": {"type": "number", "description": "The app's positive share, in percent to one decimal."}, "negative_percent": {"type": "number"}, "average_rating": {"type": "number"}, "review_count": {"type": "integer", "minimum": 0}}}, "GroupExecutiveSpotlightResponse": {"type": "object", "required": ["executive_spotlight", "apps_with_data", "apps_missing_data", "group_name"], "properties": {"executive_spotlight": {"$ref": "#/components/schemas/GroupExecutiveSpotlight"}, "apps_with_data": {"type": "array", "description": "The member apps whose analysis `alerts` and `summary_metrics` read.", "items": {"type": "string", "format": "uuid"}}, "apps_missing_data": {"type": "array", "description": "The member apps with no analysis.", "items": {"type": "string", "format": "uuid"}}, "group_name": {"type": "string"}}}, "GroupExecutiveSpotlight": {"type": "object", "required": ["trends", "user_segments", "response_effectiveness", "unavailable_metrics", "alerts", "summary_metrics"], "properties": {"trends": {"oneOf": [{"$ref": "#/components/schemas/SpotlightTrends"}, {"type": "null"}], "description": "Null when a member's reviews could not be read completely."}, "user_segments": {"oneOf": [{"$ref": "#/components/schemas/SpotlightUserSegments"}, {"type": "null"}], "description": "The members' segment counts, summed. Null when a member's segments could not be read completely."}, "response_effectiveness": {"oneOf": [{"$ref": "#/components/schemas/SpotlightResponseEffectiveness"}, {"type": "null"}], "description": "Null when a member's reviews could not be read completely."}, "unavailable_metrics": {"type": "array", "description": "The sections above that are null, in that order; empty when none is.", "items": {"type": "string", "enum": ["trends", "user_segments", "response_effectiveness"]}}, "alerts": {"type": "object", "required": ["risk_level", "anomalies"], "properties": {"risk_level": {"type": "string", "enum": ["low", "medium", "high"], "description": "`high` when an actionable insight of the group is critical, `medium` when one is high, `low` otherwise."}, "anomalies": {"type": "array", "description": "The group's critical and high actionable insights, at most 5.", "items": {"type": "object", "required": ["title", "type", "severity", "description"], "properties": {"title": {"type": "string"}, "type": {"type": "string", "enum": ["actionable_insight"]}, "severity": {"type": "string", "description": "The insight's priority, `critical` or `high`."}, "description": {"type": "string"}}}}}}, "summary_metrics": {"type": "object", "required": ["total_reviews", "average_sentiment", "average_rating"], "properties": {"total_reviews": {"type": "integer", "minimum": 0, "description": "`insights.total_reviews` of the group insights."}, "average_sentiment": {"type": "number", "description": "The positive share of the group insights, in percent."}, "average_rating": {"type": "number"}}}}}, "SpotlightUserSegments": {"type": "object", "required": ["summary"], "properties": {"summary": {"$ref": "#/components/schemas/UserSegmentsSummary"}}}, "SpotlightResponseEffectiveness": {"type": "object", "required": ["reply_rate", "reply_rate_reason", "tritonium_reply_count", "tritonium_reply_share"], "properties": {"reply_rate": {"type": ["number", "null"], "description": "Null, for the reason in `reply_rate_reason`."}, "reply_rate_reason": {"type": "string", "enum": ["store_replies_not_observable"], "description": "The stores do not report every reply: the App Store reports none written in App Store Connect, and Google Play shows one only on the reviews it serves when they are read."}, "tritonium_reply_count": {"type": "integer", "minimum": 0, "description": "The period's reviews answered through Tritonium from this workspace."}, "tritonium_reply_share": {"type": ["number", "null"], "description": "`tritonium_reply_count` as a percentage of the period's reviews, to one decimal; null when the period has none."}}}, "AppExecutiveSpotlight": {"type": "object", "description": "An app's executive spotlight over the last `period_days` days. Every member is always sent. A block that could not be read holds its empty default and is named in `unavailable_sections`.", "required": ["app_uuid", "period_days", "generated_at", "trends", "user_segments", "response_effectiveness", "geographic", "competitive", "alerts", "review_count", "average_rating", "reviews_scanned", "truncated", "unavailable_sections", "metadata"], "properties": {"app_uuid": {"type": "string", "format": "uuid"}, "period_days": {"type": "integer", "minimum": 1, "maximum": 365, "description": "The period, from `days` or `period` (30 when neither is given)."}, "generated_at": {"type": "string", "format": "date-time", "description": "When this spotlight was composed (earlier than now for a cached one)."}, "trends": {"$ref": "#/components/schemas/SpotlightTrends"}, "user_segments": {"$ref": "#/components/schemas/SpotlightUserSegments"}, "response_effectiveness": {"$ref": "#/components/schemas/SpotlightResponseEffectiveness"}, "geographic": {"$ref": "#/components/schemas/SpotlightGeographic"}, "competitive": {"$ref": "#/components/schemas/SpotlightCompetitive"}, "alerts": {"$ref": "#/components/schemas/SpotlightAlerts"}, "review_count": {"type": "integer", "minimum": 0, "description": "The period's reviews."}, "average_rating": {"type": ["number", "null"], "description": "The period's mean star rating, to two decimals; null when none of its reviews has one."}, "reviews_scanned": {"type": "integer", "minimum": 0, "description": "The same as `review_count`."}, "truncated": {"type": "boolean", "description": "True when the review read stopped at its bound before the end of either period, so the period figures cover part of it. Such a spotlight is not cached."}, "unavailable_sections": {"type": "array", "description": "The blocks of this response that could not be loaded, in response order; empty when every block loaded. `trends` covers `trends.volume_*` and `trends.sentiment_*` and the flat `review_count`, `average_rating` and `reviews_scanned`; `rating_change` covers `trends.rating_trend`, `trends.rating_delta` and `trends.rating_change`; the others are the blocks of the same name. A named block holds its empty default (null changes, risk `low`, zero counts, an empty `geographic` or `competitive`), not a measurement.", "items": {"type": "string", "enum": ["trends", "rating_change", "user_segments", "response_effectiveness", "geographic", "competitive", "alerts"]}}, "metadata": {"$ref": "#/components/schemas/AnalyticsCacheState"}}}, "AnalyticsCacheState": {"type": "object", "required": ["cache_hit", "generated_at"], "properties": {"cache_hit": {"type": "boolean", "description": "True when this was served from the analytics cache rather than computed for this request."}, "generated_at": {"type": "string", "format": "date-time", "description": "When it was computed."}}}, "SpotlightAlerts": {"type": "object", "required": ["risk_level", "anomalies"], "properties": {"risk_level": {"type": "string", "enum": ["low", "medium", "high"], "description": "`high` when an anomaly is `high`, `medium` when one is `medium`, otherwise `low` (also when the anomalies could not be read)."}, "anomalies": {"type": "array", "description": "The period's review volume anomalies, as `GET /api/v1/apps/{appUuid}/analytics/anomalies` answers them. A cached drop that a later sync no longer supports is left out.", "items": {"$ref": "#/components/schemas/VolumeAnomaly"}}}}, "SpotlightGeographic": {"type": "object", "description": "The period's reviews by market, as `GET /api/v1/apps/{appUuid}/analytics/geographic` answers them. An empty object when it could not be read (`geographic` is then in `unavailable_sections`).", "properties": {"countries": {"type": "object", "description": "Keyed by market: an ISO 3166-1 alpha-2 code for reviews with a store territory, `LANG:` and a language code for reviews with only a detected language, `XX` for the rest. Empty when the period has no reviews.", "additionalProperties": {"$ref": "#/components/schemas/GeographicMarket"}}, "insights": {"type": "array", "items": {"$ref": "#/components/schemas/AnalyticsTemplateInsight"}}, "generated_at": {"type": "string", "format": "date-time"}, "metadata": {"$ref": "#/components/schemas/AnalyticsCacheState"}}}, "GeographicMarket": {"type": "object", "required": ["country_name", "source", "total_reviews", "average_rating", "sentiment_distribution", "top_languages", "unique_feature_requests"], "properties": {"country_name": {"type": "string", "description": "The market's name in English, or for a language bucket, for example `Spanish-speaking (region unknown)`."}, "source": {"type": "string", "enum": ["territory", "inferred_from_language", "unknown"], "description": "What placed the reviews in this market."}, "total_reviews": {"type": "integer", "minimum": 0, "description": "The market's reviews with a star rating."}, "average_rating": {"type": "number", "description": "Their mean, to two decimals; 0 when none has a rating."}, "sentiment_distribution": {"type": "object", "description": "The market's reviews by sentiment (`positive`, `negative`, `neutral`, `mixed`).", "additionalProperties": {"type": "integer"}}, "top_languages": {"type": "array", "description": "At most five, the most common first.", "items": {"type": "object", "required": ["language", "count"], "properties": {"language": {"type": "string", "description": "A language code, or `unknown`."}, "count": {"type": "integer"}}}}, "unique_feature_requests": {"type": "array", "description": "At most five feature words found in a sample of the market's reviews, the most common first.", "items": {"type": "object", "required": ["feature", "count"], "properties": {"feature": {"type": "string"}, "count": {"type": "integer"}}}}}}, "SpotlightCompetitive": {"type": "object", "description": "The app's tracked competitors over the period. An empty object when they could not be read (`competitive` is then in `unavailable_sections`).", "properties": {"app_uuid": {"type": "string", "format": "uuid"}, "source": {"type": "string", "enum": ["tracked_competitors"]}, "days": {"type": "integer"}, "count": {"type": "integer", "description": "Tracked competitors."}, "tracked_competitors_count": {"type": "integer", "description": "The same as `count`."}, "competitors_analyzed": {"type": "integer", "description": "Those with data from the period."}, "competitors": {"type": "array", "items": {"$ref": "#/components/schemas/SpotlightCompetitor"}}, "vs_competitors": {"type": "array", "description": "The competitors ranked by rating, highest first. App Store competitors are left out: their store rating is not shown.", "items": {"type": "object", "required": ["name", "rank", "rating"], "properties": {"name": {"type": "string"}, "rank": {"type": "integer", "minimum": 1}, "rating": {"type": ["number", "null"]}}}}, "insights": {"type": "array", "items": {"$ref": "#/components/schemas/AnalyticsTemplateInsight"}}, "feature_gaps": {"type": "array", "maxItems": 0, "description": "Always empty.", "items": {}}, "generated_at": {"type": "string", "format": "date-time"}, "metadata": {"type": "object", "required": ["source", "period_days", "stale_competitors_count", "requires_competitors"], "properties": {"source": {"type": "string", "enum": ["tracked_competitors"]}, "period_days": {"type": "integer"}, "stale_competitors_count": {"type": "integer", "description": "Competitors with no data from the period."}, "requires_competitors": {"type": "boolean", "description": "True when the app tracks no competitor."}}}}}, "SpotlightCompetitor": {"type": "object", "description": "A tracked competitor. `rating_status` and `rating_unavailable_reason` are sent only for an App Store competitor, whose store rating figures are then null and whose history is empty.", "required": ["competitor_id", "platform", "external_app_id", "name", "display_name", "developer", "icon_url", "category", "average_rating", "total_reviews", "sentiment_score", "latest_average_rating", "latest_total_reviews", "rating_change", "rating_history", "history_count", "last_synced_at", "has_snapshot", "has_recent_data"], "properties": {"competitor_id": {"type": "string"}, "platform": {"type": ["string", "null"]}, "external_app_id": {"type": ["string", "null"]}, "name": {"type": "string"}, "display_name": {"type": "string"}, "developer": {"type": ["string", "null"]}, "icon_url": {"type": ["string", "null"]}, "category": {"type": ["string", "null"]}, "average_rating": {"type": ["number", "null"], "description": "The newest rating of the period; null without data from it."}, "total_reviews": {"type": ["integer", "null"]}, "sentiment_score": {"type": ["number", "null"]}, "latest_average_rating": {"type": ["number", "null"], "description": "The last synced rating, of any age."}, "latest_total_reviews": {"type": ["integer", "null"]}, "rating_change": {"type": ["number", "null"], "description": "The newest rating of the period less its first; null with fewer than two."}, "rating_history": {"type": "array", "description": "The period's rating snapshots, oldest first.", "items": {"type": "object", "required": ["date", "average_rating", "total_reviews"], "properties": {"date": {"type": "string", "description": "When the snapshot was taken, as stored."}, "average_rating": {"type": ["number", "null"]}, "total_reviews": {"type": ["integer", "null"]}}}}, "history_count": {"type": "integer"}, "rating_status": {"type": "string", "enum": ["unavailable"]}, "rating_unavailable_reason": {"type": "string", "enum": ["source_permission_unverified"]}, "last_synced_at": {"type": ["string", "null"], "description": "When it was last synced, as stored; null before its first sync."}, "has_snapshot": {"type": "boolean"}, "has_recent_data": {"type": "boolean"}}}, "GroupPlatformComparisonResponse": {"type": "object", "required": ["platforms", "platform_insights", "group_name"], "properties": {"platforms": {"type": "object", "description": "The analysed member apps by store, keyed by store (`app_store`, `google_play`).", "additionalProperties": {"$ref": "#/components/schemas/GroupPlatformStats"}}, "platform_insights": {"type": "array", "description": "What differs between the stores, when at least two have reviews: a rating gap of half a star or more, a positive share 15 points or more apart, and a store with 70% or more of the reviews.", "items": {"$ref": "#/components/schemas/GroupPlatformInsight"}}, "group_name": {"type": "string"}}}, "GroupPlatformStats": {"type": "object", "required": ["total_reviews", "sentiment_score", "average_rating", "apps"], "properties": {"total_reviews": {"type": "integer", "minimum": 0}, "sentiment_score": {"type": "number", "description": "The store's apps' positive shares weighted by their reviews, in percent to one decimal."}, "average_rating": {"type": "number", "description": "The store's apps' average ratings weighted by their reviews."}, "apps": {"type": "array", "description": "Every member app on the store, analysed or not.", "items": {"type": "object", "required": ["app_uuid", "app_name"], "properties": {"app_uuid": {"type": "string", "format": "uuid"}, "app_name": {"type": "string"}}}}}}, "GroupPlatformInsight": {"type": "object", "required": ["type", "severity", "title", "description", "best_platform", "worst_platform"], "properties": {"type": {"type": "string", "enum": ["rating_gap", "sentiment_variance", "volume_skew"]}, "severity": {"type": "string", "enum": ["warning", "info"], "description": "`warning` for a rating gap of a star or more and for positive shares 30 points or more apart; `info` otherwise."}, "title": {"type": "string", "description": "An English sentence."}, "description": {"type": "string", "description": "An English sentence."}, "best_platform": {"type": "string", "description": "The store that rates higher, is more positive, or has the most reviews."}, "worst_platform": {"type": "string", "description": "The store at the other end."}}}, "TeamMember": {"type": "object", "properties": {"user_id": {"type": ["string", "null"], "description": "Unique identifier for the team member. For an open invitation (`status` `pending`, or any row not yet accepted) the identifier is the invitation's own code, so it is returned only to an owner or admin; other callers get null for those rows."}, "name": {"type": "string", "description": "Display name of the team member."}, "email": {"type": "string", "format": "email", "description": "Email address of the team member."}, "role": {"type": "string", "enum": ["owner", "admin", "member"], "description": "Workspace role."}, "status": {"type": "string", "enum": ["active", "pending", "inactive"], "description": "`active` for an accepted member with a verified email and an active account, the people workflow email can go to; `pending` for an open invitation; `inactive` for an account whose status is not active (it cannot sign in or receive workflow email)."}, "created_at": {"type": ["string", "null"], "format": "date-time", "description": "When the member (or invitation) was created."}, "last_login": {"type": ["string", "null"], "format": "date-time", "description": "Most recent successful sign-in, or null for a member who has never signed in."}, "avatar_preset_id": {"type": ["string", "null"], "description": "Identifier of the chosen avatar preset, if any."}, "profile_picture_url": {"type": ["string", "null"], "format": "uri", "description": "Uploaded profile photo, or null when no photo is set."}, "avatar_url": {"type": "string", "format": "uri", "description": "URL to the team member's avatar image."}}}, "PendingDraft": {"type": "object", "description": "A draft as the list answers it. Every member is in every listed draft, null where the draft has no value.", "required": ["draft_id", "review_id", "app_uuid", "app_name", "platform", "status", "reply_text", "reply_language", "updated_at", "review_snapshot", "created_at", "generation_method", "template_id", "actions"], "properties": {"draft_id": {"type": "string", "format": "uuid", "description": "Unique identifier for the draft."}, "review_id": {"type": "string", "description": "ID of the review this draft is replying to."}, "app_uuid": {"type": "string", "format": "uuid", "description": "The app the review belongs to."}, "app_name": {"type": ["string", "null"], "description": "Name of the app the review belongs to; null when the app's name could not be read."}, "platform": {"type": ["string", "null"], "description": "The store the reply is for (`app_store_connect` or `google_play`)."}, "status": {"type": "string", "enum": ["pending_approval", "approved"], "description": "`pending_approval` until an admin approves or rejects the draft; `approved` once approved but not yet posted to the store (listed only when the request's `status` asks for it)."}, "reply_text": {"type": "string", "description": "AI-generated reply text."}, "reply_language": {"type": ["string", "null"], "description": "Language the reply is written in (a language code such as `en` or `de`), for marking the reply text with `lang`. `null` on drafts saved without one."}, "updated_at": {"type": ["string", "null"], "format": "date-time", "description": "When the draft last changed (edited, approved or handed back after a failed post)."}, "review_snapshot": {"type": "object", "description": "Snapshot of the original review, as it was when the draft was made. A draft made by an automatic response rule can carry other review fields as well.", "properties": {"rating": {"type": "integer", "minimum": 1, "maximum": 5}, "content": {"type": ["string", "null"]}, "author": {"type": "string"}, "title": {"type": ["string", "null"]}, "date": {"type": ["string", "null"]}, "sentiment": {"type": ["string", "null"]}, "language": {"type": ["string", "null"], "description": "The review's language code, on drafts saved since the language was recorded."}}}, "created_at": {"type": "string", "format": "date-time", "description": "When the draft was created."}, "generation_method": {"type": "string", "description": "How the draft was generated. `ai` unless an older draft stored another method."}, "template_id": {"type": ["string", "null"], "description": "Source template identifier, when used."}, "actions": {"type": "object", "description": "Action paths appropriate to the draft state: `approve` and `reject` for a draft awaiting approval, `post` and `reject` for an approved one. Server-side role permissions still apply.", "properties": {"approve": {"type": "string"}, "reject": {"type": "string"}, "post": {"type": "string"}}}}}, "ReplyDraftStatus": {"type": "string", "enum": ["pending_approval", "approved", "posting", "posted", "rejected"], "description": "`pending_approval` until an admin approves or rejects it, `approved` until it is published, `posting` while it is being published (or when the reply is live and the draft is not marked posted yet), `posted` once the store accepted it, `rejected` when an admin rejected it."}, "ReplyDraftReviewSnapshot": {"type": "object", "description": "The review the draft replies to, as it was when the draft was made. A draft made by an automatic response rule can carry other review fields as well.", "properties": {"rating": {"type": "integer", "minimum": 1, "maximum": 5}, "content": {"type": ["string", "null"]}, "author": {"type": "string"}, "title": {"type": ["string", "null"]}, "date": {"type": ["string", "null"]}, "sentiment": {"type": ["string", "null"]}, "language": {"type": ["string", "null"], "description": "The review's language code, on drafts saved since the language was recorded."}}}, "DraftRejectRequest": {"type": "object", "properties": {"reason": {"type": "string", "description": "Why the draft was rejected. Stored with the draft and answered as `rejection_reason`."}}}, "ReplyDraftDecision": {"type": "object", "description": "The draft after an approval or a rejection.", "required": ["success", "draft_id", "status", "updated_at", "posted_to_store"], "properties": {"success": {"type": "boolean", "const": true}, "draft_id": {"type": "string", "format": "uuid"}, "status": {"$ref": "#/components/schemas/ReplyDraftStatus"}, "updated_at": {"type": "string", "format": "date-time"}, "posted_to_store": {"type": "boolean", "description": "Whether the store accepted the reply, also when recording it then failed. False unless `auto_post` was asked for."}, "post_result": {"$ref": "#/components/schemas/ReplyPublication"}, "rejection_reason": {"type": "string", "description": "A rejection only, when a reason was given."}}}, "DraftApproveRequest": {"type": "object", "dependentRequired": {"reply_template": ["reply_text"]}, "properties": {"reply_template": {"$ref": "#/components/schemas/ReplyTemplateSelection"}, "auto_post": {"type": "boolean", "description": "Whether to publish the reply to the app store immediately after approval. Defaults to false: approval records the decision, and publication is an explicit, separate act. When true, the response carries a `post_result` and `posted_to_store` reflecting whether the store actually accepted the reply.", "default": false}, "reply_text": {"type": "string", "maxLength": 5970, "description": "The reply as the customer edited it. Written onto the draft before approval (and before publication when `auto_post` is true), so the text that reaches the store is the text they approved. Omit to keep the stored draft text. Refused with `REPLY_HAS_PLACEHOLDER` while it still contains an unfilled `{placeholder}` from a template."}}}, "DraftPostRequest": {"type": "object", "dependentRequired": {"reply_template": ["reply_text"]}, "properties": {"reply_template": {"$ref": "#/components/schemas/ReplyTemplateSelection"}, "reply_text": {"type": "string", "maxLength": 5970, "description": "The reply as the customer edited it, written onto the approved draft before it is published. Omit to publish the stored draft text. Refused with `REPLY_HAS_PLACEHOLDER` while it still contains an unfilled `{placeholder}` from a template."}}}, "Credential": {"type": "object", "description": "A stored store credential, as `CredentialService._format_credential` answers it. The secret itself is never returned.", "required": ["credential_id"], "properties": {"credential_id": {"type": "string"}, "provider": {"type": "string", "deprecated": true, "description": "Not returned and never was. Read `platform`."}, "created_at": {"type": "string", "format": "date-time"}, "last_verified_at": {"type": "string", "format": "date-time", "deprecated": true, "description": "Not returned and never was. Nothing records when a credential was last checked."}, "metadata": {"type": "object", "additionalProperties": true, "deprecated": true, "description": "Not returned and never was."}, "platform": {"type": "string", "description": "The store. A credential stored through `POST /api/v1/credentials` holds `app_store` or `google_play`: an alias sent on create is normalised to one of these."}, "name": {"type": "string", "description": "Label shown in the credentials list. Empty for a credential stored without one."}, "status": {"type": "string", "description": "`active` for every credential the API stores."}, "updated_at": {"type": "string", "format": "date-time", "description": "When the label or the secret was last changed."}, "created_by": {"type": ["string", "null"], "description": "User ID of whoever stored the credential."}}}, "CredentialValidationResult": {"type": "object", "description": "The outcome of `POST /api/v1/credentials/validate`. With `valid` true the answer also has `platform` and `message`; with `valid` false it has `errors` instead.", "required": ["valid"], "properties": {"valid": {"type": "boolean", "description": "Whether the submitted secret has every field the store needs."}, "errors": {"type": "array", "items": {"type": "string"}, "description": "When `valid` is false: each missing or wrong field, as an English sentence that names the request field (for example `issuer_id is required for App Store Connect`)."}, "platform": {"type": "string", "enum": ["app_store", "google_play"], "description": "When `valid` is true, the store the request's `platform` resolved to."}, "message": {"type": "string", "description": "When `valid` is true: `Credentials format is valid`."}}}, "CredentialRequest": {"type": "object", "required": ["platform", "name"], "properties": {"platform": {"type": "string", "enum": ["app_store", "google_play", "app_store_connect", "ios", "play_store", "android"], "description": "Store this credential authenticates against. `app_store` and `google_play` are canonical; `app_store_connect`/`ios` and `play_store`/`android` are accepted aliases normalised by `CredentialService.PLATFORM_ALIASES`. The dashboard sends `play_store`."}, "name": {"type": "string", "description": "Label shown in the credentials list."}, "credentials": {"$ref": "#/components/schemas/CredentialSecret"}}}, "CredentialUpdateRequest": {"type": "object", "description": "Partial by design: omit `name` to keep the stored label, omit `credentials` to keep the stored secret. `platform` is immutable: apps resolve a credential by platform, so changing it would strand every linked app.", "properties": {"name": {"type": "string"}, "platform": {"type": "string", "enum": ["app_store", "google_play", "app_store_connect", "ios", "play_store", "android"], "description": "Optional, and immutable: the edit form echoes back the credential's existing platform. Sending a value that normalises to a different store is rejected 400 rather than silently stranding linked apps."}, "credentials": {"$ref": "#/components/schemas/CredentialSecret"}}}, "CredentialSecret": {"type": "object", "description": "Store secret material, written to Secrets Manager and never returned by the API. App Store Connect uses `issuer_id`/`key_id`/`private_key`; Google Play uses `service_account_json`.", "additionalProperties": true, "properties": {"issuer_id": {"type": "string"}, "key_id": {"type": "string"}, "private_key": {"type": "string"}, "service_account_json": {"type": "string"}}}, "CredentialValidateRequest": {"type": "object", "required": ["platform"], "properties": {"platform": {"type": "string", "enum": ["app_store", "google_play", "app_store_connect", "ios", "play_store", "android"], "description": "Same aliases as CredentialRequest.platform."}, "credentials": {"$ref": "#/components/schemas/CredentialSecret"}}}, "ReplyTemplate": {"type": "object", "properties": {"template_id": {"type": "string"}, "name": {"type": "string"}, "content": {"type": "string", "description": "The reply body, with `{{placeholder}}` slots."}, "category": {"type": "string"}, "tone": {"type": "string"}, "platforms": {"type": "array", "description": "Platforms this template applies to. Empty means all.", "items": {"type": "string"}}, "is_builtin": {"type": "boolean", "description": "True for templates Tritonium ships. Built-ins carry no timestamps and cannot be edited or deleted."}, "created_at": {"type": "string", "format": "date-time", "description": "Absent on built-in templates."}, "updated_at": {"type": "string", "format": "date-time", "description": "Absent on built-in templates."}}}, "ReplyTemplateRequest": {"type": "object", "required": ["name", "content"], "properties": {"name": {"type": "string", "maxLength": 200}, "content": {"type": "string", "maxLength": 5970, "description": "The reply body, with `{{placeholder}}` slots. Bounded by the App Store's 5,970-character reply limit, which the publish path also enforces; a longer template could never be posted."}, "category": {"type": "string", "maxLength": 100, "description": "Defaults to `custom` when omitted."}, "tone": {"type": "string", "maxLength": 100, "description": "Defaults to `neutral` when omitted."}, "platforms": {"type": "array", "description": "Platforms this template applies to. Defaults to `[app_store, google_play]` when omitted.", "items": {"type": "string"}}}}, "SentimentBaseline": {"type": "object", "description": "Tenant-wide sentiment rates used by sentiment alert settings. Every member is always sent.", "required": ["positive_rate", "negative_rate", "neutral_rate", "calculated_at", "window_days", "review_count", "analyzed_reviews", "sample_limited", "apps_counted"], "properties": {"positive_rate": {"type": "number", "minimum": 0, "maximum": 100, "description": "Percentage of positive sentiment across apps (0 to 100)."}, "negative_rate": {"type": "number", "minimum": 0, "maximum": 100, "description": "Percentage of negative sentiment across apps (0 to 100)."}, "neutral_rate": {"type": "number", "minimum": 0, "maximum": 100, "description": "What is left of 100 after the positive and negative rates, and never below 0."}, "calculated_at": {"type": "string", "format": "date-time", "description": "When this answer was computed (the baseline is not stored)."}, "window_days": {"type": "integer", "minimum": 1, "description": "How stale a contributing *insights run* may be, in days. It has never filtered reviews by age; an insights run analyses the newest reviews in an app regardless of when they were written."}, "review_count": {"type": "integer", "minimum": 1, "description": "Total reviews across the contributing apps. This is the corpus the rates are weighted by, not the number of reviews read to produce them (see `analyzed_reviews`)."}, "analyzed_reviews": {"type": "integer", "minimum": 1, "description": "How many reviews the contributing analysis runs actually read. Lower than `review_count` whenever any run hit its sample limit (the newest ~500 per app), which is the usual case."}, "sample_limited": {"type": "boolean", "description": "True when at least one contributing analysis run measured a sample rather than the whole app corpus, so the rates are an extrapolation."}, "apps_counted": {"type": "integer", "minimum": 1, "description": "Apps that contributed a usable insights run to the baseline."}}}, "BillingCheckoutRequest": {"type": "object", "properties": {"billing_cycle": {"type": "string", "enum": ["monthly", "annual"], "default": "monthly", "description": "Billing cadence for per-integration Checkout line items."}, "plan": {"type": "string", "deprecated": true, "description": "Legacy alias for billing_cycle when the value is monthly or annual."}, "integration_count": {"type": "integer", "minimum": 1, "description": "Number of integrations to bill (canonical)."}, "app_count": {"type": "integer", "minimum": 1, "description": "Alias for integration_count (SelectPlan legacy field name)."}, "member_count": {"type": "integer", "minimum": 1, "default": 1, "description": "Team size; first member is included per integration."}, "seats": {"type": "integer", "minimum": 1, "deprecated": true, "description": "Legacy alias for integration_count."}, "success_url": {"type": "string", "format": "uri"}, "cancel_url": {"type": "string", "format": "uri"}}}, "PaymentMethodRequest": {"type": "object", "required": ["payment_method_id"], "properties": {"payment_method_id": {"type": "string"}, "make_default": {"type": "boolean", "default": true, "description": "Whether this card also becomes the one the tenant is billed on. Defaults to true, so an attach with no `make_default` promotes the card. Send false to store a card without changing the default. The response's `is_default` reports the customer's actual default after the attach."}}}, "Subscription": {"type": "object", "description": "A tenant's current subscription. `subscription_id` and the Stripe-backed fields are null while the tenant is on a plan that has not been taken through Stripe checkout.", "properties": {"subscription_id": {"type": ["string", "null"]}, "tenant_id": {"type": "string"}, "status": {"type": "string"}, "pricing_model": {"type": "string"}, "billing_cycle": {"type": "string"}, "app_count": {"type": "integer", "description": "Apps connected now (usage, as `/billing/usage` counts them)."}, "member_count": {"type": "integer", "description": "Members and pending invitations now (usage)."}, "billed": {"type": "boolean", "description": "Whether a Stripe subscription bills this workspace: it has one and it is active, trialing or past due. When false nothing is billed: the `billed_*` quantities and every amount are 0."}, "billed_app_count": {"type": "integer", "description": "The app slots the subscription bills. Stripe invoices what was bought, whatever is connected: two slots with one app connected are billed as two."}, "billed_member_count": {"type": "integer", "description": "The team seats the subscription bills, the included one among them."}, "included_members": {"type": "integer"}, "additional_members": {"type": "integer", "description": "Billed seats beyond the included one."}, "app_subtotal": {"type": "number", "description": "`billed_app_count` at the monthly per-app price of the workspace's tier."}, "member_subtotal": {"type": "number"}, "monthly_cost": {"type": "number", "description": "What the billed quantities cost per month at monthly prices, in USD. 0 when `billed` is false."}, "annual_discount_applied": {"type": ["number", "null"]}, "effective_monthly_rate": {"type": "number"}, "current_cost": {"type": "number"}, "current_period_start": {"type": ["string", "null"], "format": "date-time"}, "current_period_end": {"type": ["string", "null"], "format": "date-time"}, "next_billing_date": {"type": ["string", "null"], "format": "date-time"}, "trial_end": {"type": ["string", "null"], "format": "date-time"}, "cancel_at_period_end": {"type": "boolean"}, "default_payment_method": {"type": ["object", "null"], "additionalProperties": true}, "stripe_customer_id": {"type": ["string", "null"]}}}, "Invoice": {"type": "object", "description": "One Stripe invoice of the workspace, as `GET /billing/invoices` lists it. Amounts are in `currency` units (dollars), not cents.", "required": ["id", "status", "amount_due", "amount_paid", "currency", "created"], "properties": {"id": {"type": "string", "description": "Stripe's invoice id. It is also the paging cursor."}, "number": {"type": ["string", "null"]}, "status": {"type": "string", "description": "Stripe's invoice status (`draft`, `open`, `paid`, `void`, `uncollectible`)."}, "amount_due": {"type": "number"}, "amount_paid": {"type": "number"}, "currency": {"type": "string"}, "created": {"type": "string", "format": "date-time"}, "period_start": {"type": "string", "format": "date-time"}, "period_end": {"type": "string", "format": "date-time"}, "hosted_invoice_url": {"type": ["string", "null"], "format": "uri", "description": "Stripe's page for the invoice. An open invoice can be paid there, with the card on file or another one."}, "invoice_pdf": {"type": ["string", "null"], "format": "uri"}, "next_payment_attempt": {"type": ["string", "null"], "format": "date-time", "description": "When Stripe will next try to collect the invoice by itself. Null when no attempt is scheduled: the invoice is settled, the retries are used up, or the payment waits for the customer's bank."}, "subscription_id": {"type": ["string", "null"], "description": "The Stripe subscription the invoice bills. The list is the customer's, so it can hold invoices of an earlier subscription; null for an invoice of no subscription."}}}, "Integration": {"type": "object", "description": "One connection to another tool. No answer carries a credential: tokens, API keys and signed addresses are kept in Secrets Manager, and a `webhook_url` is removed from `config`. Every connection Tritonium stores has `integration_id`, `integration_type`, `status`, `config`, `metadata`, `tenant_id`, `created_by`, `created_at` and `updated_at`.", "required": ["integration_id", "integration_type"], "properties": {"integration_id": {"type": "string", "description": "The connection's id."}, "integration_type": {"type": "string", "description": "Which tool this is, for example `slack`, `jira`, `linear`, `discord`, `teams` or `github`."}, "transport": {"type": "string", "enum": ["teams_workflows"], "readOnly": true, "description": "Microsoft Teams only. `teams_workflows` for a Workflows connection; a Teams connection without it is a retired Office connector that must be reconnected."}, "name": {"type": "string", "description": "The name given on connect or update. Left out when none was given, except on Microsoft Teams, which is then `Microsoft Teams`."}, "status": {"$ref": "#/components/schemas/IntegrationStatus"}, "config": {"$ref": "#/components/schemas/IntegrationConfig"}, "metadata": {"type": "object", "additionalProperties": true, "description": "Stored as given on connect or update."}, "tenant_id": {"type": "string"}, "created_by": {"type": "string", "description": "The user id of whoever connected it."}, "created_at": {"type": "string", "format": "date-time"}, "updated_at": {"type": "string", "format": "date-time"}, "deleted_at": {"type": "string", "format": "date-time", "description": "Only on a disconnected Microsoft Teams connection whose credential cleanup is still pending (`config.credential_cleanup_pending`). It delivers nothing; disconnect it again."}, "last_sync_requested": {"type": "string", "format": "date-time", "deprecated": true, "description": "When a sync was last asked for, on a connection stamped before integration sync was retired. Nothing writes it any more."}}}, "IntegrationStatus": {"type": "string", "enum": ["active", "disabled", "pending"], "description": "`active` delivers. `disabled` delivers nothing: an owner or admin turned it off, or it is a Microsoft Teams connection kept only to finish its credential cleanup. `pending` is set only by an update that asks for it."}, "IntegrationConfig": {"type": "object", "description": "The connection's settings. Which keys are set depends on the tool, and keys saved through the config operations come back as saved. A `webhook_url` is never returned.", "properties": {"channel_name": {"type": "string", "description": "Discord and Microsoft Teams: the channel name given on connect. A Discord connection given none is `Unknown`."}, "server_name": {"type": "string", "description": "Discord. The server name given on connect."}, "team_name": {"type": "string", "description": "Slack, the Slack workspace's name. Microsoft Teams, the team name given on connect."}, "team_id": {"type": "string", "description": "Slack, the Slack workspace's id. Linear, the team the workflow action creates issues in."}, "default_channel_id": {"type": "string", "description": "Slack. The public channel alerts are posted in."}, "default_channel_name": {"type": "string", "description": "Slack. That channel's name, stored when the channel is chosen."}, "default_channel": {"type": "string", "description": "Slack. The same channel's name, kept for older readers."}, "default_mentions": {"type": "array", "items": {"type": "string"}, "description": "Discord and Microsoft Teams. Mentioned in every message the connection posts."}, "severity_mentions": {"type": "object", "description": "Discord and Microsoft Teams. Who is mentioned for each alert severity.", "properties": {"critical": {"type": "array", "items": {"type": "string"}}, "high": {"type": "array", "items": {"type": "string"}}, "medium": {"type": "array", "items": {"type": "string"}}, "low": {"type": "array", "items": {"type": "string"}}}}, "project_id": {"type": "string", "description": "Linear. The project the workflow action puts each issue in."}, "label_ids": {"type": "array", "items": {"type": "string"}, "description": "Linear. The labels each such issue gets."}, "severity_priority_mapping": {"type": "object", "additionalProperties": {"type": "integer"}, "description": "Linear. Linear's priority (0 to 4) for each alert severity."}, "default_project_key": {"type": "string", "description": "Jira. The project an issue is created in when none is named."}, "default_issue_type": {"type": "string", "description": "Jira. The issue type used when none is named."}, "severity_mapping": {"type": "object", "additionalProperties": {"type": "string"}, "description": "Jira. The Jira priority for each alert severity."}, "assignee_mapping": {"type": "object", "additionalProperties": {"type": "string"}, "description": "Jira. Who an alert's issue is assigned to."}, "cloud_id": {"type": "string", "description": "Jira. The Atlassian site the connection creates issues on."}, "owner": {"type": "string", "description": "GitHub. The repository's owner."}, "repo": {"type": "string", "description": "GitHub. The repository issues are created in."}, "page_id": {"type": "string", "description": "Statuspage. The page incidents are created on."}, "credential_cleanup_pending": {"type": "boolean", "readOnly": true, "description": "Microsoft Teams. The connection's credential could not be deleted, or its save could not be confirmed. It delivers nothing; disconnect it again."}}}, "IntegrationConfigUpdate": {"type": "object", "additionalProperties": true, "description": "Settings to merge into the connection's `config`: each key sent replaces the stored value, and keys left out are kept. Any key is stored as given (`IntegrationConfig` lists the keys Tritonium reads), after the checks below; a failed check answers 400 and changes nothing. Microsoft Teams refuses `credential_cleanup_pending` and any credential or signed address.", "properties": {"default_channel_id": {"type": "string", "description": "Slack: the public channel to post alerts in, as a Slack channel id (`C` and up to 79 capital letters or digits). Tritonium asks Slack whether the connected app can see it and refuses a private or archived channel, then stores the channel's name with it. Slack is only read; nothing is posted."}, "webhook_url": {"type": "string", "description": "A new address, checked as a new connection's address is. Refused for Microsoft Teams: reconnect instead. Never returned."}, "domain": {"type": "string", "description": "Freshdesk. A new domain replaces the stored address unless `base_url` comes with it."}, "base_url": {"type": "string", "description": "Freshdesk. An HTTPS Freshdesk address."}, "instance_url": {"type": "string", "description": "Salesforce. An HTTPS Salesforce address."}}}, "IntegrationTestRequest": {"type": "object", "properties": {"message": {"type": "string", "description": "Shown with the test card, cut to 500 characters. Left out or empty, the card is sent alone."}, "severity": {"type": "string", "deprecated": true, "description": "Not read."}, "payload": {"type": "object", "additionalProperties": true, "deprecated": true, "description": "Not read."}}}, "IntegrationTestResponse": {"type": "object", "required": ["integration_id", "result"], "properties": {"integration_id": {"type": "string"}, "result": {"$ref": "#/components/schemas/IntegrationDeliveryResult"}, "success": {"type": "boolean", "description": "Microsoft Teams only. The same as `result.success`."}}}, "IntegrationDeliveryResult": {"type": "object", "required": ["success"], "description": "What the tool answered when Tritonium posted to it.", "properties": {"success": {"type": "boolean", "description": "Whether the tool accepted the message (a 2xx answer)."}, "status_code": {"type": "integer", "description": "The tool's HTTP status. Left out when no answer came back."}, "accepted": {"type": "boolean", "description": "Microsoft Teams only, when Teams accepted the message."}, "error": {"type": "string", "enum": ["teams_request_invalid", "teams_endpoint_blocked", "teams_dns_unavailable", "teams_delivery_uncertain", "teams_rate_limited", "teams_provider_rejected"], "description": "Microsoft Teams only, when `success` is false."}, "retryable": {"type": "boolean", "description": "Microsoft Teams only, when `success` is false. Whether sending again may work."}}}, "IntegrationConnectFields": {"type": "object", "description": "What a connection needs. Each tool reads only its own fields; the description of each field names the tools that read it. A secret field is kept in Secrets Manager and never returned. For GitHub, Microsoft Teams, PagerDuty, Freshdesk, Help Scout, HubSpot, Intercom, Salesforce and Zapier, every other top-level field is stored in `config` as given. Jira and Slack read no field.", "properties": {"name": {"type": "string", "description": "The connection's display name. Cut to 255 characters."}, "metadata": {"type": "object", "additionalProperties": true, "description": "Stored as given."}, "config": {"type": "object", "additionalProperties": true, "description": "The connection's settings. Linear, Opsgenie, VictorOps and Statuspage store it as given, without an `api_key`; Linear's workflow action needs `team_id`, and Statuspage needs `page_id`. The other token tools merge it over the top-level fields. Discord does not read it."}, "webhook_url": {"type": "string", "description": "Required for Discord, Microsoft Teams and Zapier. Discord: `https://discord.com/api/webhooks/<id>/<token>`. Teams: a signed Workflows URL. A secret: never returned."}, "channel_name": {"type": "string", "description": "Discord and Microsoft Teams. The name the connection is listed under; Discord stores `Unknown` when it is left out."}, "server_name": {"type": "string", "description": "Discord. Stored trimmed."}, "team_name": {"type": "string", "description": "Discord and Microsoft Teams. Stored trimmed for Discord."}, "default_mentions": {"type": "array", "items": {"type": "string"}, "description": "Discord and Microsoft Teams. Mentioned in every message the connection posts. Discord also takes a comma-separated string and keeps up to 50, each cut to 255 characters."}, "severity_mentions": {"type": "object", "additionalProperties": {"type": "array", "items": {"type": "string"}}, "description": "Microsoft Teams. Who is mentioned for each severity (`critical`, `high`, `medium`, `low`)."}, "api_key": {"type": "string", "maxLength": 512, "description": "Required for Linear, Opsgenie, VictorOps, Statuspage and Freshdesk. VictorOps: a REST API key or an `https://alert.victorops.com/integrations/...` address. A secret."}, "access_token": {"type": "string", "description": "Required for GitHub, HubSpot, Intercom and Salesforce; optional for Help Scout. A secret."}, "owner": {"type": "string", "description": "GitHub, required. The repository's owner; also read from `config`."}, "repo": {"type": "string", "description": "GitHub, required. The repository; also read from `config`."}, "routing_key": {"type": "string", "description": "PagerDuty, required. The Events API v2 integration key. A secret."}, "domain": {"type": "string", "description": "Freshdesk, required. Also read from `config`."}, "base_url": {"type": "string", "description": "Freshdesk. An HTTPS Freshdesk address used instead of the one built from `domain`."}, "refresh_token": {"type": "string", "description": "Required for Help Scout and Salesforce. A secret."}, "expires_at": {"type": "string", "description": "Help Scout. Stored with the credential."}, "mailbox_id": {"type": "string", "description": "Help Scout, required. Also read from `config`."}, "user_id": {"type": "string", "description": "Salesforce, required. Stored with the credential."}, "issued_at": {"type": "string", "description": "Salesforce. Stored with the credential."}, "id": {"type": "string", "description": "Salesforce. Stored with the credential."}, "instance_url": {"type": "string", "description": "Salesforce, required. An HTTPS Salesforce address; also read from `config`."}, "signing_secret": {"type": "string", "description": "Zapier. A secret."}}}, "IntegrationConnectRequest": {"allOf": [{"type": "object", "required": ["integration_type"], "properties": {"integration_type": {"type": "string", "enum": ["jira", "slack", "linear", "discord", "teams", "asana", "clickup", "trello", "zapier", "sms", "twilio", "gitlab", "github", "zendesk", "helpscout", "salesforce", "freshdesk", "hubspot", "intercom", "monday", "pagerduty", "opsgenie", "victorops", "statuspage"], "description": "The tool to connect. Any letter case is accepted."}}}, {"$ref": "#/components/schemas/IntegrationConnectFields"}]}, "LinearItem": {"type": "object", "required": ["id", "name"], "description": "A Linear team or project, as Linear names it.", "properties": {"id": {"type": "string"}, "name": {"type": "string"}}}, "CreateExportRequest": {"type": "object", "required": ["format"], "description": "Create an export job. Provide a `format` plus exactly one of `type` (canonical) or `export_type` (deprecated alias); the API accepts either field name and rejects a request that supplies neither. A top-level `anyOf` is intentionally avoided here because it forces the Python SDK generator to emit an untyped `body: Any`; the \"one of type/export_type\" rule is enforced by the export service. Closed set, like `filters`: a key outside `properties` is rejected with `VALIDATION_ERROR` naming it. A top-level `app_uuid` (the spelling the rest of the API uses) used to be accepted and never read, so the job completed normally while exporting every app in the tenant instead of the one asked for. Scope an export with `filters`.", "additionalProperties": false, "properties": {"type": {"type": "string", "enum": ["reviews", "insights", "alerts", "replies", "analytics"], "description": "Data set to export. The legacy field name `export_type` is also accepted for backward compatibility. A `reviews` file has one row per review with these columns, in this order: `review_id`, `app_uuid`, `platform`, `rating`, `title`, `content`, `author`, `sentiment`, `created_at`, `sentiment_source`, `language` (the language code the `language` filter matches on, empty when none is known), `territory` (the store country, where the store gives one), `app_version`, `tags` (the names of the workspace's own tags on the review, in name order, separated by a comma and a space), `reply_text` and `reply_posted_at` (the developer reply, empty when there is none). New columns are only ever added after the last one."}, "export_type": {"type": "string", "enum": ["reviews", "insights", "alerts", "replies", "analytics"], "deprecated": true, "description": "Deprecated alias for `type`."}, "format": {"type": "string", "enum": ["csv", "json"], "description": "Output format. `xlsx` was previously accepted and documented as \"will fail the job\"; it is now rejected with `VALIDATION_ERROR` at request time, because a job that is queued and then fails minutes later is a worse answer than a 400 the caller can act on. A `csv` file is UTF-8 and starts with a byte-order mark, so spreadsheet apps (Excel in particular) read non-English reviews and typographic punctuation correctly."}, "filters": {"type": "object", "description": "Closed set. A key outside `properties` is rejected with `VALIDATION_ERROR` naming the offending key and listing the accepted set. Unknown keys used to be stored and never read, so a singular/plural slip (`rating` for `ratings`, `sentiment` for `sentiments`) produced a job that succeeded and returned the entire corpus while reporting itself as a normal completed export. `search`, `search_hex`, `language`, `assignee` and `tag` are the review list's filters, spelled and applied as `GET /api/v1/reviews` spells and applies them, so a `reviews` export created with the filters of a list holds the reviews that list returns. They are rejected for an `insights`, `alerts` or `replies` export, which does not read reviews.", "additionalProperties": false, "properties": {"start_date": {"type": "string", "format": "date", "description": "Inclusive lower bound, compared against each review's **UTC** calendar day, the same day the dashboard's trends and analytics use, so an export and the charts agree on which day a review belongs to. A store review's raw `created_at` is local to the store (an App Store row reads `2026-06-10T18:00:00-07:00`, which is `2026-06-11` in UTC); that offset is applied before the comparison."}, "end_date": {"type": "string", "format": "date", "description": "Inclusive upper bound on the same UTC calendar day as `start_date`, so an `end_date` of `2026-06-11` includes a review at `2026-06-11T23:59:59Z`."}, "app_uuids": {"type": "array", "items": {"type": "string"}, "description": "The workspace's apps to export. An id that is not one of them answers `404` `APP_NOT_FOUND` and no job is created."}, "platforms": {"type": "array", "items": {"type": "string"}}, "ratings": {"type": "array", "items": {"type": "integer"}}, "sentiments": {"type": "array", "description": "Keep only reviews with these sentiments. A value outside the enum is rejected with `INVALID_SENTIMENT` rather than accepted and matched against nothing. Sentiment is the label stored on the review where there is one and is otherwise the star rating in three buckets (4 to 5 positive, 3 neutral, 1 to 2 negative), the same rule `GET /api/v1/apps/{app_uuid}/reviews?sentiment=` applies, so a review with no usable rating matches no sentiment filter. Each exported review carries a `sentiment_source` column beside `sentiment` (`rating` or `analysis`, as on `ReviewSummary`) saying which of the two its label is.", "items": {"type": "string", "enum": ["positive", "negative", "neutral", "mixed"]}}, "search": {"type": "string", "minLength": 1, "maxLength": 2048, "description": "`reviews` and `analytics` exports only. Keep only reviews whose text, title or author (or the English translation of the text or title) contains this term, by the rule `GET /api/v1/reviews?search=` applies: case and curly-versus-straight quotes are ignored, and each field is tested on its own. A blank value is rejected. The edge firewall refuses a request body that looks like an attack with a 403 that carries no JSON body, and review words can look like one (`../`, `<script>`). Send such a term as `search_hex` instead."}, "search_hex": {"type": "string", "pattern": "^h([0-9a-f]{2})+$", "maxLength": 1500, "description": "The term of `search`, encoded as the review list takes it: the letter `h` followed by the term's UTF-8 bytes in lowercase hexadecimal (`crash` is `h6372617368`). It is decoded and applied exactly as `search` is. Send `search` or `search_hex`, not both. A value that is not of this form, is longer than 1,500 characters, does not decode to UTF-8 text or decodes to a blank term is rejected."}, "language": {"type": "string", "minLength": 1, "maxLength": 35, "description": "`reviews` and `analytics` exports only. `english`, `non-english`, `unknown`, or a language code such as `de`, decided for each review by the rule `GET /api/v1/reviews?language=` applies. A code no review carries matches nothing."}, "assignee": {"type": "string", "maxLength": 128, "pattern": "^[A-Za-z0-9._@:-]+$", "description": "`reviews` and `analytics` exports only. `assigned`, `unassigned`, or a team member's user id, as on `GET /api/v1/reviews?assignee=`. Assignments are the workspace's own: a review another workspace tracking the same app has assigned is not assigned here. An unknown member id matches nothing."}, "tag": {"type": "string", "format": "uuid", "description": "`reviews` and `analytics` exports only. Keep only reviews that carry this tag, one of the workspace's own tag ids (`GET /api/v1/review-tags`), as on `GET /api/v1/reviews?tag=`. An id that is not one of the workspace's tags matches nothing."}}}}}, "ExportJob": {"type": "object", "description": "One export job, as the list, the create and the single read answer it. Every job has the four required members and also `filters`, `truncated`, `record_limit`, `record_count`, `file_size` and `error_message`; the dates, `error_code` and `download_url` are sent only where their own descriptions say.", "required": ["job_id", "export_type", "format", "status"], "properties": {"job_id": {"type": "string"}, "export_type": {"type": "string", "description": "The data set, one of the `type` values of `CreateExportRequest`. A job stored before the data set was checked may hold another one; such a job failed with `unsupported_type`."}, "format": {"type": "string", "description": "`csv` or `json`. A job stored before `xlsx` was refused may hold `xlsx`; such a job failed with `unsupported_format`."}, "status": {"type": "string", "enum": ["pending", "processing", "completed", "failed", "expired"], "description": "`expired` means the job completed but its download file has passed the exports bucket's retention window and is no longer available. The listing and the single read both report it, from `expires_at`."}, "file_name": {"type": "string", "deprecated": true, "description": "Never sent. The download itself names the file (its `Content-Disposition`)."}, "download_url": {"type": "string", "format": "uri", "description": "A link to the file, valid for one hour. Sent only by the single read (`GET /api/v1/exports/{job_id}`) of a completed job whose file is still kept; the list and the create never send one. A read that could not make the link answers the job without it."}, "file_size": {"type": ["integer", "null"], "description": "Export file size in bytes, or null until a file is produced."}, "record_count": {"type": ["integer", "null"], "description": "Number of exported records, or null until processing finishes."}, "truncated": {"type": "boolean", "description": "Whether the file is a partial answer: the export stopped at the record cap, or (an analytics export) its totals stopped before the last review."}, "record_limit": {"type": "integer", "description": "The limit that applies: the most records a file holds, or, for a truncated analytics export, the number of reviews its totals count."}, "filters": {"type": "object", "additionalProperties": true, "description": "The filters the job was created with, as stored (see `CreateExportRequest.filters`). An empty object means the whole workspace. A job created before filter keys were validated may hold a key outside that set; such a key was never applied."}, "error_message": {"type": ["string", "null"], "description": "An English sentence about a failed job, for logs. Show a person the reason from `error_code` instead."}, "error_code": {"type": "string", "enum": ["processing_failed", "unsupported_format", "unsupported_type", "invalid_filters"], "description": "Why a failed job failed. Sent for a failed job only. `processing_failed`: the export could not be produced; create it again. `unsupported_format` and `unsupported_type`: the job asked for a format or a data set the processor does not produce (such a request is now refused when it is made). `invalid_filters`: a stored filter value cannot be applied, or the job was finished by a processor that did not apply one of its `search`, `search_hex`, `language`, `assignee` or `tag` filters; create the export again."}, "created_at": {"type": "string", "format": "date-time"}, "completed_at": {"type": "string", "format": "date-time", "description": "When the job completed. Left out, not sent as null, for a job that has not completed (pending, processing or failed)."}, "expires_at": {"type": "string", "format": "date-time", "description": "When the download file stops being available: seven days after the job completed. Its stored copy is removed from storage within about two more days. Left out for a job that has not completed (pending, processing or failed). No `download_url` is issued after it."}}}, "ExportJobResponse": {"$ref": "#/components/schemas/ExportJob"}, "ExportJobList": {"type": "object", "description": "One page of the workspace's export jobs, newest first.", "required": ["exports", "count", "has_more"], "properties": {"exports": {"type": "array", "maxItems": 20, "items": {"$ref": "#/components/schemas/ExportJob"}}, "count": {"type": "integer", "minimum": 0, "description": "Number of jobs on this page."}, "has_more": {"type": "boolean", "description": "Whether a further page is available. Only ever true when a `cursor` is also returned."}, "cursor": {"type": "string", "description": "Pass as `cursor` to fetch the next page. Sent only when `has_more` is true."}}}, "ExportDeleted": {"type": "object", "required": ["message"], "properties": {"message": {"type": "string", "description": "`Export deleted successfully`."}}}, "CreateReportTemplateRequest": {"type": "object", "required": ["name"], "properties": {"name": {"type": "string", "minLength": 1, "maxLength": 200}, "template_type": {"type": "string", "enum": ["executive_summary", "weekly_team", "monthly_stakeholder", "quarterly_review", "custom"], "description": "Defaults to custom when neither template_type nor its legacy type alias is supplied."}, "sections": {"type": "array", "items": {"type": "string", "enum": ["sentiment_overview", "review_volume", "review_volume_trends", "rating_distribution", "top_issues", "feature_requests", "competitor_mentions", "geographic_breakdown", "response_metrics", "testimonials", "alert_summary", "ai_recommendations"]}, "default": [], "description": "Select available sections; review_volume is an alias for review_volume_trends."}, "output_formats": {"type": "array", "items": {"type": "string", "enum": ["pdf", "pptx", "html"]}, "minItems": 1, "default": ["pdf"]}, "type": {"type": "string", "enum": ["executive_summary", "weekly_team", "monthly_stakeholder", "quarterly_review", "custom"], "description": "Legacy alias for template_type. When both are provided, their values must agree."}, "branding": {"$ref": "#/components/schemas/ReportBranding"}}}, "ReportTemplate": {"type": "object", "description": "One report template. Every answer that carries a template has all nine members.", "required": ["template_id", "name", "template_type", "sections", "branding", "output_formats", "created_at", "updated_at", "type"], "properties": {"template_id": {"type": "string"}, "name": {"type": "string", "maxLength": 200}, "template_type": {"type": "string", "enum": ["executive_summary", "weekly_team", "monthly_stakeholder", "quarterly_review", "custom"]}, "sections": {"type": "array", "description": "The sections, in the order they were chosen. A section chosen as `review_volume` is answered as `review_volume_trends`.", "items": {"type": "string", "enum": ["sentiment_overview", "review_volume_trends", "rating_distribution", "top_issues", "feature_requests", "competitor_mentions", "geographic_breakdown", "response_metrics", "testimonials", "alert_summary", "ai_recommendations"]}}, "branding": {"$ref": "#/components/schemas/ReportBranding"}, "output_formats": {"type": "array", "description": "The formats a report from this template can be generated in. `pdf` when none was chosen.", "items": {"type": "string", "enum": ["pdf", "pptx", "html"]}}, "created_at": {"type": ["string", "null"], "format": "date-time"}, "updated_at": {"type": ["string", "null"], "format": "date-time", "description": "When the template last changed. A change or a delete must not race another one, which answers `409`."}, "type": {"type": ["string", "null"], "description": "The same value as `template_type`, under its earlier name."}}}, "ReportTemplateResponse": {"description": "A template, with its members at the top level and again in `template`. Read `template`; the top-level copy is kept for earlier clients.", "allOf": [{"$ref": "#/components/schemas/ReportTemplateEnvelope"}, {"$ref": "#/components/schemas/ReportTemplate"}]}, "ReportTemplateEnvelope": {"type": "object", "description": "The `template` member of a template answer.", "required": ["template"], "properties": {"template": {"$ref": "#/components/schemas/ReportTemplate"}}}, "ReportTemplateList": {"type": "object", "description": "Every report template of the workspace, in one answer (not paged), ordered by template id rather than by date.", "required": ["templates", "count"], "properties": {"templates": {"type": "array", "items": {"$ref": "#/components/schemas/ReportTemplate"}}, "count": {"type": "integer", "minimum": 0}}}, "ReportTemplateDeleted": {"type": "object", "required": ["message"], "properties": {"message": {"type": "string", "description": "`Template deleted successfully`."}}}, "GenerateReportRequest": {"type": "object", "required": ["template_id"], "properties": {"template_id": {"type": "string", "format": "uuid"}, "output_format": {"type": "string", "enum": ["pdf", "pptx", "html"], "default": "pdf"}, "filters": {"type": "object", "properties": {"start_date": {"type": "string", "format": "date"}, "end_date": {"type": "string", "format": "date"}, "app_uuids": {"type": "array", "items": {"type": "string", "format": "uuid"}}}, "additionalProperties": false, "description": "Inclusive UTC calendar dates. end_date defaults to today and start_date to 29 days before end_date. Omitting app_uuids selects all current apps owned by this workspace. An explicit empty array selects no apps. Product groups cannot be selected."}}, "additionalProperties": false}, "ReportJob": {"type": "object", "description": "One report job. Every answer that carries a job has the four required members and also `created_at`, `completed_at` and `error_message`; `download_url`, `expires_at` and `delivery_status` are sent only where their own descriptions say.", "required": ["job_id", "template_id", "status", "output_format"], "properties": {"job_id": {"type": "string"}, "template_id": {"type": "string"}, "status": {"type": "string", "enum": ["pending", "processing", "completed", "failed"]}, "output_format": {"type": "string", "enum": ["pdf", "pptx", "html"]}, "download_url": {"type": "string", "format": "uri", "description": "A fresh URL valid for at most one hour and bounded by expires_at. Sent only by `GET /api/v1/reports/jobs/{job_id}` while a completed file is available; the generate and send-now answers never carry one."}, "error_message": {"type": ["string", "null"], "description": "An English sentence: why a failed job failed, or, on a pending job, that it is being retried. Null otherwise."}, "created_at": {"type": "string", "format": "date-time"}, "completed_at": {"type": ["string", "null"], "format": "date-time", "description": "When the job completed or failed; null until then."}, "expires_at": {"type": "string", "format": "date-time", "description": "File availability ends seven days after the artifact checkpoint set before upload and preserved during delivery retries. Sent once the job has completed, and still sent when a read answers the job as failed because this time has passed. Metadata remains for seven days after completion; file expiration is independent of that metadata retention."}, "delivery_status": {"type": "string", "enum": ["pending", "accepted", "failed"], "description": "Present only for delivery jobs. accepted means all recipients were accepted by the email provider, not confirmed inbox delivery."}}}, "ReportJobResponse": {"description": "A report job, with its members at the top level and again in `job`. Read `job`; the top-level copy is kept for earlier clients.", "allOf": [{"$ref": "#/components/schemas/ReportJobEnvelope"}, {"$ref": "#/components/schemas/ReportJob"}]}, "ReportJobEnvelope": {"type": "object", "description": "The `job` member of a report job answer.", "required": ["job"], "properties": {"job": {"$ref": "#/components/schemas/ReportJob"}}}, "CreateScheduledReportRequest": {"type": "object", "required": ["template_id", "recipients"], "properties": {"template_id": {"type": "string", "format": "uuid"}, "name": {"type": "string", "minLength": 1, "maxLength": 200, "description": "Defaults to the template name."}, "frequency": {"type": "string", "enum": ["daily", "weekly", "biweekly", "monthly"], "default": "weekly"}, "delivery_day": {"type": "integer", "minimum": 0, "maximum": 28, "description": "Weekly and biweekly: Monday=0 through Sunday=6, default 0. Monthly: day 1 through 28, default 1. Ignored for daily cadence, but must be 0 through 6 if supplied."}, "delivery_hour": {"type": "integer", "minimum": 0, "maximum": 23, "default": 9, "description": "Delivery hour in UTC."}, "recipients": {"type": "array", "items": {"type": "string", "format": "email", "maxLength": 254}, "minItems": 1, "maxItems": 50}, "filters": {"type": "object", "properties": {"app_uuids": {"type": "array", "items": {"type": "string", "format": "uuid"}}, "date_range_days": {"type": "integer", "minimum": 1, "maximum": 365, "default": 30, "description": "Number of completed UTC calendar days ending the day before the delivery date."}}, "additionalProperties": false}, "enabled": {"type": "boolean", "default": true}}, "additionalProperties": false}, "ScheduledReport": {"type": "object", "description": "One scheduled report. Every answer that carries a schedule has all sixteen members.", "required": ["schedule_id", "template_id", "template_name", "name", "frequency", "delivery_day", "delivery_hour", "recipients", "filters", "enabled", "last_sent_at", "next_send_at", "created_at", "updated_at", "last_error", "last_job_id"], "properties": {"schedule_id": {"type": "string"}, "template_id": {"type": "string"}, "template_name": {"type": "null", "description": "Always null: the template's name is not kept with the schedule. Read it from the template (`template_id`)."}, "name": {"type": "string"}, "frequency": {"type": "string", "enum": ["daily", "weekly", "biweekly", "monthly"]}, "delivery_day": {"type": "integer", "minimum": 0, "maximum": 28, "description": "Weekly and biweekly: Monday 0 through Sunday 6. Monthly: day 1 through 28. Daily: not used, 0 unless another day 0 through 6 was sent."}, "delivery_hour": {"type": "integer", "minimum": 0, "maximum": 23, "description": "Delivery hour in UTC."}, "recipients": {"type": "array", "items": {"type": "string"}}, "filters": {"type": "object", "description": "`date_range_days` is always sent; `app_uuids` only when the schedule names its apps (otherwise every current app of the workspace is reported).", "properties": {"app_uuids": {"type": "array", "items": {"type": "string"}}, "date_range_days": {"type": "integer", "minimum": 1, "maximum": 365}}}, "enabled": {"type": "boolean"}, "last_sent_at": {"type": ["string", "null"], "format": "date-time", "description": "Updated only after the email provider accepts every recipient."}, "next_send_at": {"type": ["string", "null"], "format": "date-time"}, "created_at": {"type": ["string", "null"], "format": "date-time"}, "updated_at": {"type": ["string", "null"], "format": "date-time"}, "last_error": {"type": ["string", "null"], "description": "An English sentence about the last delivery that could not be queued or sent, or null. A saved change, or a delivery that is sent, clears it."}, "last_job_id": {"type": ["string", "null"], "format": "uuid", "description": "The job of the last delivery that was sent or failed, or null."}}}, "ScheduledReportResponse": {"description": "A scheduled report, with its members at the top level and again in `schedule`. Read `schedule`; the top-level copy is kept for earlier clients.", "allOf": [{"$ref": "#/components/schemas/ScheduledReportEnvelope"}, {"$ref": "#/components/schemas/ScheduledReport"}]}, "ScheduledReportEnvelope": {"type": "object", "description": "The `schedule` member of a scheduled report answer.", "required": ["schedule"], "properties": {"schedule": {"$ref": "#/components/schemas/ScheduledReport"}}}, "ScheduledReportList": {"type": "object", "description": "Every scheduled report of the workspace, in one answer (not paged), ordered by schedule id rather than by date.", "required": ["schedules", "count"], "properties": {"schedules": {"type": "array", "items": {"$ref": "#/components/schemas/ScheduledReport"}}, "count": {"type": "integer", "minimum": 0}}}, "ScheduledReportDeleted": {"type": "object", "required": ["message"], "properties": {"message": {"type": "string", "description": "`Schedule deleted successfully`."}}}, "ReportBranding": {"type": "object", "properties": {"logo_url": {"type": "string", "maxLength": 2048, "description": "Optional public HTTPS PNG or JPEG on port 443. Private addresses, redirects, oversized files, and unsupported images are rejected. Remove or replace an unavailable logo before regenerating. Empty text, which is kept as sent, means no logo."}, "primary_color": {"type": "string", "pattern": "^(#[0-9A-Fa-f]{6})?$", "maxLength": 2048}, "secondary_color": {"type": "string", "pattern": "^(#[0-9A-Fa-f]{6})?$", "maxLength": 2048}, "company_name": {"type": "string", "maxLength": 200}}, "additionalProperties": false}, "UpdateReportTemplateRequest": {"type": "object", "properties": {"name": {"type": "string", "minLength": 1, "maxLength": 200}, "template_type": {"type": "string", "enum": ["executive_summary", "weekly_team", "monthly_stakeholder", "quarterly_review", "custom"]}, "sections": {"type": "array", "items": {"type": "string", "enum": ["sentiment_overview", "review_volume", "review_volume_trends", "rating_distribution", "top_issues", "feature_requests", "competitor_mentions", "geographic_breakdown", "response_metrics", "testimonials", "alert_summary", "ai_recommendations"]}, "description": "Select available sections; review_volume is an alias for review_volume_trends."}, "output_formats": {"type": "array", "items": {"type": "string", "enum": ["pdf", "pptx", "html"]}, "minItems": 1}, "type": {"type": "string", "enum": ["executive_summary", "weekly_team", "monthly_stakeholder", "quarterly_review", "custom"], "description": "Legacy alias for template_type. When both are provided, their values must agree."}, "branding": {"$ref": "#/components/schemas/ReportBranding"}}}, "UpdateScheduledReportRequest": {"type": "object", "properties": {"template_id": {"type": "string", "format": "uuid"}, "name": {"type": "string", "minLength": 1, "maxLength": 200, "description": "Defaults to the template name."}, "frequency": {"type": "string", "enum": ["daily", "weekly", "biweekly", "monthly"]}, "delivery_day": {"type": "integer", "minimum": 0, "maximum": 28, "description": "Weekly and biweekly: Monday=0 through Sunday=6, default 0. Monthly: day 1 through 28, default 1. Ignored for daily cadence, but must be 0 through 6 if supplied."}, "delivery_hour": {"type": "integer", "minimum": 0, "maximum": 23, "description": "Delivery hour in UTC."}, "recipients": {"type": "array", "items": {"type": "string", "format": "email", "maxLength": 254}, "minItems": 1, "maxItems": 50}, "filters": {"type": "object", "properties": {"app_uuids": {"type": "array", "items": {"type": "string", "format": "uuid"}}, "date_range_days": {"type": "integer", "minimum": 1, "maximum": 365, "description": "Number of completed UTC calendar days ending the day before the delivery date."}}, "additionalProperties": false}, "enabled": {"type": "boolean"}}, "additionalProperties": false}, "MfaSetupStartResponse": {"type": "object", "description": "The authenticator secret for a new enrollment. This is the one answer that carries it: confirming the enrollment does not send it again.", "required": ["secret_code", "qr_code", "issuer"], "properties": {"secret_code": {"type": "string", "description": "Base32-encoded TOTP secret for manual entry."}, "qr_code": {"type": "string", "description": "otpauth:// URI for QR code generation. It carries the same secret."}, "issuer": {"type": "string", "description": "Issuer name shown in authenticator app.", "example": "Tritonium"}}}, "MfaVerifyRequest": {"type": "object", "required": ["code"], "properties": {"code": {"type": "string", "pattern": "^\\d{6}$", "description": "6-digit TOTP code from authenticator app.", "example": "123456"}}}, "MfaSetupVerifyResponse": {"type": "object", "description": "The enrollment is complete. Neither the secret nor the code is sent back.", "required": ["success", "message"], "properties": {"success": {"type": "boolean", "enum": [true]}, "message": {"type": "string", "example": "Multi-factor authentication enabled"}, "data": {"description": "Sent only when the call was made with `X-MFA-Setup-Token` (an enrollment a workspace requires before the first sign-in): the session that sign-in was waiting for. A signed-in call has no `data`.", "allOf": [{"$ref": "#/components/schemas/SignInSession"}]}}}, "MfaStatusResponse": {"type": "object", "description": "Every member is always sent.", "required": ["enabled", "preferred", "methods", "attributes"], "properties": {"enabled": {"type": "boolean", "description": "Whether MFA is enabled for this account."}, "preferred": {"type": ["string", "null"], "description": "The preferred method as the identity provider names it (`SOFTWARE_TOKEN_MFA` or `SMS_MFA`), else `SOFTWARE_TOKEN` when the account recorded an enrollment the provider does not report, else null.", "example": "SOFTWARE_TOKEN_MFA"}, "methods": {"type": "array", "items": {"type": "string"}, "description": "The configured methods, named as `preferred` is (or `SMS` or `EMAIL` for a delivery-based method). Empty when MFA is off."}, "attributes": {"type": "object", "required": ["mfa_enrolled_at", "mfa_method", "email_verified"], "properties": {"mfa_enrolled_at": {"type": ["string", "null"], "format": "date-time", "description": "When MFA was enrolled through Tritonium, or null."}, "mfa_method": {"type": ["string", "null"], "description": "`SOFTWARE_TOKEN` once enrolled through Tritonium, else null."}, "email_verified": {"type": "boolean", "description": "Whether the identity provider has confirmed the email address. False when it holds no value."}}}}}, "MfaChallengeRequest": {"type": "object", "required": ["email", "session", "code"], "properties": {"email": {"type": "string", "format": "email", "description": "User's email address."}, "session": {"type": "string", "description": "Session token from login challenge response."}, "challenge": {"type": "string", "enum": ["SOFTWARE_TOKEN_MFA", "SMS_MFA"], "default": "SOFTWARE_TOKEN_MFA", "description": "Type of MFA challenge being responded to."}, "code": {"type": "string", "pattern": "^\\d{6}$", "description": "6-digit MFA code."}}}, "MfaChallengeResponse": {"type": "object", "description": "The code was accepted and the sign-in completed.", "required": ["success", "message", "data"], "properties": {"success": {"type": "boolean", "enum": [true]}, "message": {"type": "string"}, "data": {"$ref": "#/components/schemas/SignInSession"}}}, "MfaSetupLoginChallenge": {"type": "object", "description": "The code was accepted, but the identity provider asks for one more step before it signs the person in. No session is issued; sign in again.", "required": ["success", "message", "data"], "properties": {"success": {"type": "boolean", "enum": [false]}, "message": {"type": "string", "example": "Additional authentication required"}, "data": {"type": "object", "required": ["challenge", "session", "challenge_parameters"], "properties": {"challenge": {"type": "string", "description": "The step the identity provider asks for, as it names it."}, "session": {"type": ["string", "null"]}, "challenge_parameters": {"type": "object", "additionalProperties": {"type": "string"}}}}}}, "MfaSetupStartLoginRequest": {"type": "object", "required": ["session", "email"], "properties": {"session": {"type": "string", "description": "Session token from login MFA_SETUP challenge."}, "email": {"type": "string", "format": "email", "description": "User's email address."}}}, "MfaSetupStartLoginResponse": {"type": "object", "description": "The authenticator secret for an enrollment during sign-in, with the session to confirm it with. Confirming does not send the secret again.", "required": ["secret_code", "qr_code", "issuer", "session"], "properties": {"secret_code": {"type": "string", "description": "Base32-encoded TOTP secret."}, "qr_code": {"type": "string", "description": "otpauth:// URI for QR code."}, "issuer": {"type": "string", "description": "Issuer name for authenticator."}, "session": {"type": "string", "description": "Updated session token for next step."}}}, "MfaSetupVerifyLoginRequest": {"type": "object", "required": ["session", "email", "code"], "properties": {"session": {"type": "string", "description": "Session token from setup start response."}, "email": {"type": "string", "format": "email", "description": "User's email address."}, "code": {"type": "string", "pattern": "^\\d{6}$", "description": "6-digit TOTP code from authenticator."}}}, "PamRequestCreate": {"type": "object", "required": ["justification", "requested_duration_minutes"], "properties": {"justification": {"type": "string", "maxLength": 500, "description": "Business justification for the access request.", "example": "Investigating billing bug reported in ticket 12345"}, "requested_duration_minutes": {"type": "integer", "minimum": 5, "maximum": 480, "description": "Requested access duration in minutes.", "example": 120}, "scope": {"type": "object", "description": "What the access is for, stored with the request as sent and copied to the session an approval opens. The API does not check its members.", "properties": {"apps": {"type": "array", "items": {"type": "string"}, "description": "Limit access to specific app UUIDs."}, "resource_type": {"type": "string", "description": "Type of resource access requested, as the requester names it. The dashboard sends `admin_access`, `billing_access`, `data_export`, `user_management`, `api_keys` or `security_settings`."}, "resource_id": {"type": "string", "description": "The resource the access is for, when the requester names one."}}}, "metadata": {"type": "object", "additionalProperties": true, "description": "Free-form metadata stored with the request."}}}, "PamRequest": {"type": "object", "description": "An access request as `PamService._sanitize_request` answers it: every member is always present, null where nothing has set it.", "required": ["request_id", "tenant_id", "requester_user_id", "requester_email", "status", "justification", "requested_duration_minutes", "scope", "session_id", "approved_by", "approved_by_email", "approved_at", "denial_reason", "created_at", "updated_at", "expires_at"], "properties": {"request_id": {"type": "string", "description": "Unique request identifier.", "example": "req-abc123"}, "tenant_id": {"type": "string", "description": "Target tenant ID."}, "requester_user_id": {"type": "string", "description": "User ID of the requester."}, "requester_email": {"type": "string", "format": "email", "description": "Email of the requester."}, "status": {"type": "string", "enum": ["pending", "approved", "denied", "expired", "revoked"], "description": "Current request status."}, "justification": {"type": "string", "description": "Business justification provided."}, "requested_duration_minutes": {"type": "integer", "description": "Requested access duration."}, "scope": {"type": "object", "additionalProperties": true, "description": "The scope the access request was made with (`PamRequestCreate.scope`), as it was sent. The API does not check its members."}, "session_id": {"type": ["string", "null"], "description": "The session an approval opened. Null unless the request was approved."}, "approved_by": {"type": ["string", "null"], "description": "User ID of whoever approved or denied the request. Null while it is pending."}, "approved_by_email": {"type": ["string", "null"], "format": "email", "description": "Email of whoever approved or denied the request. Null while it is pending."}, "approved_at": {"type": ["string", "null"], "format": "date-time", "description": "When the request was approved or denied. Null while it is pending."}, "denial_reason": {"type": ["string", "null"], "description": "The reason given. Null unless the request was denied."}, "created_at": {"type": "string", "format": "date-time"}, "updated_at": {"type": ["string", "null"], "format": "date-time"}, "expires_at": {"type": ["string", "null"], "format": "date-time", "description": "When the access an approval granted ends. Null unless the request was approved."}}}, "PamSession": {"type": "object", "description": "A privileged session as `PamService._sanitize_session` answers it. The deprecated members are never present; every other one always is.", "required": ["session_id", "request_id", "tenant_id"], "properties": {"session_id": {"type": "string", "description": "Unique session identifier.", "example": "sess-xyz789"}, "request_id": {"type": "string", "description": "Associated request ID."}, "tenant_id": {"type": "string", "description": "Target tenant ID."}, "user_id": {"type": "string", "deprecated": true, "description": "Not returned and never was. Read `requester_user_id`."}, "user_email": {"type": "string", "format": "email", "deprecated": true, "description": "Not returned and never was."}, "role_granted": {"type": "string", "description": "Internal privileged access profile granted for this session; this is not a tenant account role.", "example": "support_viewer"}, "scope": {"type": "object", "additionalProperties": true, "description": "The scope the access request was made with (`PamRequestCreate.scope`), as it was sent. The API does not check its members."}, "started_at": {"type": "string", "format": "date-time", "deprecated": true, "description": "Not returned and never was. Read `created_at`."}, "expires_at": {"type": "string", "format": "date-time"}, "revoked_at": {"type": "string", "format": "date-time", "deprecated": true, "description": "Not returned and never was. A revoked session has `status` `revoked`."}, "revoked_by": {"type": "string", "deprecated": true, "description": "Not returned and never was."}, "requester_user_id": {"type": "string", "description": "User ID of whoever asked for the access."}, "granted_by": {"type": "string", "description": "User ID of whoever approved the request."}, "status": {"type": "string", "enum": ["active", "revoked"], "description": "`active` from approval until the session is revoked. A session past `expires_at` grants nothing, whatever this says."}, "created_at": {"type": "string", "format": "date-time", "description": "When the session was opened."}}}, "PamEvent": {"type": "object", "description": "One audit event, as `PamService.list_events` derives it from an access request: every member is always present.", "required": ["event_id", "event_type", "tenant_id", "user_id", "user_email", "status", "justification", "approved_by", "approved_at", "created_at"], "properties": {"event_id": {"type": "string", "description": "Unique event identifier. For an `access_request` event it is the access request's `request_id`."}, "event_type": {"type": "string", "enum": ["access_request", "request_created", "request_approved", "request_denied", "session_started", "session_revoked", "session_expired"], "description": "`access_request` is the only type returned: one event per access request, with the request's present state in `status`. The other values are not returned and never were; they are kept so that existing clients still compile."}, "tenant_id": {"type": "string"}, "user_id": {"type": "string", "description": "User ID of who performed the action."}, "user_email": {"type": "string", "format": "email"}, "status": {"type": "string", "description": "The access request's status now (pending, approved, denied, expired or revoked)."}, "justification": {"type": ["string", "null"], "description": "Business justification carried from the request."}, "approved_by": {"type": ["string", "null"], "description": "User ID of whoever approved or denied the request. Null while it is pending."}, "approved_at": {"type": ["string", "null"], "format": "date-time", "description": "When the request was approved or denied. Null while it is pending."}, "created_at": {"type": "string", "format": "date-time", "description": "When the event was recorded."}}}, "WorkflowTrigger": {"type": "object", "required": ["type"], "properties": {"type": {"type": "string", "enum": ["event", "schedule"], "description": "Trigger type."}, "event_type": {"type": "string", "enum": ["rating_drop", "sentiment_spike_negative", "volume_spike", "volume_drop", "crash_spike", "sync_failure", "review_received", "critical_technical_issue", "analysis_completed", "competitor_rating_change", "alert_triggered"], "description": "Event type (required if type is 'event'). `sentiment_spike_negative` is raised by the low-rating spike alert: more 1 and 2 star reviews than the 30-day average. The token is kept for saved workflows; no alert reads sentiment from review text."}, "conditions": {"type": "array", "items": {"$ref": "#/components/schemas/WorkflowCondition"}, "description": "The conditions an event must meet for the workflow to run; all of them must be met. A workflow has one list of conditions and this is where it is stored and returned. A request may send the list here or as top-level `conditions`; it is the same list either way. Only an event trigger takes conditions: a schedule has no event to compare with, and conditions sent with one are refused with 400 `INVALID_CONDITIONS`."}, "schedule": {"$ref": "#/components/schemas/ScheduleConfig"}, "config": {"type": "object", "additionalProperties": true, "description": "Settings of the trigger itself, stored and returned as sent. One setting is read when the workflow runs: `threshold`, on a `competitor_rating_change` trigger. Any other key is kept and has no effect.", "properties": {"threshold": {"type": "number", "description": "On a `competitor_rating_change` trigger, the smallest rating change, up or down, that runs the workflow: 0.2 runs it when a competitor's rating moves by 0.2 or more. Absent, zero or negative means every change runs it. Not read on any other trigger."}}}}}, "ScheduleConfig": {"type": "object", "required": ["type"], "description": "When a scheduled workflow runs. Every time is UTC; a schedule has no other time zone. The scheduler passes every 5 minutes: a run starts on the first pass at or after its scheduled minute, never before it, so up to 5 minutes after it. Each scheduled minute runs once. If the scheduler could not run it within 10 minutes of the scheduled minute it is skipped, and the schedule carries on from its next minute: missed runs are not replayed. A scheduled minute that passed before the workflow was created, before its schedule was last changed, or while it was a draft or paused, is not run.", "properties": {"type": {"type": "string", "enum": ["cron", "interval"], "description": "Schedule type."}, "cron": {"type": "string", "maxLength": 200, "description": "Cron expression (required if type is 'cron'), read in UTC. It has five fields separated by spaces: minute (0-59), hour (0-23), day of month (1-31), month (1-12 or JAN-DEC) and day of week (0-7 or SUN-SAT; 0 and 7 are both Sunday). A field is `*`, a number, a range (`MON-FRI`), a step over `*` or over a range (`*/15`, `10-40/10`), or a comma list of those (`0,30`). When neither day field starts with `*`, the workflow runs when either one matches; otherwise both must. A schedule the scheduler would not run as written is refused with a 400 whose `error.code` says why and whose `error.details.field` is `trigger.schedule.cron`: `SCHEDULE_CRON_REQUIRED` (missing or empty), `SCHEDULE_CRON_INVALID` (not five fields, for example words; `details.fields_found`), `SCHEDULE_CRON_UNSUPPORTED` (a seconds or a year field, a macro such as `@daily`, or `?`, `L`, `W`, `#`), `SCHEDULE_CRON_INVALID_FIELD` (a field that cannot be read: `details.cron_field` is `minute`, `hour`, `day_of_month`, `month` or `day_of_week`, and `details.value` its text; this includes a step after a single number such as `5/15`, and a range that wraps such as `FRI-MON`), `SCHEDULE_NEVER_RUNS` (a date that does not exist, such as 31 February) and `SCHEDULE_TOO_FREQUENT` (two runs closer than 5 minutes, such as `* * * * *`; `details.gap_minutes` and `details.minimum_gap_minutes`).", "example": "0 9 * * 1-5"}, "timezone": {"type": "string", "enum": ["UTC"], "description": "Optional, and only `UTC`: schedules run in UTC. Any other value is refused with 400 `SCHEDULE_TIMEZONE_UNSUPPORTED`."}, "interval_minutes": {"type": "integer", "minimum": 5, "maximum": 525600, "description": "Interval in minutes (required if type is 'interval'). Minimum 5: scheduled workflows are dispatched once every 5 minutes, so a shorter interval cannot be honoured and is rejected with a 400. Maximum 525600 (365 days). Runs fall on a fixed grid counted from the first whole minute at or after the workflow was created.", "example": 60}}}, "WorkflowScheduleError": {"type": "object", "description": "Why a stored schedule does not run. Possible only for a workflow saved before schedules were checked on save: the same check now refuses the save with a 400 carrying the same `code`.", "properties": {"code": {"type": "string", "description": "Stable code for localization: `SCHEDULE_CRON_REQUIRED`, `SCHEDULE_CRON_INVALID`, `SCHEDULE_CRON_UNSUPPORTED`, `SCHEDULE_CRON_INVALID_FIELD`, `SCHEDULE_NEVER_RUNS`, `SCHEDULE_TOO_FREQUENT`, `SCHEDULE_TIMEZONE_UNSUPPORTED`, or an `INVALID_TRIGGER.SCHEDULE...` code for a schedule object or an `interval_minutes` that cannot be read."}, "message": {"type": "string", "description": "The reason in English, naming the field that is wrong."}, "field": {"type": "string", "description": "The request field at fault, for example `trigger.schedule.cron`."}}}, "WorkflowAction": {"description": "Saved workflow action. Retired Teams actions remain readable for migration.", "type": "object", "required": ["type"], "properties": {"type": {"type": "string", "enum": ["slack", "email", "jira", "linear", "discord", "teams", "webhook"], "description": "Saved action type. Teams is legacy-only and cannot be submitted in new or updated actions."}, "config": {"type": "object", "additionalProperties": true, "description": "Action-specific configuration. A delivery address in it (`url` on a `webhook` action, `webhook_url` on a Discord action) is returned in full only to a signed-in admin or owner; a member and an API key receive its scheme and host, and `destination_masked` is `true`."}, "destination_masked": {"type": "boolean", "description": "Present and `true` when a delivery address in `config` was reduced to its scheme and host for this caller. Absent otherwise."}, "on_failure": {"type": "string", "enum": ["continue", "stop"], "default": "continue", "description": "Behavior when action fails."}, "action_id": {"type": "string", "description": "Unique action ID."}}}, "WorkflowActionRequest": {"description": "Action submitted when creating or editing a workflow. Teams requires an explicitly enabled workspace and its saved signed Workflows connection; plaintext Teams webhook overrides are rejected.", "type": "object", "required": ["type"], "properties": {"type": {"type": "string", "enum": ["slack", "email", "jira", "linear", "discord", "teams", "webhook"], "description": "Action type. Limited to the delivery targets the workflow executor actually supports; the backend rejects any other value. (Previously this also listed asana/clickup/trello/zapier/sms/gitlab/zendesk/ helpscout/salesforce/in_app_notification, which were never deliverable.)"}, "config": {"type": "object", "additionalProperties": true, "description": "Action-specific configuration. For `email`, `recipients` (an array, or a comma-separated string) may name at most 20 addresses, and each must belong to a current member of this workspace: an active teammate who has accepted their invitation. Any other address is refused with 400 `RECIPIENTS_NOT_MEMBERS`, whose `details.rejected_recipients` lists them; more than 20 is 400 `TOO_MANY_RECIPIENTS`. At run time an address whose member has since left the workspace is skipped, not emailed."}, "on_failure": {"type": "string", "enum": ["continue", "stop"], "default": "continue", "description": "Behavior when action fails."}, "action_id": {"type": "string", "description": "Unique action ID."}}}, "WorkflowCondition": {"type": "object", "required": ["field", "operator", "value"], "properties": {"field": {"type": "string", "description": "Field of the trigger's event to evaluate. It must be a field every event of that type carries: a condition on any other field is refused with 400 `INVALID_CONDITIONS[n].FIELD` (`n` is the condition's position, counted from 0), because an event without the field never meets \"at most\" and always meets \"is not\". The refusal's `error.details` holds `condition_field`, `event_type` and `available_fields`, the fields that event type does carry. Every event carries `tenant_id`, `app_uuid` and `app_name`. `review_received` adds `platform`, `review_id`, `rating`, `sentiment`, `sentiment_source`, `author`, `content`, `language`, `review_date`, `developer_responded` and `review_age_hours`. `sync_failure` adds `platform`, `description`, `sync_status` and `severity`. `analysis_completed` adds `description`, `current_rating`, `sentiment`, `generated_at`, `key_topics`, `technical_issues` and `insights_id`. `competitor_rating_change` adds `platform` (the competitor's store), `competitor_id`, `competitor_name`, `competitor_platform`, `previous_rating`, `current_rating`, `rating_change`, `rating_direction`, `total_reviews`, `description`, `severity` and `synced_at`. `alert_triggered` runs for every alert, so it adds only what all alerts carry: `alert_id`, `alert_type`, `severity`, `title`, `description`, `triggered_at`, `rule_id`, `rule_ids`, `alert_code`, `dedup_signature` and `source`. The event of one alert rule (`rating_drop`, `sentiment_spike_negative`, `volume_spike`, `volume_drop`, `crash_spike`, `critical_technical_issue`) adds that rule's own values as well, which `available_fields` lists (`rating_change` on `rating_drop`, for example); on `alert_triggered` those are refused, because the other alerts do not have them. An alert and a completed analysis carry no `platform`, and only `review_received` carries a `rating`. A scheduled workflow has no event and takes no conditions.", "example": "rating"}, "operator": {"type": "string", "enum": ["eq", "ne", "gt", "lt", "gte", "lte", "in", "not_in", "contains"], "description": "Comparison operator."}, "value": {"description": "Value to compare against. Required, and not empty: `null`, blank text and a list with nothing in it are refused with `400`, because no event can be compared with them."}}}, "WorkflowSettings": {"type": "object", "properties": {"cooldown_minutes": {"type": "integer", "minimum": 0, "maximum": 525600, "default": 60, "description": "Minimum minutes between executions of an event-triggered workflow: a matching event that arrives within this many minutes of the last run is skipped. It does not apply to a scheduled workflow, which runs at every minute its schedule names; `max_runs_per_day` applies to both."}, "max_runs_per_day": {"type": "integer", "minimum": 1, "default": 1000, "description": "The most times the workflow runs in one UTC day, counting every run a schedule or an event starts, whatever its outcome. Once the day's limit is reached, later triggers that day are skipped without running, and the workflow's stats say when the limit was reached (`daily_limit_reached_at`). The count starts again at 00:00 UTC."}, "execution_mode": {"type": "string", "enum": ["parallel", "sequential"], "default": "sequential", "description": "Reserved. Actions always run one at a time, in the order they are listed, so a failed action set to `on_failure: stop` can end the run. `parallel` is accepted and stored but runs the same way as `sequential`."}}}, "CreateWorkflowRequest": {"type": "object", "required": ["name", "trigger", "actions"], "properties": {"name": {"type": "string", "minLength": 1, "maxLength": 200, "description": "Workflow name."}, "description": {"type": "string", "maxLength": 1000, "description": "Workflow description."}, "trigger": {"$ref": "#/components/schemas/WorkflowTrigger"}, "actions": {"type": "array", "minItems": 1, "items": {"$ref": "#/components/schemas/WorkflowActionRequest"}, "description": "List of actions to execute."}, "conditions": {"type": "array", "items": {"$ref": "#/components/schemas/WorkflowCondition"}, "description": "The conditions an event must meet for the workflow to run: the same list as `trigger.conditions`, and stored there. Send it in either place. If both are sent they must be equal; two different lists are refused with 400 `CONDITIONS_CONFLICT`. Only an event trigger takes conditions; with a schedule trigger they are refused with 400 `INVALID_CONDITIONS`."}, "settings": {"$ref": "#/components/schemas/WorkflowSettings"}, "status": {"type": "string", "enum": ["active", "paused", "draft"], "default": "draft", "description": "Initial workflow status."}}}, "UpdateWorkflowRequest": {"description": "Legacy Teams workflows may be paused without changing actions. Before resuming Teams delivery, reconnect with a signed Workflows endpoint in an explicitly enabled workspace and remove legacy webhook overrides.", "type": "object", "properties": {"name": {"type": "string", "minLength": 1, "maxLength": 200, "description": "New workflow name."}, "description": {"type": "string", "maxLength": 1000, "description": "New description."}, "trigger": {"$ref": "#/components/schemas/WorkflowTrigger"}, "actions": {"type": "array", "minItems": 1, "items": {"$ref": "#/components/schemas/WorkflowActionRequest"}, "description": "New list of actions."}, "conditions": {"type": "array", "items": {"$ref": "#/components/schemas/WorkflowCondition"}, "description": "The trigger's conditions: the same list as `trigger.conditions`, and stored there. Sent without `trigger`, it replaces the stored trigger's conditions and leaves the rest of the trigger as it is; an empty list removes them. Sent with `trigger`, it is that trigger's list; if `trigger.conditions` is sent too the two must be equal, and two different lists are refused with 400 `CONDITIONS_CONFLICT`. A `trigger` sent with no conditions in either place replaces the trigger and leaves the workflow without conditions. Only an event trigger takes conditions; for a schedule trigger they are refused with 400 `INVALID_CONDITIONS`."}, "settings": {"$ref": "#/components/schemas/WorkflowSettings"}, "status": {"type": "string", "enum": ["active", "paused", "draft"]}, "expected_revision": {"type": "integer", "minimum": 0, "description": "The `revision` of the workflow this update was made from. When sent, the update is applied only if the workflow is still at that revision, and is otherwise refused with 409 `WORKFLOW_CHANGED`. A workflow with no `revision` counts as revision 0. It is a precondition, not a field of the workflow, and is never stored."}}}, "Workflow": {"type": "object", "properties": {"workflow_id": {"type": "string", "description": "Unique workflow identifier."}, "tenant_id": {"type": "string", "description": "Owner tenant ID."}, "name": {"type": "string", "description": "Workflow name."}, "description": {"type": "string", "description": "Workflow description."}, "status": {"type": "string", "enum": ["active", "paused", "draft"], "description": "Current workflow status."}, "trigger": {"$ref": "#/components/schemas/WorkflowTrigger"}, "actions": {"type": "array", "items": {"$ref": "#/components/schemas/WorkflowAction"}}, "conditions": {"type": "array", "items": {"$ref": "#/components/schemas/WorkflowCondition"}, "description": "The trigger's conditions, repeated here from `trigger.conditions` for an event-triggered workflow. Every one of them is evaluated."}, "settings": {"$ref": "#/components/schemas/WorkflowSettings"}, "stats": {"title": "WorkflowEmbeddedStats", "type": "object", "description": "The run counter and the last run times kept on the workflow itself. The three times are null until the workflow has a run of that kind. Counts by outcome are read from `GET /api/v1/workflows/{workflow_id}/stats`.", "properties": {"total_runs": {"type": "integer", "description": "Number of runs recorded for the workflow."}, "successful_runs": {"type": "integer", "deprecated": true, "description": "Not returned on a workflow and never was. Read it from `GET /api/v1/workflows/{workflow_id}/stats`."}, "failed_runs": {"type": "integer", "deprecated": true, "description": "Not returned on a workflow and never was. Read it from `GET /api/v1/workflows/{workflow_id}/stats`."}, "last_run_at": {"type": ["string", "null"], "format": "date-time", "description": "When the workflow last ran. Null until its first run."}, "last_success_at": {"type": ["string", "null"], "format": "date-time", "description": "When a run last succeeded. Null until one has."}, "last_failure_at": {"type": ["string", "null"], "format": "date-time", "description": "When a run last failed. Null until one has."}}}, "created_at": {"type": "string", "format": "date-time"}, "updated_at": {"type": "string", "format": "date-time"}, "created_by": {"type": "string", "description": "User ID who created the workflow."}, "template_id": {"type": ["string", "null"], "description": "The template the workflow was created from. Null for a workflow that was not created from a template."}, "revision": {"type": "integer", "description": "Moves on by one with every update to the workflow, including a pause or a resume; a run does not move it. Send it back as `expected_revision` to update only what you read. Absent on a workflow stored before revisions existed."}, "schedule_timezone": {"type": "string", "enum": ["UTC"], "description": "The time zone the workflow's schedule is read in. Present only on a workflow whose trigger is a schedule, and always `UTC`."}, "next_run_at": {"type": ["string", "null"], "format": "date-time", "description": "The next scheduled minute after now, in UTC. Present only on a workflow whose trigger is a schedule. Null when the workflow is not active or its schedule does not run (`schedule_error`). The run starts on the scheduler's first pass at or after this minute, up to 5 minutes later."}, "schedule_error": {"oneOf": [{"$ref": "#/components/schemas/WorkflowScheduleError"}, {"type": "null"}], "description": "Present only on a workflow whose trigger is a schedule. Null when the schedule runs; otherwise why the scheduler does not run it."}}}, "CreateFromTemplateRequest": {"type": "object", "required": ["template_id", "name"], "properties": {"template_id": {"type": "string", "description": "ID of the template to use: the `id` of a `GET /api/v1/workflow-templates` entry, e.g. `tpl_negative_review_alert`."}, "name": {"type": "string", "description": "Name for the new workflow. Required: the API rejects a request without it (`400 name is required`) rather than falling back to the template's name."}, "description": {"type": "string", "description": "Description for the new workflow. Defaults to the template's own description when omitted."}, "placeholders": {"type": "object", "additionalProperties": true, "description": "Values for the template's placeholders, keyed by placeholder `key`. Every placeholder marked `required` must be supplied or the request is rejected (`400 Missing required placeholder: <label>`)."}, "status": {"type": "string", "enum": ["draft", "active", "paused"], "default": "draft", "description": "Status for the new workflow."}}}, "WorkflowTemplate": {"type": "object", "properties": {"id": {"type": "string", "description": "Template identifier.", "example": "tpl_launch_monitoring"}, "name": {"type": "string", "description": "Template name.", "example": "Launch Monitoring"}, "description": {"type": "string", "description": "Template description."}, "category": {"type": "string", "enum": ["monitoring", "alerting", "reporting", "competitive"], "description": "Template category."}, "icon": {"type": "string", "description": "Icon name for UI display."}, "trigger": {"$ref": "#/components/schemas/WorkflowTrigger"}, "conditions": {"type": "array", "items": {"$ref": "#/components/schemas/WorkflowCondition"}}, "actions": {"type": "array", "items": {"$ref": "#/components/schemas/WorkflowAction"}}, "settings": {"$ref": "#/components/schemas/WorkflowSettings"}, "placeholders": {"type": "array", "items": {"type": "object", "properties": {"key": {"type": "string"}, "label": {"type": "string"}, "description": {"type": "string"}, "type": {"type": "string"}, "required": {"type": "boolean"}}}}, "trigger_type": {"type": "string"}, "event_type": {"type": "string"}, "category_label": {"type": "string"}, "action_count": {"type": "integer", "description": "Number of configured template actions."}, "action_types": {"type": "array", "items": {"type": "string"}, "description": "Action types present in this template."}}}, "WorkflowTestResult": {"type": "object", "description": "The body of POST /api/v1/workflows/{workflow_id}/test. The dry run answers in its own vocabulary (`would_*`, not the execution statuses) and envelopes the verdict under `test_result`. This schema previously documented `{success, conditions_matched, actions_would_execute, errors}`, a shape the endpoint has never returned; the generated SDK inherited it, and the dashboard's hand-written copy diverged a third way, which is how every dry run came to render as \"Test Failed\" with no detail.", "properties": {"test_result": {"$ref": "#/components/schemas/WorkflowDryRunResult"}, "workflow_id": {"type": "string"}}}, "WorkflowDryRunResult": {"type": "object", "properties": {"workflow_id": {"type": "string"}, "status": {"type": "string", "enum": ["would_succeed", "would_partial", "would_fail", "skipped"], "description": "Overall configuration verdict, including draft and paused workflows. `skipped` means the sample did not match the trigger conditions, or the inactive workflow was not opted into previewing."}, "dry_run": {"type": "boolean", "description": "Always true; nothing is delivered."}, "reason": {"type": "string", "description": "Compatibility explanation when status is `skipped`."}, "reason_code": {"type": "string", "description": "Stable skip code for localization; `conditions_not_met`, `workflow_inactive`, or `schedule_invalid` when the workflow's stored schedule is one the scheduler does not run (`schedule_error` says why, and `reason` carries its message)."}, "schedule_error": {"$ref": "#/components/schemas/WorkflowScheduleError"}, "workflow_status": {"type": "string", "description": "Stored status. Testing never activates or resumes a workflow and never delivers actions."}, "trigger_event": {"type": "object", "additionalProperties": true, "description": "The synthetic event the dry run evaluated."}, "conditions_evaluated": {"type": "array", "items": {"type": "object", "additionalProperties": true}}, "action_results": {"type": "array", "items": {"$ref": "#/components/schemas/WorkflowDryRunActionResult"}}, "summary": {"type": "object", "properties": {"actions_total": {"type": "integer"}, "actions_would_succeed": {"type": "integer"}, "actions_would_fail": {"type": "integer"}, "actions_skipped": {"type": "integer"}}}}}, "WorkflowDryRunActionResult": {"type": "object", "required": ["action_type", "status"], "properties": {"action_id": {"type": "string"}, "action_type": {"type": "string"}, "status": {"type": "string", "enum": ["would_succeed", "would_skip", "would_fail"]}, "dry_run": {"type": "boolean"}, "reason": {"type": "string", "description": "On `would_fail`, the executor's own failure code (`no_channel`, `slack_token_missing`, `webhook_url_missing`, `jira_credentials_incomplete`, `no_project_key`, `linear_api_key_missing`, `no_team_id`, and for email `no_recipients`, `too_many_recipients`, `no_member_recipients`, `recipient_check_failed`), the same string the execution log shows. On `would_skip`, `delivery_unsupported`. When the connection itself is missing it is a sentence (\"No active slack integration configured\"); read `reason_code` for that case."}, "reason_code": {"type": "string", "description": "`integration_not_configured` when the action's connection is missing: the code a run records for the same thing."}, "message": {"type": "string", "description": "Human summary, e.g. \"Would send email to 2 recipient(s)\" on a pass or the reason in words on a failure."}, "non_member_recipients": {"type": "array", "items": {"type": "string"}, "description": "Email only: configured addresses that do not belong to a current member of this workspace. A run skips them; when nobody else is left the action fails with `no_member_recipients`."}}}, "WorkflowExecution": {"type": "object", "properties": {"execution_id": {"type": "string", "description": "Unique execution identifier."}, "workflow_id": {"type": "string"}, "status": {"type": "string", "enum": ["success", "failed", "partial", "running"], "description": "Execution status. A run in which any action was skipped is `partial`, including a run in which every action was skipped and nothing was delivered: see `outcome`, which tells the two apart."}, "outcome": {"type": "string", "enum": ["success", "failed", "partial", "skipped", "running"], "description": "What the run came to, in the words `GET /api/v1/workflows/{workflow_id}/stats` counts runs by: `skipped` when every action was skipped (counted there as `skipped_runs`), otherwise the same as `status`."}, "trigger_event": {"type": "object", "additionalProperties": true, "description": "Event that triggered the execution."}, "actions_executed": {"type": "array", "items": {"type": "object", "properties": {"action_id": {"type": "string"}, "action_type": {"type": "string"}, "status": {"type": "string", "enum": ["success", "failed", "skipped"]}, "reason": {"type": "string", "description": "Why the action was skipped or failed, as a code: `integration_not_configured` (the connection is missing), `integration_lookup_failed` (the connection or its credentials could not be read, so nothing was sent; see `retryable`), `platform_unavailable`, `delivery_unsupported`, `no_channel`, `no_project_key`, `no_team_id`, `no_webhook_url`, `webhook_url_missing`, `slack_token_missing`, `jira_credentials_incomplete`, `linear_api_key_missing`, `request_failed`, `url_validation_failed`, `http_<status>`, `jira_http_<status>`, `slack_<error>`, `email_send_failed` and the email recipient codes."}, "retryable": {"type": "boolean", "description": "On a failed action, `true` when nothing was sent and the failure was temporary, so the action can be delivered again (a connection, credentials or member list that could not be read, or a Teams delivery that was not sent). A run that resumes (one started by an alert, an analysis, an event or a scheduled time) stops at that action, stays `running`, and tries it again without repeating the actions it already delivered, at most three attempts in all. Absent or `false` otherwise."}, "delivery_attempts": {"type": "integer", "description": "How many times a resumable run has tried this action, when it has been retried (see `retryable`)."}, "retry_exhausted": {"type": "boolean", "description": "`true` when the action failed on every attempt `retryable` allowed, so it is not tried again."}, "detail": {"type": "string", "description": "On success, what the destination answered (a message id, an issue key, `http_200`)."}, "error": {"type": "string"}, "duration_ms": {"type": "integer"}, "non_member_recipients": {"type": "array", "items": {"type": "string"}, "description": "Email only: configured addresses not emailed because they no longer belong to a member of this workspace. A delivered email that skipped anyone makes the run `partial`."}}}}, "started_at": {"type": "string", "format": "date-time"}, "completed_at": {"type": "string", "format": "date-time"}, "duration_ms": {"type": "integer", "description": "Total execution time in milliseconds."}}}, "WorkflowMatchesNotRun": {"type": "object", "description": "A note in a workflow's history about what was not run, and why (`reason`). `event_run_limit`: matches of one sync or event that were not run because the run limit all of the workspace's workflows share for that sync or event was already used up; one note per workflow per sync or event. `subscription_inactive`: the workspace's subscription did not let workflows run, by the rule that answers `402` on the API; one note per workflow per sync or event, or, for a scheduled workflow (`event_type` `schedule`), one note per workflow per UTC day with that day's count of scheduled runs.", "properties": {"workflow_id": {"type": "string"}, "recorded_at": {"type": "string", "format": "date-time", "description": "When the sync or event happened. For a scheduled workflow, the first scheduled run of that UTC day that was not made."}, "event_type": {"type": "string", "description": "The workflow event the matches were of, e.g. `review_received`, or `schedule` for scheduled runs."}, "reason": {"type": "string", "enum": ["event_run_limit", "subscription_inactive"], "description": "Why the matches or scheduled runs were not run."}, "matches_not_run": {"type": "integer", "description": "How many matches, or scheduled runs, were not run."}, "run_limit": {"type": "integer", "description": "The shared run limit that was used up. Only with `event_run_limit`."}, "subscription_code": {"type": "string", "description": "Only with `subscription_inactive`: which state the subscription was in, as the `402` response names it (`SUBSCRIPTION_CANCELED`, `SUBSCRIPTION_UNPAID`, `TRIAL_EXPIRED`, `SUBSCRIPTION_REQUIRED`), or `WORKSPACE_NOT_FOUND`."}, "app_uuid": {"type": "string"}, "app_name": {"type": "string"}, "platform": {"type": "string"}}}, "WorkflowActivationWarning": {"type": "object", "description": "Something a workflow that was just turned on will not do. It does not stop the workflow from being turned on.", "required": ["code"], "properties": {"code": {"type": "string", "enum": ["integration_not_configured"], "description": "`integration_not_configured`: the action delivers through a connection (Slack, Discord, Jira, Linear) that this workspace does not have, so each run skips it with the same code."}, "action_id": {"type": "string"}, "action_type": {"type": "string", "description": "The action's type, which names the missing connection."}}}, "WorkflowStats": {"type": "object", "properties": {"workflow_id": {"type": "string"}, "total_runs": {"type": "integer", "description": "Total number of runs."}, "successful_runs": {"type": "integer", "description": "Number of runs that completed successfully."}, "failed_runs": {"type": "integer", "description": "Number of runs that failed."}, "partial_runs": {"type": "integer", "description": "Runs stored as `partial` in which at least one action was delivered or failed: some of what was configured did not happen."}, "skipped_runs": {"type": "integer", "description": "Runs stored as `partial` in which every action was skipped, so nothing was delivered (for example a Slack action in a workspace with no Slack connection). `successful_runs`, `failed_runs`, `partial_runs` and `skipped_runs` add up to the finished runs."}, "avg_duration_ms": {"type": "integer", "description": "Average run duration in milliseconds."}, "last_run_at": {"type": ["string", "null"], "format": "date-time", "description": "Timestamp of the most recent run."}, "matches_not_run": {"type": ["integer", "null"], "description": "Matches that were not run because the shared run limit of a sync or event was used up, totalled over at most 5,000 notes, looked for among the newest 10,000 the history still holds (see `not_run` on the executions list). They are in no run count. What was not run because the workspace's subscription did not let workflows run (`not_run` notes with `reason` `subscription_inactive`) is not in this number. Null when the notes could not be read."}, "max_runs_per_day": {"type": "integer", "description": "The workflow's daily run limit (`settings.max_runs_per_day`, 1000 when it is not set)."}, "runs_today": {"type": "integer", "description": "Runs counted against the limit since 00:00 UTC today."}, "daily_limit_reached_at": {"type": ["string", "null"], "format": "date-time", "description": "When the daily run limit first skipped a trigger today, or null when it has not. Triggers skipped from then until 00:00 UTC did not run and are not in the execution history."}}}, "CreateReviewTagRequest": {"type": "object", "required": ["name", "color"], "properties": {"name": {"type": "string", "minLength": 1, "maxLength": 100, "description": "Tag name.", "example": "Bug Report"}, "color": {"type": "string", "description": "Tag color: a name (red, orange, yellow, green, teal, blue, indigo, purple, pink, gray) or a hex color (#RGB, #RGBA, #RRGGBB or #RRGGBBAA). Any other value is stored as `blue`.", "example": "red"}, "description": {"type": "string", "maxLength": 500, "description": "Optional tag description."}}}, "UpdateReviewTagRequest": {"type": "object", "properties": {"name": {"type": "string", "minLength": 1, "maxLength": 100, "description": "New tag name."}, "color": {"type": "string", "description": "New tag color, a name or hex color as for create. Any other value is stored as `blue`."}, "description": {"type": "string", "maxLength": 500, "description": "New description."}}}, "ReviewTag": {"type": "object", "description": "A review tag. Every member is always sent.", "required": ["tag_id", "name", "color", "description", "usage_count", "created_at", "updated_at"], "properties": {"tag_id": {"type": "string", "description": "Unique tag identifier."}, "name": {"type": "string", "description": "Tag name.", "example": "Bug Report"}, "color": {"type": "string", "description": "Tag display color: a name (red, orange, yellow, green, teal, blue, indigo, purple, pink, gray) or a hex color. A stored value that is neither is sent as `blue`.", "example": "red"}, "description": {"type": "string", "description": "Tag description; empty text when none was given."}, "usage_count": {"type": "integer", "minimum": 0, "description": "Number of reviews with this tag."}, "created_at": {"type": "string", "format": "date-time"}, "updated_at": {"type": "string", "format": "date-time"}}}, "ReviewTagList": {"type": "object", "description": "Every review tag of the workspace, in no particular order.", "required": ["tags", "count"], "properties": {"tags": {"type": "array", "items": {"$ref": "#/components/schemas/ReviewTag"}}, "count": {"type": "integer", "minimum": 0, "description": "Number of tags returned."}}}, "ReviewTagDeleted": {"type": "object", "required": ["message"], "properties": {"message": {"type": "string", "description": "`Tag deleted successfully`."}}}, "ReviewTagRef": {"type": "object", "description": "A tag as it appears on a review.", "required": ["tag_id", "name", "color"], "properties": {"tag_id": {"type": "string"}, "name": {"type": "string"}, "color": {"type": "string"}}}, "ReviewTagsUpdateRequest": {"type": "object", "required": ["tag_ids", "mode"], "properties": {"tag_ids": {"type": "array", "maxItems": 25, "items": {"type": "string", "format": "uuid"}, "description": "Tag ids from `GET /api/v1/review-tags`. Every id must be one of the workspace's own tags, or the request is refused with 400 `UNKNOWN_TAG` and nothing is written. May be empty only for `set`, which then clears the review's tags."}, "mode": {"type": "string", "enum": ["add", "remove", "set"], "description": "`add` keeps the review's other tags, `remove` takes these off, and `set` makes these the review's only tags. Every mode is idempotent."}}}, "ReviewTagsUpdateResponse": {"type": "object", "properties": {"review_id": {"type": "string"}, "app_uuid": {"type": "string", "format": "uuid"}, "mode": {"type": "string", "enum": ["add", "remove", "set"]}, "tag_ids": {"type": "array", "items": {"type": "string"}, "description": "The review's tags after this request."}, "tags": {"type": "array", "items": {"$ref": "#/components/schemas/ReviewTagRef"}}, "added": {"type": "array", "items": {"type": "string"}, "description": "Tags this request put on the review. Empty when they were already there; a repeated request changes nothing."}, "removed": {"type": "array", "items": {"type": "string"}, "description": "Tags this request took off the review."}}}, "BulkReviewTagsRequest": {"type": "object", "required": ["app_uuid", "review_ids", "tag_ids", "mode"], "properties": {"app_uuid": {"type": "string", "format": "uuid", "description": "The app the reviews belong to."}, "review_ids": {"type": "array", "minItems": 1, "maxItems": 100, "items": {"type": "string"}, "description": "Reviews of that app. Ids that are not reviews of the app are skipped and listed in `not_found`."}, "tag_ids": {"type": "array", "maxItems": 25, "items": {"type": "string", "format": "uuid"}, "description": "As for `PUT /api/v1/reviews/{reviewId}/tags`."}, "mode": {"type": "string", "enum": ["add", "remove", "set"]}}}, "BulkReviewTagsResult": {"type": "object", "properties": {"review_id": {"type": "string"}, "tag_ids": {"type": "array", "items": {"type": "string"}}, "added": {"type": "array", "items": {"type": "string"}}, "removed": {"type": "array", "items": {"type": "string"}}}}, "BulkReviewTagsResponse": {"type": "object", "properties": {"app_uuid": {"type": "string", "format": "uuid"}, "mode": {"type": "string", "enum": ["add", "remove", "set"]}, "updated": {"type": "integer", "description": "Reviews whose tags this request actually changed."}, "results": {"type": "array", "items": {"$ref": "#/components/schemas/BulkReviewTagsResult"}}, "not_found": {"type": "array", "items": {"type": "string"}, "description": "Requested ids that are not reviews of the app."}, "tags": {"type": "array", "items": {"$ref": "#/components/schemas/ReviewTagRef"}, "description": "The tags named in this response."}}}, "ReviewTagStats": {"type": "object", "description": "One tag's usage. Every member is always sent.", "required": ["tag_id", "name", "color", "usage_count"], "properties": {"tag_id": {"type": "string"}, "name": {"type": "string"}, "color": {"type": "string", "description": "The tag's color, as `ReviewTag.color`."}, "usage_count": {"type": "integer", "minimum": 0, "description": "Total reviews carrying this tag, counted from the reviews themselves."}}}, "CreateAutoTagRuleRequest": {"type": "object", "required": ["name", "tag_id", "conditions"], "properties": {"name": {"type": "string", "minLength": 1, "maxLength": 200, "description": "Rule name.", "example": "Tag negative crash reports"}, "tag_id": {"type": "string", "description": "ID of tag to apply when conditions match."}, "conditions": {"type": "array", "minItems": 1, "maxItems": 20, "items": {"$ref": "#/components/schemas/AutoTagCondition"}, "description": "Conditions that must match for tag to apply."}, "match_all": {"type": "boolean", "default": true, "description": "Whether every condition must match (true, the default) or any one condition (false)."}, "enabled": {"type": "boolean", "default": true, "description": "Whether rule is active."}}}, "UpdateAutoTagRuleRequest": {"type": "object", "properties": {"name": {"type": "string", "minLength": 1, "maxLength": 200, "description": "New rule name."}, "tag_id": {"type": "string", "description": "New tag ID to apply."}, "conditions": {"type": "array", "minItems": 1, "maxItems": 20, "items": {"$ref": "#/components/schemas/AutoTagCondition"}, "description": "New conditions."}, "match_all": {"type": "boolean", "description": "New match mode (all conditions vs any one)."}, "enabled": {"type": "boolean", "description": "Enable/disable rule."}}}, "AutoTagCondition": {"type": "object", "required": ["field", "operator", "value"], "properties": {"field": {"type": "string", "enum": ["content", "sentiment", "rating", "platform", "title"], "description": "Field to evaluate."}, "operator": {"type": "string", "enum": ["contains", "equals", "gte", "lte", "not_contains", "not_equals"], "description": "Comparison operator."}, "value": {"oneOf": [{"type": "string", "minLength": 1, "maxLength": 500}, {"type": "number"}], "description": "Value to compare against: text of at most 500 characters (surrounding whitespace is trimmed), or a number. A `rating` value must be a number from 1 to 5, sent as a number or as text. With `equals` or `not_equals`, a `sentiment` value must be one of `mixed`, `negative`, `neutral`, `positive`, and a `platform` value one of `app_store`, `google_play`, `steam`.", "example": "crash"}}}, "AutoTagRule": {"type": "object", "description": "An auto-tagging rule. Every member is always sent except `tag_name` and `tag_color`, which are sent while the rule's tag exists. `created_by` is never sent.", "required": ["rule_id", "name", "tag_id"], "properties": {"rule_id": {"type": "string", "description": "Unique rule identifier."}, "name": {"type": "string", "description": "Rule name."}, "tag_id": {"type": "string", "description": "ID of tag to apply."}, "tag_name": {"type": "string", "description": "Name of tag to apply. Sent while the tag exists."}, "tag_color": {"type": "string", "description": "Color of tag to apply. Sent while the tag exists."}, "conditions": {"type": "array", "items": {"$ref": "#/components/schemas/AutoTagCondition"}, "description": "Matching conditions. Always sent."}, "match_all": {"type": "boolean", "default": true, "description": "Whether every condition must match (true) or any one condition (false). Rules stored before this field existed are match-all."}, "enabled": {"type": "boolean", "description": "Whether rule is active. Always sent."}, "matches_count": {"type": "integer", "minimum": 0, "description": "Number of reviews matched by this rule. Always sent."}, "last_matched_at": {"type": ["string", "null"], "format": "date-time", "description": "When the rule last tagged a newly synced review. Null until the rule first matches; rules are evaluated as reviews sync in."}, "created_by": {"type": "string", "deprecated": true, "description": "Not sent. The creator is stored but no answer carries it."}, "created_at": {"type": "string", "format": "date-time", "description": "Always sent."}, "updated_at": {"type": "string", "format": "date-time", "description": "Always sent."}}}, "AutoTagRuleList": {"type": "object", "description": "Every auto-tagging rule of the workspace, in no particular order.", "required": ["rules", "count"], "properties": {"rules": {"type": "array", "items": {"$ref": "#/components/schemas/AutoTagRule"}}, "count": {"type": "integer", "minimum": 0, "description": "Number of rules returned."}}}, "AutoTagRuleDeleted": {"type": "object", "required": ["message"], "properties": {"message": {"type": "string", "description": "`Rule deleted successfully`."}}}, "CreateAlertRuleRequest": {"type": "object", "required": ["name", "trigger_type", "threshold"], "properties": {"name": {"type": "string", "minLength": 1, "maxLength": 200, "description": "Alert rule name. Required here so that every saved rule has a name a person chose; the API itself accepts a body without one, or with an empty one, and names the rule after its trigger type (for example `rating_drop rule`).", "example": "Rating Drop Alert"}, "description": {"type": "string", "maxLength": 1000, "deprecated": true, "description": "Not stored and not returned: the API does not read it."}, "trigger_type": {"type": "string", "enum": ["critical_technical_issue", "rating_drop", "sync_failure", "volume_spike", "volume_drop", "crash_spike", "low_rating_spike", "custom_category_spike", "sla_warning", "sla_critical", "sla_breach"], "description": "Type of event that triggers the alert."}, "threshold": {"type": "number", "format": "float", "minimum": 0, "description": "Threshold value to trigger the alert. A negative value is rejected with 400. For `sla_warning` and `sla_critical` the value is stored and not used: those alerts are raised when a review reaches the workspace's `warn_at_percent` or `critical_at_percent` (`PUT /api/v1/sla/config`). For `rating_drop` it is the fall in stars between two windows of whole days that raises the alert (see `Alert.context`).", "example": 0.5}, "window_minutes": {"type": "integer", "minimum": 5, "maximum": 10080, "default": 60, "description": "Time window in minutes for threshold evaluation."}, "cooldown_minutes": {"type": "integer", "minimum": 5, "maximum": 1440, "default": 120, "description": "Minimum minutes between alert triggers."}, "severity": {"type": "string", "enum": ["critical", "high", "medium", "low"], "default": "medium", "description": "Alert severity level."}, "destinations": {"type": "array", "items": {"type": "string", "enum": ["slack", "email", "in_app", "push", "webhook", "pagerduty", "teams"]}, "description": "Accepted saved destination values. Slack and push are delivered; push reaches the workspace members who turned on push notifications on a device. Every alert is listed in the dashboard (`GET /api/v1/alerts` and the notification bell) whatever its rule's destinations, so `in_app` adds no delivery: `[in_app]` means the dashboard only. PagerDuty requires its connected integration. Email and webhook are retained legacy values without alert-rule dispatch. Teams requires an explicitly enabled workspace and its signed Workflows connection; retired connector URLs cannot deliver. Omitted or empty, the rule is saved with `[in_app]`.", "default": ["in_app"]}, "enabled": {"type": "boolean", "default": true, "description": "Whether alert rule is active."}, "app_filter": {"type": "array", "items": {"type": "string"}, "description": "Restrict this rule to specific apps. Omit it, or send an empty list, to alert on every app in the tenant. An id that is not one of the workspace's apps answers `404` `APP_NOT_FOUND` and the rule is not created."}, "conditions": {"type": "object", "additionalProperties": true, "description": "Optional extra conditions, stored as sent and returned on the rule. Supported keys depend on the trigger type. A value that is not an object is rejected with 400."}, "routing": {"type": "object", "additionalProperties": true, "description": "Optional routing settings, stored as sent and returned on the rule (for example `slack_channel`, `assigned_to`, `priority`). An alert the rule raises carries them. A value that is not an object is rejected with 400."}}}, "UpdateSentimentConfigRequest": {"type": "object", "description": "Sentiment alert settings to change. Every field is optional; omitted fields keep their stored value. At least one field must be supplied.", "minProperties": 1, "properties": {"enabled": {"type": "boolean", "description": "Whether sentiment alerting is active for the tenant."}, "negative_spike_threshold": {"type": "integer", "minimum": 1, "maximum": 100, "description": "Percentage-point increase in negative sentiment that raises an alert."}, "positive_drop_threshold": {"type": "integer", "minimum": 1, "maximum": 100, "description": "Percentage-point decrease in positive sentiment that raises an alert."}, "comparison_window": {"type": "string", "enum": ["daily", "weekly", "monthly"], "description": "Period the current sentiment is compared against."}, "min_reviews_required": {"type": "integer", "minimum": 1, "maximum": 1000, "description": "Minimum reviews in the window before an alert can fire."}, "severity": {"type": "string", "enum": ["critical", "high", "medium", "low"], "description": "Severity stamped on alerts raised by this rule."}, "destinations": {"type": "array", "description": "Where a raised alert is delivered.", "items": {"type": "string", "enum": ["email", "slack", "in_app", "webhook"]}}, "include_sample_reviews": {"type": "boolean", "description": "Whether to attach example reviews to the alert."}, "sample_reviews_count": {"type": "integer", "minimum": 1, "maximum": 10, "description": "Number of example reviews to attach."}, "cooldown_minutes": {"type": "integer", "minimum": 5, "maximum": 1440, "description": "Minimum gap between two alerts from this rule."}, "webhook_url": {"type": ["string", "null"], "description": "Destination for the `webhook` delivery channel. Send null to clear it."}}}, "SentimentAlertConfig": {"type": "object", "description": "The stored sentiment alert settings, with the default of any member never saved. Legacy preference storage: these settings do not trigger automatic alerts.", "required": ["enabled", "negative_spike_threshold", "positive_drop_threshold", "comparison_window", "min_reviews_required", "severity", "destinations", "include_sample_reviews", "sample_reviews_count", "cooldown_minutes"], "properties": {"enabled": {"type": "boolean", "description": "Whether sentiment alerting is on. Off by default."}, "negative_spike_threshold": {"type": "integer", "minimum": 1, "maximum": 100, "description": "Percentage-point increase in negative sentiment that raises an alert. 15 by default."}, "positive_drop_threshold": {"type": "integer", "minimum": 1, "maximum": 100, "description": "Percentage-point decrease in positive sentiment that raises an alert. 10 by default."}, "comparison_window": {"type": "string", "enum": ["daily", "weekly", "monthly"], "description": "Period the current sentiment is compared against. `weekly` by default."}, "min_reviews_required": {"type": "integer", "minimum": 1, "maximum": 1000, "description": "Minimum reviews in the window before an alert can fire. 10 by default."}, "severity": {"type": "string", "enum": ["critical", "high", "medium", "low"], "description": "Severity stamped on alerts raised by this rule. `high` by default."}, "destinations": {"type": "array", "items": {"type": "string", "enum": ["email", "slack", "in_app", "webhook"]}, "description": "Where a raised alert is delivered. `in_app` only by default."}, "include_sample_reviews": {"type": "boolean", "description": "Whether to attach example reviews to the alert. True by default."}, "sample_reviews_count": {"type": "integer", "minimum": 1, "maximum": 10, "description": "Number of example reviews to attach. 3 by default."}, "cooldown_minutes": {"type": "integer", "minimum": 5, "maximum": 1440, "description": "Minimum gap between two alerts from this rule. 60 by default."}, "webhook_url": {"type": ["string", "null"], "description": "The address for the `webhook` destination. Sent once an address has been saved, and null after it is cleared. A member and an API key are sent only its scheme and host (see `webhook_url_masked`)."}, "webhook_url_masked": {"type": "boolean", "description": "Sent by the read whenever `webhook_url` holds an address: true when it holds only the scheme and host, false when it holds the whole address (an admin or owner signed in)."}}}, "SentimentAlertConfigAnswer": {"type": "object", "description": "The sentiment alert settings, under `config`.", "required": ["config"], "properties": {"config": {"$ref": "#/components/schemas/SentimentAlertConfig"}}}, "SentimentAlertTestResult": {"type": "object", "description": "What happened at each stored destination when a test was sent.", "required": ["success", "message", "results"], "properties": {"success": {"type": "boolean", "description": "False when the test failed at any destination. A skipped destination is not a failure."}, "message": {"type": "string", "description": "How many tests were sent and how many failed, in English, for example `Test alerts: 1 sent, 0 failed`."}, "results": {"type": "array", "description": "One entry per stored destination, in the stored order.", "items": {"type": "object", "required": ["destination", "status", "message"], "properties": {"destination": {"type": "string", "description": "The destination: `email` (to the person who asked for the test), `slack` (the workspace's active Slack connection), `in_app` (a test alert in the Alerts tab) or `webhook`. A stored name the test does not know is answered as `skipped`."}, "status": {"type": "string", "enum": ["sent", "failed", "skipped"], "description": "`sent`, `failed`, or `skipped` when there was nowhere to send it (no active Slack connection, no webhook address, an unknown destination)."}, "message": {"type": "string", "description": "What happened, in English."}}}}}}, "UpdateAlertRuleRequest": {"type": "object", "properties": {"name": {"type": "string", "minLength": 1, "maxLength": 200, "description": "New alert rule name. The API does not refuse an empty name here and stores it as sent, so send at least one character."}, "description": {"type": "string", "maxLength": 1000, "deprecated": true, "description": "Not stored and not returned: the API does not read it."}, "trigger_type": {"type": "string", "enum": ["critical_technical_issue", "rating_drop", "sync_failure", "volume_spike", "volume_drop", "crash_spike", "low_rating_spike", "custom_category_spike", "sla_warning", "sla_critical", "sla_breach"], "description": "New trigger type."}, "threshold": {"type": "number", "format": "float", "minimum": 0, "description": "New threshold value. A negative value is rejected with 400. For `sla_warning` and `sla_critical` the value is stored and not used: those alerts are raised when a review reaches the workspace's `warn_at_percent` or `critical_at_percent` (`PUT /api/v1/sla/config`). For `rating_drop` it is the fall in stars between two windows of whole days that raises the alert (see `Alert.context`)."}, "window_minutes": {"type": "integer", "minimum": 5, "maximum": 10080, "description": "New time window in minutes."}, "cooldown_minutes": {"type": "integer", "minimum": 5, "maximum": 1440, "description": "New cooldown period in minutes."}, "severity": {"type": "string", "enum": ["critical", "high", "medium", "low"], "description": "New severity level."}, "destinations": {"type": "array", "items": {"type": "string", "enum": ["slack", "email", "in_app", "push", "webhook", "pagerduty", "teams"]}, "description": "Replacement saved destinations. Slack and push are delivered; push reaches the workspace members who turned on push notifications on a device. Every alert is listed in the dashboard whatever its rule's destinations, so `in_app` adds no delivery: `[in_app]` means the dashboard only, while an empty list gets the default Slack delivery. PagerDuty requires its connected integration. Email and webhook are retained legacy values without alert-rule dispatch. Teams requires an explicitly enabled workspace and its signed Workflows connection. To pause a legacy Teams rule, omit destinations."}, "enabled": {"type": "boolean", "description": "Enable/disable alert rule."}, "app_filter": {"oneOf": [{"type": "array", "items": {"type": "string"}}, {"type": "null"}], "description": "Replacement app scoping. An empty list or null clears the filter and restores alerting on every app in the tenant. An id that is not one of the workspace's apps answers `404` `APP_NOT_FOUND` and the rule is not changed."}, "conditions": {"oneOf": [{"type": "object", "additionalProperties": true}, {"type": "null"}], "description": "Replacement extra conditions, stored as sent. `null` clears them (`{}`). Any other value that is not an object is rejected with 400."}, "routing": {"oneOf": [{"type": "object", "additionalProperties": true}, {"type": "null"}], "description": "Replacement routing settings, stored as sent. `null` clears them (`{}`). Any other value that is not an object is rejected with 400."}}}, "AlertRule": {"type": "object", "description": "A saved rule has every member below except the deprecated ones and `baseline_days`. A built-in rule (`is_default: true`) is not stored: it has `rule_id`, `trigger_type`, `threshold`, `cooldown_minutes`, `severity`, `destinations`, `enabled` and `is_default`, and some built-in rules also have `window_minutes` or `baseline_days`.", "required": ["rule_id"], "properties": {"rule_id": {"type": "string", "description": "Unique alert rule identifier. A built-in rule's id starts with `default-`."}, "tenant_id": {"type": "string", "deprecated": true, "description": "Not returned: no rule answer has ever carried it."}, "name": {"type": "string", "description": "Alert rule name. Absent on a built-in rule."}, "description": {"type": "string", "deprecated": true, "description": "Not returned: the API does not store a rule description."}, "trigger_type": {"type": "string", "enum": ["critical_technical_issue", "rating_drop", "sync_failure", "volume_spike", "volume_drop", "crash_spike", "low_rating_spike", "custom_category_spike", "sla_warning", "sla_critical", "sla_breach", "sentiment_spike_negative", "negative_sentiment_spike"], "description": "Type of event that triggers the alert. `sentiment_spike_negative` and `negative_sentiment_spike` are retired: neither can be created or set on an update, and neither raises an alert. They are listed because a rule of either type stored earlier is still returned, so that it can be found and deleted."}, "threshold": {"type": "number", "format": "float", "description": "Threshold value to trigger the alert. For `sla_warning` and `sla_critical` the value is stored and not used: those alerts are raised when a review reaches the workspace's `warn_at_percent` or `critical_at_percent` (`PUT /api/v1/sla/config`). For `rating_drop` it is the fall in stars between two windows of whole days that raises the alert (see `Alert.context`)."}, "window_minutes": {"type": "integer", "description": "Time window in minutes for evaluation. Absent on the built-in rules whose evaluator reads no window."}, "cooldown_minutes": {"type": "integer", "description": "Minimum minutes between triggers."}, "severity": {"type": "string", "enum": ["critical", "high", "medium", "low"], "description": "Alert severity level."}, "destinations": {"type": "array", "items": {"type": "string"}, "description": "Notification destinations."}, "enabled": {"type": "boolean", "description": "Whether rule is active."}, "app_filter": {"type": "array", "items": {"type": "string"}, "description": "App UUIDs this rule applies to. Empty means every app in the tenant. Built-in default rules omit the field entirely."}, "last_triggered_at": {"type": "string", "format": "date-time", "deprecated": true, "description": "Not returned: the API does not record when a rule last raised an alert."}, "trigger_count": {"type": "integer", "deprecated": true, "description": "Not returned: the API does not count the alerts a rule raised."}, "created_by": {"type": "string", "description": "User ID who created the rule. Absent on a built-in rule."}, "created_at": {"type": "string", "format": "date-time", "description": "Absent on a built-in rule."}, "updated_at": {"type": "string", "format": "date-time", "description": "Absent on a built-in rule."}, "is_default": {"type": "boolean", "description": "True for a built-in rule rather than a workspace-created rule. A built-in rule cannot be changed or deleted."}, "baseline_days": {"type": "integer", "description": "Historical days used by relative-volume rules. Only the two built-in `low_rating_spike` rules have it."}, "conditions": {"type": "object", "additionalProperties": true, "description": "Optional alert conditions settings, as saved. Supported keys depend on the trigger or destination. Absent on a built-in rule."}, "routing": {"type": "object", "additionalProperties": true, "description": "Optional alert routing settings, as saved. Supported keys depend on the trigger or destination. Absent on a built-in rule."}}}, "SLATargetsByRating": {"type": "object", "description": "Response time targets in minutes by star rating; null is no target. An answer always has all five. An update replaces the stored map as a whole, so a rating it leaves out is stored with no target: send all five.", "required": ["1", "2", "3", "4", "5"], "properties": {"1": {"type": ["integer", "null"], "minimum": 0, "description": "Target minutes for 1-star reviews.", "example": 240}, "2": {"type": ["integer", "null"], "minimum": 0, "description": "Target minutes for 2-star reviews.", "example": 480}, "3": {"type": ["integer", "null"], "minimum": 0, "description": "Target minutes for 3-star reviews.", "example": 1440}, "4": {"type": ["integer", "null"], "minimum": 0, "description": "Target minutes for 4-star reviews.", "example": 2880}, "5": {"type": ["integer", "null"], "minimum": 0, "description": "Target minutes for 5-star reviews (null = no SLA).", "example": null}}}, "SLATargetsBySentiment": {"type": "object", "description": "Response time targets in minutes by sentiment. A review's sentiment is the label stored on it where there is one, otherwise its star rating in three buckets (4 to 5 positive, 3 neutral, 1 to 2 negative), so in practice `negative` is a target for 1 and 2 star reviews. When a review has both a rating target and a sentiment target, the more urgent (smaller) one applies. Null is no target. An answer always has all three. An update replaces the stored map as a whole, so a sentiment it leaves out is stored with no target: send all three.", "required": ["negative", "neutral", "positive"], "properties": {"negative": {"type": ["integer", "null"], "minimum": 0, "description": "Target minutes for negative reviews (1 to 2 stars unless labelled).", "example": 240}, "neutral": {"type": ["integer", "null"], "minimum": 0, "description": "Target minutes for neutral reviews (3 stars unless labelled)."}, "positive": {"type": ["integer", "null"], "minimum": 0, "description": "Target minutes for positive reviews (4 to 5 stars unless labelled)."}}}, "SLATargets": {"type": "object", "description": "Combined SLA targets configuration. An answer always has both maps.", "required": ["by_rating", "by_sentiment"], "properties": {"by_rating": {"$ref": "#/components/schemas/SLATargetsByRating"}, "by_sentiment": {"$ref": "#/components/schemas/SLATargetsBySentiment"}}}, "SLATargetsUpdate": {"type": "object", "description": "The targets to change. Each map sent replaces the stored one as a whole; a map left out keeps its stored targets.", "properties": {"by_rating": {"$ref": "#/components/schemas/SLATargetsByRating"}, "by_sentiment": {"$ref": "#/components/schemas/SLATargetsBySentiment"}}}, "SLAAlertConfig": {"type": "object", "description": "Stored SLA alert thresholds and a stored destinations list. The thresholds decide when a review's SLA status becomes warning or critical, which is when the sla_warning and sla_critical alert rules raise their alert. The destinations list is kept for compatibility and is not used for delivery; legacy Teams values remain readable for migration. An answer always has all three members.", "required": ["warn_at_percent", "critical_at_percent", "destinations"], "properties": {"warn_at_percent": {"type": "integer", "minimum": 0, "maximum": 100, "description": "Alert when this percentage of SLA window has elapsed. 50 until it is saved."}, "critical_at_percent": {"type": "integer", "minimum": 0, "maximum": 100, "description": "Critical alert when this percentage of SLA window has elapsed. 80 until it is saved."}, "destinations": {"type": "array", "items": {"type": "string", "enum": ["slack", "email", "in_app", "push", "teams", "discord", "webhook"]}, "description": "`slack` and `in_app` until it is saved. Stored for compatibility and not used for delivery. SLA warning, critical and breach alerts are delivered to the destinations set on the sla_warning, sla_critical and sla_breach alert rules (/api/v1/alert-rules)."}}}, "SLAAlertConfigRequest": {"type": "object", "description": "Writable SLA alert thresholds and the stored destinations list; retired Teams cannot be saved. The destinations list is not used for delivery.", "properties": {"warn_at_percent": {"type": "integer", "minimum": 0, "maximum": 100, "default": 50, "description": "Alert when this percentage of SLA window has elapsed."}, "critical_at_percent": {"type": "integer", "minimum": 0, "maximum": 100, "default": 80, "description": "Critical alert when this percentage of SLA window has elapsed."}, "destinations": {"type": "array", "items": {"type": "string", "enum": ["slack", "email", "in_app", "push", "discord", "webhook"]}, "default": ["slack", "in_app"], "description": "Stored for compatibility and not used for delivery. SLA warning, critical and breach alerts are delivered to the destinations set on the sla_warning, sla_critical and sla_breach alert rules (/api/v1/alert-rules); change those rules to change where SLA alerts go."}}}, "SLAConfig": {"type": "object", "description": "Complete SLA configuration for a tenant. Every answer has every member.", "required": ["sla_enabled", "targets", "alerts", "updated_at", "updated_by"], "properties": {"sla_enabled": {"type": "boolean", "description": "Whether SLA tracking is enabled."}, "targets": {"$ref": "#/components/schemas/SLATargets"}, "alerts": {"$ref": "#/components/schemas/SLAAlertConfig"}, "updated_at": {"type": ["string", "null"], "format": "date-time", "description": "When configuration was last updated. Null until it is first saved."}, "updated_by": {"type": ["string", "null"], "description": "User ID who last updated the configuration. Null until it is first saved."}}}, "SLAConfigUpdated": {"description": "`PUT /api/v1/sla/config`: the whole configuration after the change, and `message`.", "allOf": [{"$ref": "#/components/schemas/SLAConfig"}, {"type": "object", "required": ["message"], "properties": {"message": {"type": "string", "description": "`SLA configuration updated`."}}}]}, "UpdateSLAConfigRequest": {"type": "object", "description": "Request to update SLA configuration. Remove legacy Teams before enabling; pausing without destinations remains allowed.", "properties": {"sla_enabled": {"type": "boolean", "description": "Enable or disable SLA tracking."}, "targets": {"$ref": "#/components/schemas/SLATargetsUpdate"}, "alerts": {"$ref": "#/components/schemas/SLAAlertConfigRequest"}}}, "SLARatingMetrics": {"type": "object", "description": "SLA metrics for a specific rating. Every member is always sent.", "required": ["count", "responded", "responded_in_time", "breached", "past_target_no_reply", "compliance_rate"], "properties": {"count": {"type": "integer", "description": "Total reviews with this rating."}, "responded": {"type": "integer", "description": "Reviews in this band this workspace answered through Tritonium, including those the policy exempts from a target."}, "responded_in_time": {"type": "integer", "description": "Reviews in this band answered through Tritonium within their target."}, "breached": {"type": "integer", "description": "Reviews in this band past their target with no reply Tritonium has seen, or answered through Tritonium after it (see `SLAMetrics.breached_count`)."}, "past_target_no_reply": {"type": "integer", "description": "Of `breached`, the reviews in this band with no reply Tritonium has seen."}, "compliance_rate": {"type": ["number", "null"], "format": "float", "description": "Percentage of reviews responded in time, over the reviews in this rating band whose SLA has resolved (answered through Tritonium with a target, or `past_target_no_reply`). `null` when the band has no such review, most often because the SLA policy exempts the band entirely, in which case it has nothing to be compliant with."}}}, "SLAMetrics": {"type": "object", "description": "Aggregate SLA compliance metrics. Every member is always sent.", "required": ["period_days", "total_reviews", "reviews_with_sla", "responded_count", "responded_in_time", "breached_count", "past_target_no_reply_count", "replied_elsewhere_count", "responded_counts", "compliance_rate", "average_response_minutes", "median_response_minutes", "by_rating", "by_status", "trend", "measured_from", "reply_channel_connected", "metadata"], "properties": {"period_days": {"type": "integer", "description": "Number of days in the analysis period."}, "total_reviews": {"type": "integer", "description": "Total reviews in the period."}, "reviews_with_sla": {"type": "integer", "description": "Reviews that have an SLA target."}, "responded_count": {"type": "integer", "description": "Reviews this workspace answered through Tritonium. No store reports every developer reply (the App Store reports none written in App Store Connect, and Google Play shows a reply only on the reviews it serves when they are read), so only replies posted through Tritonium are timed against the workspace's targets. It includes answers to reviews the policy exempts (5 stars by default), which have no target and count toward no compliance figure. See `responded_counts` and `replied_elsewhere_count`."}, "responded_in_time": {"type": "integer", "description": "Reviews this workspace answered through Tritonium within their target."}, "breached_count": {"type": "integer", "description": "Reviews past their target with no reply Tritonium has seen, plus replies through Tritonium that came after the target. A review answered in App Store Connect, or in Google Play Console when the store did not show the reply, is counted here: the store does not report it."}, "past_target_no_reply_count": {"type": "integer", "description": "Of `breached_count`, the reviews past their target with no reply Tritonium has seen. The rest of `breached_count` (`breached_count` minus this) are this workspace's replies through Tritonium that came after the target. `responded_count` minus `responded_in_time` is not that number: it also holds answers to reviews the policy exempts, which have no target."}, "replied_elsewhere_count": {"type": "integer", "description": "Reviews with a target and a reply Tritonium saw that this workspace did not post through Tritonium: one Google Play showed when the review was read, or one another workspace posted. Neither responded nor breached. Also in `by_status` as `replied_elsewhere`."}, "responded_counts": {"type": "string", "enum": ["tritonium_replies"], "description": "What `responded_count` counts: `tritonium_replies`, replies this workspace posted through Tritonium."}, "compliance_rate": {"type": ["number", "null"], "format": "float", "description": "Overall SLA compliance percentage: reviews responded to within target over the reviews whose SLA has resolved (answered through Tritonium with a target, or past the target with no reply seen). `null` when none has resolved yet: a review still inside its target is neither a success nor a failure, so there is no percentage to report. Clients must render `null` as \"no data\", not as 0%.", "example": 87.5}, "average_response_minutes": {"type": ["number", "null"], "format": "float", "description": "Average response time in minutes."}, "median_response_minutes": {"type": ["number", "null"], "format": "float", "description": "Median response time in minutes."}, "by_rating": {"type": "object", "additionalProperties": {"$ref": "#/components/schemas/SLARatingMetrics"}, "description": "Metrics broken down by star rating, keyed `1` to `5`. A rating with no review in the period has no entry."}, "by_status": {"type": "object", "additionalProperties": {"type": "integer"}, "description": "Count of reviews by SLA status (`on_track`, `warning`, `critical`, `breached`, `replied_elsewhere`) among those not answered through Tritonium by this workspace."}, "trend": {"type": "array", "items": {"type": "object", "required": ["date", "compliance_rate", "total"], "properties": {"date": {"type": "string", "format": "date", "description": "The day the replies were posted."}, "compliance_rate": {"type": "number", "format": "float", "description": "Percentage of that day's replies posted within their target."}, "total": {"type": "integer", "minimum": 1, "description": "Replies through Tritonium posted that day to reviews with a target. Sent as a decimal number such as `3.0`."}}}, "description": "Daily compliance rate, oldest first, for at most the 14 newest days on which a reply through Tritonium was posted to a review with a target. A day without one has no entry."}, "measured_from": {"type": ["string", "null"], "format": "date-time", "description": "Earliest instant these figures reach. Equal to the start of the requested window in the normal case, and later than it when an app was connected inside the window: SLA is measured from when Tritonium started surfacing an app's reviews, not over the store listing's whole back catalogue, so a customer is never shown breaches against reviews that predate their account. Null when the tenant tracks no app."}, "reply_channel_connected": {"type": "boolean", "description": "Whether a reply could have been published through Tritonium at all for the apps in scope, that is, whether any of them has a store credential attached. Connecting an app does not require one, so a new customer reaches this screen before they can answer anything. When false, `breached_count` counts reviews with no reply the customer could have sent from here, and `compliance_rate` is null rather than 0.0 for the same reason a 0/0 ratio is: it is not measurable, not zero. False does not mean no reply exists anywhere: a reply written in the store's own console is not reported by the App Store at all, and by Google Play only on the reviews it serves when they are read (`replied_elsewhere_count`). A workspace whose `responded_count` is above zero posted through Tritonium before, so it still gets a rate."}, "metadata": {"$ref": "#/components/schemas/ResponseMetadata"}}}, "AtRiskReview": {"type": "object", "description": "A review approaching or past SLA breach.", "properties": {"review_id": {"type": "string", "description": "Unique review identifier."}, "app_uuid": {"type": "string", "description": "App this review belongs to."}, "app_name": {"type": "string", "description": "Name of the app."}, "rating": {"type": "integer", "minimum": 1, "maximum": 5, "description": "Star rating."}, "sentiment": {"oneOf": [{"type": "string", "enum": ["positive", "neutral", "negative", "mixed"]}, {"type": "null"}], "description": "The review's sentiment, as the SLA sentiment target was chosen on it: the label stored on the review where there is one, otherwise its star rating in three buckets (4 to 5 positive, 3 neutral, 1 to 2 negative). `sentiment_source` says which. `null` when there is neither a label nor a usable rating; such a review is given the neutral target."}, "sentiment_source": {"oneOf": [{"type": "string", "enum": ["rating", "analysis"]}, {"type": "null"}], "description": "Where `sentiment` came from. `rating`: the star rating in three buckets, which says nothing about the review's text. `analysis`: a label stored on the review. `null` exactly when `sentiment` is `null`."}, "content": {"type": "string", "description": "Review text content."}, "author": {"type": "string", "description": "Review author name."}, "created_at": {"type": "string", "format": "date-time", "description": "When the review was created."}, "sla_status": {"type": "string", "enum": ["on_track", "warning", "critical", "breached", "responded", "no_sla"], "description": "Current SLA status."}, "sla_target_minutes": {"type": ["integer", "null"], "description": "SLA target in minutes."}, "sla_remaining_minutes": {"type": ["number", "null"], "format": "float", "description": "Minutes remaining (negative if breached)."}, "sla_percent_used": {"type": ["number", "null"], "format": "float", "description": "Percentage of SLA window used (over 100 = breached)."}, "assignee_name": {"type": ["string", "null"], "description": "Name of assigned team member."}, "assignee_email": {"type": ["string", "null"], "description": "Email of assigned team member."}}}, "AtRiskReviewsResponse": {"type": "object", "description": "Response containing at-risk reviews.", "properties": {"reviews": {"type": "array", "items": {"$ref": "#/components/schemas/AtRiskReview"}, "description": "List of at-risk reviews."}, "total_at_risk": {"type": "integer", "description": "Total count of at-risk reviews."}, "by_status": {"type": "object", "additionalProperties": {"type": "integer"}, "description": "Count by SLA status (warning, critical, breached)."}}}, "CreateWebhookRequest": {"type": "object", "required": ["url", "trigger_events"], "properties": {"url": {"type": "string", "format": "uri", "description": "HTTPS endpoint URL to receive webhook events.", "example": "https://api.example.com/webhooks/tritonium"}, "trigger_events": {"type": "array", "minItems": 1, "items": {"type": "string", "enum": ["new_insight", "alert_fired", "sync_complete", "export.completed", "analysis.completed", "analysis.failed", "analysis.cancelled"]}, "description": "Events that trigger webhook delivery. `analysis.completed`, `analysis.failed` and `analysis.cancelled` are sent once when an analysis run reaches that terminal state; see `GET /api/v1/apps/{appUuid}/analysis-runs/{runId}`."}, "description": {"type": "string", "maxLength": 500, "description": "Optional description of the webhook purpose."}, "auth_header": {"type": "string", "maxLength": 100, "description": "Optional authentication header name.", "example": "Authorization"}, "auth_value": {"type": "string", "maxLength": 500, "description": "Optional authentication header value.", "example": "Bearer your-secret-token"}}}, "UpdateWebhookRequest": {"type": "object", "properties": {"url": {"type": "string", "format": "uri", "description": "New HTTPS endpoint URL."}, "trigger_events": {"type": "array", "minItems": 1, "items": {"type": "string", "enum": ["new_insight", "alert_fired", "sync_complete", "export.completed", "analysis.completed", "analysis.failed", "analysis.cancelled"]}, "description": "New list of trigger events."}, "description": {"type": "string", "maxLength": 500, "description": "New description."}, "enabled": {"type": "boolean", "description": "Enable or disable the webhook."}, "auth_header": {"type": "string", "maxLength": 100, "description": "New authentication header name."}, "auth_value": {"type": "string", "maxLength": 500, "description": "New authentication header value."}}}, "Webhook": {"type": "object", "required": ["webhook_id", "tenant_id", "url", "url_masked", "trigger_events", "enabled", "description", "auth_header", "auth_value"], "properties": {"webhook_id": {"type": "string", "description": "Unique webhook identifier."}, "tenant_id": {"type": "string", "description": "Owner tenant ID."}, "url": {"type": "string", "format": "uri", "description": "Where deliveries are sent. The full address is returned only to a signed-in admin or owner, the people who can create and change webhooks: for Slack, Discord, Zapier and most receivers the address is itself the credential. A member, an API key and an MCP connection receive the scheme and host alone (for example `https://hooks.slack.com`), and `url_masked` is `true`."}, "url_masked": {"type": "boolean", "description": "`true` when `url` holds only the scheme and host of the destination (see `url`). Do not send a masked value back as the webhook's URL."}, "trigger_events": {"type": "array", "items": {"type": "string"}, "description": "Events that trigger this webhook."}, "enabled": {"type": "boolean", "description": "Whether webhook is active."}, "description": {"type": "string", "description": "Webhook description. Empty when none was given."}, "auth_header": {"type": "string", "description": "Authentication header name. Empty when none is configured."}, "auth_value": {"type": "string", "description": "Masked authentication value (shows *** if set). Empty when none is configured."}, "signing_secret": {"type": "string", "description": "HMAC-SHA256 signing secret. Returned in full ONLY in the creation response (POST /webhooks); every other response masks it as `whsec_****<last4>`. Each delivery is signed and carries the headers `X-Tritonium-Timestamp` (unix seconds) and `X-Tritonium-Signature` (`sha256=<hex>`), where the signature is `HMAC_SHA256(secret, \"<timestamp>.<raw_request_body>\")`. Recompute it with this secret to verify authenticity."}, "total_deliveries": {"type": "integer", "description": "Total delivery attempts."}, "successful_deliveries": {"type": "integer", "description": "Successful deliveries."}, "failed_deliveries": {"type": "integer", "description": "Failed deliveries."}, "last_delivery_at": {"type": "string", "format": "date-time", "description": "When the last delivery was attempted. Written only by a delivery, so the attribute is absent on a webhook that has never fired; treat a missing value as \"no deliveries yet\", not as an error."}, "created_at": {"type": "string", "format": "date-time"}, "updated_at": {"type": "string", "format": "date-time"}}}, "WebhookTestResult": {"type": "object", "deprecated": true, "description": "Not the answer of any operation, and never was: `POST /api/v1/webhooks/{webhook_id}/test` answers `WebhookTestResponse`, whose `result` (`WebhookTestDelivery`) holds the delivery. Kept so that existing clients still compile.", "properties": {"delivery_id": {"type": "string", "description": "Unique delivery identifier."}, "webhook_id": {"type": "string", "description": "Webhook that was tested."}, "success": {"type": "boolean", "description": "Whether test delivery succeeded."}, "status_code": {"type": "integer", "description": "HTTP response status code."}, "attempts": {"type": "integer", "description": "Number of delivery attempts made."}, "error": {"type": ["string", "null"], "description": "Error message if delivery failed."}, "response_preview": {"type": "string", "description": "First 200 characters of response body."}}}, "WebhookTestResponse": {"type": "object", "description": "The answer of `POST /api/v1/webhooks/{webhook_id}/test` (`WebhookAPIService.test_webhook`).", "required": ["success", "message", "event_type", "payload_preview", "result"], "properties": {"success": {"type": "boolean", "description": "Whether the endpoint accepted the test delivery (answered 2xx)."}, "message": {"type": "string", "description": "`Test webhook delivered` or `Test webhook delivery failed`, in English."}, "event_type": {"type": "string", "description": "The event type the test was sent as, `test` by default."}, "payload_preview": {"type": "object", "additionalProperties": true, "description": "The `data` the test delivery carried: the sample event, with `webhook_id`, `timestamp` and `is_test: true`."}, "result": {"$ref": "#/components/schemas/WebhookTestDelivery"}}}, "WebhookTestDelivery": {"type": "object", "description": "The test delivery itself. `status_code` and `attempts` are absent when the address was refused before anything was sent; `error` then starts with `invalid_url:`.", "required": ["delivery_id", "webhook_id", "success"], "properties": {"delivery_id": {"type": "string", "description": "The delivery's id, also sent as the `X-Tritonium-Delivery` header."}, "webhook_id": {"type": "string", "description": "Webhook that was tested."}, "success": {"type": "boolean", "description": "Whether the endpoint answered 2xx."}, "status_code": {"type": ["integer", "null"], "description": "The endpoint's last HTTP status. Null when no attempt got an answer."}, "attempts": {"type": "integer", "description": "Number of delivery attempts made (at most 4)."}, "error": {"type": ["string", "null"], "description": "Why the last failed attempt failed: `HTTP <status> <reason>`, with a short preview of a plain-text answer, when the endpoint answered; `invalid_url: <reason>` when the address was refused; otherwise the transport's own message. Null when the first attempt succeeded."}}}, "WebhookDeliveryLog": {"type": "object", "description": "One delivery attempt as `get_delivery_logs` answers it. Every member is always present: each delivery writes all of them.", "required": ["delivery_id", "webhook_id", "event_type", "app_uuid", "url", "url_masked", "started_at", "completed_at", "success", "status_code", "attempts", "error", "response_preview", "tenant_id"], "properties": {"delivery_id": {"type": "string", "description": "Unique delivery identifier."}, "webhook_id": {"type": "string", "description": "Webhook ID."}, "event_type": {"type": "string", "description": "Event that triggered the delivery."}, "app_uuid": {"type": "string", "description": "Related app UUID, or empty when the event is not about one app."}, "url": {"type": "string", "description": "The address this delivery was sent to. Reduced to its scheme and host for a member, an API key and an MCP connection, like `Webhook.url`; `url_masked` is then `true`."}, "url_masked": {"type": "boolean", "description": "`true` when `url` holds only the scheme and host. `error` is then one of three fixed things (the HTTP status line such as `HTTP 404 Not Found`, `invalid_url` or `request_failed`) and `response_preview` is empty: both are free text that can quote the address, so neither is passed on."}, "started_at": {"type": "string", "format": "date-time", "description": "When delivery started."}, "completed_at": {"type": "string", "format": "date-time", "description": "When delivery completed."}, "success": {"type": "boolean", "description": "Whether delivery succeeded."}, "status_code": {"type": ["integer", "null"], "description": "HTTP response status code."}, "attempts": {"type": "integer", "description": "Number of attempts (max 4 with retries)."}, "error": {"type": "string", "description": "Error message if failed. The stored message for a signed-in admin or owner; the failure's class alone when `url_masked` is `true`."}, "response_preview": {"type": "string", "description": "First 200 characters of the receiver's response. Empty when `url_masked` is `true`."}, "tenant_id": {"type": "string", "description": "The workspace the delivery belongs to."}}}, "CompetitorSearchRequest": {"type": "object", "required": ["query", "platform"], "properties": {"query": {"type": "string", "minLength": 1, "description": "Search query (app name).", "example": "Instagram"}, "platform": {"type": "string", "enum": ["app_store", "google_play"], "description": "Platform to search. `steam` was advertised here but no Steam search exists; it is rejected as an unsupported platform, matching `GET /api/v1/apps/search`."}, "country": {"type": "string", "default": "us", "description": "Country code for results.", "example": "us"}, "limit": {"type": "integer", "minimum": 1, "maximum": 50, "default": 10, "description": "Maximum results to return."}}}, "CompetitorSearchResponse": {"type": "object", "properties": {"results": {"type": "array", "items": {"$ref": "#/components/schemas/CompetitorSearchResult"}}, "count": {"type": "integer", "description": "Number of results returned."}, "query": {"type": "string", "description": "Original search query."}, "platform": {"type": "string", "description": "Platform searched."}}}, "CompetitorSearchResult": {"type": "object", "properties": {"app_id": {"type": "string", "description": "External app ID."}, "name": {"type": "string", "description": "App name."}, "developer": {"type": "string", "description": "Developer/publisher name."}, "icon_url": {"type": "string", "format": "uri", "description": "App icon URL."}, "rating": {"type": ["number", "null"], "format": "float", "description": "Current rating, or null when the aggregate is unavailable."}, "rating_count": {"type": ["integer", "null"], "description": "Total number of ratings, or null when the aggregate is unavailable."}, "average_rating": {"type": ["number", "null"], "description": "Alias of rating, including null when the aggregate is unavailable."}, "total_reviews": {"type": ["integer", "null"], "description": "Alias of rating_count, including null when the aggregate is unavailable."}, "category": {"type": "string", "description": "App category."}, "platform": {"type": "string", "description": "Platform."}, "store_url": {"type": "string", "format": "uri", "description": "App store URL."}}}, "AddCompetitorRequest": {"type": "object", "required": ["name", "external_app_id", "platform"], "properties": {"name": {"type": "string", "maxLength": 200, "description": "Competitor app name.", "example": "Instagram"}, "external_app_id": {"type": "string", "description": "External app ID (iTunes ID or package name). Must not contain `#`.", "example": "389801252"}, "platform": {"type": "string", "enum": ["ios", "app_store", "app_store_connect", "android", "google_play", "play_store"], "description": "Store on which the competitor is tracked. Only App Store and Google Play are supported. Other stores return 400."}, "icon_url": {"type": "string", "format": "uri", "description": "App icon URL."}, "developer": {"type": "string", "description": "Developer name."}, "country": {"type": "string", "default": "us", "description": "Country for review fetching."}, "metadata": {"type": "object", "additionalProperties": true, "description": "Additional metadata."}}}, "Competitor": {"type": "object", "description": "A competitor tracked against one app through /api/v1/apps/{appUuid}/competitors. Workspace-wide competitive-intelligence routes return the separate TrackedCompetitor shape. Every member is sent for every competitor, except `rating_status` and `rating_unavailable_reason`, which only an App Store competitor carries.", "properties": {"rating_status": {"type": "string", "enum": ["available", "unavailable"], "description": "Whether aggregate rating data can be used. Sent only for an App Store competitor, as `unavailable`: Apple public aggregates are unavailable pending source permission."}, "rating_unavailable_reason": {"type": ["string", "null"], "description": "`source_permission_unverified`, sent only for an App Store competitor."}, "competitor_id": {"type": "string", "description": "`<platform>_<external_app_id>`, the id the competitive intelligence routes take."}, "display_name": {"type": "string", "description": "The name given when the competitor was added, else the store's. Each sync replaces it with the store's name. Empty text when neither is known."}, "external_app_id": {"type": "string", "description": "The competitor's id in its store."}, "platform": {"type": "string", "enum": ["ios", "app_store", "app_store_connect", "android", "google_play", "play_store"], "description": "The store, as it was given when the competitor was added: any of the names `CompetitorUpsertRequest` accepts, in lowercase."}, "icon_url": {"type": "string", "description": "The address of the competitor's icon (http or https), or empty text when none is known."}, "last_average_rating": {"type": ["number", "null"], "format": "float", "description": "Average rating at the last sync; null before sync or when aggregate data is unavailable."}, "last_total_reviews": {"type": ["integer", "null"], "description": "Listing count or fetched review-set size at the last sync; review_count_source identifies which. Null when unavailable."}, "rating_source": {"$ref": "#/components/schemas/CompetitorMetricSource"}, "review_count_source": {"$ref": "#/components/schemas/CompetitorMetricSource"}, "last_synced_at": {"type": ["string", "null"], "format": "date-time", "description": "When this competitor was last synced; null before its first sync."}, "created_at": {"type": "string", "format": "date-time", "description": "When the competitor was added."}}}, "CompetitorList": {"type": "object", "description": "The competitors the workspace tracks for one app.", "required": ["competitors", "count"], "properties": {"competitors": {"type": "array", "items": {"$ref": "#/components/schemas/Competitor"}}, "count": {"type": "integer", "minimum": 0, "description": "The number of entries in `competitors`."}}}, "CompetitorRemoved": {"type": "object", "required": ["message"], "properties": {"message": {"type": "string", "description": "Always `Competitor removed`."}}}, "CompetitorSuggestion": {"type": "object", "description": "An app its store lists in the same category as yours. Add it with its `platform` and `external_app_id`. App Store and Google Play suggestions share the nine required members; a Google Play suggestion also carries `app_id`, `name`, `rating`, `rating_count` and `match_reason`.", "required": ["platform", "external_app_id", "display_name", "developer", "icon_url", "category", "store_url", "average_rating", "total_reviews"], "properties": {"platform": {"type": "string", "enum": ["app_store", "google_play"]}, "external_app_id": {"type": "string", "description": "The app's id in its store."}, "display_name": {"type": "string", "description": "The store's name for the app; empty text when the store gives none."}, "developer": {"type": "string", "description": "The developer's name; empty text when the store gives none."}, "icon_url": {"type": "string", "description": "The address of the app's icon, or empty text when the store gives none."}, "category": {"type": "string", "description": "The store's category for the app; empty text when the store gives none."}, "store_url": {"type": "string", "description": "The app's store page; empty text when the store gives none."}, "average_rating": {"type": ["number", "null"], "description": "The rating Google Play publishes, or null when it shows none. Always null for an App Store suggestion: Apple public aggregates are unavailable pending source permission."}, "total_reviews": {"type": ["integer", "null"], "minimum": 0, "description": "The number of ratings Google Play publishes (0 when it shows none). Always null for an App Store suggestion."}, "app_id": {"type": "string", "description": "Google Play only. The same as `external_app_id`."}, "name": {"type": "string", "description": "Google Play only. The same as `display_name`."}, "rating": {"type": "number", "description": "Google Play only. `average_rating`, or 0 when Google Play shows no rating."}, "rating_count": {"type": "integer", "minimum": 0, "description": "Google Play only. The same as `total_reviews`."}, "match_reason": {"type": "string", "description": "Google Play only. Why the app was suggested, in English, for example \"Same Google Play category: Productivity\"."}}}, "CompetitorSuggestions": {"type": "object", "required": ["suggestions", "app_uuid"], "properties": {"suggestions": {"type": "array", "description": "At most `limit` apps, in the order the store ranks them.", "items": {"$ref": "#/components/schemas/CompetitorSuggestion"}}, "app_uuid": {"type": "string", "format": "uuid", "description": "The app the suggestions are for."}}}, "CompetitorSyncSummary": {"type": "object", "description": "What one sync call did. `processed` is `synced` plus `errors`, and `total` is `processed` plus `remaining`.", "required": ["status", "synced", "errors", "rating_changes", "total", "processed", "remaining", "batch_limit", "results"], "properties": {"status": {"type": "string", "enum": ["sync_completed", "sync_partial"], "description": "`sync_completed` when this call tried every tracked competitor, `sync_partial` when `remaining` wait for a later one. It does not say whether they synced: see `errors`."}, "synced": {"type": "integer", "minimum": 0, "description": "Competitors whose store data was fetched and saved."}, "errors": {"type": "integer", "minimum": 0, "description": "Competitors tried that did not sync: the store could not be read, it had no public data for the competitor, or saving failed."}, "rating_changes": {"type": "integer", "minimum": 0, "description": "Synced competitors whose published store rating moved by 0.01 or more since their previous sync."}, "total": {"type": "integer", "minimum": 0, "description": "Competitors the app tracks."}, "processed": {"type": "integer", "minimum": 0, "description": "Competitors this call tried."}, "remaining": {"type": "integer", "minimum": 0, "description": "Competitors this call did not try."}, "batch_limit": {"type": "integer", "minimum": 1, "description": "The most competitors one call tries (3)."}, "results": {"type": "array", "description": "One entry for each competitor tried, in the order tried, except a competitor whose saving failed part way, which is counted in `errors` with no entry.", "items": {"anyOf": [{"$ref": "#/components/schemas/CompetitorSynced"}, {"$ref": "#/components/schemas/CompetitorSyncStoreUnavailable"}, {"$ref": "#/components/schemas/CompetitorSyncFailed"}]}}}}, "CompetitorSynced": {"type": "object", "description": "A competitor whose store data was fetched and saved.", "required": ["success", "competitor_id", "platform", "average_rating", "total_reviews", "reviews_fetched", "new_count", "duplicate_count", "stored_review_count", "rating_changed", "rating_change", "message", "snapshot"], "properties": {"success": {"type": "boolean", "enum": [true]}, "competitor_id": {"type": "string"}, "platform": {"type": "string", "enum": ["app_store", "google_play"]}, "average_rating": {"type": ["number", "null"], "description": "The listing's rating, or the mean of the fetched reviews when the listing has none (`snapshot.rating_source` says which). Null when there is neither, and always for an App Store competitor."}, "total_reviews": {"type": ["integer", "null"], "minimum": 0, "description": "The listing's rating count, or the number of reviews fetched when the listing has none (`snapshot.review_count_source` says which). Always null for an App Store competitor."}, "reviews_fetched": {"type": "integer", "minimum": 0, "description": "Reviews this sync fetched (at most 25)."}, "new_count": {"type": ["integer", "null"], "minimum": 0, "description": "Fetched reviews that were not stored before. Null when the stored reviews could not be counted."}, "duplicate_count": {"type": ["integer", "null"], "minimum": 0, "description": "Fetched reviews already stored. Null when the stored reviews could not be counted."}, "stored_review_count": {"type": ["integer", "null"], "minimum": 0, "description": "Reviews stored for the competitor after this sync. Null when they could not be counted."}, "rating_changed": {"type": "boolean", "description": "Whether the published store rating moved by 0.01 or more since the previous sync."}, "rating_change": {"type": ["number", "null"], "description": "How far it moved, to two decimals; null when it did not."}, "message": {"type": "string", "description": "`Competitor reviews fetched successfully`, or `Competitor metadata fetched; no reviews fetched` when the store listed no reviews."}, "snapshot": {"$ref": "#/components/schemas/CompetitorListingSnapshot"}}}, "CompetitorSyncStoreUnavailable": {"type": "object", "description": "A competitor whose store could not be read. Nothing was saved for it; retry later.", "required": ["success", "competitor_id", "platform", "error", "error_code", "retryable"], "properties": {"success": {"type": "boolean", "enum": [false]}, "competitor_id": {"type": "string"}, "platform": {"type": "string", "enum": ["app_store", "google_play"]}, "error": {"type": "string", "description": "What could not be read, in English."}, "error_code": {"type": "string", "enum": ["STORE_UNAVAILABLE"]}, "retryable": {"type": "boolean", "enum": [true]}}}, "CompetitorSyncFailed": {"type": "object", "description": "A competitor that could not be synced for another reason: the store has no public data for it, or it has no store id, workspace or supported store. Nothing was saved for it.", "required": ["success", "error"], "properties": {"success": {"type": "boolean", "enum": [false]}, "error": {"type": "string", "description": "Why, in English."}}}, "CompetitorListingSnapshot": {"type": "object", "description": "What a sync read from the competitor's store listing and newest reviews, as it was saved. An App Store snapshot carries no aggregate figures (Apple public aggregates are unavailable pending source permission) and also has `rating_status` and `rating_unavailable_reason`.", "required": ["competitor_id", "platform", "external_app_id", "name", "developer", "icon_url", "category", "store_url", "average_rating", "rating_source", "review_count_source", "total_reviews", "rating_distribution", "sentiment_score", "reviews_fetched", "insights", "insight_parts", "insights_generated_at", "fetched_at", "store_rating"], "properties": {"competitor_id": {"type": "string"}, "platform": {"type": "string", "enum": ["app_store", "google_play"]}, "external_app_id": {"type": "string"}, "name": {"type": "string", "description": "The store's name for the app, else the competitor's own name, else its store id."}, "developer": {"type": "string", "description": "The developer's name; empty text when none is known."}, "icon_url": {"type": "string", "description": "The address of the icon; empty text when none is known."}, "category": {"type": "string", "description": "The store's category; empty text when the store gave none."}, "store_url": {"type": "string", "description": "The store page; empty text when the store gave none."}, "average_rating": {"type": ["number", "null"], "description": "The listing's rating, or the mean of the fetched reviews when the listing has none."}, "rating_source": {"$ref": "#/components/schemas/CompetitorMetricSource"}, "review_count_source": {"$ref": "#/components/schemas/CompetitorMetricSource"}, "total_reviews": {"type": ["integer", "null"], "minimum": 0, "description": "The listing's rating count, or the number of reviews fetched when the listing has none."}, "rating_distribution": {"type": "object", "description": "The fetched reviews by star rating.", "required": ["1", "2", "3", "4", "5"], "properties": {"1": {"type": "integer", "minimum": 0}, "2": {"type": "integer", "minimum": 0}, "3": {"type": "integer", "minimum": 0}, "4": {"type": "integer", "minimum": 0}, "5": {"type": "integer", "minimum": 0}}}, "sentiment_score": {"type": ["number", "null"], "minimum": 0, "maximum": 1, "description": "The share of the fetched reviews rated 3 stars or more, from 0 to 1. A star-rating figure: it is not a reading of review text. Null when no review was fetched."}, "reviews_fetched": {"type": "integer", "minimum": 0}, "insights": {"type": "array", "description": "The summary sentences, in English, as the competitor's summary serves them.", "items": {"type": "string"}}, "insight_parts": {"type": "array", "description": "The same sentences, each with the code of its template and the values it prints.", "items": {"$ref": "#/components/schemas/CompetitorInsightPart"}}, "insights_generated_at": {"type": "string", "format": "date-time"}, "fetched_at": {"type": "string", "format": "date-time"}, "store_rating": {"type": ["number", "null"], "description": "The rating the listing publishes; null when it publishes none, and always for an App Store competitor."}, "rating_status": {"type": "string", "enum": ["unavailable"], "description": "App Store only."}, "rating_unavailable_reason": {"type": "string", "enum": ["source_permission_unverified"], "description": "App Store only."}}}, "CompetitorBenchmark": {"type": "object", "description": "The app beside the competitors the workspace tracks for it, over one period.", "required": ["app_uuid", "period", "days", "period_start", "generated_at", "your_app", "competitors", "competitor_count"], "properties": {"app_uuid": {"type": "string", "format": "uuid"}, "period": {"type": "string", "enum": ["7d", "30d", "90d", "180d", "365d"]}, "days": {"type": "integer", "enum": [7, 30, 90, 180, 365], "description": "The period in days."}, "period_start": {"type": "string", "format": "date-time", "description": "The start of the period each change was asked for: `days` before `generated_at`. Each competitor's `change_since` says where its measurement actually starts."}, "generated_at": {"type": "string", "format": "date-time"}, "your_app": {"$ref": "#/components/schemas/BenchmarkOwnApp"}, "competitors": {"type": "array", "items": {"$ref": "#/components/schemas/BenchmarkCompetitor"}}, "competitor_count": {"type": "integer", "minimum": 0, "description": "The number of entries in `competitors`."}}}, "BenchmarkOwnApp": {"type": "object", "description": "The app itself. Its rating is measured from its reviews, which this answer does not read: `current_rating`, `rating_change` and `review_count_change` are always null. Read them from `GET /api/v1/apps/{appUuid}/ratings/summary` for the same period.", "required": ["app_uuid", "name", "platform", "icon_url", "review_count", "current_rating", "rating_change", "review_count_change"], "properties": {"app_uuid": {"type": "string", "format": "uuid"}, "name": {"type": ["string", "null"], "description": "The app's name; null when none is stored."}, "platform": {"type": ["string", "null"], "description": "The app's store, as it was connected; null when none is stored."}, "icon_url": {"type": ["string", "null"], "description": "The address of the app's icon; null when none is stored."}, "review_count": {"type": ["integer", "null"], "minimum": 0, "description": "The reviews stored for the app over its whole life, not the period; null when no count is stored."}, "current_rating": {"type": "null", "description": "Not measured here; always null."}, "rating_change": {"type": "null", "description": "Not measured here; always null."}, "review_count_change": {"type": "null", "description": "Not measured here; always null."}}}, "BenchmarkCompetitor": {"type": "object", "description": "One tracked competitor's latest figures and their change over the period.", "required": ["competitor_id", "external_app_id", "platform", "name", "icon_url", "current_rating", "rating_source", "rating_change", "review_count", "review_count_source", "review_count_change", "last_synced_at", "snapshots_in_period", "change_since", "change_covers_period"], "properties": {"competitor_id": {"type": "string"}, "external_app_id": {"type": "string"}, "platform": {"type": "string", "description": "The store, as it was given when the competitor was added."}, "name": {"type": ["string", "null"], "description": "The competitor's name; null until a name is known (show it as an unnamed app, not as its store id)."}, "icon_url": {"type": ["string", "null"], "description": "The address of its icon; null when none is known."}, "current_rating": {"type": ["number", "null"], "description": "The latest rating, identified by `rating_source`; null before the first sync, and always for an App Store competitor."}, "rating_source": {"$ref": "#/components/schemas/CompetitorMetricSource"}, "rating_change": {"type": ["number", "null"], "description": "The rating's change between the first and the last snapshot inside the period, when both have the same known source; null otherwise, and when fewer than two snapshots fall in the period."}, "review_count": {"type": ["integer", "null"], "minimum": 0, "description": "The latest rating count or fetched review count, identified by `review_count_source`."}, "review_count_source": {"$ref": "#/components/schemas/CompetitorMetricSource"}, "review_count_change": {"type": ["number", "null"], "description": "The rating count's change over the same snapshots, in percent, when both are store counts; null otherwise."}, "last_synced_at": {"type": ["string", "null"], "format": "date-time", "description": "When the competitor was last synced; null before its first sync."}, "snapshots_in_period": {"type": "integer", "minimum": 0, "description": "Rating snapshots inside the period."}, "change_since": {"type": ["string", "null"], "format": "date-time", "description": "When the snapshot the changes are measured from was taken; null when no change was measured."}, "change_covers_period": {"type": ["boolean", "null"], "description": "False when that snapshot is more than 36 hours after the start of the period, so the change covers only part of it (snapshots begin at the competitor's first sync). Null when no change was measured."}}}, "BenchmarkTrends": {"type": "object", "description": "The tracked competitors' rating lines over one period.", "required": ["app_uuid", "period", "days", "your_app", "competitors"], "properties": {"app_uuid": {"type": "string", "format": "uuid"}, "period": {"type": "string", "enum": ["7d", "30d", "90d", "180d", "365d"]}, "days": {"type": "integer", "enum": [7, 30, 90, 180, 365], "description": "The period in days."}, "your_app": {"type": "object", "required": ["name", "data"], "properties": {"name": {"type": ["string", "null"], "description": "The app's name; null when none is stored."}, "data": {"type": "array", "maxItems": 0, "description": "Always empty: read the app's own line from `GET /api/v1/apps/{appUuid}/ratings/history`.", "items": {"$ref": "#/components/schemas/BenchmarkTrendPoint"}}}}, "competitors": {"type": "array", "description": "Every tracked competitor, with or without snapshots in the period.", "items": {"$ref": "#/components/schemas/BenchmarkTrendSeries"}}}}, "BenchmarkTrendSeries": {"type": "object", "required": ["competitor_id", "external_app_id", "name", "data"], "properties": {"competitor_id": {"type": "string"}, "external_app_id": {"type": "string"}, "name": {"type": ["string", "null"], "description": "The competitor's name; null until a name is known."}, "data": {"type": "array", "description": "The competitor's rating snapshots inside the period, oldest first. A snapshot without a rating is left out, and an App Store competitor has none.", "items": {"$ref": "#/components/schemas/BenchmarkTrendPoint"}}}}, "BenchmarkTrendPoint": {"type": "object", "required": ["date", "rating", "rating_source", "review_count", "review_count_source"], "properties": {"date": {"type": "string", "format": "date-time", "description": "When the snapshot was taken."}, "rating": {"type": "number"}, "rating_source": {"$ref": "#/components/schemas/CompetitorMetricSource"}, "review_count": {"type": ["integer", "null"], "minimum": 0}, "review_count_source": {"$ref": "#/components/schemas/CompetitorMetricSource"}}}, "BenchmarkFeatures": {"type": "object", "description": "A feature comparison, which is not measured yet.", "required": ["app_uuid", "features", "categories", "available", "reason"], "properties": {"app_uuid": {"type": "string", "format": "uuid"}, "features": {"type": "array", "maxItems": 0, "description": "Always empty.", "items": {"type": "object"}}, "categories": {"type": "array", "maxItems": 0, "description": "Always empty.", "items": {"type": "string"}}, "available": {"type": "boolean", "enum": [false]}, "reason": {"type": "string", "description": "Why there is no comparison, in English."}}}, "BenchmarkMarketPosition": {"type": "object", "description": "The app's rank in its store category, which is not measured yet.", "required": ["app_uuid", "market_position", "available", "reason"], "properties": {"app_uuid": {"type": "string", "format": "uuid"}, "market_position": {"type": "null", "description": "Always null."}, "available": {"type": "boolean", "enum": [false]}, "reason": {"type": "string", "description": "Why there is no position, in English."}}}, "TrackedCompetitor": {"type": "object", "description": "A competitor tracked workspace-wide through /api/v1/competitive-intelligence/competitors. Includes name and developer; the app-scoped Competitor shape instead includes display_name.", "properties": {"rating_status": {"type": "string", "enum": ["available", "unavailable"], "description": "Whether aggregate rating data can be used. Apple public aggregates are unavailable pending source permission."}, "rating_unavailable_reason": {"type": ["string", "null"], "description": "source_permission_unverified for Apple public rating aggregates."}, "competitor_id": {"type": "string", "description": "Unique competitor identifier, `\"{platform}_{external_app_id}\"`."}, "name": {"type": "string", "description": "Competitor app name."}, "developer": {"type": "string", "description": "Developer/publisher name. Empty string until first sync."}, "external_app_id": {"type": "string", "description": "External app ID (iTunes ID or Play package name)."}, "platform": {"type": "string", "enum": ["app_store", "google_play", "steam"], "description": "App platform."}, "icon_url": {"type": "string", "description": "App icon URL. Empty string until first sync."}, "last_average_rating": {"type": ["number", "null"], "format": "float", "description": "Average rating at the last sync; null before first sync."}, "last_total_reviews": {"type": ["integer", "null"], "description": "Listing count or fetched review-set size at the last sync; review_count_source identifies which. Null when unavailable."}, "rating_source": {"$ref": "#/components/schemas/CompetitorMetricSource"}, "review_count_source": {"$ref": "#/components/schemas/CompetitorMetricSource"}, "last_synced_at": {"type": ["string", "null"], "format": "date-time", "description": "When this competitor was last synced; null before first sync."}, "created_at": {"type": "string", "format": "date-time", "description": "When the competitor was added."}}}, "CompetitorFetchResult": {"type": "object", "properties": {"average_rating": {"type": ["number", "null"], "description": "Listing rating or fetched-review mean, identified by rating_source; null when unavailable."}, "total_reviews": {"type": ["integer", "null"], "description": "Listing count or fetched review-set size, identified by review_count_source; null when unavailable."}, "rating_source": {"$ref": "#/components/schemas/CompetitorMetricSource"}, "review_count_source": {"$ref": "#/components/schemas/CompetitorMetricSource"}, "rating_status": {"type": "string", "enum": ["available", "unavailable"], "description": "Whether aggregate rating data can be used. Apple public aggregates are unavailable pending source permission."}, "rating_unavailable_reason": {"type": ["string", "null"], "description": "source_permission_unverified for Apple public rating aggregates."}, "competitor_id": {"type": "string", "description": "Competitor ID."}, "app_key": {"type": "string", "description": "Internal app key."}, "cached": {"type": "boolean", "description": "Whether cached data was returned."}, "total_reviews_stored": {"type": ["integer", "null"], "description": "Total reviews in storage. Null when the stored reviews could not be counted; the fetch itself was still saved."}, "last_fetched_at": {"type": "string", "format": "date-time", "description": "When data was fetched."}, "message": {"type": "string", "description": "Status message."}, "new_count": {"type": ["integer", "null"], "description": "New reviews fetched. Null when the stored reviews could not be counted, so how many were new is not known."}, "duplicate_count": {"type": ["integer", "null"], "description": "Duplicate reviews skipped. Null when the stored reviews could not be counted."}}}, "CompetitorReviewsResponse": {"type": "object", "properties": {"competitor_id": {"type": "string", "description": "The competitor these reviews belong to."}, "reviews": {"type": "array", "items": {"$ref": "#/components/schemas/CompetitorReview"}}, "count": {"type": "integer", "description": "Number of reviews on this page, not a corpus total."}, "cursor": {"type": ["string", "null"], "description": "Pass as `cursor` to fetch the next page. Null on the last page."}, "has_more": {"type": "boolean", "description": "Whether another page exists."}}}, "CompetitorReview": {"type": "object", "description": "A review fetched from a competitor's public store listing, as it is stored. It carries no sentiment: nothing reads a competitor's reviews one by one, and the star rating is the only signal on the row. This schema used to document `title`, `content`, `sentiment`, `created_at` and `version`, none of which the route returns.", "properties": {"review_id": {"type": "string", "description": "Unique review identifier."}, "author": {"type": "string", "description": "Review author name."}, "rating": {"type": "integer", "minimum": 1, "maximum": 5, "description": "Star rating."}, "text": {"type": "string", "description": "Review text."}, "date": {"type": "string", "description": "When the review was posted, as the store reported it."}, "platform": {"type": "string", "description": "The store the review was fetched from."}}}, "CompetitorMetricSource": {"oneOf": [{"type": "string", "enum": ["store", "fetched_reviews"]}, {"type": "null"}], "description": "Explicit provenance of the corresponding metric value. `store` is the value published on the listing; `fetched_reviews` is the fetched-set mean or size, not a store-wide rating or total. Null when the value is unavailable, explicit provenance is missing or unrecognized, or the value differs from its source snapshot. Rating and count sources are independent; do not infer one from the other or from legacy metadata."}, "CompetitorStats": {"type": "object", "description": "The figures sit under `stats`. This schema used to document them at the top level, with a `sentiment_distribution`, a `review_velocity` and a `generated_at` the route never returns.", "properties": {"competitor_id": {"type": "string", "description": "Competitor ID."}, "stats": {"type": "object", "description": "Figures from the latest fetch of the competitor's public store listing.", "properties": {"rating_status": {"type": "string", "enum": ["available", "unavailable"], "description": "Whether the aggregate rating can be used. Apple public aggregates are unavailable pending source permission."}, "rating_unavailable_reason": {"type": ["string", "null"], "description": "source_permission_unverified for Apple public rating aggregates."}, "average_rating": {"type": ["number", "null"], "format": "float", "description": "The store's published average rating, or the average of the fetched reviews when the store published none. Null before the first fetch or when source permission is unverified. `rating_source` says which it is."}, "rating_source": {"oneOf": [{"type": "string", "enum": ["store", "fetched_reviews"]}, {"type": "null"}], "description": "Where `average_rating` comes from: `store` is the rating the store publishes on the listing (which may use recency weighting); `fetched_reviews` is the mean of the star ratings of the reviews the latest fetch brought back (as many as `rating_distribution` counts, so a sample the size of that fetch, not of the store), used when the listing carried no rating. Null when there is no rating, and for a fetch written before this field whose source is not recorded."}, "total_reviews": {"type": ["integer", "null"], "description": "The store's published rating count, or the number of reviews fetched when the store published none."}, "review_count_source": {"$ref": "#/components/schemas/CompetitorMetricSource"}, "rating_distribution": {"type": "object", "properties": {"1": {"type": "integer"}, "2": {"type": "integer"}, "3": {"type": "integer"}, "4": {"type": "integer"}, "5": {"type": "integer"}}, "description": "The fetched reviews by star rating. Empty before the first fetch."}, "sentiment_score": {"type": ["number", "null"], "format": "float", "description": "The share of the fetched reviews rated 3 stars or more, from 0 to 1 (one minus the share rated 1 or 2 stars). A star-rating figure under an old name: it is not a reading of review text. Null when no review was fetched."}, "last_synced_at": {"type": ["string", "null"], "format": "date-time", "description": "When the listing was last fetched."}}}}}, "CompetitorInsights": {"type": "object", "description": "What `get_competitor_insights` returns. This schema used to document `strengths`, `weaknesses`, `feature_gaps` and `switching_signals`, none of which the route returns.", "properties": {"competitor_id": {"type": "string", "description": "Competitor ID."}, "insights": {"type": "array", "items": {"type": "string"}, "description": "Sentences about the competitor's public store listing, in the order they were written: the store rating and its rating count, then the share of the fetched reviews rated 1 or 2 stars. Computed, not model-written, and in English. Empty before the first fetch."}, "insight_parts": {"type": "array", "items": {"$ref": "#/components/schemas/CompetitorInsightPart"}, "description": "The same sentences, one entry per line of `insights` in the same order, each with the code of the template it was written from and the values it prints, so a client can write it in its own language and number format. Empty before the first fetch and for a summary written before the codes were recorded; show `insights` then."}, "generated_at": {"type": ["string", "null"], "format": "date-time", "description": "When the sentences were written (the latest fetch). Null before the first fetch."}}}, "CompetitorInsightPart": {"type": "object", "description": "One sentence of a competitor summary: its English text, the code of the template it was written from and the values it prints. Every sentence is written from a template, so every entry has a code.", "required": ["text", "code", "params"], "properties": {"text": {"type": "string", "description": "The sentence in English, as `insights` carries it."}, "code": {"type": "string", "description": "Stable identifier of the template, an open set so that a client built against this version still parses a response that carries a code added later. Today: `competitor.store_rating`, the store's published rating across its rating count; `competitor.sample_rating`, the mean of the fetched reviews, when the store listed no rating; `competitor.low_star_share`, the share of the fetched reviews rated 1 or 2 stars; `competitor.no_low_star_reviews`, none of the fetched reviews was rated 1 or 2 stars. Show `text` for a code you do not know."}, "params": {"type": "object", "additionalProperties": true, "description": "The values the sentence prints: `rating` (the average, unrounded; the text prints two decimals), `count` (the store's rating count for `competitor.store_rating`, else the number of fetched reviews the sentence is about) and `percent` (a whole number of percent, for `competitor.low_star_share`)."}}}, "CompetitorRatingHistory": {"type": "object", "properties": {"rating_status": {"type": "string", "enum": ["available", "unavailable"], "description": "Whether aggregate rating data can be used. Apple public aggregates are unavailable pending source permission."}, "rating_unavailable_reason": {"type": ["string", "null"], "description": "source_permission_unverified for Apple public rating aggregates."}, "competitor_id": {"type": "string", "description": "Competitor ID."}, "history": {"type": "array", "items": {"type": "object", "properties": {"date": {"type": "string", "format": "date"}, "rating": {"type": "number", "format": "float"}, "review_count": {"type": "integer"}, "rating_source": {"oneOf": [{"type": "string", "enum": ["store", "fetched_reviews"]}, {"type": "null"}], "description": "Explicit source of this point's average_rating: the listing's published value or the mean of fetched reviews. Null when unavailable or not recorded on the historical row."}, "review_count_source": {"oneOf": [{"type": "string", "enum": ["store", "fetched_reviews"]}, {"type": "null"}], "description": "Explicit source of this point's total_reviews: the listing's published count or the fetched review-set size. Independent of rating_source. Null when unavailable or not recorded; a fetched-set size is not total-count growth."}}}, "description": "Daily rating snapshots."}, "period_days": {"type": "integer", "description": "Days of history returned."}}}, "CompetitiveComparison": {"type": "object", "description": "Headline metrics for each competitor tracked by the tenant. Metric fields are null for a competitor that has not been synced yet.", "properties": {"comparisons": {"type": "array", "items": {"type": "object", "properties": {"competitor_id": {"type": "string", "description": "Identifier in the form `platform_externalAppId`."}, "name": {"type": ["string", "null"]}, "platform": {"type": "string"}, "average_rating": {"type": ["number", "null"], "format": "float"}, "total_reviews": {"type": ["integer", "null"]}, "rating_source": {"$ref": "#/components/schemas/CompetitorMetricSource"}, "review_count_source": {"$ref": "#/components/schemas/CompetitorMetricSource"}, "last_synced_at": {"type": ["string", "null"], "format": "date-time"}}}}, "count": {"type": "integer"}}}, "PaymentMethod": {"type": "object", "properties": {"id": {"type": "string", "description": "Payment method ID."}, "brand": {"type": "string", "description": "Card brand (visa, mastercard, etc.).", "example": "visa"}, "last4": {"type": "string", "description": "Last 4 digits of card.", "example": "4242"}, "exp_month": {"type": "integer", "description": "Expiration month.", "example": 12}, "exp_year": {"type": "integer", "description": "Expiration year.", "example": 2025}, "is_default": {"type": "boolean", "description": "Whether this is the default payment method."}}}, "BillingPlan": {"type": "object", "required": ["id"], "properties": {"id": {"type": "string", "description": "Plan identifier.", "example": "per_app"}, "name": {"type": "string", "description": "Plan name.", "example": "Per App"}, "description": {"type": "string", "description": "Plan description."}, "pricing": {"type": "object", "required": ["monthly", "annual"], "properties": {"monthly": {"type": "object", "required": ["per_app", "per_additional_member"], "properties": {"per_app": {"type": "number", "format": "float", "example": 19.99}, "per_additional_member": {"type": "number", "format": "float", "example": 9.99}}}, "annual": {"type": "object", "required": ["per_app", "per_additional_member", "discount_percentage"], "properties": {"per_app": {"type": "number", "format": "float"}, "per_additional_member": {"type": "number", "format": "float"}, "discount_percentage": {"type": "integer", "example": 10}}}}}, "included_members": {"type": "integer", "example": 1, "description": "Team members included per workspace, regardless of the number of connected apps."}, "included_per_app": {"type": "object", "required": ["members", "ai_analyses"], "properties": {"members": {"type": "integer", "example": 1, "deprecated": true, "description": "Legacy alias of included_members. This is per workspace, not per app; use the plan-level included_members field. Retained for compatibility with existing clients."}, "ai_analyses": {"type": "integer", "example": 5000}}}, "features": {"type": "array", "items": {"type": "string"}, "description": "List of included features."}}}, "PromoValidationResult": {"type": "object", "properties": {"valid": {"type": "boolean", "description": "Whether the code is valid."}, "code": {"type": "string", "description": "The validated code."}, "promo_id": {"type": "string", "description": "Stripe promotion code ID."}, "coupon_id": {"type": "string", "description": "Stripe coupon ID."}, "discount": {"type": "object", "properties": {"type": {"type": "string", "enum": ["percent", "amount"], "description": "Discount type."}, "value": {"type": "number", "description": "Discount value (percentage or amount)."}, "currency": {"type": "string", "description": "Currency for amount discounts."}, "description": {"type": "string", "description": "Human-readable description.", "example": "20% off"}}}, "duration": {"type": "string", "enum": ["once", "repeating", "forever"], "description": "How long the discount applies."}, "duration_in_months": {"type": ["integer", "null"], "description": "Number of months for repeating discounts."}, "message": {"type": "string", "description": "Error message if invalid."}}}, "BillingCycleChangePreview": {"type": "object", "description": "What Stripe invoices for a change of billing cycle, from its preview of exactly that change. Amounts are in `currency`.", "properties": {"billing_cycle": {"type": "string", "enum": ["monthly", "annual"]}, "charged_at_trial_end": {"type": "boolean", "description": "True for a trialing subscription: nothing is charged now and `next_renewal_amount` is the trial's first invoice."}, "new_period_charge": {"type": "number", "description": "The price of the new period that starts at the change."}, "unused_time_credit": {"type": "number", "description": "Credit for the unused time of the period being left."}, "credit_balance_applied": {"type": "number", "description": "Credit already on the account that goes towards this charge."}, "balance_owed_added": {"type": "number", "description": "An amount the customer owes from earlier invoices, which Stripe adds to this charge (for a trialing subscription, to the trial's first invoice). 0 when there is none."}, "amount_due_today": {"type": "number", "description": "What the change is invoiced today: the new period, less the credit for unused time and credit on the account, plus `earlier_changes` and `balance_owed_added`. The card is charged this amount, except the part in `added_to_next_invoice`. Not present on `charge`, which carries `amount_charged` instead."}, "added_to_next_invoice": {"type": "number", "description": "The part of `amount_due_today` that is not taken today because it is under the payment provider's minimum charge (0.50 in USD): the whole amount, or 0. It is added to the next invoice. On `charge`, what the invoice of the change left owed."}, "amount_charged": {"type": ["number", "null"], "description": "What the invoice of the applied change was paid (on `charge` only)."}, "credit_balance_after": {"type": "number", "description": "Credit left on the account, applied to the following invoices."}, "earlier_changes": {"type": "number", "description": "Prorations an earlier change of apps or members left for the next invoice. They are invoiced with this change and are part of `amount_due_today`. 0 when there are none."}, "next_renewal_date": {"type": ["string", "null"], "format": "date-time"}, "next_renewal_amount": {"type": "number", "description": "The recurring price of the new plan. For a trialing subscription, what its first invoice charges: that price less any credit on the account."}, "invoice_number": {"type": ["string", "null"]}, "invoice_url": {"type": ["string", "null"]}, "currency": {"type": "string"}}}, "UpcomingInvoice": {"type": "object", "properties": {"amount_due": {"type": "integer", "description": "Amount due in cents."}, "currency": {"type": "string", "description": "Currency code.", "example": "usd"}, "period_start": {"type": "string", "format": "date-time", "description": "Start of the invoice's own period. For a subscription renewal this looks back one period (Sep 17 for the charge on Oct 17); the period the charge pays for is service_period_start."}, "period_end": {"type": "string", "format": "date-time", "description": "End of the invoice's own period: for a subscription renewal, when the invoice is created and charged."}, "service_period_start": {"type": ["string", "null"], "format": "date-time", "description": "Start of the period the charge pays for (the service period of the invoice's largest charged line): Oct 17 for the renewal charged on Oct 17."}, "service_period_end": {"type": ["string", "null"], "format": "date-time", "description": "End of the period the charge pays for (Nov 17 for the renewal charged on Oct 17)."}, "next_payment_attempt": {"type": "string", "format": "date-time", "description": "When payment will be attempted."}, "credit_balance": {"type": "number", "description": "Credit Stripe holds for the customer, in the invoice currency: what is left of a year after a switch to monthly billing. It is applied to this and the following invoices before the card is charged; `amount_due` is already net of it. 0 when there is none."}, "lines": {"type": "array", "items": {"type": "object", "properties": {"description": {"type": "string"}, "amount": {"type": "integer"}, "quantity": {"type": "integer"}}}, "description": "Invoice line items."}, "subtotal": {"type": "integer", "description": "Subtotal in cents."}, "tax": {"type": "integer", "description": "Tax amount in cents."}, "total": {"type": "integer", "description": "Total in cents."}, "discount": {"type": ["object", "null"], "properties": {"amount": {"type": "integer"}, "description": {"type": "string"}}, "description": "Applied discount if any."}}}, "BillingUsage": {"type": "object", "description": "Envelope returned by `GET /api/v1/billing/usage`.", "properties": {"usage": {"type": "object", "properties": {"apps": {"type": "integer"}, "reviews": {"type": "integer"}, "ai_analyses": {"type": "integer", "description": "AI metered units recorded in the current UTC calendar month."}, "members": {"type": "integer"}, "limits": {"type": "object", "description": "AI entitlements and overage amounts for the current UTC calendar month, including `included_ai_analyses`, `ai_overage`, `ai_usage_unit` and its human-readable description. If `ai_allowance_available` is false, the allowance and overage fields are null because the live connected-listing count could not be confirmed. A confirmed zero count means a zero included allowance and a numeric overage based on all recorded usage. `ai_overage_cost` is what the overage is billed: 0 while overage billing is off, and 0 for a workspace the monthly overage run does not invoice (it invoices active subscriptions only); `potential_ai_overage_cost` is what it would cost.", "properties": {"ai_allowance_available": {"type": "boolean"}, "included_ai_analyses": {"type": ["integer", "null"]}, "ai_overage": {"type": ["integer", "null"]}, "ai_usage_unit": {"type": "string"}, "ai_usage_unit_description": {"type": "string"}, "ai_overage_billing_enabled": {"type": "boolean"}, "ai_overage_cost": {"type": ["number", "null"]}, "potential_ai_overage_cost": {"type": ["number", "null"]}}, "additionalProperties": true}, "period_start": {"type": "string", "format": "date-time"}, "period_end": {"type": "string", "format": "date-time"}}}}}, "CreateCategoryRequest": {"type": "object", "required": ["name", "description"], "description": "`name` and `description` are stored without their leading and trailing spaces, and the length limits apply to what is stored: a value of spaces alone is refused.", "properties": {"name": {"type": "string", "minLength": 1, "maxLength": 100, "description": "Category name.", "example": "Pricing Complaints"}, "description": {"type": "string", "minLength": 1, "maxLength": 500, "description": "Description of what this category captures.", "example": "Reviews mentioning pricing issues, subscription costs, or value concerns"}, "example_phrases": {"type": "array", "maxItems": 20, "items": {"type": "string"}, "description": "Example phrases that match this category. May be empty or left out.", "example": ["too expensive", "not worth the price", "subscription cost"]}, "alert_on_spike": {"type": "boolean", "default": true, "description": "Whether to alert when mentions spike."}, "spike_threshold_pct": {"type": "integer", "minimum": 10, "maximum": 1000, "default": 100, "description": "Percentage increase that triggers spike alert."}}}, "UpdateCategoryRequest": {"type": "object", "description": "Only the fields sent change. `name` and `description` are stored without their leading and trailing spaces, as on create.", "properties": {"name": {"type": "string", "minLength": 1, "maxLength": 100, "description": "New category name."}, "description": {"type": "string", "minLength": 1, "maxLength": 500, "description": "New description."}, "example_phrases": {"type": "array", "maxItems": 20, "items": {"type": "string"}, "description": "New example phrases, replacing the stored list. An empty list removes them all."}, "enabled": {"type": "boolean", "description": "Enable or disable the category."}, "alert_on_spike": {"type": "boolean", "description": "Enable or disable spike alerts."}, "spike_threshold_pct": {"type": "integer", "minimum": 10, "maximum": 1000, "description": "New spike threshold percentage."}}}, "Category": {"type": "object", "required": ["category_id", "tenant_id", "name", "description", "example_phrases", "enabled", "alert_on_spike", "spike_threshold_pct", "mention_count", "created_at", "updated_at"], "properties": {"category_id": {"type": "string", "description": "Unique category identifier."}, "tenant_id": {"type": "string", "description": "Owner tenant ID."}, "name": {"type": "string", "description": "Category name."}, "description": {"type": "string", "description": "Category description."}, "example_phrases": {"type": "array", "items": {"type": "string"}, "description": "Example phrases for AI matching. Empty when there are none."}, "enabled": {"type": "boolean", "description": "Whether category is active."}, "alert_on_spike": {"type": "boolean", "description": "Whether spike alerts are enabled."}, "spike_threshold_pct": {"type": "integer", "description": "Spike alert threshold percentage."}, "mention_count": {"type": "integer", "minimum": 0, "description": "Mentions of this category in the latest analysis that a `custom_category_spike` alert rule evaluated: the baseline the next evaluation compares against. Not a running total. It stays 0 until such a rule has counted the category, which needs the category's spike alerts on."}, "created_at": {"type": "string", "format": "date-time"}, "updated_at": {"type": "string", "format": "date-time", "description": "Last change, including a stored `mention_count`."}, "mention_snapshot_at": {"type": "string", "format": "date-time", "description": "When the analysis behind `mention_count` was generated. Present only once a `custom_category_spike` alert rule has stored a count."}}}, "AppCheckResponse": {"type": "object", "required": ["exists"], "description": "Result of an app-existence check. When `exists` is false the body carries `exists` alone; the remaining fields are present only on a match.", "properties": {"exists": {"type": "boolean", "description": "Whether this tenant already has the app connected."}, "app_uuid": {"type": "string", "description": "App UUID. Present only when `exists` is true."}, "app_name": {"type": "string", "description": "Stored app name. Present only when `exists` is true."}, "status": {"type": "string", "description": "Connection status of the app. Present only when `exists` is true."}, "review_count": {"type": "integer", "description": "Reviews already synced for the app. Present only when `exists` is true. Shown by the reconnect dialog so the user can see what reusing the existing data would give them."}, "last_sync": {"type": "string", "format": "date-time", "description": "When the app last completed a sync, or null if it never has. Present only when `exists` is true."}, "is_fresh": {"type": "boolean", "description": "Whether `last_sync` is within the last 24 hours. Fails closed: a missing or unparseable timestamp reports false. Present only when `exists` is true."}, "has_insights": {"type": "boolean", "description": "Whether analysis has completed AND the app has at least one review, i.e. there are insights worth reusing. Present only when `exists` is true."}}}, "ConnectAppAsyncRequest": {"type": "object", "required": ["platform", "app_id", "app_name"], "properties": {"platform": {"type": "string", "enum": ["app_store", "google_play", "steam"], "description": "App platform."}, "app_id": {"type": "string", "description": "Platform-specific app identifier."}, "app_name": {"type": "string", "description": "Display name for the app."}, "credential_id": {"type": "string", "description": "Optional credential ID for authenticated access."}, "platform_metadata": {"type": "object", "description": "Platform-specific metadata."}, "auto_sync_enabled": {"type": "boolean", "default": true, "description": "Enable automatic review syncing."}, "auto_sync_frequency": {"type": "string", "enum": ["hourly", "daily", "weekly"], "default": "daily", "description": "How often to sync reviews."}}}, "AppCreateResponse": {"type": "object", "required": ["app", "status", "message"], "properties": {"app": {"$ref": "#/components/schemas/AppSummary"}, "status": {"type": "string", "enum": ["created"]}, "message": {"type": "string"}}}, "AppReactivationResponse": {"type": "object", "required": ["app_uuid", "status", "message", "review_count", "has_insights"], "properties": {"app_uuid": {"type": "string", "format": "uuid"}, "status": {"type": "string", "enum": ["reactivated"]}, "message": {"type": "string"}, "review_count": {"type": "integer", "minimum": 0}, "has_insights": {"type": "boolean"}}}, "ConnectAppAsyncResponse": {"type": "object", "properties": {"success": {"type": "boolean"}, "app_uuid": {"type": "string", "description": "UUID of the created/reactivated app."}, "app": {"type": "object", "description": "App details."}, "sync_status": {"type": "string", "description": "Current sync status (initializing, syncing, etc.)."}, "message": {"type": "string"}, "status": {"type": "string", "description": "Current app sync status, including syncing, complete, idle, or error."}, "already_connected": {"type": "boolean", "description": "Present when the app was already connected and needs no new sync."}}}, "AutoSyncSettingsRequest": {"type": "object", "properties": {"enabled": {"type": "boolean", "description": "Enable or disable auto-sync globally."}, "frequency": {"type": "string", "enum": ["hourly", "daily", "weekly"], "description": "Default sync frequency for all apps."}}}, "SentimentTrendsResponse": {"type": "object", "description": "Body of GET /api/v1/predictions/sentiment-trends. `current_sentiment` is the measured split the forecast starts from: the newest reviews when enough of them carry a sentiment label (`current_sentiment_source` `labelled_reviews`), otherwise the app's latest finished AI analysis from the last 14 days (`analysis`, measured at `current_sentiment_measured_at`), the split the Insights page shows. It is absent when neither is available, and `predicted_sentiment` only when a forecast was produced; neither is ever a placeholder. `anomalies_detected` is present only when both exist and shifts were assessed. When `insufficient_data` is true the app has fewer than the minimum reviews required to forecast and there is no predicted split.", "properties": {"app_uuid": {"type": "string"}, "platform": {"type": "string"}, "forecast_horizon_days": {"type": "integer", "description": "The horizon `predicted_sentiment` is for. The requested `horizon_days`, except when the app has no measured `current_sentiment` to read a shorter horizon from: the estimate is then the one made for 90 days, and this says 90. Title the estimate from this field, not from the request."}, "generated_at": {"type": "string", "format": "date-time"}, "model_version": {"type": "string"}, "current_sentiment": {"$ref": "#/components/schemas/SentimentDistribution"}, "current_sentiment_source": {"type": "string", "enum": ["labelled_reviews", "analysis"], "description": "Where `current_sentiment` was measured. Present exactly when `current_sentiment` is."}, "current_sentiment_measured_at": {"type": "string", "format": "date-time", "description": "When the analysis behind `current_sentiment` ran. Present when `current_sentiment_source` is `analysis`."}, "predicted_sentiment": {"$ref": "#/components/schemas/SentimentDistribution"}, "sentiment_shift": {"type": "string", "enum": ["improving", "declining", "stable"], "description": "Direction of the predicted shift. An unknown value from the model becomes stable."}, "dominant_themes": {"type": "array", "items": {"$ref": "#/components/schemas/SentimentForecastTheme"}}, "emerging_concerns": {"type": "array", "description": "The English names of the themes in `emerging_concern_themes`, in the same order, written by code.", "items": {"type": "string"}}, "positive_drivers": {"type": "array", "description": "The English names of the themes in `positive_driver_themes`, in the same order, written by code.", "items": {"type": "string"}}, "emerging_concern_themes": {"type": "array", "description": "Themes expected to reduce sentiment, ordered by supporting review_count from highest to lowest. Returns at most six.", "items": {"$ref": "#/components/schemas/ForecastThemeCount"}}, "positive_driver_themes": {"type": "array", "description": "Themes expected to improve sentiment, ordered by supporting review_count from highest to lowest. Returns at most six.", "items": {"$ref": "#/components/schemas/ForecastThemeCount"}}, "forecast_reasoning": {"type": "string", "description": "English summary computed from sentiment_shift, emerging_concern_themes and positive_driver_themes. Figures reflect measured counts. For insufficient_data, explains why a forecast is unavailable."}, "anomalies_detected": {"type": "object", "additionalProperties": true}, "confidence": {"type": "object", "additionalProperties": true}, "data_summary": {"type": "object", "additionalProperties": true, "description": "`reviews_analyzed` (every review loaded, up to 500), `model_input_reviews` (how many of the newest the model read: the `sample_size` of every theme) and `time_span_days`."}, "insufficient_data": {"type": "boolean", "description": "True when the app has too few reviews to forecast."}, "cache_hit": {"type": "boolean"}}}, "SentimentDistribution": {"type": "object", "description": "Sentiment split, as percentages summing to about 100.", "properties": {"positive_percent": {"type": "number"}, "negative_percent": {"type": "number"}, "neutral_percent": {"type": "number"}, "mixed_percent": {"type": "number"}}}, "AccuracyMetricsDict": {"type": "object", "description": "Descriptive checks on loaded resolved records with submitted outcomes, not verified forecast accuracy. Missing stored errors count as zero; zero error metrics do not establish complete observations or zero actual error. Records can mix targets, units and horizons, and unpaginated queries can omit records. `directional_accuracy` and `within_confidence_interval` are fractions in [0,1], not percentages.", "properties": {"mae": {"type": "number", "description": "Recorded mean absolute error (unverified); missing absolute errors count as zero."}, "mse": {"type": "number", "description": "Recorded mean squared error (unverified); missing errors count as zero."}, "rmse": {"type": "number", "description": "Square root of the recorded mean squared error, with the same missing-error and sample limits."}, "mape": {"type": "number", "description": "Mean recorded percentage error (unverified). Each error divides by the signed submitted value; zero values contribute zero and negative values can contribute negative errors. This does not establish valid percentage accuracy."}, "directional_accuracy": {"type": "number", "description": "Fraction of loaded resolved records flagged for matching signs of raw predicted and submitted values. This is not trend-direction accuracy; positive rating levels can match without predicting movement."}, "within_confidence_interval": {"type": "number", "description": "Fraction of loaded resolved records flagged inside their stored model ranges. Outcomes are submitted and ranges are uncalibrated; this is not calibrated confidence coverage or verified forecast accuracy."}, "sample_size": {"type": "integer", "description": "Number of loaded resolved records, including records with missing error fields. This is not the count issued or a complete denominator of pending, missing, valid or excluded predictions."}}}, "AccuracyReportResponse": {"type": "object", "description": "Returned unwrapped: this object is the whole response body, not nested under `data`. It describes loaded resolved records with submitted, unverified outcomes, not validated future store-rating accuracy. Queries use a creation-time window and are not paginated, so the sample may be incomplete. Issued, pending, missing, valid and excluded counts are not supplied. Pooled types, units and horizons, missing errors counted as zero, raw-value sign flags and uncalibrated model ranges limit the metrics and derived trends and recommendations.", "properties": {"generated_at": {"type": "string", "format": "date-time"}, "lookback_days": {"type": "integer"}, "app_uuid": {"type": "string"}, "overall": {"description": "Null until at least ten resolved records are loaded. Otherwise pools the loaded types, units and horizons; this is not a verified overall forecast evaluation.", "oneOf": [{"$ref": "#/components/schemas/AccuracyMetricsDict"}, {"type": "null"}]}, "by_type": {"type": "object", "description": "Keyed by prediction type: rating_forecast, sentiment_forecast, issue_severity, feature_impact. Each entry can still mix horizons and has the same incomplete-sample and submitted-outcome limits.", "additionalProperties": {"$ref": "#/components/schemas/AccuracyMetricsDict"}}, "trends": {"type": "array", "description": "Descriptive changes in the same unverified recorded metrics; not measured forecast improvement.", "items": {"type": "object"}}, "recommendations": {"type": "array", "description": "Heuristics derived from the same unverified recorded metrics; not validated model-quality findings.", "items": {"type": "object"}}}}, "AccuracyMetricsResponse": {"type": "object", "description": "Recorded outcome checks for a loaded resolved subset, not verified forecast accuracy. Submitted outcomes lack independent provenance verification; unpaginated queries can omit records and mix horizons or types. Missing errors can count as zero, so a returned zero does not establish zero actual error. Raw sign flags do not measure trend accuracy, and stored range flags do not establish calibrated coverage.", "properties": {"success": {"type": "boolean"}, "data": {"type": ["object", "null"], "properties": {"prediction_type": {"type": "string"}, "sample_size": {"type": "integer", "description": "Loaded resolved record count, including missing error fields; not a complete issued or valid-outcome denominator."}, "accuracy": {"type": "number"}, "mean_absolute_error": {"type": "number", "description": "Recorded mean absolute error (unverified); missing stored errors can count as zero and targets or units may differ."}, "mean_squared_error": {"type": "number", "description": "Recorded mean squared error (unverified), with the same missing-error and sample limits."}, "within_confidence_interval": {"type": "number", "description": "Fraction in [0,1] of loaded resolved records flagged inside stored uncalibrated model ranges, not a percentage or calibrated confidence coverage. Submitted outcomes are not independently verified."}}}, "message": {"type": "string", "description": "Message if insufficient data."}}}, "RecordOutcomeRequest": {"type": "object", "description": "A submitted value for recorded outcome checks. The service does not independently verify its source, territory, units or exact target and horizon; submission does not establish validated store-rating accuracy.", "required": ["app_uuid", "prediction_type", "actual_value"], "properties": {"app_uuid": {"type": "string", "description": "App UUID."}, "prediction_type": {"type": "string", "description": "Type of prediction (rating_forecast, sentiment_trends, etc.)."}, "actual_value": {"type": "number", "description": "Submitted numeric outcome; source provenance and correspondence to the predicted target are not independently verified."}, "outcome_date": {"type": "string", "format": "date", "description": "Submitted date used to select overdue pending records (defaults to today), not proof of an exactly matched observation horizon."}}}, "AnalyticsEventsRequest": {"type": "object", "description": "A batch of analytics events. `events` may be omitted, in which case the\nrequest body is treated as a single event with the same shape as one\n`events` item.\n", "properties": {"events": {"type": "array", "maxItems": 50, "items": {"type": "object", "required": ["name"], "properties": {"name": {"type": "string", "description": "The event name. Rejected unless it is one of the recognised\nanalytics events: `page_view`, `user_action`, `navigation`,\n`search`, `filter`, `error`, `performance`, or a name\nbeginning with `feature_`, `funnel_`, `page_view_`,\n`user_action_`, `app_connector_` or `competitor_suggestions_`.\n"}, "properties": {"type": "object", "description": "Event-specific properties. At most 20 keys; keys are limited\nto 64 characters of `[A-Za-z0-9_.-]` and may not be one of the\nreserved storage keys (`pk`, `sk`, `gsi1pk`, \u2026).\n"}, "timestamp": {"type": "string", "format": "date-time", "description": "Defaults to the time the event is received."}}}}, "session": {"type": "object", "description": "Session data. Accepted for forward compatibility and currently\ndiscarded; the service records per-event context only.\n", "properties": {"session_id": {"type": "string"}, "started_at": {"type": "string", "format": "date-time"}}}, "engagement": {"type": "object", "description": "Engagement metrics. Accepted for forward compatibility and\ncurrently discarded; the service records per-event context only.\n", "properties": {"time_on_page": {"type": "integer"}, "scroll_depth": {"type": "number"}}}}}, "MyReviewsResponse": {"type": "object", "properties": {"reviews": {"type": "array", "items": {"$ref": "#/components/schemas/AssignedReview"}}, "count": {"type": "integer", "description": "Number of reviews in `reviews`."}}}, "AssignedReview": {"type": "object", "properties": {"review_id": {"type": "string"}, "app_uuid": {"type": "string"}, "app_name": {"type": "string"}, "platform": {"type": "string"}, "author": {"type": "string"}, "rating": {"type": "integer"}, "title": {"type": "string"}, "content": {"type": "string"}, "sentiment": {"oneOf": [{"type": "string", "enum": ["positive", "negative", "neutral", "mixed"]}, {"type": "null"}], "description": "The sentiment label stored on the review by an analysis that read it, or `null` when the review stores none, which is the usual case. Unlike `ReviewSummary.sentiment` it never falls back to the star rating; `rating` is beside it."}, "sentiment_source": {"oneOf": [{"type": "string", "enum": ["analysis"]}, {"type": "null"}], "description": "Where `sentiment` came from, with the values `ReviewSummary` uses: `analysis` for a label stored on the review, `null` exactly when `sentiment` is `null`."}, "assigned_at": {"type": "string", "format": "date-time"}, "due_at": {"type": "string", "format": "date-time"}, "status": {"type": "string", "enum": ["pending", "in_progress", "completed"]}}}, "PostReplyRequest": {"type": "object", "required": ["review_id", "reply_text"], "properties": {"review_id": {"type": "string", "description": "ID of the review to reply to."}, "reply_text": {"type": "string", "description": "The reply content."}, "app_uuid": {"type": "string", "description": "App UUID for the review."}}}, "PredictMetadataRequest": {"type": "object", "required": ["reviews"], "properties": {"reviews": {"type": "array", "items": {"type": "object", "properties": {"review_id": {"type": "string"}, "content": {"type": "string"}}}, "description": "Reviews to analyze."}}}, "PredictRepliesRequest": {"type": "object", "required": ["reviews"], "properties": {"reviews": {"type": "array", "minItems": 1, "maxItems": 1, "items": {"type": "object", "properties": {"review_id": {"type": "string"}, "content": {"type": "string"}, "rating": {"type": "integer"}}}, "description": "Reviews to generate replies for."}, "tone": {"type": "string", "enum": ["professional", "friendly", "empathetic"], "default": "professional"}, "max_length": {"type": "integer", "default": 500, "description": "Maximum reply length in characters."}}}, "PredictRepliesResponse": {"type": "object", "properties": {"replies": {"type": "array", "items": {"type": "object", "properties": {"review_id": {"type": "string"}, "suggested_reply": {"type": "string"}, "alternative_replies": {"type": "array", "items": {"type": "string"}}, "confidence": {"type": "number"}}}}}}, "MfaDisableRequest": {"type": "object", "required": ["code"], "properties": {"code": {"type": "string", "pattern": "^\\d{6}$", "description": "Current 6-digit MFA code to confirm disabling.", "example": "123456"}}}, "RefreshTokenRequest": {"type": "object", "required": ["refresh_token"], "properties": {"refresh_token": {"type": "string", "description": "The refresh token from login."}}}, "RefreshTokenResponse": {"type": "object", "description": "New access and ID tokens. No refresh token is sent: keep using the one this request sent.", "required": ["access_token", "id_token", "token_type", "expires_in"], "properties": {"access_token": {"type": "string", "description": "New JWT access token."}, "id_token": {"type": "string", "description": "New JWT ID token."}, "token_type": {"type": "string", "example": "Bearer"}, "expires_in": {"type": "integer", "description": "Token lifetime in seconds.", "example": 3600}}}, "SsoCallbackRequest": {"type": "object", "required": ["code", "redirect_uri", "state"], "properties": {"code": {"type": "string", "description": "Authorization code from Cognito Hosted UI."}, "redirect_uri": {"type": "string", "format": "uri", "description": "Redirect URI used in the authorization request."}, "state": {"type": "string", "description": "Signed state token for CSRF protection."}, "provider": {"type": "string", "description": "Identity provider name (Google, Microsoft)."}}}, "SsoCallbackResponse": {"type": "object", "properties": {"success": {"type": "boolean"}, "message": {"type": "string"}, "data": {"type": "object", "properties": {"requires_login": {"type": "boolean", "description": "True when an invitation was accepted. No tokens are returned; sign in again."}, "user": {"type": "object", "properties": {"user_id": {"type": "string"}, "email": {"type": "string"}, "name": {"type": "string"}, "tenant_id": {"type": "string"}, "company_name": {"type": "string"}, "role": {"type": "string"}, "email_verified": {"type": "boolean"}}}, "tokens": {"type": "object", "properties": {"access_token": {"type": "string"}, "id_token": {"type": "string"}, "refresh_token": {"type": "string"}, "token_type": {"type": "string"}, "expires_in": {"type": "integer"}}}, "state": {"type": "string"}, "provider": {"type": "string"}}}}}, "ProfilePictureUploadRequest": {"type": "object", "properties": {"content_type": {"type": "string", "enum": ["image/jpeg", "image/png", "image/webp"], "default": "image/jpeg", "description": "MIME type of the image to upload.", "example": "image/jpeg"}}}, "ProfilePictureUploadResponse": {"type": "object", "description": "Every member is always sent.", "required": ["upload_url", "upload_fields", "picture_url", "max_size_bytes", "expires_in"], "properties": {"upload_url": {"type": "string", "format": "uri", "description": "Presigned S3 POST URL for uploading the image."}, "upload_fields": {"type": "object", "additionalProperties": {"type": "string"}, "description": "Form fields to include in the multipart POST upload."}, "picture_url": {"type": "string", "format": "uri", "description": "Public URL of the uploaded image after upload completes."}, "max_size_bytes": {"type": "integer", "description": "Maximum allowed file size in bytes.", "example": 5242880}, "expires_in": {"type": "integer", "description": "Seconds until the presigned URL expires.", "example": 300}}}, "DigestPreferencesRequest": {"type": "object", "description": "All fields are optional; omitted fields retain their current value.", "properties": {"enabled": {"type": "boolean", "description": "Whether digest emails are enabled."}, "frequency": {"type": "string", "enum": ["daily", "weekly", "biweekly", "monthly", "never"], "description": "How often to send digest emails."}, "day_of_week": {"type": "string", "enum": ["monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday"], "description": "Day of week for weekly/biweekly digests."}, "include_ratings": {"type": "boolean", "description": "Include ratings section in digest."}, "include_sentiment": {"type": "boolean", "description": "Include sentiment breakdown."}, "include_alerts": {"type": "boolean", "description": "Include alerts section in digest."}}}, "DigestPreferencesResponse": {"type": "object", "properties": {"digest_preferences": {"type": "object", "properties": {"enabled": {"type": "boolean"}, "frequency": {"type": "string"}, "day_of_week": {"type": "string"}, "include_ratings": {"type": "boolean"}, "include_sentiment": {"type": "boolean"}, "include_alerts": {"type": "boolean"}}}}}, "DigestTestSendResponse": {"type": "object", "required": ["status", "email"], "properties": {"status": {"type": "string", "enum": ["queued"], "description": "The test digest has been handed to the digest worker."}, "email": {"type": "string", "format": "email", "description": "The address the test digest will arrive at (the signed-in user's own)."}}}, "EmailPreferencesRequest": {"type": "object", "required": ["product_emails"], "properties": {"product_emails": {"type": "boolean", "description": "Receive non-transactional product emails (milestones, celebrations). Transactional email is always sent."}}}, "EmailPreferencesResponse": {"type": "object", "properties": {"email_preferences": {"type": "object", "properties": {"product_emails": {"type": "boolean", "description": "Whether non-transactional product emails are sent to this user."}}}}}, "TeamInviteRequest": {"type": "object", "required": ["email"], "properties": {"email": {"type": "string", "format": "email", "description": "Email address of the person to invite."}, "role": {"type": "string", "enum": ["member", "admin"], "default": "member", "description": "Role to assign to the new team member. Ownership is not granted by invitation; the API rejects `owner`."}, "name": {"type": "string", "maxLength": 100, "description": "Optional display name for the person being invited. It is stored on the pending invitation and used to greet them in the invitation email; they choose their own name when they accept."}}}, "TeamInviteResponse": {"type": "object", "properties": {"invitation": {"type": "object", "properties": {"invitation_id": {"type": "string", "description": "Pending-member id; also the `?invitation=` value in the acceptance link."}, "email": {"type": "string"}, "name": {"type": ["string", "null"]}, "role": {"type": "string", "enum": ["member", "admin"]}, "status": {"type": "string", "enum": ["pending"]}, "invited_by": {"type": ["string", "null"]}, "email_sent": {"type": "boolean", "description": "False when the invitation row was created but the email could not be delivered to SES; the inviter can use resend-invite."}, "created_at": {"type": "string", "format": "date-time"}}}}}, "TeamMemberDeleteResponse": {"type": "object", "required": ["success"], "properties": {"success": {"type": "boolean", "const": true}}}, "TeamMemberUpdateResponse": {"type": "object", "required": ["member"], "properties": {"member": {"$ref": "#/components/schemas/TeamMember"}}}, "TeamInviteResendResponse": {"type": "object", "required": ["message"], "properties": {"message": {"type": "string"}}}, "UpdateTeamMemberRequest": {"type": "object", "required": ["role"], "properties": {"role": {"type": "string", "enum": ["member", "admin"], "description": "New role for the team member."}}}, "ContactChatRequest": {"type": "object", "required": ["message", "email", "verification_token"], "properties": {"message": {"type": "string", "minLength": 1, "maxLength": 2000, "description": "User's chat message."}, "conversation_history": {"type": "array", "items": {"$ref": "#/components/schemas/ChatMessage"}, "description": "Previous messages in the conversation."}, "session_id": {"type": "string", "pattern": "^[a-zA-Z0-9_-]{1,64}$", "maxLength": 64, "description": "Session ID for conversation continuity."}, "email": {"type": "string", "format": "email", "description": "Verified email address for follow-up."}, "verification_token": {"type": "string", "minLength": 32, "maxLength": 256, "pattern": "^[A-Za-z0-9_-]{32,256}$", "description": "Verification token returned by /api/v1/contact/verify-code."}, "turnstile_token": {"type": "string", "maxLength": 2048, "description": "Cloudflare Turnstile token minted in the browser for the `contact_chat` action. Required only while the API enforces Turnstile; otherwise it is ignored or recorded without effect."}}}, "ContactChatResponse": {"type": "object", "properties": {"message": {"type": "string", "description": "AI-generated response."}, "session_id": {"type": "string", "pattern": "^[a-zA-Z0-9_-]{1,64}$", "maxLength": 64, "description": "Session ID for conversation continuity."}, "suggestions": {"type": "array", "items": {"type": "string"}, "description": "Suggested follow-up questions."}}}, "SendVerificationCodeRequest": {"type": "object", "required": ["email"], "properties": {"email": {"type": "string", "format": "email", "description": "Email address to send the verification code to."}, "turnstile_token": {"type": "string", "maxLength": 2048, "description": "Cloudflare Turnstile token minted in the browser for the `contact_send_code` action. Required only while the API enforces Turnstile; otherwise it is ignored or recorded without effect."}}}, "VerifyCodeRequest": {"type": "object", "required": ["email", "code"], "properties": {"email": {"type": "string", "format": "email", "description": "Email address that received the code."}, "code": {"type": "string", "pattern": "^\\d{6}$", "description": "6-digit verification code."}}}, "VerifyCodeResponse": {"type": "object", "properties": {"verified": {"type": "boolean", "description": "Whether the code was valid."}, "token": {"type": "string", "description": "Verification token for subsequent requests."}, "expires_at": {"type": "string", "format": "date-time", "description": "When the verification token expires."}}}, "SupportChatRequest": {"type": "object", "required": ["message"], "properties": {"message": {"type": "string", "minLength": 1, "maxLength": 5000, "description": "User's support message, at most 5,000 characters once surrounding spaces are removed."}, "conversation_history": {"type": "array", "maxItems": 20, "items": {"$ref": "#/components/schemas/ChatMessage"}, "description": "Previous messages in the conversation, at most 20 and 100,000 bytes. The last 10 are given to the model; entries of another role are left out."}, "session_id": {"type": "string", "pattern": "^[a-zA-Z0-9_-]{1,64}$", "maxLength": 64, "description": "Session ID for conversation continuity. Left out, a new session is started and its id is in the answer."}}}, "SupportChatResponse": {"type": "object", "description": "Every member that is not deprecated is always sent.", "properties": {"message": {"type": "string", "deprecated": true, "description": "Deprecated. Never sent: the answer is `response`."}, "session_id": {"type": "string", "pattern": "^[a-zA-Z0-9_-]{1,64}$", "maxLength": 64, "description": "Session ID for conversation continuity. Always sent."}, "escalated": {"type": "boolean", "deprecated": true, "description": "Deprecated. Never sent: the chat does not escalate."}, "ticket_id": {"type": "string", "deprecated": true, "description": "Deprecated. Never sent: the chat opens no ticket."}, "suggestions": {"type": "array", "items": {"type": "string"}, "deprecated": true, "description": "Deprecated. Never sent."}, "response": {"type": "string", "description": "The answer. When the model cannot answer, a fixed message asking to retry or to write to support. Always sent."}}}, "ChatMessage": {"type": "object", "required": ["role", "content"], "properties": {"role": {"type": "string", "enum": ["user", "assistant"], "description": "Message author role."}, "content": {"type": "string", "description": "Message content."}, "timestamp": {"type": "string", "format": "date-time", "description": "When the message was sent."}}}, "ConversationListResponse": {"type": "object", "required": ["conversations", "count", "truncated"], "properties": {"conversations": {"type": "array", "description": "Sessions ordered by most recent message first.", "items": {"type": "object", "required": ["session_id", "message_count", "last_message_at", "first_message"], "properties": {"session_id": {"type": "string"}, "message_count": {"type": "integer", "minimum": 1, "description": "Exchanges (a message and its answer) in the session. Complete unless `truncated` is true."}, "last_message_at": {"type": "string", "format": "date-time"}, "first_message": {"type": "string", "description": "First 100 characters of the opening message."}}}}, "count": {"type": "integer", "description": "Number of sessions returned."}, "truncated": {"type": "boolean", "description": "True when the user has more chat messages than a single request reads, so older sessions may be missing and `message_count` may undercount. False means the list and every count is complete."}}}, "ConversationDetailResponse": {"type": "object", "required": ["session_id", "messages", "count", "truncated"], "properties": {"session_id": {"type": "string"}, "messages": {"type": "array", "description": "Exchanges in chronological order, oldest first. Never empty.", "minItems": 1, "items": {"type": "object", "required": ["user_message", "assistant_response", "created_at"], "properties": {"user_message": {"type": "string"}, "assistant_response": {"type": "string"}, "created_at": {"type": "string", "format": "date-time"}}}}, "count": {"type": "integer", "description": "Number of exchanges returned."}, "truncated": {"type": "boolean", "description": "True when the transcript is longer than one request returns. Only the most recent exchanges are included; earlier ones are omitted."}}}, "KnowledgeBaseArticle": {"type": "object", "description": "One help article. The dashboard opens it by `article_id` and shows it in its own words; `title` and `summary` are the English text.", "required": ["article_id", "title", "summary", "category"], "properties": {"article_id": {"type": "string"}, "title": {"type": "string"}, "summary": {"type": "string"}, "category": {"type": "string"}, "url": {"type": "string", "description": "Sent only for an article kept in the managed article store, which no environment uses today: where it is published, or empty text."}, "priority": {"type": "number", "description": "Sent only with `url`, for the same articles: the order they are listed in, lowest first."}}}, "KnowledgeBaseSearchResult": {"type": "object", "required": ["articles", "count", "total"], "properties": {"articles": {"type": "array", "items": {"$ref": "#/components/schemas/KnowledgeBaseArticle"}}, "count": {"type": "integer", "minimum": 0, "description": "Number of articles in this answer."}, "total": {"type": "integer", "minimum": 0, "description": "Number of articles there are, matching or not."}}}, "Dashboard": {"type": "object", "description": "A custom dashboard. Every member is always sent.", "required": ["dashboard_id", "name", "description", "layout", "widgets", "is_default", "created_at", "updated_at"], "properties": {"dashboard_id": {"type": "string", "format": "uuid"}, "name": {"type": "string"}, "description": {"type": "string", "description": "The description; empty text when none was given."}, "layout": {"oneOf": [{"type": "string"}, {"$ref": "#/components/schemas/DashboardLayout"}], "description": "Grid presentation settings, as they were sent: the declared object or a preset name such as grid. `{}` when none was sent. Individual widget geometry is specified by WidgetPosition."}, "widgets": {"type": "array", "items": {"$ref": "#/components/schemas/DashboardWidget"}, "description": "The widgets, in the order they were sent."}, "is_default": {"type": "boolean", "description": "Whether this is the default dashboard for the tenant. Nothing stops two dashboards from both being the default."}, "created_at": {"type": "string", "format": "date-time"}, "updated_at": {"type": "string", "format": "date-time"}}}, "DashboardList": {"type": "object", "description": "Every custom dashboard of the workspace, in no particular order.", "required": ["dashboards", "count"], "properties": {"dashboards": {"type": "array", "items": {"$ref": "#/components/schemas/Dashboard"}}, "count": {"type": "integer", "minimum": 0, "description": "Number of dashboards returned."}}}, "DashboardDeleted": {"type": "object", "required": ["message"], "properties": {"message": {"type": "string", "description": "`Dashboard deleted successfully`."}}}, "DuplicateDashboardRequest": {"type": "object", "description": "Optional. Without a body, or without `name`, the copy is named \"<original> (copy)\".", "properties": {"name": {"type": "string", "minLength": 1, "maxLength": 100, "description": "The copy's name."}}}, "DashboardLayout": {"type": "object", "properties": {"columns": {"type": "integer", "default": 6, "description": "Number of columns in the grid."}, "row_height": {"type": "integer", "default": 100, "description": "Height of each row in pixels."}, "gap": {"type": "integer", "default": 16, "description": "Gap between widgets in pixels."}}}, "DashboardWidget": {"type": "object", "required": ["widget_id", "position"], "properties": {"widget_id": {"type": "string", "minLength": 1, "description": "Client-generated identifier that must be unique within the dashboard. It need not be a UUID."}, "widget_type": {"type": "string", "description": "Type of widget, one of the `type` values `GET /api/v1/dashboards/widget-types` lists, for example rating_summary. Stored as sent and not checked."}, "position": {"$ref": "#/components/schemas/WidgetPosition"}, "config": {"type": "object", "additionalProperties": true, "description": "Widget-specific configuration, stored as sent and not checked."}}}, "WidgetPosition": {"type": "object", "required": ["x", "y", "w", "h"], "properties": {"x": {"type": "integer", "description": "Column position (0-based)."}, "y": {"type": "integer", "description": "Row position (0-based)."}, "w": {"type": "integer", "description": "Width in grid columns, supplied as w."}, "h": {"type": "integer", "description": "Height in rows. Named `h` for the same reason as `w`."}}}, "WidgetType": {"type": "object", "properties": {"type": {"type": "string", "description": "Widget type identifier."}, "name": {"type": "string", "description": "Display name."}, "description": {"type": "string"}, "icon": {"type": "string", "description": "Icon identifier."}, "category": {"type": "string", "enum": ["metrics", "charts", "lists", "insights", "other"]}, "default_size": {"type": "string", "enum": ["small", "medium", "large", "full"], "description": "Preset widget size the builder applies when the widget is added."}, "min_width": {"type": "integer"}, "min_height": {"type": "integer"}, "max_width": {"type": "integer"}, "max_height": {"type": "integer"}, "configurable_fields": {"type": "array", "items": {"type": "string"}, "description": "Fields that can be configured for this widget."}}}, "CreateDashboardRequest": {"type": "object", "required": ["name"], "properties": {"name": {"type": "string", "minLength": 1, "maxLength": 100, "description": "Dashboard name."}, "description": {"type": "string", "maxLength": 500}, "layout": {"oneOf": [{"type": "string"}, {"$ref": "#/components/schemas/DashboardLayout"}], "description": "Grid presentation settings. Accepts the declared object or a preset name such as grid. Individual widget geometry is specified by WidgetPosition."}, "widgets": {"type": "array", "items": {"$ref": "#/components/schemas/DashboardWidget"}}, "is_default": {"type": "boolean", "default": false}}}, "UpdateDashboardRequest": {"type": "object", "properties": {"name": {"type": "string", "minLength": 1, "maxLength": 100}, "description": {"type": "string", "maxLength": 500}, "layout": {"oneOf": [{"type": "string"}, {"$ref": "#/components/schemas/DashboardLayout"}], "description": "Grid presentation settings. Accepts the declared object or a preset name such as grid. Individual widget geometry is specified by WidgetPosition."}, "widgets": {"type": "array", "items": {"$ref": "#/components/schemas/DashboardWidget"}}, "is_default": {"type": "boolean"}}}, "Release": {"type": "object", "description": "One stored release. Every member that is not deprecated is always sent, null when the release was stored without it. Release ingestion is not available yet, so no release is stored today.", "required": ["release_id", "app_uuid", "version"], "properties": {"release_id": {"type": ["string", "null"]}, "app_uuid": {"type": ["string", "null"], "format": "uuid"}, "version": {"type": ["string", "null"], "description": "Version string (for example \"1.2.3\")."}, "build_number": {"type": "string", "deprecated": true, "description": "Deprecated. Never sent: no release records a build number."}, "release_date": {"type": "string", "format": "date-time", "deprecated": true, "description": "Deprecated. Never sent: when the release went out is `released_at`."}, "platform": {"type": ["string", "null"], "description": "The store the release was stored for, as stored. Always sent."}, "track": {"type": "string", "enum": ["production", "beta", "alpha", "internal"], "deprecated": true, "description": "Deprecated. Never sent: no release records a track."}, "release_notes": {"type": ["string", "null"], "description": "Release notes, as stored. Always sent."}, "impact_calculated": {"type": "boolean", "deprecated": true, "description": "Deprecated. Never sent: the impact is known when `rating_before` and `rating_after` are both set."}, "impact_summary": {"$ref": "#/components/schemas/ReleaseImpactSummary", "deprecated": true, "description": "Deprecated. Never sent: no release answer carries an impact summary."}, "pre_release_metrics": {"$ref": "#/components/schemas/ReleaseMetrics", "deprecated": true, "description": "Deprecated. Never sent: no release answer carries a metrics window."}, "post_release_metrics": {"$ref": "#/components/schemas/ReleaseMetrics", "deprecated": true, "description": "Deprecated. Never sent: no release answer carries a metrics window."}, "created_at": {"type": ["string", "null"], "description": "When the release was stored. Always sent."}, "released_at": {"type": ["string", "null"], "description": "When the release went out, as stored. Always sent."}, "rating_before": {"type": ["number", "null"], "description": "The average rating stored for before the release. Always sent."}, "rating_after": {"type": ["number", "null"], "description": "The average rating stored for after the release. Always sent."}, "review_count": {"type": "integer", "minimum": 0, "description": "The review count stored with the release; 0 when none was stored. Always sent."}}}, "ReleaseMetrics": {"type": "object", "deprecated": true, "description": "Deprecated. Never sent: no release answer carries a metrics window.", "properties": {"review_count": {"type": "integer", "description": "Number of reviews in the analysis window."}, "average_rating": {"type": "number", "format": "float", "description": "Average rating (1-5)."}, "sentiment_score": {"type": "number", "format": "float", "description": "Average sentiment score (-100 to 100)."}, "rating_distribution": {"type": "object", "properties": {"1": {"type": "integer"}, "2": {"type": "integer"}, "3": {"type": "integer"}, "4": {"type": "integer"}, "5": {"type": "integer"}}}}}, "ReleaseImpactSummary": {"type": "object", "deprecated": true, "description": "Deprecated. Never sent: no release answer carries an impact summary.", "properties": {"rating_delta": {"type": "number", "format": "float", "description": "Change in average rating."}, "sentiment_delta": {"type": "number", "format": "float", "description": "Change in sentiment score."}, "volume_change_pct": {"type": "number", "format": "float", "description": "Percentage change in review volume."}, "top_new_issues": {"type": "array", "items": {"$ref": "#/components/schemas/ReleaseIssue"}}, "alerts": {"type": "array", "items": {"$ref": "#/components/schemas/ReleaseAlert"}}, "alert_triggered": {"type": "boolean"}}}, "ReleaseList": {"type": "object", "description": "One app's releases, newest first.", "required": ["releases", "count", "truncated"], "properties": {"releases": {"type": "array", "items": {"$ref": "#/components/schemas/Release"}}, "count": {"type": "integer", "minimum": 0, "description": "Number of releases in this answer."}, "truncated": {"type": "boolean", "description": "True when the read stopped at its page budget before reaching the end of the app's releases. Newest-first ordering is applied after reading, so a truncated answer is not guaranteed to hold the newest releases."}}}, "TenantReleaseList": {"type": "object", "description": "The workspace's releases, newest release date first.", "required": ["releases", "count"], "properties": {"releases": {"type": "array", "items": {"$ref": "#/components/schemas/Release"}}, "count": {"type": "integer", "minimum": 0, "description": "Number of releases in this answer, rather than the workspace total."}}}, "ReleaseImpactAnalyticsRelease": {"type": "object", "description": "One release and the reviews in the `window_days` before and after it (`GET /api/v1/apps/{appUuid}/analytics/release-impact`).", "properties": {"version_string": {"type": "string", "description": "The app version, or the `version` label given with `release_date`."}, "release_date": {"type": "string", "description": "When the release was first seen: the first stored review of the version, or the `release_date` given."}, "inferred": {"type": "boolean", "description": "True when the boundary was inferred from first-seen app_version sightings (there is no releases store; every detected boundary is inferred). False only for a caller-supplied `release_date`. The same labelling the per-issue entries carry in `boundary.inferred`."}, "impact_calculated": {"type": "boolean", "description": "True when the two windows were compared. False when `impact_summary.reason` says why they were not."}, "pre_release_metrics": {"$ref": "#/components/schemas/ReleaseImpactWindow"}, "post_release_metrics": {"$ref": "#/components/schemas/ReleaseImpactWindow"}, "impact_summary": {"$ref": "#/components/schemas/ReleaseImpactAnalyticsSummary"}}}, "ReleaseImpactWindow": {"type": "object", "description": "The stored reviews of one window, as they are, whether or not the windows were compared.", "properties": {"average_rating": {"type": ["number", "null"], "format": "float", "description": "The window's average star rating."}, "sentiment_score": {"type": "null", "description": "Not measured; always null."}, "review_count": {"type": "integer", "description": "Reviews in the window."}}}, "ReleaseImpactAnalyticsSummary": {"type": "object", "properties": {"rating_delta": {"type": ["number", "null"], "format": "float", "description": "The after window's average star rating minus the before window's. Null when `reason` is set."}, "sentiment_delta": {"type": ["number", "null"], "format": "float", "description": "The change in the share of reviews with a positive sentiment, in percentage points. Null when `reason` is set."}, "volume_change_pct": {"type": ["number", "null"], "format": "float", "description": "The percentage change in reviews from the before window to the after window. Null when the windows were not compared for any reason but `too_few_ratings`, or when the before window holds no review."}, "reason": {"oneOf": [{"type": "string", "enum": ["not_settled", "feed_limited", "sync_limited", "insufficient_history", "no_recent_sync", "too_few_ratings", "unknown"]}, {"type": "null"}], "description": "Null when the windows were compared. Otherwise: `not_settled`, the days after the release have not all settled; `feed_limited`, the store's capped feed has not been read whole back to the days before the release; `sync_limited`, the last sync was cut by the plan's allowance or a failed request; `insufficient_history`, no complete day is known; `no_recent_sync`, no completed sync in the last 14 days; `too_few_ratings`, fewer than 30 reviews in a window (only `volume_change_pct` is given); `unknown`, the app's coverage could not be read."}, "top_new_issues": {"type": "array", "description": "Per-issue before/after complaint measurement across this release boundary: for each issue the analysis tracked when the release shipped, the share of eligible stored reviews mentioning it in the `window_days` before versus after. Empty when no stored analysis could supply issues (`issues_source.reason` says why): an absent measurement, never a measured zero. Each entry carries a machine-coded `state`; only `complaints_decreased`, `complaints_increased` and `no_meaningful_change` present a rate comparison (`observed_change`), and no entry claims the release caused anything (`attribution` is always `not_established`). Additive change: this list was documented \"Always empty\" before issue-outcome measurement shipped.", "items": {"$ref": "#/components/schemas/ReleaseIssueOutcome"}}, "issues_source_document": {"oneOf": [{"type": "object", "additionalProperties": true, "properties": {"analysis_version": {"type": "string", "description": "The stored analysis' partition/sort key."}, "generated_at": {"type": "string", "description": "When the analysis was generated."}, "pre_boundary": {"type": "boolean", "description": "True when the analysis was the latest published at or before this boundary (the issues known when it shipped); false when the boundary predates every stored analysis and the latest one was used instead."}, "analysis_sample_limited": {"oneOf": [{"type": "boolean"}, {"type": "null"}]}, "analysis_coverage_percent": {"oneOf": [{"type": "number", "format": "float"}, {"type": "null"}]}}}, {"type": "null"}], "description": "The analysis this release's issue entries were measured from, or null when there are none."}, "alerts": {"type": "array", "description": "Always empty.", "items": {"$ref": "#/components/schemas/ReleaseAlert"}}, "alert_triggered": {"type": "boolean", "description": "Always false."}}}, "ReleaseIssue": {"type": "object", "deprecated": true, "description": "Deprecated. Never sent: no release answer lists new issues.", "properties": {"topic": {"type": "string", "description": "Issue topic/keyword."}, "count": {"type": "integer", "description": "Number of mentions post-release."}, "pre_count": {"type": "integer", "description": "Number of mentions pre-release."}, "is_new": {"type": "boolean", "description": "Whether this is a new issue."}}}, "ReleaseIssueOutcome": {"type": "object", "description": "One tracked issue's before/after complaint measurement across a release boundary. The populations are event-time windows of stored reviews around the boundary date, never two analysis snapshots, and a review counts only when it carries one of the topic's declared phrasings (whole-word) or a verbatim evidence quote (precision over recall; `estimated_undercount` discloses the direction of the error).", "properties": {"topic": {"type": "string", "description": "The issue topic as the analysis named it."}, "topic_sentiment": {"oneOf": [{"type": "string"}, {"type": "null"}]}, "count": {"type": "integer", "description": "Matched reviews in the after window (legacy field)."}, "pre_count": {"type": "integer", "description": "Matched reviews in the before window (legacy field)."}, "is_new": {"type": "boolean", "description": "True only when the issue had no before-window matches, a real after-window baseline, and a measured state: a missing before-window mention is never presented as a measured zero on its own."}, "linked_insights": {"type": "array", "description": "The actionable insights that verifiably describe this issue (the insight\u2194release join): a shared verbatim evidence quote, or a title sharing at least two content words including the topic name's longest. An insight joined under neither rule is absent: a guessed join is worse than none.", "items": {"type": "object", "properties": {"id": {"oneOf": [{"type": "string"}, {"type": "null"}]}, "title": {"oneOf": [{"type": "string"}, {"type": "null"}]}, "priority": {"oneOf": [{"type": "string"}, {"type": "null"}]}, "link_basis": {"type": "string", "enum": ["evidence_quote", "title_overlap"]}}}}, "state": {"type": "string", "enum": ["complaints_decreased", "complaints_increased", "no_meaningful_change", "insufficient_comparable_data", "boundary_uncertain", "coverage_incomplete", "monitoring"], "description": "The outcome, machine-coded. `monitoring`: the after window is still open. `coverage_incomplete`: the release's own coverage gates (`feed_limited`/`not_settled`/\u2026) refuse the comparison. `boundary_uncertain`: the boundary date is thin evidence (truncated detection or a version seen only a handful of times). `insufficient_comparable_data`: a window below its review floor, or no usable before-window baseline. The three measured states require both windows past their floors and a rate move of >=30% relative AND >=1 percentage point absolute."}, "state_reason": {"oneOf": [{"type": "string"}, {"type": "null"}], "description": "Why the entry is in a limited state; null when measured."}, "attribution": {"type": "string", "enum": ["not_established"], "description": "Always `not_established`: the entry reports an observed change, never that the release caused it."}, "cohort_definition": {"type": "string", "enum": ["event_time_window_boundary_in_after"], "description": "The before/after population rule this entry was classified under: event-time windows around the boundary, the boundary instant counted in the after cohort only (before < boundary <= after)."}, "boundary": {"type": "object", "properties": {"version_string": {"type": "string"}, "release_date": {"type": "string"}, "inferred": {"type": "boolean", "description": "True for a first-seen-version boundary (no releases store exists; every detected boundary is inferred), false for a caller-supplied `release_date`."}, "sightings": {"oneOf": [{"type": "integer"}, {"type": "null"}], "description": "Reviews of the boundary version seen during detection (null for an explicit `release_date`)."}}}, "observed_change": {"oneOf": [{"type": "object", "properties": {"pre_rate": {"type": "number", "format": "float"}, "post_rate": {"type": "number", "format": "float"}, "delta_pp": {"type": "number", "format": "float"}, "relative_delta_pct": {"oneOf": [{"type": "number", "format": "float"}, {"type": "null"}]}}}, {"type": "null"}], "description": "The observed before/after rates and their change, present only in a measured state, null in every limited state."}, "pre_window": {"$ref": "#/components/schemas/ReleaseIssueOutcomeWindow"}, "post_window": {"$ref": "#/components/schemas/ReleaseIssueOutcomeWindow"}, "match_basis": {"type": "array", "description": "Which matcher inputs were used (topic_name, also_mentioned_as, evidence_quotes, linked_insight_quotes).", "items": {"type": "string"}}, "matcher": {"type": "object", "properties": {"phrase_forms_used": {"type": "integer"}, "phrase_forms_total": {"type": "integer"}, "quotes_used": {"type": "integer"}}}, "estimated_undercount": {"type": "object", "description": "The matcher's deliberate narrowness, disclosed: a direction and its derivation, never a corrected number.", "properties": {"direction": {"oneOf": [{"type": "string", "enum": ["likely", "possible", "unknown"]}, {"type": "null"}]}, "basis": {"oneOf": [{"type": "string"}, {"type": "null"}]}, "declared_mentions_latest_analysis": {"oneOf": [{"type": "integer"}, {"type": "null"}]}, "declared_mentions_scope": {"oneOf": [{"type": "string"}, {"type": "null"}]}}}}}, "ReleaseIssueOutcomeWindow": {"type": "object", "description": "One event-time window's measurement for an issue.", "properties": {"start": {"type": "string"}, "end": {"type": "string"}, "complete": {"type": "boolean", "description": "False while the window end is still in the future."}, "eligible_reviews": {"type": "integer", "description": "The window's reviews whose text is observable (the complaint rate's denominator)."}, "matched_reviews": {"type": "integer"}, "quote_fold_matches": {"type": "integer", "description": "How many of the matches came via verbatim quote folds."}, "rate": {"oneOf": [{"type": "number", "format": "float"}, {"type": "null"}], "description": "matched_reviews / eligible_reviews; null while the window is still open or empty."}, "version_coverage_pct": {"oneOf": [{"type": "number", "format": "float"}, {"type": "null"}], "description": "The share of the window's eligible reviews carrying an app_version, disclosed because version coverage varies by listing, which is why the cohorts are event-time windows, not version cohorts."}}}, "ReleaseAlert": {"type": "object", "properties": {"type": {"type": "string", "enum": ["release_sentiment_drop", "release_rating_drop", "release_bug_cluster"]}, "severity": {"type": "string", "enum": ["low", "medium", "high"]}, "value": {"type": "number", "description": "The metric value that triggered the alert."}, "topic": {"type": "string", "description": "Related topic (for bug cluster alerts)."}}}, "ReleaseImpact": {"type": "object", "description": "The figures stored with one release, before and after it. Every member that is not deprecated is always sent. Release ingestion is not available yet, so no release is stored today.", "properties": {"pre_metrics": {"$ref": "#/components/schemas/ReleaseMetrics", "deprecated": true, "description": "Deprecated. Never sent: the stored figures are the members below."}, "post_metrics": {"$ref": "#/components/schemas/ReleaseMetrics", "deprecated": true, "description": "Deprecated. Never sent: the stored figures are the members below."}, "impact_summary": {"$ref": "#/components/schemas/ReleaseImpactSummary", "deprecated": true, "description": "Deprecated. Never sent: the change in rating is `rating_delta`."}, "release_id": {"type": "string", "format": "uuid", "description": "The `releaseId` asked for. Always sent."}, "version": {"type": ["string", "null"], "description": "Always sent."}, "released_at": {"type": ["string", "null"], "description": "Always sent."}, "rating_before": {"type": ["number", "null"], "description": "Always sent."}, "rating_after": {"type": ["number", "null"], "description": "Always sent."}, "rating_delta": {"type": ["number", "null"], "description": "`rating_after` less `rating_before`, rounded to two decimal places; null unless both are known. Always sent."}, "review_volume_before": {"type": "number", "description": "The review volume stored for before the release; 0 when none was stored. Always sent."}, "review_volume_after": {"type": "number", "description": "The review volume stored for after the release; 0 when none was stored. Always sent."}, "sentiment_before": {"type": ["number", "null"], "description": "Always sent."}, "sentiment_after": {"type": ["number", "null"], "description": "Always sent."}, "top_topics": {"type": "array", "description": "The topics stored with the release; empty when none were stored. Always sent.", "items": {}}, "new_issues": {"type": "array", "description": "The new issues stored with the release; empty when none were stored. Always sent.", "items": {}}}}, "ReleaseReport": {"type": "object", "description": "The app's newest stored releases. No trend is measured across them. Release ingestion is not available yet, so the list is empty.", "required": ["app_uuid", "report_type", "releases", "count", "truncated"], "properties": {"app_uuid": {"type": "string", "format": "uuid"}, "report_type": {"type": "string", "enum": ["multi_release_comparison"]}, "releases": {"type": "array", "items": {"$ref": "#/components/schemas/Release"}}, "count": {"type": "integer", "minimum": 0, "description": "Number of releases in this answer."}, "truncated": {"type": "boolean", "description": "True when the read stopped at its page budget before reaching the end of the app's releases, so the newest may be missing."}}}, "VapidKeyResponse": {"type": "object", "properties": {"public_key": {"type": "string", "description": "VAPID public key in URL-safe base64 format."}}}, "PushSubscribeRequest": {"type": "object", "required": ["subscription"], "properties": {"subscription": {"$ref": "#/components/schemas/PushSubscription"}}}, "PushSubscription": {"type": "object", "required": ["endpoint", "keys"], "properties": {"endpoint": {"type": "string", "format": "uri", "maxLength": 2048, "description": "The push service endpoint URL: HTTPS on fcm.googleapis.com, android.googleapis.com, updates.push.services.mozilla.com, web.push.apple.com or a *.notify.windows.com host."}, "expirationTime": {"type": ["integer", "null"], "description": "Subscription expiration time (milliseconds since epoch)."}, "keys": {"type": "object", "required": ["p256dh", "auth"], "properties": {"p256dh": {"type": "string", "maxLength": 256, "description": "P-256 ECDH public key in URL-safe base64."}, "auth": {"type": "string", "maxLength": 256, "description": "Authentication secret in URL-safe base64."}}}}}, "PushSubscribeResponse": {"type": "object", "properties": {"success": {"type": "boolean", "deprecated": true, "description": "Legacy optional field; current responses use message and endpoint_hash."}, "subscription_id": {"type": "string", "deprecated": true, "description": "Legacy optional identifier; current responses use endpoint_hash."}, "error": {"type": "string", "deprecated": true, "description": "Legacy optional field; errors use the structured error response."}, "message": {"type": "string", "description": "Confirmation that the subscription was registered."}, "endpoint_hash": {"type": "string", "maxLength": 32, "pattern": "^[0-9a-f]{32}$", "description": "Identifier derived from the registered browser endpoint."}}}, "PushUnsubscribeRequest": {"type": "object", "required": ["endpoint"], "properties": {"endpoint": {"type": "string", "format": "uri", "description": "The push service endpoint URL to unsubscribe."}}}, "EmailTemplateMetricsSummary": {"type": "object", "description": "One template's event counts over the window. Every member that is not deprecated is always sent. Rates are percentages to two decimal places, 0 when their base is 0: delivery, bounce and complaint rates are shares of `sent`, open and click rates shares of `delivered`.", "required": ["template_name", "sent", "delivered"], "properties": {"template_name": {"type": "string", "description": "The template name, or `unknown` for email sent without one."}, "sent": {"type": "integer", "minimum": 0}, "delivered": {"type": "integer", "minimum": 0}, "opens": {"type": "integer", "deprecated": true, "description": "Deprecated. Never sent: the count is `opened`."}, "clicks": {"type": "integer", "deprecated": true, "description": "Deprecated. Never sent: the count is `clicked`."}, "open_rate": {"type": "number", "format": "float", "description": "Percentage of delivered emails opened."}, "click_rate": {"type": "number", "format": "float", "description": "Percentage of delivered emails clicked."}, "opened": {"type": "integer", "minimum": 0}, "clicked": {"type": "integer", "minimum": 0}, "bounced": {"type": "integer", "minimum": 0}, "complained": {"type": "integer", "minimum": 0}, "delivery_rate": {"type": "number", "format": "float", "description": "Percentage of sent emails delivered."}, "bounce_rate": {"type": "number", "format": "float", "description": "Percentage of sent emails that bounced."}, "complaint_rate": {"type": "number", "format": "float", "description": "Percentage of sent emails marked as spam."}}}, "EmailTemplateMetricsList": {"type": "object", "required": ["templates", "days"], "properties": {"templates": {"type": "array", "description": "Every template with an event in the window, most sent first.", "items": {"$ref": "#/components/schemas/EmailTemplateMetricsSummary"}}, "days": {"type": "integer", "minimum": 1, "description": "The window, in days."}}}, "EmailTemplateMetrics": {"type": "object", "description": "One template's event counts over the window, its rates (percentages to two decimal places, 0 when their base is 0) and its counts per day. Every member that is not deprecated is always sent.", "required": ["template_name"], "properties": {"template_name": {"type": "string"}, "period_days": {"type": "integer", "deprecated": true, "description": "Deprecated. Never sent: the window is `days`."}, "counts": {"type": "object", "deprecated": true, "description": "Deprecated. Never sent: the counts are `totals`.", "properties": {"sent": {"type": "integer"}, "delivered": {"type": "integer"}, "opens": {"type": "integer"}, "clicks": {"type": "integer"}, "bounces": {"type": "integer"}, "complaints": {"type": "integer"}}}, "unique_recipients": {"type": "object", "deprecated": true, "description": "Deprecated. Never sent: recipients are not counted.", "properties": {"sent": {"type": "integer"}, "opened": {"type": "integer"}, "clicked": {"type": "integer"}}}, "rates": {"type": "object", "required": ["delivery_rate", "open_rate", "click_rate"], "properties": {"delivery_rate": {"type": "number", "format": "float", "description": "Percentage of sent emails delivered."}, "open_rate": {"type": "number", "format": "float", "description": "Percentage of delivered emails opened."}, "click_rate": {"type": "number", "format": "float", "description": "Percentage of delivered emails clicked."}, "click_to_open_rate": {"type": "number", "format": "float", "deprecated": true, "description": "Deprecated. Never sent."}, "bounce_rate": {"type": "number", "format": "float", "deprecated": true, "description": "Deprecated. Never sent: the bounces are in `totals`."}, "complaint_rate": {"type": "number", "format": "float", "deprecated": true, "description": "Deprecated. Never sent: the complaints are in `totals`."}}}, "days": {"type": "integer", "minimum": 1, "description": "The window, in days."}, "totals": {"type": "object", "required": ["sent", "delivered", "opened", "clicked", "bounced", "complained"], "properties": {"sent": {"type": "integer", "minimum": 0}, "delivered": {"type": "integer", "minimum": 0}, "opened": {"type": "integer", "minimum": 0}, "clicked": {"type": "integer", "minimum": 0}, "bounced": {"type": "integer", "minimum": 0}, "complained": {"type": "integer", "minimum": 0}}}, "daily": {"type": "object", "description": "Per day with an event, keyed `YYYY-MM-DD` (UTC): the number of events of each type that day, keyed by event type (`Send`, `Delivery`, `Open`, `Click`, `Bounce`, `Complaint`).", "additionalProperties": {"type": "object", "additionalProperties": {"type": "integer"}}}}}, "DailyEmailMetrics": {"type": "object", "description": "One day's email event counts across every template. Every member that is not deprecated is always sent.", "required": ["date", "sent", "delivered"], "properties": {"date": {"type": "string", "format": "date"}, "sent": {"type": "integer", "minimum": 0}, "delivered": {"type": "integer", "minimum": 0}, "opens": {"type": "integer", "deprecated": true, "description": "Deprecated. Never sent: the count is `opened`."}, "clicks": {"type": "integer", "deprecated": true, "description": "Deprecated. Never sent: the count is `clicked`."}, "bounces": {"type": "integer", "deprecated": true, "description": "Deprecated. Never sent: the count is `bounced`."}, "open_rate": {"type": "number", "format": "float", "deprecated": true, "description": "Deprecated. Never sent."}, "click_rate": {"type": "number", "format": "float", "deprecated": true, "description": "Deprecated. Never sent."}, "opened": {"type": "integer", "minimum": 0}, "clicked": {"type": "integer", "minimum": 0}, "bounced": {"type": "integer", "minimum": 0}, "delivery_rate": {"type": "number", "format": "float", "description": "Percentage of sent emails delivered, to two decimal places; 0 when none was sent."}}}, "DailyEmailMetricsList": {"type": "object", "required": ["daily", "days"], "properties": {"daily": {"type": "array", "description": "Every day with an event, oldest first.", "items": {"$ref": "#/components/schemas/DailyEmailMetrics"}}, "days": {"type": "integer", "minimum": 1, "description": "The window, in days."}}}, "EmailEventCounts": {"type": "object", "description": "Every member that is not deprecated is always sent.", "properties": {"Send": {"type": "integer", "deprecated": true, "description": "Deprecated. Never sent: the count is `events.Send`."}, "Delivery": {"type": "integer", "deprecated": true, "description": "Deprecated. Never sent: the count is `events.Delivery`."}, "Open": {"type": "integer", "deprecated": true, "description": "Deprecated. Never sent: the count is `events.Open`."}, "Click": {"type": "integer", "deprecated": true, "description": "Deprecated. Never sent: the count is `events.Click`."}, "Bounce": {"type": "integer", "deprecated": true, "description": "Deprecated. Never sent: the count is `events.Bounce`."}, "Complaint": {"type": "integer", "deprecated": true, "description": "Deprecated. Never sent: the count is `events.Complaint`."}, "days": {"type": "integer", "minimum": 1, "description": "The window, in days."}, "events": {"type": "object", "description": "Events of each type in the window. A type with none is left out.", "properties": {"Send": {"type": "integer", "minimum": 1}, "Delivery": {"type": "integer", "minimum": 1}, "Open": {"type": "integer", "minimum": 1}, "Click": {"type": "integer", "minimum": 1}, "Bounce": {"type": "integer", "minimum": 1}, "Complaint": {"type": "integer", "minimum": 1}}}, "total": {"type": "integer", "minimum": 0, "description": "All events in the window."}}}, "RecipientEmailEvent": {"type": "object", "description": "Every member that is not deprecated is always sent.", "required": ["event_type", "template_name", "timestamp"], "properties": {"event_type": {"type": "string", "enum": ["Send", "Delivery", "Open", "Click", "Bounce", "Complaint"], "description": "The email event, as recorded."}, "template_name": {"type": "string", "description": "The template name, or `unknown` for email sent without one."}, "timestamp": {"type": "string", "format": "date-time", "description": "When the email was sent."}, "message_id": {"type": "string", "deprecated": true, "description": "Deprecated. Never sent."}, "click_url": {"type": "string", "deprecated": true, "description": "Deprecated. Never sent."}, "bounce_type": {"type": "string", "deprecated": true, "description": "Deprecated. Never sent."}, "subject": {"type": "null", "description": "Always sent, always null. The subject of an email is not recorded."}}}, "RecipientEmailHistory": {"type": "object", "required": ["email", "history", "count"], "properties": {"email": {"type": "string", "description": "The address asked for, as sent."}, "history": {"type": "array", "description": "The address's newest events first.", "items": {"$ref": "#/components/schemas/RecipientEmailEvent"}}, "count": {"type": "integer", "minimum": 0, "description": "Number of events in this answer."}}}, "AutoResponseRuleDeleteResponse": {"type": "object", "required": ["message"], "properties": {"message": {"type": "string", "description": "Confirmation that the rule was deleted.", "example": "Rule deleted successfully"}}}, "AutoResponseRule": {"type": "object", "properties": {"rule_id": {"type": "string", "format": "uuid"}, "app_uuid": {"type": "string", "format": "uuid"}, "name": {"type": "string"}, "description": {"type": "string"}, "is_enabled": {"type": "boolean"}, "priority": {"type": "integer", "description": "Lower number = higher priority."}, "conditions": {"$ref": "#/components/schemas/AutoResponseConditions"}, "template_id": {"type": "string", "description": "Reply template ID to use."}, "response_variables": {"type": "object", "description": "Template variables with names of at most 100 characters. Each value's rendered text must contain at most 500 characters. Values must be finite JSON values, and the complete rule must fit within 30,000 UTF-8 bytes. support_email is validated and normalized as an email address.", "additionalProperties": true}, "settings": {"$ref": "#/components/schemas/AutoResponseSettings"}, "stats": {"$ref": "#/components/schemas/AutoResponseStats"}, "created_at": {"type": "string", "format": "date-time"}, "updated_at": {"type": "string", "format": "date-time"}}}, "AutoResponseConditions": {"type": "object", "properties": {"rating": {"type": "object", "properties": {"operator": {"type": "string", "enum": ["eq", "gte", "lte", "between"]}, "value": {"oneOf": [{"type": "integer"}, {"type": "array", "items": {"type": "integer"}}]}}}, "sentiment": {"type": "array", "items": {"type": "string", "enum": ["positive", "negative", "neutral"]}}, "platforms": {"type": "array", "items": {"type": "string", "enum": ["app_store", "google_play", "steam", "amazon", "samsung"]}}, "languages": {"type": "array", "items": {"type": "string"}}, "min_word_count": {"type": "integer"}, "max_word_count": {"type": "integer"}, "keywords_include": {"type": "array", "items": {"type": "string"}}, "keywords_exclude": {"type": "array", "items": {"type": "string"}}}}, "AutoResponseSettings": {"type": "object", "properties": {"require_approval": {"type": "boolean", "description": "Queue for human approval before posting."}, "daily_limit": {"type": "integer", "description": "Max auto-responses per day (0 = unlimited)."}, "cooloff_days": {"type": "integer", "description": "Days before auto-replying to same user again."}, "ai_validation": {"type": "boolean", "description": "AI validates reply appropriateness."}, "ai_personalization": {"type": "boolean", "description": "Personalize the selected template for the review using AI. Disabled for new rules by default."}}}, "AutoResponseStats": {"type": "object", "properties": {"total_matched": {"type": "integer"}, "total_queued": {"type": "integer"}, "total_approved": {"type": "integer"}, "total_rejected": {"type": "integer"}, "total_posted": {"type": "integer"}, "last_matched_at": {"type": ["string", "null"], "format": "date-time"}}}, "AutoResponseTemplateRevision": {"type": "object", "required": ["content"], "additionalProperties": false, "description": "The displayed custom template content and optional revision. Required when creating, enabling or editing an active rule using a custom template; omission returns 400 VALIDATION_ERROR. Optional for previews; omission previews the current server template. A changed snapshot returns 409 RULE_CONFLICT. Disabled rules and immutable built-in templates do not require it.", "properties": {"content": {"type": "string", "minLength": 1, "maxLength": 5970}, "updated_at": {"type": ["string", "null"], "minLength": 1, "maxLength": 128, "description": "The displayed updated_at, or null when a legacy template has no revision."}}}, "CreateAutoResponseRuleRequest": {"type": "object", "required": ["name", "template_id"], "properties": {"expected_template": {"$ref": "#/components/schemas/AutoResponseTemplateRevision"}, "name": {"type": "string", "minLength": 1, "maxLength": 200}, "description": {"type": "string", "maxLength": 1000}, "is_enabled": {"type": "boolean", "default": false}, "priority": {"type": "integer", "minimum": 1, "maximum": 1000, "default": 100}, "conditions": {"$ref": "#/components/schemas/AutoResponseConditions"}, "template_id": {"type": "string"}, "response_variables": {"type": "object", "description": "Template variables with names of at most 100 characters. Each value's rendered text must contain at most 500 characters. Values must be text, finite numbers, or booleans. Objects, arrays, and null are rejected. The complete rule must fit within 30,000 UTF-8 bytes. support_email is validated and normalized as an email address.", "additionalProperties": {"oneOf": [{"type": "string", "maxLength": 500}, {"type": "number"}, {"type": "boolean"}]}}, "settings": {"$ref": "#/components/schemas/AutoResponseSettings"}}}, "UpdateAutoResponseRuleRequest": {"type": "object", "properties": {"expected_template": {"$ref": "#/components/schemas/AutoResponseTemplateRevision"}, "expected_updated_at": {"type": "string", "minLength": 1, "maxLength": 128, "description": "Original updated_at revision returned when the editor loaded the rule. A stale revision returns 409 RULE_CONFLICT without changing the rule."}, "name": {"type": "string", "minLength": 1, "maxLength": 200}, "description": {"type": "string", "maxLength": 1000}, "is_enabled": {"type": "boolean"}, "priority": {"type": "integer", "minimum": 1, "maximum": 1000}, "conditions": {"$ref": "#/components/schemas/AutoResponseConditions"}, "template_id": {"type": "string"}, "response_variables": {"type": "object", "description": "Template variables with names of at most 100 characters. Each value's rendered text must contain at most 500 characters. Values must be text, finite numbers, or booleans. Objects, arrays, and null are rejected. The complete rule must fit within 30,000 UTF-8 bytes. support_email is validated and normalized as an email address.", "additionalProperties": {"oneOf": [{"type": "string", "maxLength": 500}, {"type": "number"}, {"type": "boolean"}]}}, "settings": {"$ref": "#/components/schemas/AutoResponseSettings"}}}, "TestAutoResponseRuleRequest": {"type": "object", "required": ["review_content", "rating"], "properties": {"expected_template": {"$ref": "#/components/schemas/AutoResponseTemplateRevision", "description": "The custom template displayed for this preview. A stale snapshot returns 409 RULE_CONFLICT without rendering newer text. Clients omitting it preview the current template."}, "expected_updated_at": {"type": "string", "minLength": 1, "maxLength": 128, "description": "The displayed rule revision. A stale revision returns 409 RULE_CONFLICT."}, "review_content": {"type": "string", "minLength": 1, "maxLength": 5000}, "rating": {"type": "integer", "minimum": 1, "maximum": 5}, "sentiment": {"type": "string", "enum": ["positive", "negative", "neutral"], "description": "Optional sample sentiment. Uses the production rating-derived fallback when omitted."}, "platform": {"type": "string", "enum": ["app_store", "google_play", "steam", "amazon", "samsung"], "description": "Optional sample platform. Defaults to the selected app's current platform when omitted."}, "language": {"type": "string", "default": "en"}, "author_name": {"type": "string", "minLength": 1, "maxLength": 200, "description": "Sample reviewer name used to render reviewer placeholders."}, "date": {"type": "string", "format": "date", "description": "Sample review date used to render date placeholders."}}}, "TestAutoResponseRuleResponse": {"type": "object", "properties": {"matches": {"type": "boolean", "description": "Whether the rule matched the sample review."}, "match_details": {"type": "object", "additionalProperties": true, "description": "Details about which conditions matched/failed."}, "generated_response": {"type": ["string", "null"], "description": "The rendered template preview. AI personalization may change it at execution, as indicated by blocked_reason."}, "would_queue": {"type": "boolean", "description": "Whether the response would be queued for approval."}, "blocked_reason": {"type": ["string", "null"], "description": "Reason if the response would be blocked."}}}, "AutoResponseAnalytics": {"type": "object", "description": "Lifetime counters for this workspace's current auto-response rules. period_days is null because these counters do not represent a rolling date window. Time-saved values are estimates using three minutes per successfully posted response.", "properties": {"period_days": {"type": ["integer", "null"], "description": "Null for lifetime counters."}, "counter_period": {"type": "string", "enum": ["all_time"]}, "rules_count": {"type": "integer"}, "enabled_rules_count": {"type": "integer"}, "total_matched": {"type": "integer"}, "total_queued": {"type": "integer"}, "total_approved": {"type": "integer"}, "total_rejected": {"type": "integer"}, "total_posted": {"type": "integer"}, "approval_rate": {"type": "number"}, "time_saved_minutes": {"type": "number", "description": "Estimated minutes saved, using three minutes per posted reply."}, "time_saved_hours": {"type": "number", "description": "Estimated hours saved, using three minutes per posted reply."}, "rules_performance": {"type": "array", "items": {"type": "object", "properties": {"rule_id": {"type": "string"}, "name": {"type": "string"}, "is_enabled": {"type": "boolean"}, "matched": {"type": "integer"}, "posted": {"type": "integer"}}}}, "total_rules": {"type": "integer"}, "enabled_rules": {"type": "integer"}, "disabled_rules": {"type": "integer"}, "total_matches": {"type": "integer"}, "rules": {"type": "array", "items": {"type": "object", "additionalProperties": true}}}}, "UserSegment": {"type": "object", "description": "A classified user segment from review analysis", "properties": {"segment_type": {"type": "string", "enum": ["influencer", "power_user", "early_adopter", "at_risk", "churned", "competitor_mentioner", "casual"], "description": "Type of user segment"}, "segment_score": {"type": "number", "minimum": 0, "maximum": 100, "description": "Confidence score for segment classification. Absent on an `at_risk` row whose churn risk was not assessed (`evidence.risk_assessment`): nothing stands in for the missing assessment, and `sort=score` places the row after every scored row."}, "author_id": {"type": "string", "description": "Hashed identifier for the reviewer"}, "author_display_name": {"type": "string", "description": "Display name of the reviewer"}, "platforms": {"type": "array", "items": {"type": "string"}, "description": "Platforms the user has reviewed on"}, "review_ids": {"type": "array", "items": {"type": "string"}, "description": "IDs of this user's reviews that wrote this row. A review scored after a newer review of the same user was complete leaves the row as it is, because the row describes the newest review, so it is not listed here."}, "review_count": {"type": "integer", "description": "Number of this user's reviews classified into the segment. Derived from `review_ids`, so the two always agree."}, "aggregate_sentiment": {"type": "string", "enum": ["positive", "negative", "neutral", "mixed"], "description": "Overall sentiment from user's reviews"}, "aggregate_rating": {"type": "number", "description": "Average rating from user's reviews"}, "first_seen": {"type": "string", "format": "date-time", "description": "When the user was first classified"}, "last_updated": {"type": "string", "format": "date-time", "description": "When the segment was last updated"}, "evidence": {"type": "object", "description": "Evidence supporting the classification. `risk_assessment` set to `not_assessed` on an `at_risk` row means the reviewer is there because of a 1 or 2 star rating and the model gave no churn risk for the review: the row then has no `segment_score`, `ai_analysis` or `ai_risk_score`. It does not mean the risk is low. `ai_analysis.retention_suggestions` can be an empty list, or hold fewer suggestions than the model wrote: one that fails the length check is left out and none is made up. No score depends on it.", "additionalProperties": true}, "reviewer_segments": {"type": "array", "description": "Every segment this row's reviewer is in, this one included, highest score first. A reviewer's rows are spread over the pages by score, so this is how a client shows a person whole without reading the list to its end. Present on a list with no `segment_type` filter; absent on a filtered list (only that type's rows are read) and on a response cached before the API returned it.", "items": {"$ref": "#/components/schemas/UserSegmentMembership"}}, "app_uuid": {"type": "string", "description": "App associated with this segment."}}}, "UserSegmentMembership": {"type": "object", "description": "One segment a reviewer is in, with its score.", "properties": {"segment_type": {"type": "string", "enum": ["influencer", "power_user", "early_adopter", "at_risk", "churned", "competitor_mentioner", "casual"], "description": "Type of user segment"}, "segment_score": {"type": ["number", "null"], "minimum": 0, "maximum": 100, "description": "Confidence score for segment classification. Null on an `at_risk` membership whose churn risk was not assessed (see `UserSegment.evidence`). It does not mean the risk is zero."}}}, "UserSegmentsSummary": {"type": "object", "description": "Summary counts of reviewer segments. Every count except `total_reviewers` and `total_multi_segment_reviewers` is a count of segment rows, and one reviewer can have several.", "properties": {"total_segments": {"type": "integer", "description": "Number of segment rows in the selection: one per reviewer and segment type. A reviewer who is in several segments is counted once for each, so this is not a count of people; that is `total_reviewers`."}, "total_reviewers": {"type": "integer", "description": "Number of distinct reviewers (`author_id`) among the segment rows of the selection. Equal to `total_segments` when the request filters by `segment_type`, because a reviewer has one row per type. Absent from a response cached before the API counted it."}, "total_multi_segment_reviewers": {"type": "integer", "description": "How many of those reviewers are in more than one segment. Every `churned` reviewer is also `at_risk` (both start from a 1 or 2 star review), so this is rarely 0 without a `segment_type` filter, and always 0 with one."}, "total_influencers": {"type": "integer", "description": "Count of influencer segments. The classifier assigns no new `influencer` segments today, so only rows stored earlier are counted."}, "total_power_users": {"type": "integer", "description": "Count of power user segments"}, "total_churned": {"type": "integer", "description": "Count of churned and at-risk user segments together. The at-risk part is `total_at_risk`, so the churned segments alone are `total_churned - total_at_risk`."}, "total_at_risk": {"type": "integer", "description": "Count of at-risk user segments; included in `total_churned`."}, "total_casual": {"type": "integer", "description": "Count of casual user segments"}, "total_early_adopters": {"type": "integer", "description": "Count of early adopter user segments"}, "total_competitor_mentioners": {"type": "integer", "description": "Count of competitor mentioner user segments"}}}, "AnalyticsTemplateInsight": {"type": "object", "description": "A finding the analytics services compose from a fixed English template. `title`, `description` and `action` are that English text, kept for clients that print it. `code` names the template and `params` holds the values its sentence is built from, so a client can render the finding in another language: market codes are ISO 3166-1 alpha-2, store values are platform enums, and bug keys are the keyword keys of the bug matrix.", "required": ["type", "title", "description", "priority"], "properties": {"type": {"type": "string", "enum": ["critical", "warning", "info", "success"]}, "title": {"type": "string"}, "description": {"type": "string"}, "action": {"type": "string"}, "priority": {"type": "string", "enum": ["critical", "high", "medium", "low"]}, "code": {"type": "string", "description": "Stable template code. Known values: `geo.high_negativity` (params `country_code`, `negative_pct`), `geo.rating_gap` (`best_country_code`, `best_rating`, `worst_country_code`, `worst_rating`), `platform.rating_gap` (`best_platform`, `best_rating`, `worst_platform`, `worst_rating`), `platform.sentiment_divergence` (no params) and `platform.unique_bugs` (`platform`, `issues`). A client should fall back to the English text for a code it does not know.", "example": "geo.high_negativity"}, "params": {"type": "object", "additionalProperties": true, "description": "The values the template's sentence is built from, keyed as listed under `code`.", "example": {"country_code": "MX", "negative_pct": 67}}}}, "AnalyticsUnavailableFlag": {"type": "boolean", "description": "Present and `true` only when a backend failure forced this otherwise successful response to degrade to empty or partial data. Absence means the response is a genuine measurement, including a genuine empty result."}, "AnomaliesResponse": {"type": "object", "properties": {"app_uuid": {"type": "string", "format": "uuid"}, "anomalies": {"type": "array", "description": "Most extreme first (by absolute `z_score`).", "items": {"$ref": "#/components/schemas/VolumeAnomaly"}}, "coverage": {"$ref": "#/components/schemas/AnomalyCoverage"}, "unavailable": {"$ref": "#/components/schemas/AnalyticsUnavailableFlag"}}}, "VolumeAnomaly": {"type": "object", "properties": {"date": {"type": "string", "format": "date", "description": "The UTC day."}, "type": {"type": "string", "enum": ["volume_spike", "volume_drop"]}, "severity": {"type": "string", "enum": ["high", "medium"], "description": "`high` beyond three spreads, `medium` from two."}, "z_score": {"type": "number"}, "review_count": {"type": "integer", "description": "Reviews on that day."}, "expected_count": {"type": "integer", "description": "The mean of the days it was compared with."}}}, "AnomalyCoverage": {"type": "object", "description": "What each check could judge. Absent only when the detection itself failed (`unavailable` is then true).", "required": ["spikes", "drops"], "properties": {"spikes": {"$ref": "#/components/schemas/AnomalyCheckCoverage"}, "drops": {"$ref": "#/components/schemas/AnomalyCheckCoverage"}}}, "AnomalyCheckCoverage": {"type": "object", "required": ["evaluated", "first_day", "last_day", "reason"], "properties": {"evaluated": {"type": "boolean", "description": "Whether at least one day of the window was compared with the days before it. A day needs three comparable days before it, and a drop is judged only on a settled day (three days old) whose count is known to be complete."}, "first_day": {"type": ["string", "null"], "format": "date", "description": "The first day the check evaluated; null when it evaluated none."}, "last_day": {"type": ["string", "null"], "format": "date", "description": "The last day the check evaluated; null when it evaluated none."}, "reason": {"oneOf": [{"type": "string", "enum": ["feed_limited", "sync_limited", "insufficient_history", "no_reviews", "unknown"]}, {"type": "null"}], "description": "Why the check evaluated no day; null when it evaluated some. `feed_limited`: the app is read from a store's public feed, which returns only the newest reviews, and the latest sync did not establish enough whole days to compare. `sync_limited`: the latest sync was cut short (the plan's review allowance, a failed request, a feed read the store left unanswered) or recorded an incomplete read, also for a window with no reviews after such a sync. `insufficient_history`: the counts are complete but there are not yet enough days of them. `no_reviews`: no reviews in the window. `unknown`: whether the counts are complete could not be read."}}}, "VolumeForecastCoverage": {"type": "object", "description": "Days used for a review-volume forecast, or the reason no forecast is available. Only complete, settled daily counts are used; the most recent three days are excluded. A complete sync uses the last 56 settled days. A public-feed sync uses whole weeks of days verified complete by that sync, with at least two weeks required. Incomplete or unverified counts produce no forecast. Reason values follow AnomalyCheckCoverage.", "required": ["evaluated", "first_day", "last_day", "reason"], "properties": {"evaluated": {"type": "boolean", "description": "True when a forecast was made."}, "first_day": {"type": ["string", "null"], "format": "date", "description": "The first day of the window the trend compares against; null when no forecast was made."}, "last_day": {"type": ["string", "null"], "format": "date", "description": "The last day of the window the rate and the projection are read from; null when no forecast was made."}, "reason": {"oneOf": [{"type": "string", "enum": ["feed_limited", "sync_limited", "insufficient_history", "no_reviews", "unknown"]}, {"type": "null"}], "description": "Why no forecast was made; null when one was. `feed_limited`: the app is read from a store's public feed and its latest sync vouches for fewer than two whole weeks of settled days. `sync_limited`: the latest sync was cut short by the plan's review allowance or a failed request, or recorded an incomplete read. `unknown`: whether the counts are whole could not be established (the app has not recorded it, or it could not be read). `insufficient_history`: the counts are whole but there are fewer than 5 reviews or 5 days with reviews. `no_reviews`: no reviews in the window."}}}, "UserSegmentsResponse": {"type": "object", "description": "Response containing user segments and summary", "properties": {"segments": {"type": "array", "items": {"$ref": "#/components/schemas/UserSegment"}, "description": "List of user segments"}, "summary": {"$ref": "#/components/schemas/UserSegmentsSummary"}, "metadata": {"$ref": "#/components/schemas/ResponseMetadata"}, "unavailable": {"$ref": "#/components/schemas/AnalyticsUnavailableFlag"}, "next_cursor": {"type": ["string", "null"], "description": "Pass as `cursor` (with the same `segment_type` and `sort`) for the next page; `null` on the last page."}, "has_more": {"type": "boolean", "description": "`true` exactly when `next_cursor` is set."}, "truncated": {"type": "boolean", "description": "`true` only when the read stopped at a safety bound (20,000 segments of this app, or 100,000 segment rows of the workspace read from storage), in which case `summary` and the pages cover the segments read rather than all of them. `false` in normal operation."}, "app_uuid": {"type": "string", "description": "App whose segments are returned."}}}, "BackfillSegmentsResponse": {"type": "object", "description": "Response from segment backfill operation", "properties": {"app_uuid": {"type": "string", "format": "uuid", "description": "The app the backfill ran against."}, "processed": {"type": "integer", "description": "Number of reviews read and classified in this call."}, "enriched_count": {"type": "integer", "description": "Number of reviews successfully enriched"}, "errors_count": {"type": "integer", "description": "Number of reviews that failed to enrich"}, "partial": {"type": "boolean", "description": "`true` when reviews remain to be processed. Pass `next_cursor` back\nas `cursor` to continue; `false` means the app is fully backfilled.\n"}, "next_cursor": {"type": ["string", "null"], "description": "Cursor for the next batch, or `null` when there is nothing further\nto process. Always `null` when `partial` is `false`.\n"}}}, "LanguageAnalyticsResponse": {"type": "object", "description": "Language distribution across app reviews for a trailing window. This endpoint provides a distribution without a time series, trend or granularity data.", "properties": {"app_uuid": {"type": "string"}, "analytics": {"type": "array", "description": "Languages ordered by review count, descending.", "items": {"type": "object", "properties": {"language": {"type": "string", "description": "ISO 639-1 language code."}, "count": {"type": "integer"}, "percentage": {"type": "number", "format": "float", "description": "Share of `total_reviews`, as a percentage to 1dp."}}}}, "total_reviews": {"type": "integer", "description": "Reviews with a detected language in the window."}, "days": {"type": "integer", "description": "The window size the response was computed over."}, "truncated": {"type": "boolean", "description": "True only when a runaway safety bound stopped the read, in which case the percentages above are over part of the window. False in normal operation: the window is read in full."}}}, "SentimentByLanguageResponse": {"type": "object", "properties": {"app_uuid": {"type": "string"}, "languages": {"type": "array", "items": {"type": "object", "properties": {"language": {"type": "string", "description": "ISO 639-1 language code."}, "total": {"type": "integer"}, "positive": {"type": "integer"}, "negative": {"type": "integer"}, "neutral": {"type": "integer"}, "mixed": {"type": "integer"}, "unmeasured": {"type": "integer", "description": "Reviews in this language with no sentiment yet; they have not been through AI analysis."}, "average_rating": {"type": "number", "format": "float"}}}}, "days": {"type": "integer", "description": "The window size the response was computed over."}, "reviews_analyzed": {"type": "integer", "description": "Reviews the read produced inside the window. Equals the sum of `languages[].total`, and is present so a cut read is visible."}, "truncated": {"type": "boolean", "description": "True only when a runaway safety bound stopped the read, in which case the counts above are over part of the window. False in normal operation: the window is read in full."}}}, "FeatureSuggestion": {"type": "object", "description": "AI-extracted feature request from reviews. The list and the change send every member but `impact_analysis`.", "required": ["feature_id", "feature_name", "description", "category", "mention_count", "demand_score", "status", "notes", "priority", "assignee", "sample_quotes", "user_pain", "platforms", "review_ids", "first_seen", "last_seen"], "properties": {"feature_id": {"type": "string", "description": "Unique identifier for the feature suggestion"}, "feature_name": {"type": "string", "description": "Concise name of the requested feature (2-5 words)"}, "description": {"type": "string", "description": "What users are asking for"}, "category": {"type": "string", "enum": ["ui", "performance", "content", "monetization", "integration", "accessibility", "other"], "description": "Feature category"}, "mention_count": {"type": "integer", "description": "Number of reviews mentioning this feature"}, "demand_score": {"type": "number", "minimum": 0, "maximum": 10, "description": "Calculated demand score based on volume, sentiment, and ratings"}, "status": {"type": "string", "enum": ["suggested", "planned", "in_progress", "shipped", "dismissed"], "description": "Current status of the feature suggestion"}, "notes": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Free-form tracking notes set through the PATCH endpoint. Null until set."}, "priority": {"anyOf": [{"type": "string", "enum": ["low", "medium", "high"]}, {"type": "null"}], "description": "Team-assigned triage priority set through the PATCH endpoint. Null until set."}, "assignee": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Team member the suggestion is assigned to via the PATCH endpoint. Null until set."}, "sample_quotes": {"type": "array", "items": {"type": "string"}, "description": "Direct quotes from reviews requesting this feature"}, "user_pain": {"type": "string", "description": "The problem users face without this feature"}, "platforms": {"type": "array", "items": {"type": "string"}, "description": "Platforms where this feature was requested"}, "review_ids": {"type": "array", "items": {"type": "string"}, "description": "IDs of reviews mentioning this feature"}, "first_seen": {"type": "string", "format": "date-time", "description": "When this feature was first extracted"}, "last_seen": {"type": "string", "format": "date-time", "description": "When this feature was last seen in reviews"}, "impact_analysis": {"$ref": "#/components/schemas/FeatureImpactAnalysis", "description": "Not sent. No answer carries an impact analysis yet."}, "created_at": {"type": "string", "format": "date-time"}, "updated_at": {"type": "string", "format": "date-time"}}}, "FeatureSuggestionsResponse": {"type": "object", "required": ["app_uuid", "suggestions", "count", "next_cursor", "has_more"], "properties": {"app_uuid": {"type": "string"}, "suggestions": {"type": "array", "items": {"$ref": "#/components/schemas/FeatureSuggestion"}}, "count": {"type": "integer", "description": "Number of suggestions in `suggestions`."}, "next_cursor": {"type": ["string", "null"], "description": "Pass as `cursor` (with the same `status`) for the next page; `null` on the last page."}, "has_more": {"type": "boolean", "description": "`true` exactly when `next_cursor` is set."}, "scanned_count": {"type": "integer", "description": "Present only when `status` filters the list: stored suggestions read to find the matches. They are read in storage-key order, not by date."}, "truncated": {"type": "boolean", "description": "Present only when `status` filters the list: `true` when the read stopped at its bound (2,000 suggestions) before reaching the end, so more matches may exist (of any age); `next_cursor` continues the read."}}}, "FeatureImpactAnalysis": {"type": "object", "description": "AI-generated impact analysis for a feature", "properties": {"expected_rating_lift": {"type": "number", "minimum": 0, "maximum": 0.5, "description": "Expected increase in app rating (e.g., 0.15 = +0.15 stars)"}, "confidence_interval": {"type": "array", "items": {"type": "number"}, "minItems": 2, "maxItems": 2, "description": "Lower and upper bounds of rating lift estimate"}, "confidence_level": {"type": "number", "minimum": 0.5, "maximum": 0.95, "description": "Confidence level of the prediction"}, "reasoning": {"type": "string", "description": "Explanation of the prediction"}, "user_segments_affected": {"type": "array", "items": {"type": "string"}, "description": "User segments that would benefit from this feature"}, "implementation_priority": {"type": "string", "enum": ["critical", "high", "medium", "low"], "description": "Recommended implementation priority"}, "estimated_positive_reviews": {"type": "integer", "description": "Estimated additional positive reviews if implemented"}, "risk_if_not_implemented": {"type": "string", "description": "Description of risk if feature is not built"}, "recommended_scope": {"type": "string", "description": "MVP scope recommendation"}, "success_metrics": {"type": "array", "items": {"type": "string"}, "description": "Metrics to track after implementation"}, "comparable_features": {"type": "string", "description": "Similar features in competing apps"}, "analyzed_at": {"type": "string", "format": "date-time", "description": "When the analysis was performed"}}}, "ReplyTemplateSelection": {"type": "object", "additionalProperties": false, "required": ["template_id", "source"], "description": "The template explicitly selected in the reply composer. The server resolves its exact owned source and records a content fingerprint and revision. This records attribution, including subsequent edits; it does not assert that the final reply exactly matches the original template. Historical replies without an explicit record remain unattributed.", "properties": {"template_id": {"type": "string", "minLength": 1, "maxLength": 200}, "source": {"type": "string", "enum": ["app", "workspace", "builtin"]}, "locale": {"type": "string", "maxLength": 35}}}, "TemplatePerformanceRow": {"type": "object", "required": ["template_id", "template_key", "source", "uses", "measured_outcomes", "rating_observations", "followup_observations"], "properties": {"template_id": {"type": "string"}, "template_key": {"type": "string", "description": "Distinguishes identical IDs in different template sources or apps."}, "source": {"type": "string", "enum": ["app", "workspace", "builtin"]}, "app_uuid": {"type": ["string", "null"]}, "name": {"type": "string"}, "category": {"type": ["string", "null"]}, "is_builtin": {"type": "boolean"}, "uses": {"type": "integer", "description": "Recorded, owned publications in the selected window."}, "positive_outcomes": {"type": "integer"}, "measured_outcomes": {"type": "integer", "description": "Publications with a measured rating or sentiment outcome."}, "rating_observations": {"type": "integer"}, "followup_observations": {"type": "integer"}, "average_rating_change": {"type": ["number", "null"], "description": "Mean of recorded rating changes, including measured zero; null when unmeasured."}, "effectiveness_score": {"type": ["number", "null"], "description": "Positive outcomes as a percentage of measured outcomes; null when unmeasured."}}}, "TemplateComparisonResponse": {"type": "object", "required": ["comparison", "period_days"], "properties": {"comparison": {"type": "array", "items": {"$ref": "#/components/schemas/TemplatePerformanceRow"}}, "period_days": {"type": "integer"}}}, "TemplatePerformanceResponse": {"type": "object", "properties": {"performance": {"type": "object", "properties": {"total_uses": {"type": "integer"}, "measured_outcomes": {"type": "integer"}, "rating_observations": {"type": "integer"}, "followup_observations": {"type": "integer"}, "response_rate": {"type": ["number", "null"], "format": "float", "description": "Recorded follow-up proportion among observed follow-ups; null when unmeasured."}, "average_rating_change": {"type": ["number", "null"], "format": "float"}, "top_templates": {"type": "array", "items": {"$ref": "#/components/schemas/TemplatePerformanceRow"}}}}, "period_days": {"type": "integer", "description": "The window the analysis covered, in days."}}}, "FeatureLaunch": {"type": "object", "description": "Detected feature launch with rating impact", "properties": {"feature": {"type": "string", "description": "Name of the launched feature"}, "launch_week": {"type": "string", "description": "Week when feature was detected (YYYY-Www format)"}, "mentions_before": {"type": "integer", "description": "Feature mentions before launch"}, "mentions_after": {"type": "integer", "description": "Feature mentions after launch"}, "rating_before": {"type": "number", "description": "Average rating before launch"}, "rating_after": {"type": "number", "description": "Average rating after launch"}, "rating_impact": {"type": "number", "description": "Change in rating attributed to feature"}, "confidence": {"type": "string", "enum": ["high", "medium", "low"], "description": "Confidence level of detection"}, "sample_reviews": {"type": "array", "items": {"type": "string"}, "description": "Sample review excerpts mentioning the feature"}}}, "RatingTrend": {"type": "object", "description": "Rating trend over time", "properties": {"start_rating": {"type": "number", "description": "Rating at start of period"}, "end_rating": {"type": "number", "description": "Rating at end of period"}, "change": {"type": "number", "description": "Net change in rating"}, "direction": {"type": "string", "enum": ["improving", "declining", "stable"]}}}, "FeatureLaunchesResponse": {"type": "object", "description": "Response containing detected feature launches", "properties": {"competitor": {"type": "string", "description": "Competitor name"}, "competitor_id": {"type": "string", "description": "Competitor identifier"}, "launches": {"type": "array", "items": {"$ref": "#/components/schemas/FeatureLaunch"}, "description": "Detected feature launches sorted by impact"}, "rating_trend": {"$ref": "#/components/schemas/RatingTrend"}, "analysis_period_days": {"type": "integer", "description": "Number of days analyzed"}, "reviews_analyzed": {"type": "integer", "description": "Number of reviews analyzed"}, "generated_at": {"type": "string", "format": "date-time"}}}, "CompetitorSnapshot": {"type": "object", "description": "Point-in-time competitor metrics snapshot", "properties": {"rating_status": {"type": "string", "enum": ["available", "unavailable"], "description": "Whether aggregate rating data can be used. Apple public aggregates are unavailable pending source permission."}, "rating_unavailable_reason": {"type": ["string", "null"], "description": "source_permission_unverified for Apple public rating aggregates."}, "competitor_id": {"type": "string"}, "captured_at": {"type": "string", "format": "date-time"}, "rating": {"type": ["number", "null"], "description": "Listing rating or fetched-review mean, identified by rating_source; null when unavailable."}, "rating_count": {"type": ["integer", "null"], "description": "Listing count or fetched review-set size, identified by review_count_source; null when unavailable."}, "rating_source": {"$ref": "#/components/schemas/CompetitorMetricSource"}, "review_count_source": {"$ref": "#/components/schemas/CompetitorMetricSource"}, "name": {"type": "string", "description": "Competitor app name"}, "category": {"type": "string", "description": "App store category"}}}, "CompetitorSnapshotResponse": {"type": "object", "description": "Response after capturing a snapshot", "properties": {"success": {"type": "boolean"}, "snapshot": {"$ref": "#/components/schemas/CompetitorSnapshot"}, "captured_at": {"type": "string", "format": "date-time"}}}, "CompetitiveSentimentResponse": {"type": "object", "description": "Sentiment and rating comparison against the tenant's tracked competitors. Metric fields are null for a competitor that has not been synced yet.", "properties": {"app_uuid": {"type": "string"}, "comparisons": {"type": "array", "items": {"type": "object", "properties": {"platform": {"type": "string"}, "external_app_id": {"type": "string"}, "display_name": {"type": "string"}, "average_rating": {"type": ["number", "null"], "format": "float"}, "total_reviews": {"type": ["integer", "null"]}, "rating_source": {"$ref": "#/components/schemas/CompetitorMetricSource"}, "review_count_source": {"$ref": "#/components/schemas/CompetitorMetricSource"}, "sentiment_score": {"type": ["number", "null"], "format": "float"}}}}}}, "BenchmarkSummary": {"type": "object", "description": "Illustrative static summary values, not measured performance or an actual review sample.", "properties": {"analysis_seconds": {"type": "integer", "description": "Illustrative analysis-time value in seconds, not a measured average."}, "lead_minutes": {"type": "integer", "description": "Illustrative review-window value in minutes, not a measured lead indicator."}, "coverage": {"type": "integer", "description": "Number of supported live app stores represented"}, "daily_volume": {"type": "integer", "description": "Legacy illustrative count, not measured daily production throughput."}, "benchmark_sample_size": {"type": "integer", "description": "Illustrative review count; no actual review sample is represented."}, "average_rating": {"type": "number", "description": "Illustrative rating value, not a measured platform average."}}}, "BenchmarkScenario": {"type": "object", "description": "Illustrative scenario for a use case, not measured outcomes.", "properties": {"id": {"type": "string", "description": "Scenario identifier"}, "label": {"type": "string", "description": "Human-readable scenario name"}, "analysis_seconds": {"type": "integer"}, "lead_minutes": {"type": "integer", "description": "Illustrative review-window value in minutes, not a measured lead indicator."}, "coverage": {"type": "integer"}, "throughput_per_day": {"type": "integer", "description": "Legacy illustrative count, not measured daily production throughput."}, "benchmark_sample_size": {"type": "integer", "description": "Illustrative scenario count; no actual review sample is represented."}, "summary": {"type": "string", "description": "Scenario description"}, "highlights": {"type": "array", "items": {"type": "string"}, "description": "Key features of this scenario"}}}, "PublicBenchmarksResponse": {"type": "object", "description": "Illustrative static sample response; not measured production performance.", "properties": {"data_source": {"type": "string", "description": "Provenance of these values; always illustrative_static_sample, never a measured benchmark.", "example": "illustrative_static_sample"}, "measured": {"type": "boolean", "description": "Always false; no measured performance or actual sample is represented.", "example": false}, "sample_notice": {"type": "string", "description": "Explains that the figures cannot establish production performance or actual sample size."}, "updated_at": {"type": "string", "format": "date-time", "description": "When this sample response was generated, not when any underlying performance was measured."}, "summary": {"$ref": "#/components/schemas/BenchmarkSummary"}, "platform_breakdown": {"type": "array", "items": {"type": "object", "properties": {"platform": {"type": "string"}, "percentage": {"type": "number"}}}, "description": "Illustrative supported-store mix, not a measured distribution."}, "scenarios": {"type": "array", "items": {"$ref": "#/components/schemas/BenchmarkScenario"}, "description": "Illustrative scenarios for different use cases, not measured outcomes."}}}, "PublicPlaygroundResponse": {"type": "object", "description": "Playground demo data response", "properties": {"resource": {"type": "string", "enum": ["reviews", "alerts", "health"], "description": "Type of resource returned"}, "requested_at": {"type": "string", "format": "date-time"}, "latency_ms": {"type": "integer", "description": "Simulated latency in milliseconds"}, "payload": {"type": "object", "description": "Resource-specific data", "additionalProperties": true}}}, "PublicFeedbackRequest": {"type": "object", "description": "Feedback submission request", "required": ["reason"], "properties": {"reason": {"type": "string", "maxLength": 100, "description": "Feedback reason code (e.g., too_expensive, missing_feature)"}, "source": {"type": "string", "maxLength": 50, "description": "Source of feedback (e.g., cancellation_email, survey)"}, "email": {"type": "string", "format": "email", "description": "Optional email for follow-up"}, "comment": {"type": "string", "maxLength": 2000, "description": "Additional feedback comments"}, "idempotency_key": {"type": "string", "format": "uuid", "minLength": 36, "maxLength": 36, "pattern": "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89aAbB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$", "description": "Optional client-generated UUIDv4. Reuse it with the same canonical feedback when retrying an attempt so an ambiguous lost response cannot create a duplicate. Reuse with changed feedback returns 409."}, "tenant_id": {"type": "string", "description": "Tenant ID if known"}}}, "PublicFeedbackResponse": {"type": "object", "description": "Feedback submission response", "properties": {"success": {"type": "boolean"}, "feedback_id": {"type": "string", "description": "Unique feedback identifier"}, "message": {"type": "string", "description": "Confirmation message"}}}}}}