# Cancel a scheduled deletion during its grace period Source: https://docs.asobeast.com/api-reference/account/cancel-a-scheduled-deletion-during-its-grace-period /api-reference/openapi.json delete /account/deletion # Schedule this workspace for deletion after a grace period Source: https://docs.asobeast.com/api-reference/account/schedule-this-workspace-for-deletion-after-a-grace-period /api-reference/openapi.json post /account/deletion # Stream every row this workspace owns as newline delimited JSON Source: https://docs.asobeast.com/api-reference/account/stream-every-row-this-workspace-owns-as-newline-delimited-json /api-reference/openapi.json get /account/export # Whether this workspace is scheduled for deletion Source: https://docs.asobeast.com/api-reference/account/whether-this-workspace-is-scheduled-for-deletion /api-reference/openapi.json get /account/deletion # Change the state of many actions Source: https://docs.asobeast.com/api-reference/actions/change-the-state-of-many-actions /api-reference/openapi.json patch /actions # Change the state of one action Source: https://docs.asobeast.com/api-reference/actions/change-the-state-of-one-action /api-reference/openapi.json patch /actions/{id} # List actions across every tracked app Source: https://docs.asobeast.com/api-reference/actions/list-actions-across-every-tracked-app /api-reference/openapi.json get /actions # List actions for one tracked app Source: https://docs.asobeast.com/api-reference/actions/list-actions-for-one-tracked-app /api-reference/openapi.json get /apps/{id}/actions # Queue an action generation run Source: https://docs.asobeast.com/api-reference/actions/queue-an-action-generation-run /api-reference/openapi.json post /actions/run # Read one action Source: https://docs.asobeast.com/api-reference/actions/read-one-action /api-reference/openapi.json get /actions/{id} # Report AI explanation availability Source: https://docs.asobeast.com/api-reference/actions/report-ai-explanation-availability /api-reference/openapi.json get /actions/ai-status # Report daily action activity Source: https://docs.asobeast.com/api-reference/actions/report-daily-action-activity /api-reference/openapi.json get /actions/activity # Summarize one action with the optional AI seam Source: https://docs.asobeast.com/api-reference/actions/summarize-one-action-with-the-optional-ai-seam /api-reference/openapi.json post /actions/{id}/explain # Summarize the action queue Source: https://docs.asobeast.com/api-reference/actions/summarize-the-action-queue /api-reference/openapi.json get /actions/summary # Egress proxy pool health for operators Source: https://docs.asobeast.com/api-reference/admin/egress-proxy-pool-health-for-operators /api-reference/openapi.json get /admin/proxy-pool # Every account on the instance, newest first Source: https://docs.asobeast.com/api-reference/admin/every-account-on-the-instance-newest-first /api-reference/openapi.json get /admin/support/users # Every tracked app on the instance, newest first Source: https://docs.asobeast.com/api-reference/admin/every-tracked-app-on-the-instance-newest-first /api-reference/openapi.json get /admin/support/apps # Instance totals for the platform operator Source: https://docs.asobeast.com/api-reference/admin/instance-totals-for-the-platform-operator /api-reference/openapi.json get /admin/support/overview # List every workspace with its operational state Source: https://docs.asobeast.com/api-reference/admin/list-every-workspace-with-its-operational-state /api-reference/openapi.json get /admin/support/workspaces # Operational detail for one workspace Source: https://docs.asobeast.com/api-reference/admin/operational-detail-for-one-workspace /api-reference/openapi.json get /admin/support/workspaces/{workspaceId} # Queue the daily run for one workspace Source: https://docs.asobeast.com/api-reference/admin/queue-the-daily-run-for-one-workspace /api-reference/openapi.json post /admin/support/workspaces/{workspaceId}/run-daily # Reconcile one workspace against the billing provider Source: https://docs.asobeast.com/api-reference/admin/reconcile-one-workspace-against-the-billing-provider /api-reference/openapi.json post /admin/support/workspaces/{workspaceId}/reconcile # Replay a stored billing event for this workspace Source: https://docs.asobeast.com/api-reference/admin/replay-a-stored-billing-event-for-this-workspace /api-reference/openapi.json post /admin/support/workspaces/{workspaceId}/billing-events/{eventId}/replay # Restore a suspended workspace Source: https://docs.asobeast.com/api-reference/admin/restore-a-suspended-workspace /api-reference/openapi.json post /admin/support/workspaces/{workspaceId}/restore # Suspend a workspace Source: https://docs.asobeast.com/api-reference/admin/suspend-a-workspace /api-reference/openapi.json post /admin/support/workspaces/{workspaceId}/suspend # Total daily demand against pool capacity Source: https://docs.asobeast.com/api-reference/admin/total-daily-demand-against-pool-capacity /api-reference/openapi.json get /admin/capacity # Whether the optional AI features are configured Source: https://docs.asobeast.com/api-reference/ai/whether-the-optional-ai-features-are-configured /api-reference/openapi.json get /ai/status # Flush the alert outbox into grouped deliveries Source: https://docs.asobeast.com/api-reference/alerts/flush-the-alert-outbox-into-grouped-deliveries /api-reference/openapi.json post /alerts/flush # List recent alert deliveries for one channel Source: https://docs.asobeast.com/api-reference/alerts/list-recent-alert-deliveries-for-one-channel /api-reference/openapi.json get /alerts/deliveries # Probe which alert channels are available Source: https://docs.asobeast.com/api-reference/alerts/probe-which-alert-channels-are-available /api-reference/openapi.json get /alerts/config # Report the alert delivery mode and outbox status Source: https://docs.asobeast.com/api-reference/alerts/report-the-alert-delivery-mode-and-outbox-status /api-reference/openapi.json get /alerts/delivery # Dashboard summary for an app Source: https://docs.asobeast.com/api-reference/analytics/dashboard-summary-for-an-app /api-reference/openapi.json get /apps/{id}/summary # Per app signals and cross-app movers for the portfolio dashboard Source: https://docs.asobeast.com/api-reference/analytics/per-app-signals-and-cross-app-movers-for-the-portfolio-dashboard /api-reference/openapi.json get /portfolio/insights # Portfolio dashboard summary for the workspace Source: https://docs.asobeast.com/api-reference/analytics/portfolio-dashboard-summary-for-the-workspace /api-reference/openapi.json get /portfolio # Rank distribution history bands for an app Source: https://docs.asobeast.com/api-reference/analytics/rank-distribution-history-bands-for-an-app /api-reference/openapi.json get /apps/{id}/rank-distribution-history # Ratings average and count history for an app Source: https://docs.asobeast.com/api-reference/analytics/ratings-average-and-count-history-for-an-app /api-reference/openapi.json get /apps/{id}/ratings-history # Visibility score history series for an app Source: https://docs.asobeast.com/api-reference/analytics/visibility-score-history-series-for-an-app /api-reference/openapi.json get /apps/{id}/visibility-history # List the event markers of an app Source: https://docs.asobeast.com/api-reference/annotations/list-the-event-markers-of-an-app /api-reference/openapi.json get /apps/{id}/annotations # Mark an event on the app charts Source: https://docs.asobeast.com/api-reference/annotations/mark-an-event-on-the-app-charts /api-reference/openapi.json post /apps/{id}/annotations Stores a named event for one app: a label pinned to one UTC day, drawn as a vertical line on the rank history and visibility charts. market scopes the line to one storefront of the app's store and tag groups a campaign; both are optional. Writes nothing to the store. # Remove an event marker Source: https://docs.asobeast.com/api-reference/annotations/remove-an-event-marker /api-reference/openapi.json delete /apps/{id}/annotations/{annotationId} # Delete an app Source: https://docs.asobeast.com/api-reference/apps/delete-an-app /api-reference/openapi.json delete /apps/{id} # Get an app with its latest snapshot and competitors Source: https://docs.asobeast.com/api-reference/apps/get-an-app-with-its-latest-snapshot-and-competitors /api-reference/openapi.json get /apps/{id} # Import an app from a store URL Source: https://docs.asobeast.com/api-reference/apps/import-an-app-from-a-store-url /api-reference/openapi.json post /apps # Link an app to its counterpart on the other store Source: https://docs.asobeast.com/api-reference/apps/link-an-app-to-its-counterpart-on-the-other-store /api-reference/openapi.json post /apps/{id}/link # List imported apps Source: https://docs.asobeast.com/api-reference/apps/list-imported-apps /api-reference/openapi.json get /apps # List the markets an app has a captured listing in Source: https://docs.asobeast.com/api-reference/apps/list-the-markets-an-app-has-a-captured-listing-in /api-reference/openapi.json get /apps/{id}/listing-markets # Probe whether an app is published in a storefront Source: https://docs.asobeast.com/api-reference/apps/probe-whether-an-app-is-published-in-a-storefront /api-reference/openapi.json get /apps/{id}/market-availability # Queue lookups that detect the localizations an app ships Source: https://docs.asobeast.com/api-reference/apps/queue-lookups-that-detect-the-localizations-an-app-ships /api-reference/openapi.json post /apps/{id}/localizations/detect Takes the localizations the markets of the app read, drops the ones the app ships and, when the home listing declares its languages, the ones in languages it does not declare, then queues one deferred store lookup per remaining localization in the first market that has a stored default listing and can probe it. A lookup records the localization as detected when the store serves a listing that differs from the default one. Spends one refresh allowance only when at least one lookup is newly queued; a localization already queued today queues nothing and spends nothing. Answers 202 with the number of lookups queued and every candidate. Answers 400 for a Google Play app, 404 for an unknown app and 429 when the refresh allowance is spent. # Read the guided setup of an app Source: https://docs.asobeast.com/api-reference/apps/read-the-guided-setup-of-an-app /api-reference/openapi.json get /apps/{id}/setup The status, the skipped steps, whether countries were reviewed and the selected markets of the guided setup, kept on the account so every member and every device sees the same progress. An import starts it in progress; a primary app without a row answers not_started with its home market selected and updatedAt null. Answers 404 for a competitor or an unknown app. # Refresh an app and return the snapshot diff Source: https://docs.asobeast.com/api-reference/apps/refresh-an-app-and-return-the-snapshot-diff /api-reference/openapi.json post /apps/{id}/refresh # Search a store by app name Source: https://docs.asobeast.com/api-reference/apps/search-a-store-by-app-name /api-reference/openapi.json get /store/search Lists up to 25 listings the store returns for a name in one storefront, with the store app id, title, developer, rating and icon, so an app or a competitor can be picked by name before it is imported or added by its id. Reaches the store while you wait, so it answers within the store deadline and spends one store lookup of the hourly allowance; a request the validation refuses spends nothing. # Seed a market with the keywords of its own listing Source: https://docs.asobeast.com/api-reference/apps/seed-a-market-with-the-keywords-of-its-own-listing /api-reference/openapi.json post /apps/{id}/market-seed Captures the listing of the app in one storefront now, extracts the title and subtitle phrases of that listing, of its native localizations and of every localization the app ships that the storefront reads besides its default, tracks up to 15 of them in that market as listing keywords within the keyword room of the plan, queues their first score and first position check, and asks for the listings of the competitors there. A phrase the app already tracks there, active or paused, is left as it is. Phrases the room cannot hold are counted in skipped, never refused. Answers status unavailable and writes nothing when the store has no listing of the app in that storefront. Spends one refresh allowance once the store is asked, so a refusal costs nothing. Answers 400 for the home market, for a competitor app and for a code that is not a storefront of the store the app is in, 404 for an unknown app and 429 when the refresh allowance is spent. # Suggest storefronts from the languages the home listing declares Source: https://docs.asobeast.com/api-reference/apps/suggest-storefronts-from-the-languages-the-home-listing-declares /api-reference/openapi.json get /apps/{id}/market-suggestions Reads the languages the newest home listing declares and answers every App Store storefront that reads a localization in one of them, the home storefront left out, with the localization, its role in that storefront (default or additional), whether the app already tracks keywords there and the daily store requests the market costs: the listing reads of the app and its competitors, native localizations and the localizations the app ships there included, and the requests one tracked keyword adds. English is never suggested because every storefront reads it. A Google Play app and a competitor app answer an empty list. Answers 404 for an unknown app. # Unlink an app from its group Source: https://docs.asobeast.com/api-reference/apps/unlink-an-app-from-its-group /api-reference/openapi.json delete /apps/{id}/link # Update the guided setup of an app Source: https://docs.asobeast.com/api-reference/apps/update-the-guided-setup-of-an-app /api-reference/openapi.json patch /apps/{id}/setup Changes the fields given and keeps the rest; an empty body changes nothing. Skipped steps are stored in step order. completed stamps completedAt once and keeps that time when completed again; any other status clears it. Answers 400 for an unknown status or step, a repeated step or market, a market that is not a lowercase two letter code, a market that is not a storefront of the app's store unless the setup already holds it or it is the home market of the app, no market or more than 50, and 404 for a competitor or an unknown app. # ASO audit score card for an app Source: https://docs.asobeast.com/api-reference/audit/aso-audit-score-card-for-an-app /api-reference/openapi.json get /apps/{id}/audit # ASO audit score history series for an app Source: https://docs.asobeast.com/api-reference/audit/aso-audit-score-history-series-for-an-app /api-reference/openapi.json get /apps/{id}/audit/history # Queue the AI creative analysis for an app Source: https://docs.asobeast.com/api-reference/audit/queue-the-ai-creative-analysis-for-an-app /api-reference/openapi.json post /apps/{id}/audit/ai/runs # Run the AI audit synchronously (deprecated: use POST /apps/{id}/audit/ai/runs) Source: https://docs.asobeast.com/api-reference/audit/run-the-ai-audit-synchronously-deprecated:-use-post-apps-auditairuns /api-reference/openapi.json post /apps/{id}/audit/ai # Accept an invitation and create the account Source: https://docs.asobeast.com/api-reference/auth/accept-an-invitation-and-create-the-account /api-reference/openapi.json post /workspace/invites/accept # Change password and reset other sessions Source: https://docs.asobeast.com/api-reference/auth/change-password-and-reset-other-sessions /api-reference/openapi.json post /auth/password # Confirm an email address and start the trial Source: https://docs.asobeast.com/api-reference/auth/confirm-an-email-address-and-start-the-trial /api-reference/openapi.json post /auth/verify # Create a personal api token Source: https://docs.asobeast.com/api-reference/auth/create-a-personal-api-token /api-reference/openapi.json post /auth/tokens # Current authenticated user Source: https://docs.asobeast.com/api-reference/auth/current-authenticated-user /api-reference/openapi.json get /auth/me # Email a recovery link to an account that exists Source: https://docs.asobeast.com/api-reference/auth/email-a-recovery-link-to-an-account-that-exists /api-reference/openapi.json post /auth/password/forgot # Invite a member into this workspace Source: https://docs.asobeast.com/api-reference/auth/invite-a-member-into-this-workspace /api-reference/openapi.json post /workspace/invites # List personal api tokens Source: https://docs.asobeast.com/api-reference/auth/list-personal-api-tokens /api-reference/openapi.json get /auth/tokens # Log in Source: https://docs.asobeast.com/api-reference/auth/log-in /api-reference/openapi.json post /auth/login # Log out Source: https://docs.asobeast.com/api-reference/auth/log-out /api-reference/openapi.json post /auth/logout # Members of this workspace and pending invites Source: https://docs.asobeast.com/api-reference/auth/members-of-this-workspace-and-pending-invites /api-reference/openapi.json get /workspace/team # Public auth configuration and session state Source: https://docs.asobeast.com/api-reference/auth/public-auth-configuration-and-session-state /api-reference/openapi.json get /auth/status # Redeem a recovery link and end every session Source: https://docs.asobeast.com/api-reference/auth/redeem-a-recovery-link-and-end-every-session /api-reference/openapi.json post /auth/password/reset # Register an account Source: https://docs.asobeast.com/api-reference/auth/register-an-account /api-reference/openapi.json post /auth/register # Remove a member from this workspace Source: https://docs.asobeast.com/api-reference/auth/remove-a-member-from-this-workspace /api-reference/openapi.json delete /workspace/members/{id} # Revoke a pending invitation Source: https://docs.asobeast.com/api-reference/auth/revoke-a-pending-invitation /api-reference/openapi.json delete /workspace/invites/{id} # Revoke a personal api token Source: https://docs.asobeast.com/api-reference/auth/revoke-a-personal-api-token /api-reference/openapi.json delete /auth/tokens/{id} # Send a fresh confirmation link to this account Source: https://docs.asobeast.com/api-reference/auth/send-a-fresh-confirmation-link-to-this-account /api-reference/openapi.json post /auth/verify/resend # Workspace plan, limits and usage against them Source: https://docs.asobeast.com/api-reference/auth/workspace-plan-limits-and-usage-against-them /api-reference/openapi.json get /auth/plan # Authenticate API requests Source: https://docs.asobeast.com/api-reference/authentication Send a personal API token as a bearer header on every request. Create the token in Integrations, where it is shown once and prefixed with asob_. Send your personal API token as a bearer header on every request. There is no public endpoint and no anonymous mode. Send the token in the `Authorization` header only. A token in a query string is ignored, answers `401`, and is never repeated in the error body. ```bash cURL theme={null} curl -H "Authorization: Bearer asob_…" \ https://your-host/api/backend/health ``` ```javascript JavaScript theme={null} const response = await fetch("https://your-host/api/backend/health", { headers: { Authorization: `Bearer ${process.env.ASOBEAST_API_TOKEN}` }, }); const health = await response.json(); ``` ```python Python theme={null} import os, requests response = requests.get( "https://your-host/api/backend/health", headers={"Authorization": f"Bearer {os.environ['ASOBEAST_API_TOKEN']}"}, ) health = response.json() ``` ## Two credential types | Credential | Used by | Obtained from | | - | - | - | | Session cookie | The dashboard in a browser | Signing in. The proxy forwards it for you | | Personal API token | Scripts, the MCP server, continuous integration | The API tokens card in Integrations | Both carry the same entitlement checks. A token can be narrower than a session: it carries a scope and an optional expiry, and a read only token is refused with `403` the moment it tries to change anything. Give each consumer its own token and revoke individually. A token cannot create or revoke tokens, change the password, schedule workspace deletion or manage the team; those routes need a session. See [Personal API tokens](/security/api-tokens). ## Scope and expiry | Setting | Values | Default | | - | - | - | | Scope | `read` or `write` | `read` | | Expiry | Up to 365 days, or never | Never | Read only is the right default for anything agentic. The MCP server lists the tools that change things only to a token with the write scope, so give an agent a read token unless it has to change something. An expired token fails authentication with `401` exactly like a revoked one. Called with a token, `GET /auth/me` also reports the token's scope as `tokenScope`. A browser session has no scope, so the field is absent. Rate limits count per workspace rather than per token, so minting a second token buys no extra capacity. See [Rate limits and quotas](/api-reference/rate-limits). ## Status codes you will see | Status | Meaning | | - | - | | `401` | The credential is missing, malformed, revoked or signed with a rotated secret | | `402` | The account is not entitled, which only happens when `BILLING_ENABLED` is `true` | | `403` | The token is read only and the request would change something, the route needs a signed in session and not a token, or the workspace is suspended | | `429` | A plan rate limit is spent. `Retry-After` says when it reopens | | `404` on `/docs` or `/admin/queues` | You are not the platform operator. These surfaces answer `404` rather than confirming they exist | A `401` after a deployment usually means `AUTH_SECRET` was rotated, which invalidates sessions. Personal API tokens survive a rotation, because they are stored as hashes rather than signed. See [Rotate secrets](/operations/rotate-secrets). ## Keep the token out of your history Read it from an environment variable rather than typing it into a shell. ```bash theme={null} export ASOBEAST_API_TOKEN=asob_… curl -H "Authorization: Bearer $ASOBEAST_API_TOKEN" \ https://your-host/api/backend/apps ``` ## Related > **[Error responses](/api-reference/errors)** > > The envelope every failure returns. > **[Authentication and accounts](/security/authentication)** > > Sessions, guards and entitlements. # Open the Stripe customer portal for this workspace Source: https://docs.asobeast.com/api-reference/billing/open-the-stripe-customer-portal-for-this-workspace /api-reference/openapi.json post /billing/portal # Plans available to buy and their price ids Source: https://docs.asobeast.com/api-reference/billing/plans-available-to-buy-and-their-price-ids /api-reference/openapi.json get /billing/catalog # Reconcile this workspace against Stripe now Source: https://docs.asobeast.com/api-reference/billing/reconcile-this-workspace-against-stripe-now /api-reference/openapi.json post /billing/reconcile # Start a Stripe Checkout session for a plan Source: https://docs.asobeast.com/api-reference/billing/start-a-stripe-checkout-session-for-a-plan /api-reference/openapi.json post /billing/checkout # Category rank history series for an app Source: https://docs.asobeast.com/api-reference/category-ranks/category-rank-history-series-for-an-app /api-reference/openapi.json get /apps/{id}/category-ranks # Keyword position and visibility movement after each change to an app listing Source: https://docs.asobeast.com/api-reference/changes/keyword-position-and-visibility-movement-after-each-change-to-an-app-listing /api-reference/openapi.json get /apps/{id}/changes/impact # Metadata change timeline for an app and its competitors Source: https://docs.asobeast.com/api-reference/changes/metadata-change-timeline-for-an-app-and-its-competitors /api-reference/openapi.json get /apps/{id}/changes # Recent metadata changes across the workspace Source: https://docs.asobeast.com/api-reference/changes/recent-metadata-changes-across-the-workspace /api-reference/openapi.json get /changes/recent # Capture a top chart today Source: https://docs.asobeast.com/api-reference/charts/capture-a-top-chart-today /api-reference/openapi.json post /charts/capture Fetches today's chart from the store while you wait and stores it for every workspace, then answers the page for today. A chart already captured today is answered without a store request. Reaches the store, so it answers within the store deadline and spends one chart capture of the daily allowance only when a request is made; a refused key spends nothing. A chart the store answers with no apps is stored as empty when it was empty before; a first empty answer is not stored and comes back with answeredNoApps, and the daily run stores it after retrying. # Follow a top chart Source: https://docs.asobeast.com/api-reference/charts/follow-a-top-chart /api-reference/openapi.json post /charts/follows Asks the daily run to capture this chart every day. The plan's follow limit counts categories, so the free, paid and grossing charts of one store, country, category and device cost one follow together. Answers 201 for a new follow and 200 with the existing one when the workspace already follows the chart. Writes nothing to the store; capture today separately. # List the chart categories of a store Source: https://docs.asobeast.com/api-reference/charts/list-the-chart-categories-of-a-store /api-reference/openapi.json get /charts/genres # List the top charts this workspace follows Source: https://docs.asobeast.com/api-reference/charts/list-the-top-charts-this-workspace-follows /api-reference/openapi.json get /charts/follows # Read a top chart for one day Source: https://docs.asobeast.com/api-reference/charts/read-a-top-chart-for-one-day /api-reference/openapi.json get /charts One day of a free, paid or grossing chart of one store, country, category and device: every row with its position, its position on the comparison day and the movement between them, the climbers, fallers, new entries and exits, the days a capture exists for, whether this workspace follows the chart, and which rows are its own apps or competitors. Reads what the daily run or a capture stored; never reaches the store. # Stop following a top chart Source: https://docs.asobeast.com/api-reference/charts/stop-following-a-top-chart /api-reference/openapi.json delete /charts/follows/{id} # Add a competitor app from a store URL Source: https://docs.asobeast.com/api-reference/competitors/add-a-competitor-app-from-a-store-url /api-reference/openapi.json post /apps/{id}/competitors # Keyword gap and positioning analysis Source: https://docs.asobeast.com/api-reference/competitors/keyword-gap-and-positioning-analysis /api-reference/openapi.json get /apps/{id}/competitors/analysis # List competitors for an app Source: https://docs.asobeast.com/api-reference/competitors/list-competitors-for-an-app /api-reference/openapi.json get /apps/{id}/competitors # Remove a competitor from an app Source: https://docs.asobeast.com/api-reference/competitors/remove-a-competitor-from-an-app /api-reference/openapi.json delete /apps/{id}/competitors/{competitorId} # Suggest competitors from the keyword results and similar apps Source: https://docs.asobeast.com/api-reference/competitors/suggest-competitors-from-the-keyword-results-and-similar-apps /api-reference/openapi.json get /apps/{id}/competitors/suggestions The untracked apps that recur in this app's own keyword results over the last 30 days, with how often and how high they rank, followed by the apps the store lists as similar, each with its icon, minus the app and its tracked competitors, at most ten. On Google Play a similar app is kept only when its title and description share at least half as many of the words and phrases in this app's title and short description as the closest similar app does, most shared first. The similar apps lookup reaches the store while you wait, answers within the store deadline and spends one store lookup of the hourly allowance; when the store fails or the allowance is spent the keyword result apps are still answered and similar reads unavailable. Answers 404 for a competitor or an unknown app. # Untracked apps recurring in your keyword results Source: https://docs.asobeast.com/api-reference/competitors/untracked-apps-recurring-in-your-keyword-results /api-reference/openapi.json get /apps/{id}/competitors/discovery # API conventions Source: https://docs.asobeast.com/api-reference/conventions Dates are UTC and daily granularity uses a date string. Positions are 1 based and null means checked and not found. Queued operations return 202. Seven conventions apply across every endpoint. A specification cannot express most of them, and each one has caused a wrong integration at least once. ## Dates are UTC Every timestamp is UTC ISO 8601. Every daily value is a `date` string such as `2026-07-30`, with no time and no offset, because the underlying column is a PostgreSQL `date`. Today means the UTC date. An instance in Auckland and an instance in Los Angeles agree on which day a position belongs to, which is what makes histories comparable. Do not localize a `date` string before comparing it. Parsing `2026-07-30` as local midnight and formatting it back can shift it by a day. A date you send must exist on the calendar, and a window must run forwards. A day such as `2026-02-30` or a `to` that falls on an earlier UTC day than `from` answers `400` rather than another day's data or an empty series. The MCP tools refuse the same inputs with a tool error. ## Positions are 1 based and null is meaningful Position 1 is the top result. `null` means the keyword was checked and the app was not found within that row's `depth`, which defaults to 200. > [!WARNING] > > Never coerce `null` to `0`. Zero is a better position than 1, so treating a missing app as zero makes every unranked keyword look like the best result and inverts every average, delta and trend you compute. Render `null` as a bound derived from the row's own `depth`, such as `>200`. See [Positions and rank depth](/concepts/positions). ## Queued operations return 202 Anything that enqueues store work answers immediately with `202` and a job reference rather than waiting for a result. | Operation | Returns | | - | - | | `POST /apps/{id}/run-daily` | `202`, the daily work is queued | | `POST /keywords/{keywordId}/score` | `202`, the scoring job is queued | | `POST /actions/run` | `202`, generation is queued. Two runs the same day coalesce | | `POST /apps/{id}/refresh` | `200` with a snapshot diff, synchronously | Poll the resource afterwards rather than expecting the response to carry the result. A `202` is not a promise that the job succeeded, only that it was accepted. ## Scores are shown on a 0 to 100 scale Popularity and difficulty are stored on a 0 to 10 scale with their provenance, and some responses return them on that stored scale. Volume and opportunity are always 0 to 100, and opportunity is computed on read and never persisted. See [Which scale does a response use?](/concepts/scoring#which-scale-does-a-response-use) before comparing numbers across endpoints. Apple and Google Play scores come from different public evidence and are not directly comparable. See [Popularity, difficulty and opportunity](/concepts/scoring). ## installs is a BigInt `installs` exceeds the safe integer range in the database, so it is stored as a `BigInt` and serialized as a JSON number. A client reading it into a 64 bit integer type is fine. A client reading it into a JavaScript number is also fine at realistic install counts. ## Country codes are two letters A country is two lowercase letters such as `us`, `gb` or `de`, validated by shape rather than against a fixed list, so any live storefront works. Uppercase is rejected. Country belongs to the keyword, not to the app, which is why so many endpoints take one. See [Countries and markets](/concepts/countries). ## Rate limits depend on how the instance is run A self hosted instance applies no API rate limits beyond the authentication throttle. What is limited there is store collection, by `SCRAPE_ITUNES_RPM` and `SCRAPE_GPLAY_RPM`, so a script may read as often as it likes while queuing work has a real cost. A metered instance limits requests per workspace and per plan, in three classes, and every response carries `RateLimit` headers. See [Rate limits and quotas](/api-reference/rate-limits). Either way, `GET /jobs/budget` returns the estimated daily fan out broken down by kind alongside your capacity, which is the right thing to check before adding keywords or markets programmatically. See [The daily pipeline and rate limits](/concepts/pipeline). ## Related > **[Error responses](/api-reference/errors)** > > What a failure looks like. > **[Rate limits and quotas](/api-reference/rate-limits)** > > What a metered instance allows. # Confirm an alert recipient from the link in the confirmation email Source: https://docs.asobeast.com/api-reference/email-alerts/confirm-an-alert-recipient-from-the-link-in-the-confirmation-email /api-reference/openapi.json post /email-alerts/{id}/confirm # Delete an email alert Source: https://docs.asobeast.com/api-reference/email-alerts/delete-an-email-alert /api-reference/openapi.json delete /email-alerts/{id} # List configured email alerts Source: https://docs.asobeast.com/api-reference/email-alerts/list-configured-email-alerts /api-reference/openapi.json get /email-alerts # Register an email alert Source: https://docs.asobeast.com/api-reference/email-alerts/register-an-email-alert /api-reference/openapi.json post /email-alerts # Send a sample email to a confirmed alert recipient Source: https://docs.asobeast.com/api-reference/email-alerts/send-a-sample-email-to-a-confirmed-alert-recipient /api-reference/openapi.json post /email-alerts/{id}/test # Send the recipient a new confirmation email Source: https://docs.asobeast.com/api-reference/email-alerts/send-the-recipient-a-new-confirmation-email /api-reference/openapi.json post /email-alerts/{id}/resend-confirmation # Stop one email alert from the link in its own message (RFC 8058 one click) Source: https://docs.asobeast.com/api-reference/email-alerts/stop-one-email-alert-from-the-link-in-its-own-message-rfc-8058-one-click /api-reference/openapi.json post /email-alerts/{id}/unsubscribe # Update an email alert Source: https://docs.asobeast.com/api-reference/email-alerts/update-an-email-alert /api-reference/openapi.json patch /email-alerts/{id} # Error responses Source: https://docs.asobeast.com/api-reference/errors Every failure returns the same JSON envelope with a status code, message, path and timestamp. Validation failures return 400 with the failing fields. Every failure returns the same JSON envelope, whatever produced it. A single exception filter builds it, so a Prisma error, a guard rejection and a validation failure all look alike to a client. ## The envelope ```json theme={null} { "statusCode": 404, "error": "Not Found", "message": "Resource not found", "path": "/apps/cl123", "timestamp": "2026-07-30T09:12:44.118Z" } ``` | Field | Type | Meaning | | - | - | - | | `statusCode` | number | The HTTP status, repeated in the body so a logged payload is self describing | | `error` | string | The status name, such as `Bad Request` | | `message` | string | A human readable explanation. Multiple validation messages are joined with commas | | `path` | string | The request path that failed, without its query string | | `timestamp` | string | UTC ISO 8601 timestamp of the failure | | `quota` | object | Present on `403` when a plan limit refused the write. Names the resource, the plan, the limit, the usage and the tier to upgrade to | | `entitlement` | object | Present on `402`. Names the plan in force, the trial and renewal dates, the tier to upgrade to and the path to reach it | | `rateLimit` | object | Present on `429` from a plan rate limit or a spent on demand allowance. Names the window that closed, the request class, the plan, the limit, the seconds until it reopens, the tier to upgrade to and, for an on demand allowance, the `action` | | `aiAllowance` | object | Present on `429` when the workspace has spent the AI calls its plan includes this month. Names the plan, the allowance, the calls used, when it renews and the tier to upgrade to | | `billing` | object | Present on `409` from a billing route. Names the reason the request was refused and the recovery that clears it, either `portal` or `retry` | | `retryAfterSeconds` | number | Present on `429` and on the `503` an unreachable Redis or database causes, mirroring the `Retry-After` header | The shape is `ApiErrorEnvelope` in `@asobeast/shared`, so a TypeScript client can import it rather than redeclare it. ## Status codes | Status | What causes it in this API | | - | - | | `400` | A DTO failed validation, a store URL could not be parsed, a text value contained a NUL character or an unpaired surrogate, the body or query string was nested more than 64 levels deep, the path contained an invalid percent escape such as `%zz` (the web proxy at `/api/backend` answers it with the same envelope), or the body was not valid JSON or could not be decoded | | `401` | The session cookie or personal API token is missing, malformed or revoked. The response carries a `WWW-Authenticate: Bearer realm="asobeast"` challenge | | `402` | The workspace is not entitled. Only reachable when `BILLING_ENABLED` is `true`. Reads from a browser session stay open; see [Authentication and accounts](/security/authentication) | | `403` | A plan capacity limit refused the write, and the `quota` object names it. Also returned to a suspended workspace, and to a read only token that tried to change something | | `404` | The resource does not exist, a path id contains a NUL character, or an admin surface is refusing to confirm it exists | | `429` | A plan rate limit, an on demand allowance or the monthly AI allowance is spent. `Retry-After` says when it reopens; `rateLimit` or `aiAllowance` names which. See [Rate limits and quotas](/api-reference/rate-limits) | | `409` | A unique constraint was violated, an AI endpoint was called with `OPENAI_API_KEY` unset, or a billing route refused a request the `billing` object explains | | `413` | The request body is larger than the 100 KB (102400 bytes) the API reads, counted after any `Content-Encoding` is decoded, or a form body carries more than 1000 parameters. The body is read before authentication, so an oversized request is refused even without a valid credential. The web proxy at `/api/backend` applies the same 102400 bytes to the bytes it receives, whatever their content type or `Content-Encoding`, forwards that header so the API decodes the body and counts the decoded size against its own limit, answers the same envelope as soon as a body passes the limit without reading the rest, and closes the connection | | `415` | The body names a charset the API cannot read, or a `Content-Encoding` other than `gzip`, `deflate` or `br` | | `500` | An unhandled exception. The message is generic and the stack is logged server side | | `501` | A store that asobeast does not support. Both live stores are supported, so this path stays wired for a future store | | `502` | A store request failed upstream, or the web proxy could not reach the API. A store failure answers with a fixed sentence naming the store, and the underlying error is in the API log at warn level. An import, a refresh, a new competitor or keyword suggestions that wait more than 20 seconds for a store answer this too, so the sentence arrives before the web proxy gives up. A market check that waits that long reports the market as unknown instead | | `503` | The health endpoint could not reach the database, or Redis could not be reached for a sign in or a store action that must be counted first. `Retry-After` says when to try again. The health `503` also carries `status`, `db`, `redis` and `pipeline`, described in [Health checks](/operations/health-checks#what-does-a-database-outage-look-like). See [When Redis is unreachable](/api-reference/rate-limits#when-redis-is-unreachable) | | `504` | The web proxy timed out waiting for the API to start answering, bounded by `API_PROXY_TIMEOUT_MS` | Three of these are worth reading twice. A `429` and a `403` with a `quota` object are different problems. A `429` clears by waiting; a capacity limit does not. See [Rate limits and quotas](/api-reference/rate-limits). A `504` is produced by the Next.js proxy rather than by the API, so it appears when the API is slow rather than when a request is invalid. The same proxy returns `502` when it cannot reach the API at all. See [Web app configuration](/configuration/web). A `404` on `/docs` or `/admin/queues` is a deliberate answer rather than a missing route. See [The queue dashboard and the OpenAPI surface](/security/admin-surfaces). ## A spent AI allowance When a workspace has used every AI call its plan includes this month, the four AI endpoints answer `429` with an `aiAllowance` object, and `Retry-After` counts the seconds until 00:00 UTC on the 1st. What counts as a call is described in [AI features](/guides/ai-features#how-many-ai-calls-can-a-workspace-make). ```json theme={null} { "statusCode": 429, "error": "Too Many Requests", "message": "The monthly AI allowance is spent: 200 of 200 calls used. It renews at 2026-11-01T00:00:00.000Z.", "path": "/actions/act_1/explain", "timestamp": "2026-10-17T09:12:44.120Z", "aiAllowance": { "plan": "indie", "limit": 200, "used": 200, "resetsAt": "2026-11-01T00:00:00.000Z", "upgradeTo": "ultimate" }, "retryAfterSeconds": 1262836 } ``` A self hosted instance that sets `AI_CALLS_PER_MONTH=0` has turned AI calls off, so the same `429` carries the message `This workspace includes no AI calls.` and an `aiAllowance` whose `limit` is `0`. Nothing renews in that case, so waiting does not help until the operator raises the limit. `Retry-After` is still sent, pointing at the next period, so a client that reads it keeps working. ## Validation errors Every controller input is a DTO validated by a global pipe that refuses any property the DTO does not declare. Two consequences follow. * An unknown property answers `400` with the message `property should not exist`, so a typo in a field name is refused rather than silently ignored. * A failing field produces a `400` whose `message` names the field and the rule it broke. ```json theme={null} { "statusCode": 400, "error": "Bad Request", "message": "country must be a string, country must match /^[a-z]{2}$/ regular expression", "path": "/apps/cl123/keywords", "timestamp": "2026-07-30T09:14:02.551Z" } ``` Because unknown properties are rejected rather than stripped, a misspelled field fails the request instead of letting it succeed while changing nothing. ## Text the database cannot store PostgreSQL text cannot hold the NUL character (`U+0000`), so the API refuses it before any query runs, in the path, the query string and the body alike. * A NUL in a path id answers `404` with the message `Resource not found`, because no id can contain one. * A NUL in a query value or a body field answers `400`, and the `message` names the value, such as `note must not contain a NUL character` or `keywords[1] must not contain a NUL character`. When a property name itself holds the character, the `message` names `body` or `query` instead, so the character never travels back in a response. A JSON body can also carry half of an emoji, written as a lone escape such as `\ud83d` with no partner. That is not valid Unicode text, so it answers `400` with `must be well formed Unicode text`. Every other character is accepted, including emoji and the other control characters. Passwords and the one time tokens in confirmation, reset and invitation links are hashed rather than stored, so they are never inspected and may hold any character. A refused request stores nothing. ## Nesting depth No request this API accepts nests more than a few levels, so a body or query string nested more than 64 levels deep, counting every object and array, answers `400` with the message `body must not be nested more than 64 levels deep` (or `query`). The limit applies to every field, passwords included, and is checked before validation, so a deeply nested body is refused cheaply instead of exhausting the validator. ## What an error never repeats The `path` and the `message` of an unknown route name the path only. The query string is left out on purpose: a client that puts a token in a URL by mistake would otherwise get it back in an error body that its logs, its error reporter or a proxy may keep. The API never reads a credential from a query string. Send a personal token in the `Authorization: Bearer` header, as described in [Authentication](/api-reference/authentication). Validation messages name the field that failed and the rule it broke, not the value you sent. ## Related > **[API conventions](/api-reference/conventions)** > > What the successful responses look like. > **[Troubleshooting](/operations/troubleshooting)** > > The same codes seen from the operator side. # Liveness, database and pipeline health check Source: https://docs.asobeast.com/api-reference/health/liveness-database-and-pipeline-health-check /api-reference/openapi.json get /health # asobeast API Source: https://docs.asobeast.com/api-reference/introduction The asobeast API is a REST API over HTTP. Every endpoint requires authentication with a session cookie or a personal API token, and every response is JSON. The asobeast API is the same API the dashboard uses. Every endpoint on it requires authentication, and the endpoint pages in this tab are generated from the API's own OpenAPI document. ## Base URL There are two, and which one you use depends on where your code runs. | Base URL | Use when | | - | - | | `https://your-host/api/backend` | Almost always. This is the web origin proxy, which is reachable from anywhere the dashboard is | | `http://localhost:4000` | Only when your code runs on the same host as the API container | The proxy path is not a convenience wrapper. In the Compose stack the API stays on a private network and is never published, so the web origin is the only way in from outside the host. The proxy forwards your `Authorization` header unchanged. ## Authentication Send a personal API token as a bearer header on every request. See [Authenticate API requests](/api-reference/authentication). ## Content type JSON in, JSON out. Send `Content-Type: application/json` on any request with a body. Errors are JSON too, in a consistent envelope. See [Error responses](/api-reference/errors). ## The OpenAPI document Your own instance serves it at `/docs-json`, with human readable documentation at `/docs`. Both are gated by `API_DOCS`, which defaults to `owner`. An unauthenticated request answers `404` rather than `401`, because the surface does not confirm it exists. See [The queue dashboard and the OpenAPI surface](/security/admin-surfaces). The copy powering the endpoint pages in this tab is committed in the repository, so this documentation site never needs a running instance to build. ## A worked example Authenticate, list apps, pull a ranking history and write it out. Every step uses the same bearer header. ```bash theme={null} export ASOBEAST_URL=https://your-host/api/backend export ASOBEAST_API_TOKEN=asob_… auth=(-H "Authorization: Bearer ${ASOBEAST_API_TOKEN}") curl --silent --fail "${auth[@]}" "${ASOBEAST_URL}/auth/me" app=$(curl --silent --fail "${auth[@]}" "${ASOBEAST_URL}/apps" | jq -r '.[0].id') curl --silent --fail "${auth[@]}" \ "${ASOBEAST_URL}/apps/${app}/rankings?from=2026-07-01&to=2026-07-31" \ > rankings.json jq -r '.series[] | [.text, (.points[-1].position // ">200")] | @csv' \ rankings.json > latest-positions.csv ``` Two things to carry into your own code. A position of `null` means the keyword was checked and the app was not found within that row's `depth`, so never read it as zero. Every daily value is a UTC `date` string, so compare the strings rather than parsing them into local time. See [API conventions](/api-reference/conventions). ## Export formats The API speaks JSON only, which is what a script should read. CSV is a dashboard feature: the keyword monitor, the rankings view, the competitors page, the reviews list and the top 10 sheet each export CSV that follows the filters you applied. See [Export data](/guides/export-data). Bringing keywords in works the other way round: the dashboard reads a CSV file in the browser and sends JSON rows to the keyword import endpoints, so the API still speaks JSON only. See [Import keywords from a CSV file](/guides/track-keywords#import-keywords-from-a-csv-file). Read `RateLimit-Remaining` on the way through if the instance is metered, and honour `Retry-After` on a `429`. See [Rate limits and quotas](/api-reference/rate-limits). ## Generate a client Any OpenAPI generator works against the document. ```bash theme={null} curl -H "Authorization: Bearer asob_…" \ https://your-host/api/backend/docs-json > openapi.json npx @openapitools/openapi-generator-cli generate \ -i openapi.json -g typescript-fetch -o ./asobeast-client ``` If you write TypeScript, consider using `@asobeast/shared` directly instead. It holds the same contract types the API and the dashboard are built from, which is what the MCP server does. ## Related > **[API conventions](/api-reference/conventions)** > > UTC dates, 1 based positions, 202 for queued work. > **[Rate limits and quotas](/api-reference/rate-limits)** > > What a metered instance allows and what a 429 means. # Estimate the daily store request budget Source: https://docs.asobeast.com/api-reference/jobs/estimate-the-daily-store-request-budget /api-reference/openapi.json get /jobs/budget # Manually run the daily pipeline for one app Source: https://docs.asobeast.com/api-reference/jobs/manually-run-the-daily-pipeline-for-one-app /api-reference/openapi.json post /apps/{id}/run-daily # Progress of this workspace's own daily run Source: https://docs.asobeast.com/api-reference/jobs/progress-of-this-workspaces-own-daily-run /api-reference/openapi.json get /jobs/run-status # Report what a newly imported app is still waiting for Source: https://docs.asobeast.com/api-reference/jobs/report-what-a-newly-imported-app-is-still-waiting-for /api-reference/openapi.json get /apps/{id}/first-run # Report whether each store still parses Source: https://docs.asobeast.com/api-reference/jobs/report-whether-each-store-still-parses /api-reference/openapi.json get /jobs/store-health # Today's run of one app Source: https://docs.asobeast.com/api-reference/jobs/todays-run-of-one-app /api-reference/openapi.json get /apps/{id}/run-status Counts, for one app, the listings captured for the app and its competitors in the home storefront and every tracked market, the active keywords whose position was checked and the category charts read against the charts the app is ranked in (the chart of its price class and the grossing chart, each in its category and overall), since the daily trigger or since midnight UTC once the trigger has passed. state is running while counts are missing, complete when every count is in and delayed twenty hours after the trigger with counts still missing. paused is true when the workspace is suspended or has no plan in force, which the daily run skips, so counts will not advance; state is not changed by it. Reads stored rows only and never touches the queue. Answers 404 for an unknown app. # Add an App Store localization to an app Source: https://docs.asobeast.com/api-reference/keywords/add-an-app-store-localization-to-an-app /api-reference/openapi.json post /apps/{id}/localizations Marks one of the 50 App Store localizations as shipped by the app, with no keyword field yet, and answers 201 with the item. Answers 409 when the app already ships it, the default localization of the home storefront included, and 400 for a tag outside the 50 or a Google Play or competitor app. # Add manual keywords Source: https://docs.asobeast.com/api-reference/keywords/add-manual-keywords /api-reference/openapi.json post /apps/{id}/keywords # Aggregate suggest spider probes for a term Source: https://docs.asobeast.com/api-reference/keywords/aggregate-suggest-spider-probes-for-a-term /api-reference/openapi.json get /apps/{id}/keywords/spider # Compare keyword positions against competitors Source: https://docs.asobeast.com/api-reference/keywords/compare-keyword-positions-against-competitors /api-reference/openapi.json get /apps/{id}/keywords/compare One row per active tracked keyword, in every market it is tracked in. you and each value of positions are the latest position, 1 based; null means the app was checked and not found within the captured depth. unchecked lists the ids of the apps, the app itself and its competitors, that have no recorded check for the keyword yet, so a null for such an id means not checked yet. It is empty when every app was checked. # Enqueue suggest spider probes for a term Source: https://docs.asobeast.com/api-reference/keywords/enqueue-suggest-spider-probes-for-a-term /api-reference/openapi.json post /apps/{id}/keywords/spider # Import keywords with tags, notes and markets Source: https://docs.asobeast.com/api-reference/keywords/import-keywords-with-tags-notes-and-markets /api-reference/openapi.json post /apps/{id}/keywords/import Tracks every row that is new or paused, up to 500 rows, in one transaction, with its tags and note, as manual keywords, and queues each new keyword for its first score. Rows that are invalid, duplicated, already tracked or over the plan keyword limit are reported per row and skipped. Rows that are already tracked are never modified. Answers 403 and writes nothing when another request took the remaining keyword slots first. summary and results report the plan the import ran, row by row. imported counts the keywords this request actually tracked or resumed, which is lower than new plus resume when another request tracked some of them in between. quota.used counts the keyword markets in use after the import. # List the App Store localizations an app ships Source: https://docs.asobeast.com/api-reference/keywords/list-the-app-store-localizations-an-app-ships /api-reference/openapi.json get /apps/{id}/localizations Returns the default localization of the home storefront, every localization added by hand or detected, and every localization with a saved keyword field, the home localization first and the rest by display name. Each item carries its keyword field and the markets of the app that read it, with the role each market gives it, default or additional. markets lists the home market first and then every market in which the app tracks keywords. Answers 400 for a Google Play or competitor app and 404 for a missing app. # List the markets an app tracks keywords in Source: https://docs.asobeast.com/api-reference/keywords/list-the-markets-an-app-tracks-keywords-in /api-reference/openapi.json get /apps/{id}/keyword-countries # List the stored iOS keyword fields Source: https://docs.asobeast.com/api-reference/keywords/list-the-stored-ios-keyword-fields /api-reference/openapi.json get /apps/{id}/keyword-fields Returns every saved keyword field of the app, one per App Store localization, with the markets of the app that read it. # List tracked keywords for an app Source: https://docs.asobeast.com/api-reference/keywords/list-tracked-keywords-for-an-app /api-reference/openapi.json get /apps/{id}/keywords # Preview a keyword import without writing Source: https://docs.asobeast.com/api-reference/keywords/preview-a-keyword-import-without-writing /api-reference/openapi.json post /apps/{id}/keywords/import/preview Validates up to 500 rows and answers, for every row, whether an import would track it, resume it, skip it or refuse it, with the daily store requests the import would add and the keyword limit of the plan. Writes nothing and queues nothing. Rows without a country use the request country, which is a lower case storefront code, else the home market of the app. imported is always 0 and quota.used counts the keyword markets in use now. # Preview saving the keyword field of one localization Source: https://docs.asobeast.com/api-reference/keywords/preview-saving-the-keyword-field-of-one-localization /api-reference/openapi.json post /apps/{id}/keyword-fields/{localization}/preview Answers, for every market of the app whose storefront reads the localization, the phrases the field would add to the tracked keywords, how many of them would open a new keyword market and the daily store requests that costs, plus the phrases the save would add to and drop from the stored field and the keyword limit of the plan. Writes nothing and queues nothing. Answers 400 for text over 100 characters and for a Google Play or competitor app, and 404 for a missing app. # Read a stored iOS keyword field Source: https://docs.asobeast.com/api-reference/keywords/read-a-stored-ios-keyword-field /api-reference/openapi.json get /apps/{id}/keyword-field Returns the keyword field of one App Store localization: phrases is the field as saved, in order, and tracked is those of its phrases tracked and active in country. Without a query it reads the home storefront's default localization. duplicatesRemoved is always 0 because a read discards nothing. # Read what one market search reads Source: https://docs.asobeast.com/api-reference/keywords/read-what-one-market-search-reads /api-reference/openapi.json get /apps/{id}/keyword-fields/market Answers every localization the storefront of the market reads, in read order, with its role (default or additional), whether the app ships it, its origin (home, manual or detected, null when it is not shipped), its saved phrases and the characters they use. Without a query it describes the home storefront of the app. Any App Store storefront may be asked for, whether or not the app tracks keywords there. Answers 400 when country is neither the home storefront nor an App Store storefront and for a Google Play or competitor app, and 404 for a missing app. # Remove an App Store localization from an app Source: https://docs.asobeast.com/api-reference/keywords/remove-an-app-store-localization-from-an-app /api-reference/openapi.json delete /apps/{id}/localizations/{localization} Removes a shipped localization and its keyword field in one transaction. Each phrase of the field stops being tracked as a keyword field phrase in every market that reads the localization, unless another saved field there holds it. Keywords tracked from another source stay tracked. Answers 204, 409 for the default localization of the home storefront, 404 when the app does not ship the localization, and 400 for an unknown tag or a Google Play or competitor app. # Save the keyword field of one localization Source: https://docs.asobeast.com/api-reference/keywords/save-the-keyword-field-of-one-localization /api-reference/openapi.json put /apps/{id}/keyword-fields/{localization} Replaces the keyword field of one App Store localization with the normalized, deduplicated phrases of text, ships the localization when it is not shipped yet, and tracks every phrase in each market of trackIn. trackIn may be empty and must name only markets of the app whose storefront reads the localization. A phrase removed from the field stops being tracked as a keyword field phrase in every market that reads the localization, unless another saved field read there holds it. Answers 400 when the text runs past 100 characters, when trackIn names a country that is not a market of the app or a storefront that does not read the localization, and for a Google Play or competitor app. Answers 403 with quota and writes nothing when the keyword markets would pass the plan limit. Answers 404 for a missing app, and for an empty save of a localization the app does not ship. Saves to one app are written one at a time, so the last save wins. # Set an iOS keyword field Source: https://docs.asobeast.com/api-reference/keywords/set-an-ios-keyword-field /api-reference/openapi.json put /apps/{id}/keyword-field Replaces the keyword field of one App Store localization with the normalized, deduplicated phrases of text, tracks them in country, and returns the stored field. Without a query it sets the home storefront's default localization. Answers 400 when the storefront does not read the localization, or when the phrases joined with commas run past 100 characters, the way App Store Connect counts the field. A phrase removed from the field stops being tracked as a keyword field phrase in every market that reads the localization. Saves to one app are written one at a time, so the last save wins. # Show how the listing of an app and of each competitor matches one tracked keyword Source: https://docs.asobeast.com/api-reference/keywords/show-how-the-listing-of-an-app-and-of-each-competitor-matches-one-tracked-keyword /api-reference/openapi.json get /apps/{id}/keywords/{keywordId}/breakdown Reads stored data only and makes no store request. The app comes first, then every competitor. words are the distinct words of the keyword. Each listing is one localization the keyword market searches (localization and role are null on Google Play), judged field by field: phrase is the verdict of the metadata coverage table, a word match is exact, folded (found only with accents ignored, never counted) or none, and segments split the field text into matched and unmatched runs (null for a Google Play long description). placement is the strongest place the keyword reaches in one localization: a field holding the whole phrase, else split (every word across fields of that localization), else partial, else none; an app placement is the best of its listings, and words never combine across localizations. ranking null means never checked; position null means checked and not found within depth. listingCountry names the market whose listing was read, the home market when the keyword market has none. unread lists the localizations the market searches whose text has not been captured for that app. rating comes from the listing read and names its market. insight is a rule of thumb set only on a competitor that ranks above the app while the app has been checked; ignore a rule you do not know. standing.recentChange is the newest listing change of the market or keyword field save it reads, at most 28 days old. # Stop tracking a keyword Source: https://docs.asobeast.com/api-reference/keywords/stop-tracking-a-keyword /api-reference/openapi.json delete /apps/{id}/keywords/{keywordId} # Suggest keywords from metadata, the store, competitors, seasons or reviews Source: https://docs.asobeast.com/api-reference/keywords/suggest-keywords-from-metadata-the-store-competitors-seasons-or-reviews /api-reference/openapi.json get /apps/{id}/keywords/suggestions # Update a tracked keyword active flag or relevance Source: https://docs.asobeast.com/api-reference/keywords/update-a-tracked-keyword-active-flag-or-relevance /api-reference/openapi.json patch /apps/{id}/keywords/{keywordId} # AI metadata assistant availability Source: https://docs.asobeast.com/api-reference/metadata/ai-metadata-assistant-availability /api-reference/openapi.json get /metadata/assistant # Generate AI metadata drafts for an app Source: https://docs.asobeast.com/api-reference/metadata/generate-ai-metadata-drafts-for-an-app /api-reference/openapi.json post /apps/{id}/metadata/assistant # Metadata lint and keyword coverage matrix Source: https://docs.asobeast.com/api-reference/metadata/metadata-lint-and-keyword-coverage-matrix /api-reference/openapi.json get /apps/{id}/metadata/audit # Apps that broke into the top 10 for tracked keywords Source: https://docs.asobeast.com/api-reference/rankings/apps-that-broke-into-the-top-10-for-tracked-keywords /api-reference/openapi.json get /apps/{id}/serp-movers # Ranking history series for an app Source: https://docs.asobeast.com/api-reference/rankings/ranking-history-series-for-an-app /api-reference/openapi.json get /apps/{id}/rankings # Top search results captured for a keyword Source: https://docs.asobeast.com/api-reference/rankings/top-search-results-captured-for-a-keyword /api-reference/openapi.json get /keywords/{keywordId}/serp # Rate limits and quotas Source: https://docs.asobeast.com/api-reference/rate-limits Limits are per workspace and sized per plan across three request classes. Every response carries RateLimit headers, and a 429 names the window that closed. Limits exist to protect two different things. Request rate protects the API and the database. Store capacity protects the proxy pool and the daily run, because an API caller that triggers a refresh spends exactly what a dashboard user spends by clicking refresh. ## Limits are per workspace Every limit counts against the workspace, never against the token or the client address. Minting a second token buys no extra capacity, and several people behind one office address do not share a bucket. Counters live in Redis, so every API instance sees the same window. Two things are counted per client address instead, because no workspace is known yet when they happen: the authentication endpoints, and rejected credentials. A caller that presents a session cookie or an `asob_` token the API refuses may do so 120 times a minute from one address before the next refusal is answered with `429` instead of `401`. A credential the API accepts is never counted and never refused because of its address, so a working client never meets this limit, even when it shares an address with a caller that is being refused. Only a credential the API has to look up is counted. A session cookie that does not verify, because it was signed with another secret, has expired or carries no session id, and an `Authorization` header that carries no `asob_` token are answered `401` and never counted, because neither costs a lookup or can be guessed, so a browser signed out by an `AUTH_SECRET` rotation always reaches the sign in page. A signed cookie whose session has ended, by signing out, by a password change or by a recovery, is counted like any other refused credential. A request that arrives with no client address is not counted. Refusals at the [admin surfaces](/security/admin-surfaces) spend the same budget. An IPv4 client address is counted on its own, and an IPv4 address written in IPv4-mapped IPv6 form counts as that IPv4 address. An IPv6 client address is counted by the /64 network it belongs to, because one connection is usually assigned a whole /64 and could otherwise reset the count by moving to the next address in it. Two devices on one IPv6 network therefore share an allowance. ## Self hosted instances are unlimited With `BILLING_ENABLED` set to `false` there are no API rate limits at all beyond the existing authentication throttle, which answers a refusal with the same error envelope, `retryAfterSeconds` and `Retry-After` as the plan limits. See [How are sign in attempts throttled?](/security/authentication#how-are-sign-in-attempts-throttled) The only cost of a read is your own machine, and the only real constraint is store collection, bounded by `SCRAPE_ITUNES_RPM` and `SCRAPE_GPLAY_RPM`. See [The daily pipeline and rate limits](/concepts/pipeline). AI calls are the one exception an operator can opt in to, with `AI_CALLS_PER_MONTH`. Everything below applies to a metered instance. ## Request size limits apply everywhere These ceilings are resource safety rather than plan capacity, so they hold on a self hosted instance too. | Limit | Value | Endpoint | | - | - | - | | Keywords per bulk add | 200 | `POST /apps/{id}/keywords` | | Rows per keyword import | 500 | `POST /apps/{id}/keywords/import` and `POST /apps/{id}/keywords/import/preview` | | Bytes of one import request | 100 KB, as for every request | the same two endpoints | | Characters per keyword phrase | 100 | `POST /apps/{id}/keywords` and the keyword field saves | | Characters of keyword field text | 1,000 | `PUT /apps/{id}/keyword-field`, `PUT /apps/{id}/keyword-fields/{localization}` and its preview | | Characters of the normalized keyword field | 100 | the same saves | | Markets in `trackIn` | 200 | `PUT /apps/{id}/keyword-fields/{localization}` | A keyword is also capped at five words. Each tracked keyword market costs one real store search every day, so a pasted spreadsheet column or a scripting mistake would otherwise multiply a workspace's daily store volume without anyone choosing to. Exceeding any of these returns `400` and writes nothing, so a refused batch never lands half applied. Split a larger list across several requests. A keyword import is different from a bulk add: a problem with one row, such as a phrase over 100 characters or an unknown storefront, comes back as an invalid row in the `200` answer and the other rows are imported. Only a problem with the request itself, such as more than 500 rows, answers `400` and writes nothing. The keyword field text may run to 1,000 characters, so spacing and duplicates in a pasted field never cost a refusal. The 100 character limit applies to the field once it is normalized, counted in Unicode code points, so a letter such as ą or a Chinese, Japanese or Korean character counts once, whether or not it lies outside the Basic Multilingual Plane. See [App Store and Google Play](/concepts/stores) for how a save is measured. ## Three request classes | Class | What it covers | Drawn from | | - | - | - | | Read | Anything that returns stored data, including exports and the previews of keyword imports and keyword field saves, which validate and write nothing | The generous per minute budget | | Write | Configuration changes such as adding a keyword or a competitor | The tighter write budget | | Store | Refresh, finding localizations, run daily, score, keyword suggestions, deep search, importing an app, adding a competitor, name search and similar apps lookups, and capturing a top chart | The write budget, plus its own on demand allowance | Keyword suggestions reach the store only for the search, similar, developer and competitorSearch strategies, and each of them spends one lookup only when it has something to ask the store. Search needs an active tracked keyword to expand, developer needs the developer id in the newest listing and competitorSearch needs a phrase of two or more words in a competitor listing it reads, the one in the market or the home listing of a competitor with none there; without one they answer an empty list and spend nothing, even when the hour's allowance is spent. The listing, competitor, seasonal and review strategies answer from stored data and spend no lookup. Reading a year of stored rankings is cheap. Triggering a refresh is not, which is why store endpoints carry a separate on demand allowance on top of the request rate. Three classes do not mean three budgets. There are two per minute budgets, one for reads and one shared by writes and store requests, and a single daily allowance every class spends from. The class names how a request is priced, never how much room it buys. A class is also not a permission. A read only token may call any endpoint that does not change data, including the store touching lookups, and it spends store capacity when it does. ## What each plan allows | Limit | Indie | Ultimate | | - | - | - | | Read requests per minute | 300 | 900 | | Write requests per minute | 60 | 200 | | Requests per day | 40,000 | 400,000 | | Requests in parallel | 16 | 48 | | MCP requests per minute | 60 | 300 | | Refresh per day | 50 | 500 | | Run daily per day | 5 | 20 | | Score per day | 100 | 1,000 | | Keyword suggestions per hour | 60 | 300 | | Store lookups per hour | 60 | 300 | | Deep searches per day | 12 | 50 | | App imports per day | 10 | 100 | | Competitors added per day | 60 | 300 | | Top chart captures per day | 30 | 150 | | Followed chart categories | 10 | 50 | A chart category is one store, storefront, category and device, and its free, paid and grossing charts count once. See [Read top charts](/guides/top-charts#what-does-it-cost). A trial runs at Indie limits. A workspace whose plan has lapsed keeps the same read allowance, because its data stays readable and exportable while its capacity does not. ## The hosted MCP endpoint has its own budget `POST /mcp` draws from a per minute budget of its own, the MCP requests per minute in the table above. Every JSON-RPC message in the body costs one unit. An ordinary request carries one message and costs one, and a batch of ten messages costs ten, exactly what the same ten requests would cost sent one at a time. A request the remaining budget cannot cover is refused whole with the `429` described below, and none of its messages runs. A refused request still counts against the window, so waiting for `Retry-After` is the fix. A request that carries more messages than the whole per minute budget can never be served: it is refused without being counted, and the fix is to split it into smaller requests. A batch of more than 100 messages, or a body nested more than 64 levels deep, is refused with `400` before the budget is read, whatever the plan, so it costs nothing either. `POST /mcp` reports this budget only on a `429`, so its successful responses carry no `RateLimit` headers for it. The tool calls inside a request also spend the read or write budget of the route they use, so this budget comes on top of the limits above rather than replacing them. ## Read the headers Every response carries the tightest limit the request touched. | Header | Meaning | | - | - | | `RateLimit-Limit` | The allowance for the window closest to closing | | `RateLimit-Remaining` | What is left in that window | | `RateLimit-Reset` | Seconds until that window reopens | | `Retry-After` | Present on `429` only. Seconds until the refused request can succeed | A client that reads `RateLimit-Remaining` and honours `Retry-After` never needs to guess. ## A 429 names the window that closed ```json theme={null} { "statusCode": 429, "error": "Too Many Requests", "message": "Rate limit reached: the indie plan allows 60 write requests per minute. Wait 34 seconds before the next one, because retrying before then will fail.", "path": "/apps/cl123/keywords", "timestamp": "2026-08-14T10:30:26.118Z", "rateLimit": { "window": "minute", "rateClass": "write", "plan": "indie", "limit": 60, "resetSeconds": 34, "upgradeTo": "ultimate" }, "retryAfterSeconds": 34 } ``` `window` is `minute`, `hour`, `day` or `concurrent`. Waiting for `resetSeconds` is the whole fix for every window except `concurrent`, where you reduce how many requests you have open at once. ## A spent on demand allowance names its action Refresh, run daily, score, keyword suggestions, deep search, importing an app, adding a competitor, store lookups and capturing a top chart each carry an allowance of their own, counted per day, or per hour for keyword suggestions and store lookups. A store lookup is a name search or a similar apps lookup. Competitor suggestions never answer `429` for a spent store lookup allowance: they still answer the keyword result apps, with `similar` set to `unavailable`. When one is spent the `429` carries the same `rateLimit` object with `rateClass` set to `store`, and `action` names which allowance closed: `refresh`, `runDaily`, `score`, `suggestions`, `spider`, `importApp`, `addCompetitor`, `storeLookup` or `chartCapture`. Finding localizations (`POST /apps/{id}/localizations/detect`) is not an allowance of its own. It spends one refresh, and only when it queues at least one store lookup, so a request that finds every language already listed or already asked today costs nothing. When the refresh allowance is spent it answers `429` with `action` set to `refresh`. ```json theme={null} { "statusCode": 429, "error": "Too Many Requests", "message": "Run daily limit reached: the trial plan allows 5 requests per day. Try again in 12 hours 52 minutes.", "path": "/apps/cl123/run-daily", "timestamp": "2026-10-05T10:30:26.118Z", "rateLimit": { "window": "day", "rateClass": "store", "plan": "trial", "limit": 5, "resetSeconds": 46275, "upgradeTo": "indie", "action": "runDaily" }, "retryAfterSeconds": 46275 } ``` The allowance resets at 00:00 UTC for a daily window and at the top of the hour for an hourly one, so `Retry-After` is the exact wait. A deep search spends one allowance for the 27 lookups it queues, and only when it queues something. Starting a term whose lookups are all stored for today, or still waiting in the queue from an earlier start, costs nothing. Importing an app the workspace already tracks, adding a competitor it already has, capturing a top chart already captured today or being captured by another request at that moment, and a request the plan has no room for cost nothing either, because they make no store request. ## A rate limit is not a quota Four refusals look similar and mean different things. Conflating them makes all four unfixable. | Status | Envelope field | What it means | What fixes it | | - | - | - | - | | `429` | `rateLimit` | Too many requests too quickly | Wait for the reset, or upgrade for a larger allowance | | `429` | `aiAllowance` | The month's AI calls are spent | Wait for the 1st, or upgrade for a larger allowance | | `403` | `quota` | A plan capacity limit such as apps or tracked keyword markets | Remove something, or upgrade | | `402` | `entitlement` | The workspace has no live plan | Start a plan at the upgrade path in the envelope | Waiting does not help a `403`, and upgrading a plan does not clear a `429` that is already in flight. ## The monthly AI allowance The four endpoints that call OpenAI (`POST /actions/{id}/explain`, `POST /apps/{id}/metadata/assistant`, `POST /apps/{id}/audit/ai/runs` and the deprecated `POST /apps/{id}/audit/ai`) also spend a monthly allowance of AI calls. When it is spent they answer `429` with an `aiAllowance` object and a `Retry-After` that points at 00:00 UTC on the 1st; see [A spent AI allowance](/api-reference/errors#a-spent-ai-allowance). `GET /auth/plan` reports the calls used in `usage.aiCalls`. What counts as a call is described in [AI features](/guides/ai-features#how-many-ai-calls-can-a-workspace-make). ## When Redis is unreachable Counters live in Redis, and a counter that cannot be read must never make the rest of the API wait for it. A command to Redis is given half a second. After that the request is decided by what the counter was protecting. | Counter | While Redis cannot be reached | | - | - | | Plan request limits and the parallel request cap | The request is served and not counted | | Rejected credential count per address | The credential is checked and not counted | | Sign in, register, confirm, recovery and the other authentication endpoints | Answered `503` with `Retry-After`, because an attempt that cannot be counted could be a password guess | | Refresh, finding localizations, run daily, score, keyword suggestions, deep search, importing an app, adding a competitor and capturing a top chart | Answered `503` with `Retry-After`, because the allowance they spend is counted in Redis | | Every request that queues work: importing an app, refresh, finding localizations, run daily, score, deep search, adding or importing keywords, saving a keyword field, seeding a market, adding a competitor, an AI audit run, an action run and a report flush | Answered `503` with `Retry-After`, because the queue needs Redis too | Everything that only reads PostgreSQL keeps working, so a signed in browser session keeps its dashboards and exports through a Redis restart. A client that gets the `503` should wait the seconds in `Retry-After`. The first attempt after Redis returns succeeds within a couple of seconds, with no restart of the API. The API log names the outage in one JSON warning every thirty seconds, for the request path and the queues together, and prints no stack trace for each reconnect attempt. Queued work waits and resumes on its own once Redis is back. The same holds for an API that starts while Redis is already down: it serves requests at once, registers its scheduled jobs when Redis returns, and stops promptly on a shutdown signal either way, within a few seconds, cutting any request still waiting on Redis (see [What a restart does to a job in flight](/operations/upgrades#what-a-restart-does-to-a-job-in-flight)). A self hosted instance has no plan limits, so the first row and the row about allowances do not apply to it. The other three rows apply to every instance. ## Suspension An operator can suspend a workspace after sustained abuse. A suspended workspace keeps reading and exporting its data from the dashboard and keeps full access to billing, so it can always pay and always leave with its data. Its API tokens are refused with `403` on every route except `GET /account/export`, so a script can still take the data out. Every write is refused with `403` too, and the daily run skips the workspace. The web app tells every member of the workspace on every page, with the reason the operator recorded, and `GET /auth/me` reports `suspendedAt` and `suspendedReason` for any client that wants to say the same. ## Related > **[Error responses](/api-reference/errors)** > > The envelope every failure shares. > **[Personal API tokens](/security/api-tokens)** > > Scope, expiry and revocation. # Complaint and praise phrases that stand out in the reviews of an app against its period Source: https://docs.asobeast.com/api-reference/reviews/complaint-and-praise-phrases-that-stand-out-in-the-reviews-of-an-app-against-its-period /api-reference/openapi.json get /apps/{id}/reviews/themes # Discard the reply kept for a review Source: https://docs.asobeast.com/api-reference/reviews/discard-the-reply-kept-for-a-review /api-reference/openapi.json delete /apps/{id}/reviews/{reviewId}/reply # Draft the reply to a review with AI and keep it on the review Source: https://docs.asobeast.com/api-reference/reviews/draft-the-reply-to-a-review-with-ai-and-keep-it-on-the-review /api-reference/openapi.json post /apps/{id}/reviews/{reviewId}/reply/draft # Post the kept reply of a review to its store Source: https://docs.asobeast.com/api-reference/reviews/post-the-kept-reply-of-a-review-to-its-store /api-reference/openapi.json post /apps/{id}/reviews/{reviewId}/reply/post Sends the saved reply text to App Store Connect or Google Play with the key connected in Integrations, records when and what was posted, and marks the review replied. Posting again after an edit replaces the reply on the store. Through a signed in session only. Answers 400 without saved text or over the store limit, 403 when the connected key may not reply, 404 when the store does not know the review, 409 without a usable connection, 422 when the store refuses the reply and 429 when the store asks to slow down. # Ratings distribution from the latest snapshot (Google Play only) Source: https://docs.asobeast.com/api-reference/reviews/ratings-distribution-from-the-latest-snapshot-google-play-only /api-reference/openapi.json get /apps/{id}/reviews/histogram # Replace the reply guidelines of an app Source: https://docs.asobeast.com/api-reference/reviews/replace-the-reply-guidelines-of-an-app /api-reference/openapi.json put /apps/{id}/reviews/reply-guidelines # Save the reply text of a review or mark it as replied Source: https://docs.asobeast.com/api-reference/reviews/save-the-reply-text-of-a-review-or-mark-it-as-replied /api-reference/openapi.json patch /apps/{id}/reviews/{reviewId}/reply # Stored reviews for an app, filtered by star, version, date and reply state Source: https://docs.asobeast.com/api-reference/reviews/stored-reviews-for-an-app-filtered-by-star-version-date-and-reply-state /api-reference/openapi.json get /apps/{id}/reviews # The reply guidelines every AI draft follows Source: https://docs.asobeast.com/api-reference/reviews/the-reply-guidelines-every-ai-draft-follows /api-reference/openapi.json get /apps/{id}/reviews/reply-guidelines # Translate a review with AI, or answer the translation already kept for that language Source: https://docs.asobeast.com/api-reference/reviews/translate-a-review-with-ai-or-answer-the-translation-already-kept-for-that-language /api-reference/openapi.json post /apps/{id}/reviews/{reviewId}/translation # Withdraw the reply posted to the App Store Source: https://docs.asobeast.com/api-reference/reviews/withdraw-the-reply-posted-to-the-app-store /api-reference/openapi.json delete /apps/{id}/reviews/{reviewId}/reply/post Deletes the response posted to App Store Connect and clears the post, keeping the reply text. Through a signed in session only. Answers 404 when nothing was posted and 409 for a Google Play review, whose API offers no removal. # Enqueue an on demand keyword score Source: https://docs.asobeast.com/api-reference/scoring/enqueue-an-on-demand-keyword-score /api-reference/openapi.json post /keywords/{keywordId}/score # Screenshots of the latest snapshot of a listing with the caption read from each Source: https://docs.asobeast.com/api-reference/screenshots/screenshots-of-the-latest-snapshot-of-a-listing-with-the-caption-read-from-each /api-reference/openapi.json get /apps/{id}/screenshots # Connect a store console key Source: https://docs.asobeast.com/api-reference/store-connect/connect-a-store-console-key /api-reference/openapi.json put /store-connections/{store} Verifies the key with the store, seals it at rest bound to this workspace and replaces any key connected before, then queues a report sync. App Store Connect takes an issuer ID, a key ID and a .p8 private key; Google Play takes a service account JSON key and the bucket id of its Play Console reports. The store in the body must match the path. Owner only, through a signed in session. Answers 422 when the store refuses the key and 409 when the instance has store connections off. # Disconnect a store console Source: https://docs.asobeast.com/api-reference/store-connect/disconnect-a-store-console /api-reference/openapi.json delete /store-connections/{store} Deletes the sealed key and its report requests, even while the instance has store connections off. The funnel rows already stored stay. # Finish connecting Google Play with a Google account Source: https://docs.asobeast.com/api-reference/store-connect/finish-connecting-google-play-with-a-google-account /api-reference/openapi.json post /store-connections/GOOGLE_PLAY/sign-in/complete Redeems the code Google returned, lists the bucket with the account before anything is stored, seals the refresh token bound to this workspace, replaces any Google Play connection and queues a report sync. Answers 400 for a state of another owner, an expired state or a code Google refuses, 422 when the account was not granted Cloud Storage access or cannot read the bucket, and 409 when sign in is off or Google refuses the OAuth client of the instance. # Queue a report sync for the connected store console Source: https://docs.asobeast.com/api-reference/store-connect/queue-a-report-sync-for-the-connected-store-console /api-reference/openapi.json post /store-connections/{store}/sync Queues the download of the reports the store has published since the last sync. A sync already waiting is not queued twice. Answers 409 while the store refuses the key. # Start connecting Google Play with a Google account Source: https://docs.asobeast.com/api-reference/store-connect/start-connecting-google-play-with-a-google-account /api-reference/openapi.json post /store-connections/GOOGLE_PLAY/sign-in Answers the Google consent address to send the owner to, asking for read only Cloud Storage access, with a state sealed to this owner and workspace that expires in ten minutes. Google returns to the web page /integrations/google-play. Owner only, through a signed in session. Answers 409 when the instance has store connections off or no Google OAuth client. # Store impressions, page views, downloads and conversion of an app by market and window Source: https://docs.asobeast.com/api-reference/store-connect/store-impressions-page-views-downloads-and-conversion-of-an-app-by-market-and-window /api-reference/openapi.json get /apps/{id}/funnel Reads the rows the connected store console reported: a zero filled daily series of the requested days with the total and the search share, and the 7, 14 and 28 day windows of complete days against the period before. A day is complete once it is three days old and the store has delivered it. Answers connected false and empty series for a competitor, a store without an active connection, or an app the connected key does not report. # The store consoles this workspace has connected Source: https://docs.asobeast.com/api-reference/store-connect/the-store-consoles-this-workspace-has-connected /api-reference/openapi.json get /store-connections Every connected store console key with its status, the access its report requests were granted, and each primary app of that store with its report state and the first and last day of stored funnel rows. Never returns key material. available is false when the instance has no STORE_CONNECT_ENCRYPTION_KEY. # Verify the connected store console key again Source: https://docs.asobeast.com/api-reference/store-connect/verify-the-connected-store-console-key-again /api-reference/openapi.json post /store-connections/{store}/verify Asks the store whether the stored key still works. A refused key marks the connection rejected; an accepted one clears that and queues a report sync. Answers 409 when the stored key can no longer be decrypted. # Delete an alert webhook Source: https://docs.asobeast.com/api-reference/webhooks/delete-an-alert-webhook /api-reference/openapi.json delete /webhooks/{id} # List configured alert webhooks Source: https://docs.asobeast.com/api-reference/webhooks/list-configured-alert-webhooks /api-reference/openapi.json get /webhooks # Register an alert webhook Source: https://docs.asobeast.com/api-reference/webhooks/register-an-alert-webhook /api-reference/openapi.json post /webhooks # Send a sample payload to a webhook Source: https://docs.asobeast.com/api-reference/webhooks/send-a-sample-payload-to-a-webhook /api-reference/openapi.json post /webhooks/{id}/test # Update an alert webhook Source: https://docs.asobeast.com/api-reference/webhooks/update-an-alert-webhook /api-reference/openapi.json patch /webhooks/{id} # Changelog Source: https://docs.asobeast.com/changelog Release Please owns versions, tags and CHANGELOG.md for asobeast, and every release publishes multi architecture container images to GHCR. Release Please owns versions, tags and `CHANGELOG.md`. The generated file in the repository is the complete record, and this page deliberately does not copy it, because a second copy would drift. ## Current release line The current release line is `1.x`. `1.0.0`, the first release, shipped multi-tenancy enforced by PostgreSQL row level security, typed plans with quotas, Stripe billing, a proxy pool with a cost capped residential fallback, per workspace rate limits, a remote MCP endpoint alongside the stdio binary, structured logging and per workspace metrics, account recovery, and complete workspace export and deletion. None of it changes a self hosted installation by default, because `BILLING_ENABLED` is `false`, `PROXY_PROVIDER` is `none`, and every variable has a working default. The `1.x` line takes normal feature work, constrained by the compatibility promise below. ## What does 1.0.0 promise? Compatibility for three surfaces throughout the `1.x` line: the HTTP contract, the `@asobeast/shared` contract types and the MCP tool surface. Breaking any of them requires `2.0.0`. Environment variables, the database schema and internal modules are outside that promise, and every schema change ships as a forward Prisma migration. See [Upgrade and roll back](/install/upgrade). ## Read the release notes > **[Releases](https://github.com/AsoBeast/asobeast/releases)** > > Every tagged release with its notes and its container images. > **[CHANGELOG.md](https://github.com/AsoBeast/asobeast/blob/main/CHANGELOG.md)** > > The full generated changelog in the repository. Read the notes before every upgrade. Anything an operator must act on appears in a commit subject or a breaking change footer, which is what reaches the changelog. The version at the foot of the app's sidebar opens the notes of the release your instance runs. # How asobeast works Source: https://docs.asobeast.com/concepts/architecture A Next.js frontend talks to a NestJS API over HTTP. Store collection runs in BullMQ workers behind a provider interface, and PostgreSQL holds the state. asobeast is a Next.js frontend, a NestJS API, PostgreSQL for durable state and Redis for queues. Every store request is made from the machine that hosts the deployment, never from your browser and never from a service asobeast operates. ## What is the request path? A browser request never reaches the API directly in the production Compose stack. 1. The browser calls `/api/backend/*` on the web origin, the same origin it signed in to. 2. The Next.js route handler proxies that request to `API_INTERNAL_URL`, which Compose sets to `http://api:4000`. 3. The API authenticates the session cookie or the `asob_` bearer token, authorizes the request and answers. Because the browser and the API share an origin, there is no CORS configuration and no cross site cookie handling. `API_PROXY_TIMEOUT_MS` bounds how long each proxied request waits for the API to start answering, and a request that exceeds it returns a `504` error envelope instead of hanging. Server rendered pages skip the proxy and call `API_INTERNAL_URL` directly, which is why the same variable serves both paths. ## What are the five surfaces? | Surface | What it is | Who reaches it | | - | - | - | | Web app | The Next.js dashboard on port 3000 | Anyone with an account | | HTTP API | The NestJS API behind the proxy | Sessions and personal API tokens | | Remote MCP endpoint | `POST /mcp` inside the API, read only unless the token has the write scope | An agent holding a personal API token | | Local MCP server | A stdio process over the HTTP API, read only unless the token has the write scope | An agent on your machine | | Admin surfaces | The admin area, the queue dashboard and the OpenAPI surface | The platform operator only | Both MCP surfaces register one catalog of tool definitions and serve MCP `2026-07-28` alongside the 2025 revisions. The remote endpoint builds one server instance per request and holds nothing between requests, which is no longer a transport setting but a property of the protocol: `2026-07-28` has no sessions to hold. See [The asobeast MCP server](/mcp/introduction). ## Why is store collection isolated? Every store request goes through the `StoreProvider` interface in `apps/api/src/store-providers/`. No other module may import a scraper library. That single rule contains a parser breakage to one file. When Apple or Google change a response shape, one provider module fails and the rest of the application keeps serving requests. It also keeps rate limiting in one place, and it leaves room for a future deployment to swap in proxies or a data API without touching product code. Raw scraper payloads are stored in `raw` JSON columns. Parsers change, so keeping the original response means a corrected parser can reprocess history without collecting it again. ## How does work run in queues? Collection runs in two BullMQ workers, one per store, both at concurrency 1 behind a rate limiter. | Worker | Queue | Limiter | | - | - | - | | `appstore` | Apple jobs | `SCRAPE_ITUNES_RPM`, default 15 per minute | | `gplay` | Google Play jobs | `SCRAPE_GPLAY_RPM`, default 10 job starts per minute | A parse failure fails the job. BullMQ retries it with backoff, and request handling is never affected. See [The daily pipeline and rate limits](/concepts/pipeline). The optional store console connection lives in its own module and queue, `store-connect`. It reaches App Store Connect only with a key a workspace owner connected, keeps that key sealed at rest, and never shares the store workers' limiters. See [Store console credentials](/security/store-credentials). ## The component graph ```mermaid theme={null} graph LR B[Browser] --> W[Next.js web app] W --> A[NestJS API] A --> P[(PostgreSQL)] A --> R[(Redis queues)] R --> K[Store workers] K --> S[App Store and Google Play] R --> C[Store console worker] C --> X[App Store Connect] ``` The browser talks only to the Next.js web app, which proxies to the NestJS API. The API reads and writes PostgreSQL for durable state and enqueues work in Redis. The store workers drain those queues and are the only components that reach the App Store and Google Play endpoints. ## Where do types live? Every request and response shape the frontend consumes is defined in `@asobeast/shared`, for example `AppListItem`, `TrackedKeywordItem`, `AppSummary`, `RankingSeries` and `ApiErrorEnvelope`. | Package | Role | | - | - | | `@asobeast/shared` | Contract types, the `Store` union, the store URL parser, normalization helpers | | `apps/api` | DTO classes that implement the shared interfaces | | `apps/web` | Types every fetch call with the shared contracts | | `apps/mcp` | A consumer like the web app, over HTTP only | | `@asobeast/mcp-tools` | The one tool catalog both MCP surfaces register | Prisma generated types never cross the `apps/api` boundary, so the web app and the MCP server stay independent of the database. `@asobeast/shared` is compiled with tsup to CommonJS, ESM and type declarations, because the NestJS CommonJS build cannot consume a just in time TypeScript package cleanly. # Countries and markets Source: https://docs.asobeast.com/concepts/countries An app is one row with a home storefront. Each tracked keyword carries its own country, one search serves the app and every competitor, and the listing is captured in every market you track. You import an app once. The app row carries one home storefront, and every tracked keyword carries its own country, so a single app tracks keywords across as many markets as you want. ## What is the home storefront? The home storefront is `app.country`, and it comes from the URL you imported. A URL such as `apps.apple.com/de/app/...` imports a `de` home market, and the import dialog lets you override it. A bare iTunes id, or a URL with no country segment, falls back to `DEFAULT_COUNTRY`, which defaults to `us`. Country codes are two lowercase letters, and each one must be a storefront of the app's store. See [Which codes are storefronts?](#which-codes-are-storefronts) The home storefront drives everything that asobeast stores once per app. | Stays on the home market | Per market | | - | - | | Category ranks | Tracked keywords | | Reviews and ratings history | Positions and SERP snapshots | | Auto tracked keywords from the listing | Popularity and difficulty scores | | | Market comparison in the Action Center | | | Listings: metadata snapshots and their changes | The iOS keyword field is kept once per App Store localization rather than per app or per market. See [How does asobeast keep a keyword field per localization?](#how-does-asobeast-keep-a-keyword-field-per-localization) A keyword is judged against the listing its own storefront serves. See [What is a market listing?](#what-is-a-market-listing) ## How does one search serve everyone? A position check searches `keyword.country`, not `app.country`. From that single search asobeast records positions for the primary app and for every competitor attached to it in that storefront. That is why adding a competitor costs no extra store requests. Ten competitors on a keyword are still one search. ## What does a market cost? The same phrase tracked in `us` and in `pl` is two keyword rows, checked by two searches. Markets multiply, keywords multiply, and both draw on the same fixed request budget. Rankings, difficulty and popularity all differ per storefront, so the second market is real data rather than a copy. Two places show you the cost before it bites. * The settings page shows the [platform operator](/security/admin-surfaces#who-the-platform-operator-is) a daily request budget card with the estimated requests broken down by kind, the capacity and a utilization meter. * `GET /jobs/budget` returns the same estimate, and the dashboard warns the platform operator when the daily pipeline would exceed 85 percent of capacity. Each market with keywords also adds one listing refresh per app and competitor to the daily run, and the budget counts them: `GET /jobs/budget` reports them as `marketListings`, a part of `apps`. A seeded market adds its keywords as daily searches like any keyword you track. The countries step of the guided setup and `GET /apps/{id}/market-suggestions` show what a market would cost before you add it: the listing reads a day, for the app and each competitor, and the requests each keyword adds. When you are over budget, remove keywords or markets, or raise `SCRAPE_ITUNES_RPM` and accept the risk. See [The daily pipeline and rate limits](/concepts/pipeline). ## What is a market listing? A store serves a different listing in each storefront. The App Store returns the title, the description and the rating count of the storefront you ask, so the same app can have a German title, a Japanese title and a Brazilian one, and a rating count that differs in each. asobeast captures that listing, as its own snapshot, for every market where you track at least one keyword, for your app and for each of its competitors. | You | asobeast | | - | - | | Track a keyword in a new market, or resume a paused one there | asks for the listing of that market at once, for the app and its competitors, unless it already has one | | Do nothing | refreshes each market listing in the daily run, as one job on the same store budget | | Click Refresh while a market is selected | refreshes that market's listing and shows what changed | A keyword is then judged against the listing of its own market: its coverage on the metadata page and its relevance next to the opportunity score. Until a market has a listing, a keyword is judged against the home listing, as every keyword was before. ### What about storefronts that default to English? Sixty five App Store storefronts show their listing in English by default although local users mostly read another language: Poland, Türkiye, Czechia, Greece, Saudi Arabia, Vietnam, India and Canada among them. Their search reads the English default and the native localization alike. For these storefronts asobeast asks for each native localization of the listing too, for your app and for each competitor, and keeps it as its own snapshot next to the default one when the store serves one. | Storefront default | What asobeast captures | | - | - | | English, with native localizations (for example `pl`, `tr`, `be`, `in`) | the English default and each native localization the app has | | Native, such as `de`, `fr`, `jp` | the native default | | English (U.S.) | the default only | A localization the app does not have is answered by the store with the default text, so asobeast stores nothing for it. That includes a page that repeats the default title, description, screenshots and release notes and only shows its translated category where the default has a subtitle. asobeast still reads such a page every run to learn whether the app gained a localization, so it stays in the `localizations` count of `GET /jobs/budget`. Once stored, a localization is captured on every refresh, so removing it shows up as a change. An upgrade deletes the localizations that earlier versions stored from pages that only echoed the default. A market capture also reads every localization your app ships that the storefront reads besides its default, so French and Italian in Switzerland, Catalan in Spain or Spanish (Mexico) in the United States are captured once the app ships them. It stores one only when the store serves its own text, exactly as for a native localization, and the budget counts each read under `localizations`. Competitors are captured with the native localizations only, because the localizations on the list are your app's. See [Which localizations does an app ship?](#which-localizations-does-an-app-ship) The native localizations are captured with the listing they belong to: when you import an app, when you refresh a listing and in the daily run. Each one costs one more listing read, and `GET /jobs/budget` reports them as `localizations`, a part of `apps`. India reads eleven localizations, so a market there costs the most. Coverage judges a keyword within each localization, because the words of one localization combine into phrases only with each other: a keyword in the Polish title is covered even when the English title does not contain it, and the metadata page names the localization that covers it. Auto tracked keywords take phrases from every localization of the home storefront in turn, native first, up to the same limit. The keyword field you saved for a localization is judged with that localization too. Changes to a native localization are recorded in that localization only and send no alert. A market listing carries its own screenshots. On the App Store their captions are read with that storefront's language, and the Screenshot text column of a keyword reads the captions of the same listing that judged the keyword. See [Read screenshot captions](/guides/screenshot-captions#how-do-market-listings-read-their-screenshots). Some things are still read once, from the home storefront, because they need more than the listing: category ranks, reviews and the ratings history, the ASO audit and the keywords asobeast tracks for you from your listing. Top charts are different: any storefront's chart can be read, followed or captured. See [Read top charts](/guides/top-charts). The Action Center rules that judge coverage, lint and staleness also read the home listing. See [Action Center](/guides/action-center). A keyword judged against a market listing is covered by the title and subtitle of every localization that storefront reads, and by the keyword field of each of those localizations you saved. A keyword whose market has no listing yet is judged against the whole home listing, keyword field included. A market whose keywords you all removed keeps its last listing readable in the switcher, but asobeast stops refreshing it, and the Refresh button says why instead of refreshing. If an app is not published in a market, asobeast tries again the next day and stores nothing, so a market with keywords and no listing simply shows no switcher entry. ## What does the overview count? The market switcher on the app overview, labelled **Market**, also scopes the keyword numbers. Until you choose a market it shows **All markets**: visibility, the tracked keyword count, the rank distribution, the movers and both history charts count every market the app tracks keywords in, as the portfolio dashboard does. An app with a single listing has no switcher and always counts every market. Choosing a market counts only the keywords tracked in it, and the 7 and 30 day changes are measured from that market's own latest check. The metadata coverage card counts every market whichever you choose, because it judges keywords against the home listing. The API takes the same choice as an optional `country` on `GET /apps/{id}/summary`, `GET /apps/{id}/visibility-history` and `GET /apps/{id}/rank-distribution-history`, and counts every market when it is omitted. The summary's `coverage` block counts every market either way. ## How do you add a market? The countries step of the guided setup suggests storefronts from the languages your home listing declares, groups them by localization and lets you tick the ones to track. Each ticked storefront can be seeded with up to 15 keywords from its own title and subtitle, so the keyword monitor, the coverage check and the wasted words check have something to show on day one. A storefront whose default is the localization is ticked for seeding by default; one that reads it as an additional language is not. | You | asobeast | | - | - | | Tick a storefront and leave seeding off | adds it to the markets of the setup, and tracks nothing until you add a keyword there | | Tick a storefront and seed it | captures its listing now, tracks the title and subtitle phrases of that listing, of its native localizations and of every localization your app ships that the storefront reads, as listing keywords, queues their first score and first position check and asks for the competitors' listings there | | Seed a storefront the app is not published in | answers that it is not published there, stores nothing and removes it from the setup | | Seed past the keyword limit of your plan | tracks the phrases that fit, counts the rest as left out and never refuses the request | A phrase the app already tracks in that market, active or paused, is left as it is, so seeding a market twice tracks nothing new. Seeding spends one refresh from your on demand allowance once the store is asked. See [A spent on demand allowance names its action](/api-reference/rate-limits#a-spent-on-demand-allowance-names-its-action). Later, on the keyword monitor, switch markets with the country filter, which shows the keyword count per market, then add keywords into the market you selected. A market with no keywords yet shows an empty state with an add button. The first keyword you add there asks for the market's listing, so the market appears in the listing market switcher after the next job runs. The API has the same two steps. `GET /apps/{id}/market-suggestions` lists the suggested storefronts, each with its localization, its role, whether the app already tracks keywords there and its daily cost. `POST /apps/{id}/market-seed` with a `country` seeds one market and answers `seeded`, `skipped` and a `status` that is `unavailable` when the store has no listing of the app in that storefront. ### Why are storefronts suggested from languages, not availability? The store lookup reports the languages a listing ships, and asobeast knows which localization every storefront reads, so joining the two costs no store request. The store has no call that lists where an app is published: availability is one request per storefront, 175 of them on the App Store, and a storefront can carry the app with the default listing only, so availability would answer a different question. Seeding captures the listing anyway and reports a storefront without the app from that same request. English is never suggested, because every storefront reads an English localization and your home market already covers it. An app whose listing declares English only, a competitor and every Google Play app get no suggestions, and the setup step keeps its country code field. ## Which codes are storefronts? A home storefront and every keyword market must be a place where the app's store operates. asobeast checks the code against the store before it reaches it, so a code that is not a storefront is refused at once instead of failing every job it would have queued. | Store | Storefronts | Source | | - | - | - | | App Store | 175 | [App Store pricing and availability start times by country or region](https://developer.apple.com/help/app-store-connect/reference/pricing-and-availability/app-store-pricing-and-availability-start-times-by-country-or-region/) | | Google Play | 225 | [Supported locations for distribution to Google Play users](https://play.google.com/supported-locations?hl=en) | The two lists differ, so the check is made per store. `pw` is an App Store storefront that Google Play does not list, and 51 codes such as `ad`, `bd` and `li` are Google Play locations without an App Store storefront. Kosovo is `xk` in both stores. The refusal names the store, for example `zz is not an App Store storefront` or `pw is not a Google Play location`. It applies to importing an app, adding keywords, reactivating a paused keyword, deep search, keyword suggestions, the market availability check and saving the selected markets of the guided setup. A store URL whose country segment or `gl` parameter names no storefront is refused the same way. An upgrade pauses any keyword that was already tracked in a code that is not a storefront. Its history stays, and it stops producing failed jobs. The selected markets of the guided setup are checked against the store of the app when you save them (`PATCH /apps/{id}/setup` answers `400` for a code that is not a storefront). A code the setup already holds, and the home storefront of the app, are kept as they are, so saving a list you read back never fails; remove a code you do not want from the setup. ## Which localizations does a storefront read? Apple publishes, for every App Store storefront, a default language and the additional languages it supports, in [App Store localizations](https://developer.apple.com/help/app-store-connect/reference/app-store-localizations/). asobeast carries that table unchanged, for the same 175 storefronts it accepts. | From Apple's table | Value | | - | - | | Localizations a listing can have | 50 | | Storefronts with at least one additional language | 104 | | Storefronts with English (U.K.) and nothing else | 71 | | Additional languages of the United States | 9: Arabic, Chinese (Simplified), Chinese (Traditional), French, Korean, Portuguese (Brazil), Russian, Spanish (Mexico) and Vietnamese | Many ASO guides still name only Spanish (Mexico) for the United States. asobeast follows Apple's table. Apple's page lists languages; it does not describe search. In practice, App Store search in a storefront reads the title, subtitle and keyword field of every localization on that storefront's list, and the words of one localization combine into phrases only with each other. Each additional language is therefore extra room: its own 30 character title, 30 character subtitle and 100 character keyword field. ### How does asobeast keep a keyword field per localization? App Store Connect has one private keyword field per localization, and a new localization starts with it empty. asobeast keeps one keyword field per localization too, and it is edited in one place: the **App Store localizations** section of the metadata page, which has one editor for each localization your app ships. A storefront counts the fields of every localization it reads. The German field counts in Germany, Austria, Switzerland and Luxembourg, and the Spanish (Mexico) field counts in Mexico and in the United States. The keywords page never edits a field. Its **What search reads** card reports what one market reads and links each row to the editor. See [What does a market read?](#what-does-a-market-read) | What you do | What asobeast does | | - | - | | Save a field | Stores the phrases for that localization and tracks every phrase, with the source `KEYWORD_FIELD`, in the markets you tick in the save dialog. It starts capturing the listing of each of those markets that was not captured yet | | Save a field and tick no market | Stores the field and tracks nothing new. Every market that reads the localization still counts the field for coverage | | Track a keyword in another market that reads the localization | Reports it as `KEYWORD_FIELD` while the field holds it | | Remove a phrase from the field | Stops tracking it as `KEYWORD_FIELD` in every market that reads the localization, unless another field read there still holds it | | Remove a localization | Deletes its field and stops tracking its phrases the same way | | Stop tracking a keyword | Leaves the field as you saved it, and shows the phrase as not tracked in that market | You can tick only markets of your app whose storefront reads the localization. A market of your app is your home storefront and every storefront where you track at least one keyword. The field you saved before keyword fields were kept per localization is the field of your home storefront's default localization, English (U.S.) for an app whose home storefront is the United States. If your home storefront defaults to English and you had pasted the field of its native localization, such as Polish in Poland, save it again under that localization and empty the default one. See [Edit the iOS keyword field](/guides/metadata-workbench#edit-the-ios-keyword-field). ### Which localizations does an app ship? An app ships a localization when it has its own text in App Store Connect. asobeast cannot read App Store Connect, so it keeps a list of its own. A localization is on the list for one of three reasons. | Origin | Which localization | How it got on the list | | - | - | - | | Home | The default localization of your home storefront | It is always shipped, it is never stored and it cannot be removed | | Manual | Any of the 50 localizations | You added it, or you saved a non-empty keyword field for it | | Detected | A localization the store served | asobeast captured the native localization of a listing, the listing of a market proved the market's default localization, or **Find localizations** found the store serving it | Saving a non-empty keyword field for a localization ships it, because writing a field for a language means shipping that language. A detected localization stays detected when you save its field. Emptying a field keeps the localization on the list. Only **Remove localization** takes one off, and it refuses the home localization and a localization that is not on the list. **Find localizations** asks the store, for each language that one of your markets reads, that is not on the list yet and that your home listing says the app supports, whether it serves that localization's text. The questions are store lookups that run behind the daily run, and each language is asked at most once a day, so a second click on the same day queues nothing. Finding localizations spends one refresh from your on demand allowance, and only when it queues at least one lookup. See [A spent on demand allowance names its action](/api-reference/rate-limits#a-spent-on-demand-allowance-names-its-action). A localization the store proves is added as detected. If you removed it, it comes back the next time you run **Find localizations**, because the store still says you ship it. asobeast also asks the store on its own. The lookups queue after the first capture of a market's listing, for the localizations that market reads, and once a week for every App Store app (`CRON_LOCALIZATION_SWEEP`, Monday 07:00 UTC, see the [configuration reference](/configuration/reference)). Neither spends your allowance, so **Find localizations** is for when you want the answer today. A detected localization you removed comes back only if the store still serves it. The default localization of a market needs no lookup. Every capture of a market's listing compares it with your home listing and with every additional localization read in that market at the same time. When it differs from all of them, the store is serving the market's own language, and that localization is added as detected. Germany tracked with a German listing adds German after its next capture, so by the next daily run at the latest, or at once when you press **Refresh** on that market. A market whose listing repeats your home text adds nothing, because the store is not serving its language; add it by hand if you ship it with the same words. asobeast also records a native localization when it first captures one, which is how English storefronts such as Poland or India add their native languages without a click. See [What about storefronts that default to English?](#what-about-storefronts-that-default-to-english) What asobeast still cannot know: * A store page whose text equals the default listing looks the same as a page for a language you do not ship, so a localization with the default text is never detected. The store answers a language you do not ship with your app's primary text, so **Find localizations** never detects a localization whose text equals your home listing either, even in a market whose default language is another one. The default localization itself is never detected. Add such a localization by hand. * The keyword field of a localization is private, so asobeast never learns its contents. A localization you ship starts with an empty field until you type it. * A localization you never added, never saved a field for and the store never proved is not on the list. The list is what asobeast can prove or what you tell it. ### Which markets read a localization? A market of your app reads a localization when the localization is on its storefront's list. That market is a reader of the localization, and it has a role: **Default** when the localization is the first on the storefront's list, **Additional** otherwise. Spanish (Mexico) is the default in Mexico and an additional language in the United States. The matrix at the top of the **App Store localizations** section crosses your shipped localizations with your markets and shows Default, Additional or nothing in each cell. It appears once the app has two markets or two shipped localizations. Each editor lists its readers as chips. A shipped localization that none of your markets reads is still editable. Its editor says so and names storefronts that read it, and saving it tracks nothing, because no market of yours reads it. Add one of those storefronts as a keyword market to track its phrases there. See [How do you add a market?](#how-do-you-add-a-market) ### What does a market read? The keywords page card **What search reads** follows the market you selected. It lists, in read order, the shipped localizations that storefront reads, each with its role, the characters its field uses of the 100 available, and an **Edit** link to its editor. The localizations the storefront reads that your app does not ship are folded into a line you can open, with the room they would add, 100 characters each. A market that is not an App Store storefront says so and asks for nothing. Two hints on the card are diagnostics. They never change coverage, the audit or an Action Center rule. * **Words read twice.** A word saved in the keyword fields of two or more localizations that this market reads spends characters twice, because search combines the words of a localization only with each other and gains nothing from the repeat. Stopwords are skipped. * **Split phrases.** A tracked phrase of two or more words in this market, whose words are all read by the market but sit in different localizations, cannot match as typed. The card names the localization that holds each word. The words of a localization are those of its keyword field, its title and its subtitle where asobeast captured them. asobeast reads the default listing of every storefront, the native localizations of the storefronts that default to English and every localization on your list that a storefront reads besides its default. A localization that is not on the list, such as Spanish (Mexico) in the United States before it is detected or added, is not read, so asobeast cannot tell what your listing fills there until **Find localizations**, the weekly lookups or you add it. See [Edit the iOS keyword field](/guides/metadata-workbench#edit-the-ios-keyword-field). Google Play has no such list. It shows the listing that matches the device language, so storefront localizations are App Store only. asobeast asks each Google Play storefront for one language; see [Where else do the stores differ?](/concepts/stores#where-else-do-the-stores-differ) for Taiwan and Hong Kong. # Data model Source: https://docs.asobeast.com/concepts/data-model An app is a single tracking entity with a home storefront. Keywords are scoped by text, store and country, so one app tracks keywords across many markets. An app is one row with a home storefront. Keywords are separate rows scoped by text, store and country, and a join table connects the two. That shape is what lets a single app track keywords in many markets without duplicating the app. ## Entities | Entity | Purpose | Key rule | | - | - | - | | `Workspace` | The tenant boundary | One seeded default workspace in v1 | | `User` | An account with a role and a plan | Email is unique. The first account bootstraps as owner | | `ApiToken` | A revocable personal API token | Stored as a SHA-256 hash, never as plaintext | | `AppGroup` | A linked group of listings across stores | Unique on group and store, so a group holds one app per store | | `App` | One tracked listing, with the reply guidelines its AI drafts follow in `replyGuidelines` | Unique on workspace, store, store app id and country | | `AppSnapshot` | A captured copy of the listing metadata | Keeps the raw payload for reprocessing. One stream per market: a snapshot with no market is the home listing | | `Keyword` | A phrase in one store and one storefront | Unique on `text`, `store`, `country` | | `TrackedKeyword` | The link between an app and a keyword | Keyed on app and keyword, carries `source` and `relevance` | | `KeywordField` | The iOS keyword field of one App Store localization | Keyed on app and localization. Holds the normalized phrases in the order they were saved, and is private to the owner | | `AppLocalization` | One App Store localization the app ships beyond the default one of its home storefront | Keyed on app and localization. Origin is `manual` or `detected`. Deleted with its app | | `AppSetup` | The guided setup of one tracked app: status, skipped steps, countries reviewed, selected markets | Keyed on app. Started by the import and kept on the account, so every member and every device sees the same progress. Deleted with its app | | `KeywordMetric` | Persisted popularity and difficulty for a day | Keyed on keyword and date | | `KeywordRanking` | One position observation | Keyed on app, keyword and date. Carries `depth` | | `SerpEntry` | One search result row for a keyword and day | Keyed on keyword, date and position | | `CategoryRank` | A free, paid or grossing chart position | Keyed on app, date, collection and genre | | `ChartCapture` | One day of one top chart, captured once for every workspace | Shared store data with no workspace. Unique on store, country, collection, genre, device and date. Carries `depth`, the number of positions the store answered | | `ChartEntry` | One row of a top chart capture: a position and the app's title, developer, icon, genre and price class as that storefront showed them that day | Shared store data. Keyed on capture and position, deleted with its capture | | `ChartFollow` | One chart kind of a category a workspace wants captured every day; the plan's follow limit counts the categories the rows belong to | Tenant owned. Unique on workspace and chart key | | `Review` | A synced store review, with the text of the developer reply the store shows or asobeast posted in `replyText` and the translation kept for one language | Unique on app and store review id | | `ReviewReply` | The reply kept for one review: its text, how it was made, the owner's replied mark and, once posted, when, what and by whom it was posted to the store | Keyed on review. Tenant owned, deleted with its review and its app | | `ChangeEvent` | A detected metadata change | Covers owned and competitor apps, in the home market or any other market you track | | `AuditScore` | A daily audit score snapshot | Keyed on app and date | | `ActionItem` | One Action Center recommendation | Unique on workspace and fingerprint | | `ActionEvent` | One lifecycle transition of an action | Tenant owned, pruned with its action | | `AiCall` | One AI call a workspace made, with its model and tokens | Tenant owned, counted against the monthly AI allowance, kept when its app is deleted | | `StoreConnection` | A store console key a workspace owner connected: issuer ID or service account email, key ID and fingerprint in clear, the Play bucket id, the private key sealed, its status and the report access it was granted | Tenant owned. Unique on workspace and store. The sealed key is never exported | | `StoreReportRequest` | One report request of one app at the store console, ongoing or the one time history | Tenant owned. Unique on app and access type. Deleted with its connection | | `StoreReportInstance` | One downloaded report file, an App Store daily file or a Google Play monthly file of one generation, kept so a parser change can read it again | Tenant owned. Pruned after `RETENTION_STORE_REPORTS_DAYS`. Exported without the file | | `StoreFunnelDay` | The impressions, page views and Get taps of one app on one day, by territory, source and page type | Tenant owned. Unique on app, date, territory, source and page type. The newest report wins, and the rows outlive a disconnect | | `StoreFunnelPageDay` | The same counters per custom product page, from the detailed report | Tenant owned. Unique on app, date, territory and page title | | `StoreFunnelSearchTerm` | The store listing visitors and acquisitions one Google Play search term brought one app in one month, with the term in its normalized form | Tenant owned. Unique on app, month and term. The rows of a month are replaced whenever Google writes a new version of its file | | `SnapshotScreenshot` | One screenshot of one snapshot: its position, address and the caption read from it | Tenant owned, deleted with its snapshot | | `ScreenshotText` | The text read from one public image under one reading recipe | Shared across workspaces like `Keyword`, no policy, pruned when unused for 90 days | | `Webhook`, `EmailAlert` | Alert subscriptions | Deliveries are logged per channel | ## How do they relate? ```mermaid theme={null} erDiagram Workspace ||--o{ App : owns App ||--o{ AppSnapshot : captures AppSnapshot ||--o{ SnapshotScreenshot : lists App ||--o{ TrackedKeyword : tracks App ||--o{ KeywordField : "stores one per localization" App ||--o{ AppLocalization : ships App ||--o| AppSetup : "is set up through" App ||--o{ Review : collects Review ||--o| ReviewReply : "is answered by" Keyword ||--o{ TrackedKeyword : "is tracked by" Keyword ||--o{ KeywordMetric : scores App ||--o{ KeywordRanking : "is ranked in" Keyword ||--o{ KeywordRanking : ranks Workspace ||--o{ ActionItem : queues ActionItem ||--o{ ActionEvent : records Workspace ||--o{ AiCall : spends Workspace ||--o| StoreConnection : connects StoreConnection ||--o{ StoreReportRequest : requests StoreReportRequest ||--o{ StoreReportInstance : downloads App ||--o{ StoreFunnelDay : "is measured by" App ||--o{ StoreFunnelSearchTerm : "is found by" ``` A workspace owns apps. Each app captures snapshots of its listing and tracks keywords through a join table, because the same keyword row is shared by every app that tracks it. Popularity and difficulty attach to the keyword, while a position attaches to the pair of an app and a keyword on a given date. Action items belong to the workspace, and each one records its lifecycle as events: one row per transition, such as opened, done or confirmed fixed, deleted together with the action. ## Rules that are easy to get wrong * **An app is one row.** Countries live on keyword tracking, not on a second app row. See [Countries and markets](/concepts/countries). * **`Keyword` is unique on `text`, `store` and `country`.** The same phrase tracked in two markets is two keyword rows checked by two searches. * **A competitor is an app row too.** It carries `isCompetitor` and points at the primary app it was added to. * **Popularity and difficulty persist in `KeywordMetric`. Opportunity does not persist.** It is computed in the read layer from the stored popularity and difficulty. See [Popularity, difficulty and opportunity](/concepts/scoring). * **`installs` is a `BigInt`** and serializes as a JSON number. * **All dates are UTC.** Daily granularity uses a PostgreSQL `date` column, and today means the UTC date. * **Every tenant owned row carries `workspaceId`, and PostgreSQL enforces it.** Each of those tables has a `tenant_isolation` row level security policy, so a query with no workspace in scope returns nothing rather than everything. Service code does not filter by workspace in a `where` clause. `Keyword`, `KeywordMetric` and `SerpEntry` are deliberately global and carry no policy, because one search for a phrase serves every workspace tracking it. `ScreenshotText` is global for the same reason: a screenshot is public store data, so one read serves every workspace that tracks the app. What is tenant owned is the tracking relation and the recorded position. ## How does the schema change? Only forward. Every schema change ships as a forward Prisma migration and the API applies pending migrations on boot. There are no hand written SQL migrations and no down migrations, which is why a rollback is a database restore. See [Upgrade and roll back](/install/upgrade). # The daily pipeline and rate limits Source: https://docs.asobeast.com/concepts/pipeline A daily cron enqueues rank checks, snapshots, reviews and category ranks. Two workers run at concurrency 1 behind per store rate limiters. A third reads screenshot text. Collection is scheduled, not on demand. A daily cron enqueues every position check, snapshot, review sync and category rank, and two rate limited workers drain those queues over the following hours. ## What runs and when? Every schedule is a cron expression evaluated in UTC. The defaults below come from `apps/api/.env.example`. | Variable | Default | Meaning | What it does | | - | - | - | - | | `CRON_DAILY` | `0 3 * * *` | Daily 03:00 | Enqueues position checks, snapshots, reviews and category ranks, then generates actions and flushes batched alerts | | `CRON_SCORING` | `0 4 * * 0` | Sunday 04:00 | Recomputes popularity and difficulty for tracked keywords | | `CRON_RETENTION` | `0 5 * * *` | Daily 05:00 | Prunes rows past their retention window | | `CRON_STORE_REPORTS` | `30 5 * * *` | Daily 05:30 | Downloads the reports each connected store console published since the last sync. Never scheduled without `STORE_CONNECT_ENCRYPTION_KEY` | | `CRON_AUDIT` | `0 6 * * *` | Daily 06:00 | Writes the daily audit score snapshot | | `CRON_LOCALIZATION_SWEEP` | `0 7 * * 1` | Monday 07:00 | Queues the lookups that detect the App Store localizations every app ships. An empty value removes it | | `CRON_DIGEST` | `0 8 * * 1` | Monday 08:00 | Sends the weekly digest webhook | Batched alerts do not have their own schedule. They send once the daily pipeline reports every child job settled, so the delivery time depends on workload and retries rather than on a fixed clock. ## What are the two workers? | Worker | Concurrency | Limiter | Default | | - | - | - | - | | `appstore` | 1 | `SCRAPE_ITUNES_RPM` requests per minute | 15 | | `gplay` | 1 | `SCRAPE_GPLAY_RPM` job starts per minute | 10 | The difference matters. The Apple limiter spaces requests, while the Google Play limiter spaces job starts, because one Google Play job fans out into many sequential requests of its own. Work a person starts to explore rather than to collect, today the lookups of a deep search, is queued with a priority. A worker always takes every waiting job without a priority first, the daily run's jobs among them, so a deep search waits behind them. Such a lookup also checks, before it reaches the store, that its workspace still has a plan in force and is not suspended, and does nothing otherwise. Reading screenshot captions runs in a third queue, `screenshots`, with its own worker at concurrency 1. While reading is on, every snapshot that records App Store screenshots queues one read, a market listing refresh included. With `SCREENSHOT_OCR=false` the screenshots are recorded as skipped and nothing is queued. It spends no store request budget and no OpenAI call. See [Read screenshot captions](/guides/screenshot-captions). Downloading store console reports runs in a fourth queue, `store-connect`, with its own worker at concurrency 1. It talks to App Store Connect with the key an owner connected, not to the public store, so it spends no store request budget and no proxy. A sync job per workspace is queued by `CRON_STORE_REPORTS` and by the connect, verify and sync buttons, and one already waiting is not queued twice. See [Read impressions and conversion from your console](/guides/store-funnel). ## Why are the limits conservative? The iTunes endpoints informally tolerate roughly 20 requests per minute per IP address. Google Play is more sensitive still. Neither store publishes a limit or an error you can negotiate with, so asobeast stays well underneath by default. Raising `SCRAPE_ITUNES_RPM` or `SCRAPE_GPLAY_RPM` puts the host IP address at risk of being throttled or blocked by the store, which stops collection for every app on that host. Do not point several instances at the stores from one IP address. ## How do you estimate your daily budget? Daily request volume is roughly tracked keywords multiplied by tracked markets, plus per app work for snapshots, reviews and category ranks, and one listing refresh per app, competitor and extra market. | Job | Approximate cost | | - | - | | Apple position check at depth 200 | 1 request | | Google Play position check at depth 200 | Roughly 8 requests | | Apple score job | 3 to 11 requests | | Google Play score job | 12 to 20 requests | | Category ranks per app | The free, paid and grossing charts of its category and of the overall chart, each one request a day captured once for the whole instance | | Followed category | Three charts a day, shared with any app or workspace already in them | | Market listing refresh | One job per app and market, the same weight as an app refresh | | Localization read | One more listing read per native localization of a storefront and per localization your app ships that the storefront reads besides its default | `GET /jobs/budget` returns an estimate broken down by kind, and the settings page renders it as a budget card with a utilization meter. The dashboard warns when the daily pipeline would exceed 85 percent of capacity. Both are [shown only to the platform operator](/security/admin-surfaces#views-only-the-operator-sees). Category charts are the smallest slice of the daily budget, but they count, because the daily run captures every chart kind of each category an app sits in and of the overall chart, and every followed category adds its three charts. A chart is fetched once a day however many apps and workspaces sit in it. See [Read top charts](/guides/top-charts). ## Can you call the store outside the queue? No, with two exceptions. All collection goes through the queue so the limiters apply. The exceptions are a small, user initiated suggestion lookup and a store lookup when you search a store by name or ask for similar apps, both bounded by a person clicking rather than by a schedule, and by their hourly allowances. ## What is retained? The retention job prunes each table on its own window. `0` means keep forever. Every variable is described in the [configuration reference](/configuration/reference). | Variable | Default | Prunes | | - | - | - | | `RETENTION_RANKINGS_DAYS` | 365 | Keyword positions | | `RETENTION_SERP_DAYS` | 90 | SERP entries | | `RETENTION_SNAPSHOTS_DAYS` | 180 | App snapshots, keeping the newest per app and market | | `RETENTION_CATEGORY_RANKS_DAYS` | 365 | Category ranks | | `RETENTION_CHARTS_DAYS` | 365 | Top chart captures | | `RETENTION_CHANGE_EVENTS_DAYS` | 0 | Metadata change events | | `RETENTION_DELIVERIES_DAYS` | 30 | Alert delivery log rows | | `RETENTION_ALERT_EVENTS_DAYS` | 30 | Completed alert claims, keeping unfinished ones | | `RETENTION_AUDIT_SCORES_DAYS` | 0 | Audit score history | | `RETENTION_ACTIONS_DAYS` | 180 | Closed action items only | | `RETENTION_AI_CALLS_DAYS` | 400 | AI call ledger rows, never from the current month | | `RETENTION_STORE_REPORTS_DAYS` | 35 | Downloaded store console report files. The funnel rows built from them are kept | Three exceptions are worth remembering. The newest snapshot per app is always kept regardless of age, open or snoozed action items are never pruned by age, and AI calls from the current month are never pruned because the monthly AI allowance still counts them. The same job also gives back AI calls that were reserved more than an hour ago and never settled. ## What happens when a job fails? A parse failure fails the job. BullMQ retries it with backoff, and request handling is never affected, so a broken parser degrades collection rather than taking the instance down. An App Store request that stops answering is abandoned after 15 seconds and retried like any other failure, so one slow connection cannot hold the `appstore` worker. Google Play requests have their own 30 second bound per attempt. An empty answer is not trusted when the same question had an answer within the last seven days: a search for a keyword that ranked apps, or a review feed for an app that was receiving reviews. On the App Store the job asks again three times, four seconds apart, and then fails its attempt so the queue retries it, because Apple's origin sometimes answers empty for a moment. App Store searches, lookups, charts and review feeds always reach Apple's origin rather than a copy cached at Apple's edge, so each retry sees a fresh answer. A review feed that is still empty on the last attempt ends the sync without a failed job: nothing is written, the reviews already stored stay as they are, and the next daily sync reads the feed again. On Google Play the same empty answer is a soft block, and the job fails at once without retrying. Top charts follow the same rule, with the job's attempts as the second look. A chart whose apps were last listed within seven days and that answers empty fails every attempt, because that is how a blocked request looks. Any other chart that answers empty is asked again on each attempt and stored as a capture with no rows only after the last one fails to find an app, so one blocked request on the first day of a new chart is never stored as an empty chart. The next day the chart is already known to be empty, is stored on the first attempt, and costs one request a day and no failed job. See [What does an empty chart mean?](/guides/top-charts#what-does-an-empty-chart-mean) Failed jobs are visible on the queue dashboard at `/admin/queues`, which answers only to the [platform operator](/security/admin-surfaces#who-the-platform-operator-is). A failed chart job left by an earlier version stays in the failed count, and the header badge keeps reading degraded, until the operator cleans it on the dashboard. See [Troubleshooting](/operations/troubleshooting). ## How do you follow today's run for one app? Every app page shows a pill beside **Run daily** with where today's run of that app stands: how many of its listings, position checks and chart reads are in, out of how many the day needs. A chart read is a rank row of the app: the chart of its price class and the grossing chart, each in its category and overall, so an app counts four, not the six charts the daily run captures for everyone. A market listing counts only where the store sells the app: when a refresh finds that a storefront does not list the app or one of its competitors, the pill expects no listing of it there until a later refresh of that storefront finds it, so an app tracked in a market it is not sold in still reaches **Run complete**. Open it for one row per stage and the time the run started. It counts what landed since the daily trigger, or since midnight UTC once the trigger has passed, says **Run complete** when every count is in and **Run delayed** when the run started over twenty hours ago with counts still missing. A workspace the daily run skips, because an operator suspended it or its plan is no longer in force, shows no pill and is not polled, and `GET /apps/{id}/run-status` answers `paused: true` next to the unchanged counts. A run that finished before the pause still reads **Run complete**. See [Rate limits](/api-reference/rate-limits) for suspension and [Billing](/operations/billing) for plans. While the run is collecting it asks again every ten seconds and refreshes the tables as positions land, so **Run daily** is followed by visible progress. The same answer is available as `GET /apps/{id}/run-status` and as the `app_run_status` MCP tool, computed from stored rows and never from the queue. Reviews are not counted, because a sync that finds nothing new leaves no trace. ## What happens when the store stops listing an app? A store can stop listing an app you or a competitor tracks: the developer removed it, the store took it down or it moved to a new store id. A listing refresh in the app's home storefront then gets an answer that the app does not exist, the iTunes lookup with no result or a Google Play not found, and records the time of that first answer as `delistedAt` on the app. The refresh job finishes instead of failing and being retried, and an on demand **Refresh** still answers the store's `404`. While `delistedAt` is set: * Every page of the app opens with a **No longer listed** notice naming the store, the store id, the home storefront and the UTC day, with the next step: open the store page, import the app again from its new link if it moved, or delete it, or remove a competitor, if it is gone for good. * The app's dashboard card and table row, and the competitor card on the competitors page, carry a **Delisted** badge. * The guided setup stops waiting for a listing the store can no longer answer. * `GET /apps/{id}`, `GET /apps`, `GET /apps/{id}/competitors` and `GET /portfolio` answer `delistedAt` as a timestamp, and `null` for an app the store lists. * The run pill expects no listing of that app, in its home storefront or in any market, so today's run can reach **Run complete** on its position checks and chart reads. Stored history stays, position checks and chart reads go on, and the daily run keeps asking the store. The first home capture that finds the app again clears `delistedAt`, so the notice and the badges go away and the run pill counts the listing again. A market that does not list the app leaves `delistedAt` alone, because many apps are sold in some storefronts only; the run pill stops expecting that one listing instead, as [How do you follow today's run for one app?](#how-do-you-follow-todays-run-for-one-app) describes. ## What does Run daily do after a failure? Run daily collects today's work again for one app. A job that failed earlier today, because a store was down or blocked, is queued again with a fresh set of attempts, and its failed record leaves the dashboard. A job that is still waiting, running or finished today is left alone, so a second click does not queue it again. Chart reads are queued per app, so Run daily on a second app still records its chart positions when another app of the workspace read the same charts earlier that day, from the capture already stored and without asking the store again. The response counts only what the click queued. When every count is zero, today's checks were already queued, running or done, and the click still uses one of the plan's on demand Run daily credits. Requests that reach the API at the same moment queue each job once, and at most one of them counts it, even when they replace a job that failed or carry the same request id. # Positions and rank depth Source: https://docs.asobeast.com/concepts/positions Positions are 1 based. A null position means the keyword was checked and the app was not found within the captured depth, which defaults to 200. Positions are 1 based, so position 1 is the top result. A `null` position means asobeast checked the keyword and did not find the app within the captured depth. It never means zero. ## How is a position recorded? Each check writes one row per app, keyword and UTC date, and stores the depth that check covered alongside the position. The default depth is 200. | Field | Meaning | | - | - | | `position` | 1 based rank, or `null` when the app was not found | | `depth` | How deep the search looked for that observation, default 200 | | `date` | The UTC date of the observation | The row is written even when the app was not found. That is deliberate: an absent row and a `null` position mean different things, and only storing both lets you tell "we did not look" apart from "we looked and it was not there". ## What does null mean? Checked, and not found within `depth`. It does not mean any of the following. | `null` does not mean | Which is instead | | - | - | | Position zero | Positions start at 1 | | The keyword is untracked | An untracked keyword has no rows at all | | The check failed | A failed job writes no row and is retried | | The app was removed | Nothing in a position row says that | ## How is null displayed? As a bound derived from that row's own depth, never as `0`. A row captured at depth 200 renders as `>200`, and a row captured at depth 100 renders as `>100`. Reading the bound from the row rather than from a constant matters, because historical rows may have been captured at a different depth than today's. ## How do I know a position is current? Every tracked keyword reports `latestCheckedOn`, the UTC day of its newest check, whatever that check found. It is `null` for a keyword that has never been checked. The daily run checks every active keyword, so a newest check from today or yesterday UTC is current. An older one means the checks since that day did not finish, for example because the store refused the search. A failed check never writes a row, so it can never show up as a drop. The keywords table then shows a clock in place of the info button beside the position, opens the day of the last check from it, and hides the change since yesterday, because that change compares the last check with the day before it. A paused keyword is not checked on purpose and shows no day. ## How are positions charted? Ranking charts use a reversed Y axis, so position 1 sits at the top and an improving line rises. A `null` breaks the series rather than dropping it to the bottom of the chart. Every UTC day from the first check in the range to yesterday, or to today once today has a check, sits on the chart's axis. A day without a check therefore leaves a gap in the line with no marker, while a check that did not find the app leaves a gap with a hollow marker at the depth line. A ranked day between two gaps is drawn as a dot. > [!WARNING] > > If you consume the API, treat `null` as absent rather than as `0`. Coercing `null` to zero makes every unranked keyword look like the best possible result and inverts every trend, average and delta you compute. ## How deep does the SERP snapshot go? Position checks capture to depth 200. The stored SERP snapshot, which is what powers volatility, entrants and competitor discovery, keeps the top 10 results for each keyword and date. # Popularity, difficulty and opportunity Source: https://docs.asobeast.com/concepts/scoring How popularity, difficulty and opportunity are calculated, what evidence each score carries, and when Apple's own search popularity replaces the estimate. Popularity and difficulty describe a keyword and are stored. Opportunity is derived from the two, so it is computed when you read it and never stored. ## What are the three scores? | Score | Range | Persisted | Scope | Meaning | | - | - | - | - | - | | Popularity | 0 to 10 stored, 0 to 100 shown | Yes, in `KeywordMetric` | Keyword | How much search demand the evidence suggests | | Difficulty | 0 to 10 stored, 0 to 100 shown | Yes, in `KeywordMetric` | Keyword | How hard the current top results are to displace | | Opportunity | 0 to 100 | No | Keyword | Whether the searches a keyword brings are within reach | Opportunity is the share of the page left open by difficulty, scaled down for a keyword searched too rarely to matter, so either one can veto a keyword. Every popularity number is an estimate built from public store signals, except where the source says Apple Ads search popularity. asobeast publishes the formula below and a calibration command, so the estimate can be checked against Apple's own number rather than taken on trust. ## How is popularity estimated? Each store leaves different evidence, so each store has its own estimate. ### App Store App Store popularity is estimated from the first 25 search results and the search suggestions by a model fitted against Apple's own search popularity, so the estimate is on Apple's 1 to 100 scale. It reads: | Signal | What it reads | | - | - | | Leader | Rating count of the strongest app in the first half of the results, on a log scale | | Depth | Median rating count of the 25 results, on a log scale | | Titled share | Share of titles that contain every word of the phrase | | Exact share | Share of titles that contain the phrase as written | | Exact leader | Rating count of the strongest app in the first five whose title contains the phrase, on a log scale | | Exact head | How far that exact leader passes 100,000 ratings, squared, so a dominant exact match marks a head term | | Words | Number of words in the phrase, up to six | | Relevance | Average title match strength, alone and multiplied by the leader, so the leader's weight can depend on how well the page targets the phrase | | Results | How many of the 25 places the search filled | | Continuations | How many searches Apple suggests that are the phrase or continue it with more words, when the phrase and the phrase followed by a space are typed, on a log scale | | Suggested | Whether Apple suggests the exact phrase at all, as in the reach table below | | Early | How few typed characters Apple needed before it suggested the exact phrase: 1 after one character, falling to 1/8 after eight, and 0 when it only suggests the whole phrase or never does | The estimate is a weighted sum of these signals taken as the logarithm of popularity plus one, so each signal scales the estimate rather than adding a fixed number of points. It is rounded and held between 1 and 100. The weights come from `pnpm --filter api scoring:popularity-study`, which searches terms whose popularity is known and fits the weights by least squares. The fit for the US week of 20 September 2026 used 252 terms Apple listed that week, 16 per genre, and 770 terms whose popularity was published elsewhere, most of them long tail phrases from games, trivia, meme and geography searches, because the terms people track are mostly long tail. The model carries two sets of weights from that one study: | Weights | Fitted on | Used when | | - | - | - | | General | Every term, listed and published | Apple's weekly list is not loaded, so the estimate has to cover head terms and the long tail alike | | Unlisted | The published terms only | Apple's weekly list is loaded and the term is not on it, which is the only kind of term the estimate is then asked for | Knowing that Apple left a term out of its list is evidence in itself, so the unlisted weights are not pulled upward by head terms that would read Apple's own number anyway. Held out of the fit in five rounds, the unlisted weights estimated the published terms with a Spearman correlation of 0.49 and a mean error of 5.2 points, and 85 percent of them landed in the same grade band. On terms held out of the general fit, the published terms ranked at 0.44 with a mean error of 5.1 points, and the listed terms ranked at 0.59 but read about 9 points low on average, because a term in the head of another genre looks much like a strong long tail phrase in the search results. With an Apple Ads key, a listed term reads Apple's own number instead. Apple does not suggest many short, common searches as themselves, so a phrase it never suggests can still be searched often, and the model weighs suggest reach together with the search results rather than on its own. Continuations read the suggestions differently: Apple still offers popular searches that begin with such a word, like "map my run" for "map", while a phrase few people search is continued by few, and a misspelling by none, because Apple offers the corrected spelling instead. A case form of the phrase itself is not a continuation either, the way an English plural is not, so "дневником" alone does not continue "дневник" while "дневником привычек" does, and neither does a different word that only shares the stem, like "программирование" for "программа". When the suggestion lookup fails, the model reads 5 continuations, a typical count, and treats the phrase as suggested only once typed in full, so a failed lookup neither raises nor sinks a phrase. A title contains a word when it has it as a whole word, singular or plural. "map" is found in "Google Maps" but not in "Roadmap", and "geo" is not found in "Geography". Chinese, Japanese, Thai, Lao, Khmer and Burmese are written without spaces, and Korean attaches particles to the end of a word, so in these scripts a word counts wherever it appears in the title. In Cyrillic, where both words of a comparison change their endings by case, two words count as the same word when they share at least seven characters from the start, those characters are all Cyrillic, and each word keeps at most three characters after that shared start: "тревожности" in a title finds the word "тревожностью", while "тревога" and "тревожность" stay different words, and so do "программа" and "программирование", where the longer word keeps thirteen characters after the shared start. Other scripts whose words inflect the same way join this rule as they are added. A search with fewer than five results stays at or below 10 on the shown scale, and a search with no results scores 0. ### Google Play Google Play popularity starts from suggest reach: how early the store offers the exact phrase while it is being typed. A phrase the store completes after one or two typed characters is searched by many people; a phrase it only offers once the whole text is typed is searched by few. | What the store does | Reach, 0 to 10 | | - | - | | Suggests it after 1 typed character | 6.8 | | After 2, 3, 4, 5, 6, 7 or 8 characters | 4.8, 4.4, 4, 3.2, 2.5, 2 and 1.6 | | Suggests it only once the whole phrase is typed, however short the phrase is | 1 | | Never suggests it | 0.9 | The values are the typical published popularity, divided by 10, of the phrases in each row, measured on 502 US keywords across games, trivia, board game, meme and geography searches. Where the phrase sits in the suggestion list does not change its reach, because on that sample the typical popularity was the same at every position. A phrase of two or more words counts only when the store suggests it exactly as written, in every word: "geography games" or "geography game offline" does not count for "geography game", and nor does a case form of one of its words, because a phrase people search is suggested as itself. A single word is different, because Google Play offers longer searches that begin with a word rather than the word alone, so for one word a suggestion counts when it is the word in the plural, in any of its case forms in a script whose words inflect, Cyrillic today, or continues it with more words. A search that only shares its first letters does not count. Three more inputs place a phrase within its row: | Input | What it reads | | - | - | | Untyped share | The share of the phrase that was still untyped when the store first offered it, from 0 to 1. "trivia games" offered after 3 typed characters has 0.75 of its 12 characters left. It is 0 for a phrase the store only offers in full or never offers | | Strength | The strength of the first ten results, as in the difficulty rating below | | Targeting | The share of the first ten titles that carry the phrase as written, as in the difficulty rating below | Google Play popularity is the weighted sum of the four: | Reach | Untyped share | Strength | Targeting | | - | - | - | - | | 0.5 | 1.5 | 1.1 | 1.9 | The store completing a long phrase early says more than completing a short one after the same number of characters, which is what the untyped share adds to the reach. A phrase many people search also attracts strong apps that put it in their titles, so the page itself carries evidence of demand. The weights were fitted by least squares against published popularity for 442 US keywords and checked on 200 further keywords the fit never saw. On those 200, rank agreement with the published popularity rose from 0.73 to 0.80, the mean error fell from 5.5 to 4.6 points, and 165 landed in the same grade band against 157. Two caps apply: a phrase the store never suggests stays at or below 15 on the shown scale, and a search with fewer than five results stays at or below 10. A search with no results scores 0. When the suggest lookup fails, the reach is taken as 2.5 and the untyped share as 0.4, the typical values of that sample, and confidence drops to low. ## How is difficulty rated? Difficulty measures how hard it is to reach the first ten results for the phrase. It reads the first ten results on both stores, the search results on the App Store and the ten detail lookups on Google Play, and combines three inputs: | Input | What it reads | | - | - | | Strength | Each app's rating count on a log scale: 1,000 ratings or fewer rate 0, about 31,600 rate 0.5 and a million or more rate 1. The page's strength is the mean over the apps whose rating count is known | | Targeting | The share of the ten titles that carry the phrase as written, starting at the beginning of a word. "Geometry Dash" targets `geo`, while "BADLAND" does not target `bad land` and "Quiz Geo" does not target `geo quiz`. A Chinese, Japanese, Korean, Thai, Lao, Khmer or Burmese phrase matches anywhere in the title, and in Cyrillic each word of the phrase may appear in any of its case forms | | Phrase length | The number of characters in the phrase | Each store weighs them differently: | Store | Base | Strength | Targeting | Per character | | - | - | - | - | - | | App Store | 13 | 40 | 26 | 0 | | Google Play | 15 | 78 | 21 | 0.7 | Difficulty is the base, plus strength and targeting times their weights, minus the phrase length times its weight, cut to a whole number from 1 to 100. A search with no results scores 0. The weights were fitted against published difficulty for 794 App Store and 444 Google Play keywords in the US store. On the App Store the titles count almost as much as the apps: a page of strong apps whose titles do not carry the phrase is easier to break into than one where every title does. On Google Play the strength of the page decides, and a longer phrase is easier. Star ratings, installs, days since release or update, publisher diversity, chart positions and titles past the tenth result were tested as well and are left out, because none of them changed the order by more than 0.01 of rank agreement. A brand page is scored like any other page, so difficulty always describes the apps a newcomer has to pass. The `flags` in the score signals describe the page and do not change difficulty. `brand` means the leader is named after the phrase, a misspelling included, and holds at least 10,000 ratings, ten times the typical rival whose title also targets the phrase. `weak_leader` means the top result has fewer than 100 ratings, `small_serp` three results or fewer, and `padded` that the average title match strength over the top ten is at most 0.2. On Google Play the rating counts come from one detail lookup per result. A result whose lookup fails keeps its place on the page without a rating count, and when fewer than half of the lookups succeed the keyword is not scored in that run and the job retries. ## Which scale does a response use? Popularity and difficulty are stored on a 0 to 10 scale. The API returns popularity in the `traffic` field, a name kept so existing clients keep working. asobeast shows them on a 0 to 100 scale by multiplying by 10 and clamping the result to 0 to 100, and `volume` is popularity on that scale. A field name alone does not tell you which scale a response uses, so read it from this table. | Endpoint | Fields | Scale | | - | - | - | | `GET /apps/{id}/keywords` | `traffic`, `difficulty` | 0 to 10, as stored | | `GET /apps/{id}/keywords` | `volume`, `opportunity` | 0 to 100 | | `GET /apps/{id}/keywords/compare` | `traffic`, `difficulty` | 0 to 10, as stored | | `GET /apps/{id}/competitors/analysis` | `gaps` entries `volume`, `difficulty`, `opportunity` | 0 to 100 | | `GET /actions`, `GET /apps/{id}/actions` | `keyword.add_uncovered` evidence `traffic`, `difficulty` | 0 to 10, as stored | | `GET /actions`, `GET /apps/{id}/actions` | `keyword.add_uncovered` evidence `volume`, `opportunity` | 0 to 100 | Convert a stored value the same way before showing it, or a row written outside the scoring job can display past 100. ## What evidence does a score carry? Every persisted score is stored with the provenance needed to audit or recompute it. | Field | Example | Why it is stored | | - | - | - | | Scoring source | `APPLE_SEARCH_SIGNALS`, `GOOGLE_PLAY_SUGGEST_REACH`, `APPLE_ADS_POPULARITY` | Which evidence produced the number. Rows scored before this formula keep `APPLE_SUGGEST_SEARCH`, `APPLE_SUGGEST_REACH` or `GOOGLE_PLAY_PREFIX_SEARCH` | | Formula version | `app-store-v5`, `google-play-v5` | Which calculation produced it, so old rows stay interpretable | | Captured at | A timestamp | When the evidence was collected | | Confidence | `HIGH`, `MEDIUM`, `LOW` | How complete the evidence was for that run | Confidence says how complete the evidence was. | Evidence | Confidence | | - | - | | Apple's own search popularity was used | `HIGH` | | The search returned nothing, or on Google Play the suggest lookup failed | `LOW` | | At least 10 results and, on Google Play, at least 8 of the top ten detail lookups | `HIGH` | | Anything else | `MEDIUM` | ## What are score signals? `GET /apps/{id}/keywords` returns the evidence behind each stored score as `scoreSignals`, and the score details in the keyword table say the same in words. | Field | Meaning | | - | - | | `suggestReach` | `hit`, `listed`, `absent` or `unavailable`, as in the reach table above. Recorded on both stores and read by both popularity estimates | | `suggestPrefixLength` | Typed characters before the store offered the phrase, for a `hit` | | `suggestPosition` | Its place in that suggestion list | | `serpRelevance` | Average title match strength over the top ten, 0 to 1: 1 for the exact phrase, 0.7 for every word in any order, and up to 0.4 for some of the words | | `medianRatingCount` | Median rating count of the top ten | | `flags` | `brand`, `weak_leader`, `small_serp` and `padded` | | `entryDifficulty` | On a brand page, the 0 to 10 difficulty of the page without its leader, for reference; otherwise null | | `officialPopularity` | Apple's search popularity, 1 to 100, when it was used | | `estimatedTraffic` | The estimate on the stored 0 to 10 scale, kept even when Apple's number replaced it | `scoreSignals` is `null` for a row scored by an older formula, and `scoreOutdated` is `true` for it. A score without provenance cannot be audited, so asobeast never shows one. ## How is opportunity calculated? Opportunity asks how likely the keyword is to bring installs: whether enough people search it, and how much of the page is still open. | Step | Formula | | - | - | | Reach | Volume divided by 30, at most 1, raised to the power of 1.25 | | Openness | 100 minus difficulty | | Opportunity | Reach multiplied by openness, cut to a whole number | Volume counts only up to 30. Past that point a keyword brings enough searches that the competition decides whether the installs can be won, so a keyword with a volume of 30 and one with 70 score the same on the same page. Below it, opportunity falls a little faster than the volume does: a volume of 15 keeps 42 percent of the score and a volume of 7 keeps 16 percent. Difficulty counts across the whole range: a difficulty of 20 keeps 80 percent of the reach, 50 keeps half and 80 keeps a fifth. A popular phrase held by the largest apps therefore scores low, and a moderately searched phrase on an open page scores high. `game` at a volume of 68 and a difficulty of 71 scores 29, while `geography map game` at 33 and 23 scores 77. The shape was chosen against published keyword effectiveness. On the reference's own popularity and difficulty it orders 794 App Store and 2,101 Google Play keywords at a rank agreement of 0.92 and 0.95. The power on reach is there because published effectiveness falls faster than volume in the long tail: with it, the share of keywords that land on the same side of the high opportunity threshold as the reference rose from 77 to 82 percent on 770 App Store keywords and held at 81 percent on 442 Google Play keywords, scored from estimated popularity and difficulty. A two-dimensional table of popularity and difficulty bands fits the reference more closely on its own inputs, but orders the keywords worse from estimated popularity and difficulty, because a band edge turns a small estimation error into a jump. Opportunity is the same for every app that tracks the keyword. Relevance is reported next to it and not multiplied in, so the number says what the keyword is worth and relevance says whether it belongs in this listing. The action rules read both. Storing opportunity would mean recomputing every row whenever either score changed. Deriving it on read keeps one keyword row, and the read layer says so in the response, so a consumer can tell a derived score from a stored one. ## How are scores graded? The tables grade every judged number into one of four words: strong, fair, weak or poor. The grade reads the number for what it means to the app, so a high popularity is strong while a high difficulty is poor. | Metric | Strong | Fair | Weak | Poor | | - | - | - | - | - | | Popularity (0 to 100) | 60 or more | 40 to 59 | 20 to 39 | Under 20 | | Difficulty (0 to 100) | 35 or less | 36 to 55 | 56 to 75 | Over 75 | | Opportunity (0 to 100) | 60 or more | 35 to 59 | 15 to 34 | Under 15 | | Position | 1 to 3 | 4 to 10 | 11 to 30 | 31 and beyond | | Rating (1 to 5) | 4.5 or more | 4.0 to 4.4 | 3.5 to 3.9 | Under 3.5 | A score is graded on the whole number the table shows. An unscored keyword and a keyword that was checked and not found carry no grade: they read "not scored yet" and "not ranking" instead. Each grade has a colour, green, yellow, orange and red, but the colour is never the only signal. The number stays, the meter keeps its length, and the grade word is part of what a screen reader announces. The grades apply to popularity, difficulty, opportunity and position on the keyword monitor, to every position and score in the comparison matrix, to the positions in the keyword movers card, and to the best position, average position and rating in discovery. Charts that show a distribution, such as the rank distribution, keep their own sequential colours. ## Where does relevance start? The tracked keyword's source sets the starting relevance, which is then adjusted by how the phrase overlaps the app's snapshot text. | Source | Starting relevance | | - | - | | `TITLE`, `SUBTITLE`, `KEYWORD_FIELD` | 90 | | `MANUAL` | 80 | | `DESCRIPTION` | 70 | | `SUGGESTED` | 60 | | `COMPETITOR` | 50 | Full overlap with the snapshot text adds 10 and no overlap subtracts 10. A word overlaps when the snapshot text has it as a whole word, as a whole word or a word followed by a particle for a Korean word, or anywhere at all for a Chinese, Japanese, Thai, Lao, Khmer or Burmese word. A character of those scripts ends a word written next to it as a space would, so `ai` overlaps with `AIが3行要約`. Ranking evidence follows: * An app already ranking at position 50 or better has a relevance of at least 70, because the store already treats it as relevant. * A `DESCRIPTION`, `SUGGESTED` or `COMPETITOR` keyword that was checked and not found loses 10. The result is clamped to the range 1 to 100. You can override relevance per tracked keyword, and an override wins over every rule. ## Can asobeast use Apple's own numbers? Yes, with an Apple Ads API key. Apple publishes a weekly search popularity from 1 to 100 per storefront and genre for its most searched terms. With the key configured, asobeast syncs the newest complete week every Monday and, for App Store keywords: * uses Apple's number for a term Apple lists, with the source `APPLE_ADS_POPULARITY` and high confidence; * caps the estimate for a term Apple does not list below the lowest popularity Apple published that week in the term's own genre, because the list covers the 500 most searched terms of each genre and the bars differ: in one US week the lowest listed term read 41 in Sports and 56 in Games. The genre is the one most of the first ten results belong to, and when none of them maps to a genre Apple reports, the lowest value of the whole week applies. The cap never goes under 15 on the shown scale. A week older than 28 days is ignored, so a lapsed key falls back to the estimate instead of serving old numbers. A week whose write fails part way is removed and synced again. Google Play has no equivalent, so its popularity stays an estimate. The instance connects with its own credentials; see [Connect Apple Ads](/operations/apple-ads) and the [configuration reference](/configuration/reference#apple-search-popularity). `pnpm --filter api scoring:calibrate` compares the estimate with Apple's number for every keyword that has both, and prints the rank correlation and the typical error, so an operator can see how far the estimate is from Apple's own figure. ## What happens to old scores after an upgrade? They are rescored once, automatically. When the API starts with a new formula version, every tracked keyword whose latest score used an older version is queued for one score job, through the same rate limited queues as weekly scoring. Until its job runs, the keyword keeps its old numbers and the table says "Older formula". Such a row keeps its bucket, so an audit still knows the app's priority keywords, but it never raises a keyword action or a high opportunity entry, because its numbers were not made for the current threshold. Stored history keeps the version it was scored with. ## Are scores comparable across stores? No. Apple and Google Play scores come from different public evidence and are store specific signals. See [App Store and Google Play](/concepts/stores). They are also not a substitute for first party acquisition or conversion data. asobeast has no download or revenue data and never presents one. ## When are scores recalculated? Weekly, on the `CRON_SCORING` schedule, which defaults to `0 4 * * 0`, meaning 04:00 UTC on Sunday. Scoring is the most expensive job per keyword on Google Play, which is why it is weekly rather than daily. See [The daily pipeline and rate limits](/concepts/pipeline). # App Store and Google Play Source: https://docs.asobeast.com/concepts/stores Apple indexes a title, a subtitle and a private 100 character keyword field. Google Play indexes the title, short description and long description. Both stores are live and equally supported. What differs is which fields each store indexes for search, and one of Apple's indexed fields cannot be read from the public listing at all. On the App Store, asobeast reads iPhone search, so it tracks listings that are available on iPhone. An app sold only on iPad, the Mac App Store, Apple Watch or Apple Vision Pro never appears in that search, so it is refused on import and as a competitor. Apple keeps a separate iPad catalogue that lists iPad only apps, and asobeast does not read it. An app imported before this rule shows a notice on its pages, because its positions cannot be found. ## What does each store index? | Field | App Store limit | Google Play limit | Indexed for search | Readable from the listing | | - | - | - | - | - | | Title | 30 | 30 | Both stores | Yes | | Subtitle | 30 | Not present | App Store | Yes | | Keyword field | 100 | Not present | App Store | No | | Short description | Not present | 80 | Google Play | Yes | | Long description | 4000, not indexed | 4000, indexed | Google Play | Yes | | Promotional text | 170, not indexed | Not present | Neither | Yes | Google Play search therefore reads three surfaces: the title, the 80 character short description and the 4000 character long description. There is no subtitle and no keyword field on Google Play, so those two stay Apple only concepts everywhere in asobeast, including the types, the metadata lints and the audit rubric. In a storefront that defaults to English, asobeast also captures the native localization. See [Countries and markets](/concepts/countries#what-about-storefronts-that-default-to-english). In practice, App Store search combines the words of the title, subtitle and keyword field into phrases within one localization, never across two, and the order of the words does not matter. Apple does not document either behaviour; both are the consistent observation of ASO practitioners. ## Is screenshot text indexed? Apple has not documented screenshot text as an indexed field, and Google has not either. The App Store column of the table above therefore lists three fields, not four. Since early June 2025, ASO practitioners have reported that apps rank for words that appear only in their screenshot captions on the App Store. Appfigures measured it first, and Phiture and AppTweak repeated the report. Apple has been reported to deny it, and the one controlled test published, by ConsultMyApp, found 1 of 64 caption phrases ranking without an explanation in the title, subtitle or keyword field. asobeast treats screenshot text as a weak, unconfirmed signal: it reads it on the App Store, shows it beside keyword coverage and records its changes, but never counts it as an indexed field. Google Play screenshots are not read at all. See [Read screenshot captions](/guides/screenshot-captions). ## Why is the iOS keyword field private? The 100 character iOS keyword field never appears on the public store page, so no amount of collection can read it. It exists only in App Store Connect. asobeast handles this by asking you for it. Type or paste the field on the metadata page, one localization at a time, and asobeast stores those keywords with the source `KEYWORD_FIELD`, which lets coverage checks, the audit rubric and the Action Center reason about a surface they cannot otherwise see. Leaving it empty is not neutral. Coverage will report the field as uncovered because asobeast has no evidence that it contains anything. ## How do you read and write the keyword field? Each App Store localization has one field. The per-localization routes take the localization in the path and the markets to track in the body, and they are what the web app calls. The storefront shaped routes take the storefront and the localization as optional query parameters, and without them they read and write the default localization of the home storefront. They are unchanged. The two families store the same fields. | Operation | Endpoint | | - | - | | List the localizations an app ships | `GET /apps/{id}/localizations` | | Add a localization | `POST /apps/{id}/localizations` | | Remove a localization | `DELETE /apps/{id}/localizations/{localization}` | | Find localizations in the store | `POST /apps/{id}/localizations/detect` | | Save a field and choose where to track it | `PUT /apps/{id}/keyword-fields/{localization}` | | Preview that save | `POST /apps/{id}/keyword-fields/{localization}/preview` | | Read what one market's search reads | `GET /apps/{id}/keyword-fields/market?country=` | | Submit a field for one storefront | `PUT /apps/{id}/keyword-field?country=&localization=` | | Read a stored field | `GET /apps/{id}/keyword-field?country=&localization=` | | List every stored field | `GET /apps/{id}/keyword-fields` | The two storefront shaped reads answer the `KeywordFieldResult` shape and the list answers one per localization. The new routes answer `AppLocalizationsResult`, `AppLocalizationItem`, `LocalizedKeywordFieldResult`, `KeywordFieldSavePreview`, `MarketKeywordFields` and `LocalizationDetectionResult`, all described in the API reference. Nine things the schema cannot tell you. * **The value is the saved set, not your string.** asobeast normalizes each phrase, drops the empties and removes the duplicates before it tracks anything, so `Focus Timer, POMODORO ,pomodoro` reads back as `focus timer,pomodoro`. Spacing and casing can differ from what you submitted, a repeated phrase stays where it first appeared, and the character count is the count of the normalized set. Accented letters are stored in their composed form, so `zażółć` pasted as decomposed text, which macOS copy and paste can produce, is tracked and counted the same as `zażółć` typed directly. The phrases keep the order of the latest submission. `phrases` is the whole field in that order; `tracked` is those of its phrases tracked and active in `country`. * **A save is held to the 100 character limit.** App Store Connect counts the keyword field in characters, not bytes. asobeast counts Unicode code points, so a letter such as ą and a Chinese, Japanese or Korean character each count once, including the rarer Han characters outside the Basic Multilingual Plane such as 𠮷. Apple does not document how App Store Connect counts an emoji sequence or a letter written with combining marks, so asobeast counts each of their code points: a flag is two and a family emoji joined by zero width joiners is five. The limit is measured on the normalized set, the same count `charactersUsed` reports, so spacing and duplicates in the submitted text do not count against it. A save whose normalized set runs past 100 characters answers 400 and changes nothing. A single tracked phrase is held to 100 characters counted the same way, so a phrase of 51 such Han characters can be tracked. * **A field belongs to a localization, and you choose where its phrases are tracked.** The per-localization save takes `text` and `trackIn`, the markets in which to track every phrase of the field. `trackIn` may name only markets of the app whose storefront reads the localization, or the request answers 400 naming the country, and it may be empty, which stores the field and tracks nothing. A phrase dropped from the field stops being tracked as `KEYWORD_FIELD` in every market that reads the localization, whatever `trackIn` says, unless another field read there still holds it. The storefront shaped save keeps its old meaning: `localization` must be one the `country` storefront reads, or the request answers 400 naming the ones it reads, omitted it is the storefront's default, and the phrases are tracked in `country` only. The German field saved from Austria is the same field Germany reads. See [How does asobeast keep a keyword field per localization?](/concepts/countries#how-does-asobeast-keep-a-keyword-field-per-localization) * **A preview writes nothing.** `POST .../preview` takes `text` and answers, for every market of the app that reads the localization, the phrases it would add, the keyword markets it would open and the daily store requests they cost, plus the phrases that join or leave the field and your plan's keyword market quota. It creates no keyword, spends no on demand allowance and works with a read only token. It answers the same 400 a save would for invalid input. * **A save records the localization as shipped.** Both save routes add a non-empty field's localization to the list `GET /apps/{id}/localizations` reads. See [Which localizations does an app ship?](/concepts/countries#which-localizations-does-an-app-ship) * **`duplicatesRemoved` is 0 on a read.** It reports what the last submission discarded, and a read submits nothing. * **A save never untracks a keyword something else tracks.** A phrase already tracked from the title, the subtitle, the short description or a manual add joins the field without losing that source, and the API reports it as `KEYWORD_FIELD` while it is in the field. A later save that leaves it out only takes it out of the field, so it stays tracked under its own source. A phrase that only a field tracks is deactivated, in every market that reads the localization, when a save leaves it out and no other field read there holds it. Adding such a phrase manually or reactivating it makes it a manual keyword, and a snapshot that finds it in the title, subtitle or short description gives it that source. * **Untracking a keyword leaves the field alone.** The field is what you submitted in App Store Connect, so `DELETE /apps/{id}/keywords/{keywordId}` stops tracking the phrase in that market and keeps it in `phrases`, and `charactersUsed` still counts it. * **The last save wins.** Saves to one app are written one at a time, so two saves that overlap never merge: the field holds the phrases of whichever save was written last. Each save answers with the field as it was stored when that save finished, so a save that overlapped another may be answered with the other save's phrases. A localization that has no saved field reads as an empty result with 0 characters used rather than a 404, because an empty field is a normal state. A localization tag outside the 50 answers 400 naming it. Removing a localization that is not on the list answers 404, and removing the home localization answers 409. A Google Play app refuses every operation on this page with 400. ## Developer replies Each store shows one public developer reply under a review, and the developer can edit it. | | Google Play | App Store | | - | - | - | | Length | Its API refuses a reply of more than about 350 characters | Apple publishes no limit. asobeast's composer stops at 2,000 characters of its own accord | | When it appears | Shortly after it is posted, and the reviewer is notified | Within up to 24 hours | | What asobeast reads | The reply's date and text, with every daily sync | Nothing, because the public review feed carries no response | | What asobeast posts | The reply you confirm on the reviews page, through the connected service account or Google sign in. Posting again edits it in place, and Google's API offers no removal | The reply you confirm on the reviews page, through the connected key. Posting again replaces it, and it can be withdrawn | A review is **replied** when the store shows a reply, when you posted one from the reviews page, or when you marked it as replied there. On the App Store asobeast cannot read a reply posted in App Store Connect, so only a post from asobeast or the mark counts. A Google Play review edited after the reply the store shows is **updated after reply** instead, because the reviewer changed it once the reply was public. The counter in the composer counts characters the way the stores count them, so a letter such as ą or a Chinese, Japanese or Korean character counts once. See [Reply to reviews](/guides/reviews#reply-to-reviews). ## Why is the Google Play short description its indexed surface? The 80 character short description, called `summary` in the store payload, is Google Play's equivalent of the surfaces Apple splits across a subtitle and a keyword field. asobeast treats it accordingly. * Its keywords are tracked automatically with the source `DESCRIPTION`. * The metadata workbench lints it against the 80 character limit. * Keyword coverage counts it as an indexed field. The store page escapes some characters in the short description, so `Math & Music` arrives as `Math & Music`. asobeast stores the short description as the plain text a user reads, keeps the payload exactly as received in `raw`, and counts characters the way Google Play does. An upgrade decodes short descriptions already stored and pauses the auto tracked keywords an escape produced, such as `amp music`, keeping their history. ## How are auto tracked keywords chosen? On import and on every refresh, asobeast reads the words of the title and of the subtitle (App Store) or the short description (Google Play), and tracks up to 15 phrases of one to three words. Title phrases come first, then longer phrases before shorter ones. Stopwords such as app, free and best are skipped, and so are words of one character. A listing outside the English storefronts also skips the function words of its own language, as described below. A word with an apostrophe is read as the word it contracts. The ending of `you've`, `it's`, `we'll` and `I'm` is dropped, so `RPG you've been waiting for!` gives `rpg waiting` and never `ve`, and a possessive such as `Kid's Games` gives `kid games`. A negated auxiliary such as `don't` or `can't` is dropped like a stopword. A number written with a separator between its digits, such as `10,000`, `1.000`, `2.0`, `24/7`, `10:30`, or `10 000` with a non breaking space, is a quantity and not a keyword. It is dropped together with a unit of up to three letters written right after it, as in `3.5mm` or `2.4GHz`, and ends the phrase before it, so `10,000+ magazines in one app` never gives `10` or `000`. A longer word written right after it stays a keyword, so `10,000Songs` gives `songs`. A number that is part of a word keeps that word, so `Web3.0` gives `web3`. A number without a separator, such as `2048`, `3D` or `100+`, is kept. A plain space between digit groups is not read as a separator, so `10 000` typed with a plain space still gives `10` and `000`. Function words are skipped in the languages of the storefront. Next to English, asobeast reads the Spanish, Portuguese, French, German, Italian, Dutch, Swedish, Norwegian, Danish, Finnish, Polish, Turkish, Indonesian, Romanian and Czech lists, and it applies a list only to the storefronts that read that language. A language without a list is not filtered. That includes Russian, Ukrainian, Arabic, Hebrew, Greek and Hindi, so a Russian storefront and a Russian review keep their function words. A Mexican, Argentine or Colombian storefront skips `de`, `la`, `el`, `en`, `para` and `tu`, a Swedish one skips `och`, `det` and `att`, and a Swiss or Belgian storefront skips the words of each language it is read in. * **The language comes from the storefront, not from the text.** `us`, `gb`, `au` and the other English storefronts keep reading English only. On the App Store, Canada, Belgium, Switzerland and Luxembourg read each of their languages. A stored native localization, such as the French listing of `ca`, is read in its own language, and the default listing in the languages that have no localization of their own, so the English default of an app with a French listing in Canada is read as English. On Google Play a storefront reads the one language asobeast asks the store for, so Canada and Belgium read English and Switzerland reads German. A storefront asobeast has no language for reads English only. * **A skipped word leaves the phrase, as an English stopword does.** `Juegos de Trivia` gives `juegos trivia`, `juegos` and `trivia`, and never `de`, `juegos de` or `de trivia`. * **Only function words are skipped.** Articles, prepositions, conjunctions, pronouns, possessives and the common forms of to be and to have. A word that is also a name stays, so `IO`, `UNO`, `Contra`, `Pro`, `ELLE` and `NU.nl` are kept, and so is the first person possessive that opens a name such as `Mi Claro`, `Mein Vodafone` or `Mijn KPN`. A function word that is also an English keyword, such as `men` or `care`, stays too, because many listings in these storefronts are written in English. * **Review text skips a few more.** A name never appears in a review, so keyword ideas and review themes also skip the French `mon`, `ma`, `mes` and `sans` and the German `hat` and `war`, which a listing keeps for names such as `Mon Orange` and for English keywords such as `war`. See [Mine reviews for keyword ideas](/guides/reviews#mine-reviews-for-keyword-ideas). * **A review is read in its own language too.** asobeast guesses the language of each review from its script and its words, and skips that language's function words next to the storefront's. When a short review matches two languages equally, such as Spanish and Portuguese on `como` and `que`, it skips both lists, so `Que mal servicio` offers `mal servicio` and never `que`. A review that matches no word of any list is read with the storefront's languages only, and a language without a list is not filtered, as the paragraph above says. * **An accent makes a different word.** `te` is skipped and `té`, tea, is kept. * **An elided article is skipped with its word.** `qu'il` is read as `qu` and `il`, and `dell'Italia` as `dell` and `Italia`, so `qu`, `il` and `dell` are skipped and `italia` stays. Japanese, Chinese, Thai, Lao, Khmer and Burmese have no spaces between words, so asobeast splits them with the word segmenter that ships with the runtime, `Intl.Segmenter`, and joins the words of a phrase without spaces, the way a person types it in the store. * **In these scripts a phrase never crosses a space, a dash or a punctuation mark.** `メルカリ` and `フリマアプリ` in a title are two keywords, never one joined keyword. * **Store noise words at the start and end of a run are removed first.** `無料アプリ` gives no keyword at all, and `無料ゲーム` gives `ゲーム`. * **A hiragana run of five characters or fewer stays whole.** The segmenter cuts hiragana into pieces it cannot tell from grammar, so `ふりま` and `ぐるなび` are kept as one keyword each, and `しまむら公式アプリ` gives `しまむら`. Any other short run is split like a long one, so `天気予報` gives `天気予報`, `天気` and `予報`, and `猫のゲーム` gives only `ゲーム`. A short Chinese title with a word such as `我的` in it is kept whole as well, so `我的世界` is a keyword next to `世界`. * **A grammar word ends a phrase.** Hiragana only fragments, single character Chinese particles such as `的` and `和`, and a short list of Japanese, Chinese and Thai noise words are dropped, and a phrase is never built across them. The Chinese particles apply only to text without kana, so a Japanese title keeps `東京都` and `就活`. A word written in hiragana inside a longer run is dropped with them, because the segmenter cannot tell it from grammar, so `ひまつぶしゲーム` gives only `ゲーム`. * **Thai is split with a word list first.** The segmenter cuts many Thai words into syllables, so `สนามบิน` (airport) would come out as `สนาม` and `บิน` and a phrase could start or end inside a word. asobeast splits Thai with the word list of the PyThaiNLP project, about 62,000 words dedicated to the public domain under CC0, together with a few store words the list lacks, such as `แอป`, `ช้อป` and `เดลิเวอรี่`. The segmenter only splits the text the list does not know. A compound the list knows is one keyword, so `แอปสั่งอาหาร` gives `สั่งอาหาร` and not `สั่ง` and `อาหาร`. * **A Thai function word ends a phrase, and a Thai prefix starts a word.** Words such as `ด้วย`, `ไป` and `กับ` are dropped like stopwords, and `การ`, `ความ`, `นัก` and `ผู้` join the word after them. So `จองการเดินทางไปสนามบินล่วงหน้า` gives the phrases `จองการเดินทาง` and `สนามบินล่วงหน้า` and the words `จอง`, `การเดินทาง`, `สนามบิน` and `ล่วงหน้า`, and never `จองการเดิน` or `สนามบินล่วง`. * **A single Chinese or Japanese character stays with its neighbours.** Two or more in a row form one word, as in `畅聊`. A lone one never stands alone and can end a phrase, so `家計簿` is kept and `簿` is not offered. It starts a phrase only at the very start of a run, as in `新機能` and `筋トレ`, and not after a Latin word, so `iPhone用カメラ` gives `カメラ`. Brand names the segmenter cuts into single characters are joined back this way, so `微信`, `淘宝` and `小红书` stay whole. * **A phrase of two or three words is at most 10 Chinese or Japanese characters, or 20 letters in Thai, Lao, Khmer and Burmese.** A letter counts together with its vowel and tone marks, so `สั่งอาหารออนไลน์` is a phrase. A single word is never cut, so a long loanword stays whole. * **A katakana word that the segmenter splits is joined again.** A piece of one or two characters, or one that starts with a small kana, `ン` or `ー`, joins the katakana before it. A piece that ends in `ッ`, a two character piece that ends in `ー`, a single character at the start of a katakana run, and a piece joined from such pieces that is still two characters or fewer join the katakana after them. So `スマホ` is not offered as `スマ`, `ネットフリックス` and `ユーチューブ` stay whole, and `プリティーダービー` gives `プリティー` and `ダービー` and never `プリ` or `ティー`. A two character word the segmenter returns whole, such as `ヨガ` in `ヨガレッスン`, stays a keyword of its own. * **Latin words in the same listing form phrases as usual.** A Latin brand next to Japanese text is its own phrase. Neither the segmenter nor the word list knows every word. A brand written in Thai letters still comes out in syllables, so `ลาซาด้า` gives `ลา` and `ซา` next to `ลาซาด้า`, and a katakana word cut into pieces of two characters and more, such as `ダンジョン` or `ソーセージレジェンド`, comes out in pieces too. Pause such a fragment on the keyword monitor. Arabic is written with spaces, but a conjunction and an article attach to the word after them. asobeast drops the noise words it knows, and also the same word after the conjunction `و`, so `وأكثر` goes with `أكثر`. It keeps every other word that begins with `و`, because many Arabic words do, such as `وقت` and `واتساب`, and it never strips the article `ال`. A keyword like `وطعام` therefore stays as it was written. The tatweel and the short vowel marks are ignored when a word is compared with the noise words, so `وأكـثر` and `أَكْثَر` are dropped as well, and a keyword that stays keeps the marks it was written with. Korean keeps its spaces, so a particle attached to a word, as in `메신저를`, also stays attached. asobeast has no morphological analysis for Korean or Arabic. The metadata lints, the ASO audit, the review phrases and the keyword combinations card still read words between spaces, so on a Japanese, Chinese or Thai listing they treat a run of text as one word. The audit can therefore call a keyword missing from the title that the coverage table shows as covered. Keyword coverage, on the metadata workbench and on the overview, and keyword relevance find such a keyword anywhere in the listing, so a keyword tracked from a listing counts as covered by it and earns its full relevance. See [Check keyword coverage](/guides/metadata-workbench#check-keyword-coverage) and [Where does relevance start?](/concepts/scoring#where-does-relevance-start) An upgrade does not change keywords already tracked. The next refresh of an app, which the daily run performs, adds the cleaner keywords next to the sentences and function words an earlier version tracked, and those stay active until you pause or remove them on the keyword monitor. ## What stays Apple only? The subtitle and the keyword field. On a Google Play app they carry audit weight 0 and are hidden in the web app rather than shown empty, and the Action Center skips rules that would recommend an impossible edit. ## Are scores comparable across stores? No. Popularity and difficulty are derived from different public evidence on each store, so an Apple difficulty of 60 and a Google Play difficulty of 60 do not mean the same thing. Compare a keyword against other keywords in the same store. See [Popularity, difficulty and opportunity](/concepts/scoring). ## Where else do the stores differ? | Difference | Effect | | - | - | | Neither store's suggest endpoint carries a popularity value | On Google Play asobeast probes how early the store suggests the keyword; on the App Store it estimates popularity from the first 25 search results. See [Popularity, difficulty and opportunity](/concepts/scoring#how-is-popularity-estimated) | | Google Play search results carry no rating counts | SERP rating columns stay empty for Google Play | | The App Store publishes no install count | `installs` stays empty for App Store apps and their competitors | | Google Play release dates are best effort outside the `en` storefront | Treat `released` as approximate there | | Some Google Play apps publish a different build per device | Google Play reports their version as `VARY`. asobeast stores no version for them, so the overview and the competitor list show none and a refresh records no version change while the app keeps varying. When the app later reports a real version, the change reads from no version to that version. The payload in `raw` keeps `VARY`, and an upgrade clears the marker from snapshots and version changes already stored, dropping a stored version change that only moved between no version and `VARY` | | Google Play reads a bare `zh` as Simplified Chinese | asobeast asks for Taiwan as `zh-TW` and for Hong Kong as `zh-HK`, so the stored title, short description and description, the keywords tracked from them, rank checks, suggestions and reviews all use the listing people there see, in Traditional Chinese when the developer publishes one. Every other storefront is requested in its own language, or in English where asobeast has none. An app imported before the change keeps its Simplified listing until its next refresh, which records a change for each field the two listings word differently, usually the title and often What's New and the screenshots, and one `metadata.changed` alert for each app, competitors included, whose home storefront is Taiwan or Hong Kong. Keywords already tracked from the Simplified text stay tracked, so untrack the ones you do not want. Reviews already stored stay, and the first sync after the change can store reviews of the Traditional feed and send one negative review report for them. Rank history and keyword scores in these two storefronts step once, because the store now ranks a different language | | An App Store product page without a subtitle shows one of the app's categories in its place | asobeast reads a subtitle equal to any category of the listing as no subtitle: the primary category for most apps and a subcategory such as Action for a game. The category never counts toward coverage, is never tracked as a keyword, and a refresh records no subtitle change for it. A real subtitle that is exactly a category name is read the same way, because the page cannot tell the two apart. An upgrade clears these subtitles from snapshots already stored and from each side of a stored subtitle change that was read from such a snapshot, drops a stored subtitle change that only moved between no subtitle and a category, and stops tracking the keywords asobeast added from a category unless they are paused, sit in the keyword field, carry tags, a note or a relevance, or also appear in the title or subtitle | ## What happens when a store changes its response? A parser breaks, for every installation at once. Only the provider layer parses store responses, so the break is one module, failed jobs stay contained, and no history is lost. See [Recover from a broken store parser](/operations/scraper-breakage). ## Why does a Google Play job cost more? Because one logical job fans out to many requests. A Google Play score job makes 12 to 20 sequential requests: one search, one to nine suggest probes and ten detail enrichments. An Apple score job makes 3 to 11: one search, one to nine suggest probes and one lookup of the phrase followed by a space, which counts the searches that continue it. A keyword the store never suggests costs a single probe, plus that lookup on the App Store. A depth 200 Google Play position check takes roughly eight requests against one for Apple. That is why `SCRAPE_GPLAY_RPM` spaces job starts rather than individual requests, and why its default of 10 is lower than the Apple default of 15. See [The daily pipeline and rate limits](/concepts/pipeline). # Visibility Source: https://docs.asobeast.com/concepts/visibility Visibility rolls the positions of an app's tracked keywords into one number from 0 to 100, each keyword weighted by its popularity and discounted by how far down the results it ranks. Visibility answers one question: how close is the app to ranking first for every keyword it tracks? It is 100 when the app holds position 1 for all of them and 0 when it was found for none. ## How is visibility calculated? Each tracked keyword contributes its popularity, multiplied by a weight for the position the app held that day. The sum is divided by the popularity of every keyword counted and shown as a share of 100. ```text theme={null} visibility = 100 × sum(popularity × weight) / sum(popularity) weight = 1 / log2(position + 1), or 0 when the app was not found ``` The weight halves by position 3 and keeps falling more slowly after that, so climbing from 5 to 1 moves visibility far more than climbing from 150 to 100. | Position | Weight | | - | - | | 1 | 1.00 | | 2 | 0.63 | | 3 | 0.50 | | 5 | 0.39 | | 10 | 0.29 | | 20 | 0.23 | | 50 | 0.18 | | 100 | 0.15 | | 200 | 0.13 | | Not found | 0 | This is the position discount of discounted cumulative gain, the standard measure for a ranked list of search results. Popularity plays the part of the gain, so a keyword people actually search moves the number and a phrase nobody types barely does. The API returns visibility with one decimal. The overview and portfolio cards round it to a whole number. ## A worked example An app tracks three keywords on one day. | Keyword | Popularity | Position | Weight | Counted | | - | - | - | - | - | | habit tracker | 60 | 3 | 0.50 | 30 | | daily planner | 20 | 1 | 1.00 | 20 | | focus timer | 20 | Not found | 0 | 0 | The counted values add up to 50 out of a possible 100, so visibility is 50.0. Tracking a fourth keyword with a popularity of 50 that the app does not rank for raises the possible total to 150 and lowers visibility to 33.3, although no position moved. ## Which keywords count? Every active keyword the app tracks. A paused keyword is left out of today's number and out of the history. The overview counts every market the app tracks keywords in until you choose one in its market switcher, and then only the keywords of that market; see [What does the overview count?](/concepts/countries#what-does-the-overview-count). The portfolio card for an app counts every market, and a group pools the keywords of every listing in it. | Input | What is used | | - | - | | Day | The latest day on which any keyword of the app was checked | | Position | The position recorded for that keyword on that day. A keyword not checked that day counts as not found | | Popularity | The latest score on or before that day, on the scale described in [Popularity, difficulty and opportunity](/concepts/scoring#which-scale-does-a-response-use) | | No popularity yet | Counts as a popularity of 10 out of 100, so a new keyword still counts a little before its first score | | Popularity of 0 | Adds nothing to either sum, so the keyword cannot move the number | What not found means, and why it is never position zero, is explained in [Positions](/concepts/positions). ## What do the 7 and 30 day changes mean? The difference in points between visibility on the latest checked day and visibility 7 or 30 days earlier. It is missing when no keyword was checked on that earlier day. Both days are calculated over the keywords tracked today, each with the popularity it had on that day. Adding a keyword therefore changes past values too: on the days before its first check it counts as not found, and its own position moves the change from then on. ## Where else is visibility used? | Place | Difference | | - | - | | [Portfolio](/guides/portfolio) | Each app shows its visibility with the 7 day change and a 30 day sparkline. A group shows one blended number over the keywords of all its listings | | [Change impact](/guides/listing-changes#measure-the-impact-of-a-change) | Covers one market and only the keywords checked on both days, and weights both numbers by the popularity on the last check before the change, so only positions move it | | [Action Center](/guides/action-center) | Calculated per market from the keywords checked each day, weighted by today's popularity. `rank.investigate_drop` and `rank.investigate_unexplained_drop` read a fall in it, after a listing change or without one, and `market.improve_country` reads the gap between a market and the home market | ## How should I read it? As a trend. Visibility depends on which keywords you track, so the useful comparison is the same app against itself over time. * **Adding keywords moves it.** A keyword you do not rank for yet lowers the number on the day you add it and on every past day. One you already rank for lifts the 7 day change for a week and the 30 day change for a month, because the days before its first check count it as not found. Read each change once its earlier day comes after the keyword's first check. * **A rescore moves it.** The weekly scoring job can change popularity, which changes how much each position counts, without any position moving. * **Stores are not comparable.** App Store and Google Play popularity come from different evidence, so the same number means different things on each. See [App Store and Google Play](/concepts/stores). * **Apps are only comparable on the same keywords.** Two apps tracking different keywords measure different things. # Configuration reference Source: https://docs.asobeast.com/configuration/reference Every environment variable the asobeast API reads, with its default and its effect. AUTH_SECRET is required and must be at least 32 characters. Every variable below is read by the API from `apps/api/.env`, or from the environment of the API container. Everything except `DATABASE_URL` and `AUTH_SECRET` has a working default. > [!WARNING] > > Three settings stop a deployment cold. `AUTH_SECRET` must be at least 32 characters or the API refuses to boot, and changing it signs out every user. Compose refuses to start when `POSTGRES_PASSWORD` or `AUTH_SECRET` is missing or empty from the root `.env`. ## Required | Variable | Default | Required | Effect | | - | - | - | - | | `DATABASE_URL` | `postgresql://asobeast:asobeast@localhost:5433/asobeast` | Yes | PostgreSQL connection string. Compose sets it from `POSTGRES_PASSWORD` | | `AUTH_SECRET` | None | Yes | Signs session cookies. At least 32 characters. Generate with `openssl rand -hex 32` | ## Core | Variable | Default | Required | Effect | | - | - | - | - | | `REDIS_HOST` | `localhost` | No | Host holding the BullMQ queues | | `REDIS_PORT` | `6380` | No | Redis port | | `REDIS_DB` | `0` | No | Redis database index for the queues. The end to end suite uses a separate index | | `PORT` | `4000` | No | Port the API listens on | | `DEFAULT_COUNTRY` | `us` | No | Home storefront used when an import URL carries no country segment | | `LOG_LEVEL` | `debug`, and `log` in the Compose stack | No | One of `error`, `warn`, `log`, `debug`, `verbose`. Logs are JSON in production and human readable in development, and every line inside a request or job carries its workspace and correlation id. Compose lowers it to `log` because `debug` records every request, and container logs rotate at 10 MB across 3 files | | `ACCOUNT_DELETION_GRACE_DAYS` | `7` | No | Days a scheduled workspace deletion stays reversible. The retention job erases the workspace on the first run after the grace period ends | | `ERROR_TRACKING_DSN` | Empty | No | Sentry dsn for scrubbed error reports. Ignored unless `NODE_ENV` is `production` **and** `BILLING_ENABLED` is `true`, so a self hosted deployment never reports its errors to anyone else. Reports carry the error type, a secret scrubbed message and stack, the workspace and correlation id, and the request method with identifiers masked out of the path. They never carry headers, cookies, query strings, request bodies or account details. See [Hosted observability](/operations/hosted-observability#error-reporting) | ## Scheduling Every cron expression is evaluated in UTC. A `CRON_` value anywhere on this page that is not a cron expression refuses to boot, rather than leaving that schedule and every one registered after it silently unscheduled. The one exception is an empty `CRON_STORE_CANARY` or `CRON_STORE_STATUS`, which removes that schedule. See [The daily pipeline and rate limits](/concepts/pipeline). | Variable | Default | Required | Effect | | - | - | - | - | | `CRON_DAILY` | `0 3 * * *` | No | Starts the daily pipeline. Batched alerts flush after its processing completes, not on a clock | | `CRON_SCORING` | `0 4 * * 0` | No | Recomputes popularity and difficulty weekly on Sunday | | `CRON_RETENTION` | `0 5 * * *` | No | Prunes rows past their retention window | | `CRON_AUDIT` | `0 6 * * *` | No | Writes the daily audit score snapshot | | `CRON_LOCALIZATION_SWEEP` | `0 7 * * 1` | No | Probes the store once a week for the localizations every App Store app ships, so a localization added later is found and a removed one comes back only if the store still serves it. An empty value removes the schedule. See [Which localizations does an app ship?](/concepts/countries#which-localizations-does-an-app-ship) | | `CRON_DIGEST` | `0 8 * * 1` | No | Sends the weekly digest webhook on Monday | | `CRON_PROXY_SYNC` | `0 2 * * *` | No | Reconciles the egress proxy pool against the provider. Never scheduled while `PROXY_PROVIDER` is `none` | | `CRON_STORE_CANARY` | `0 2,8,14,20 * * *` | No | Asks each store whether it still parses, one hour before the daily run so the day's first collection meets a known state. An empty value removes the schedule and switches the canary off. See [Recover from a broken store parser](/operations/scraper-breakage) | ## Collection rate limits | Variable | Default | Required | Effect | | - | - | - | - | | `SCRAPE_ITUNES_RPM` | `15` | No | iTunes requests per minute. The App Store worker runs concurrency 1 behind this limiter | | `SCRAPE_GPLAY_RPM` | `10` | No | Google Play job starts per minute, not requests, because one job fans out to many | Raising either value risks the host IP address being throttled by the store. See [The daily pipeline and rate limits](/concepts/pipeline). ## Egress proxy pool Optional. With `PROXY_PROVIDER` left at `none` every store request leaves from the host address, which is what a self hosted instance wants. Point it at a provider only when one address cannot carry the collection volume. | Variable | Default | Required | Effect | | - | - | - | - | | `PROXY_PROVIDER` | `none` | No | `none` disables the pool. `webshare` reconciles the pool against that account's proxy list | | `PROXY_API_URL` | `https://proxy.webshare.io/api/v2` | No | Provider API base URL | | `PROXY_API_KEY` | Empty | When a provider is set | Provider API key. The API refuses to boot when a provider is configured without one | | `PROXY_USERNAME` | Empty | No | Credentials shared across the pool. Leave empty for an IP authorized account | | `PROXY_PASSWORD` | Empty | No | Password for `PROXY_USERNAME` | | `PROXY_ENDPOINT_RPM` | `15` | No | Requests per minute allowed per pool endpoint. This is the limiter that replaces the single host budget once the pool is on | | `PROXY_ACQUIRE_TIMEOUT_MS` | `120000` | No | How long a job waits for a free endpoint. A saturated pool delays work rather than dropping it, and only fails once this budget runs out so the queue can retry with backoff. A shutdown ends the wait at once, so the budget never holds up a drain | | `PROXY_WORKER_MAX_CONCURRENCY` | `8` | No | Ceiling for store worker concurrency once the pool is on. Actual concurrency tracks the healthy endpoint count and falls back as endpoints cool down. Ignored while `PROXY_PROVIDER` is `none` | Google Play orders its search results by the country of the address a request comes from, so a Google Play request for a storefront waits for a healthy pool endpoint in that country instead of leaving through one abroad, and borrows another country's endpoint only while every endpoint in its own country is cooling down. The App Store answers the same from any address and takes whichever endpoint is free first. Google Play collection for a storefront is therefore bounded by the endpoints in that country: place them where your Google Play keywords are, because a request that finds its country's endpoints busy for longer than `PROXY_ACQUIRE_TIMEOUT_MS` fails and the queue retries it with backoff. With `PROXY_PROVIDER` at `none` the store workers keep concurrency 1 behind `SCRAPE_ITUNES_RPM` and `SCRAPE_GPLAY_RPM`. Those two variables never change meaning: they describe the budget of a single egress address, which is exactly what a self hosted instance has. Credentials stay in configuration. The database records only which credential a pool endpoint uses, never the secret itself. ### Residential fallback Datacenter addresses carry the traffic. A residential gateway is an emergency path for a single request that a datacenter address was blocked on, never a primary route, because residential bandwidth is metered and costs orders of magnitude more per request. | Variable | Default | Required | Effect | | - | - | - | - | | `PROXY_RESIDENTIAL_URL` | Empty | No | Gateway used only after a datacenter address is blocked. Empty disables the fallback | | `PROXY_RESIDENTIAL_USERNAME` | Empty | No | Gateway credentials | | `PROXY_RESIDENTIAL_PASSWORD` | Empty | No | Password for `PROXY_RESIDENTIAL_USERNAME` | | `PROXY_RESIDENTIAL_MONTHLY_CAP_USD` | `0` | No | Hard monthly ceiling. Every residential request is claimed against it before it leaves, so a job that fans out to many requests stops at the ceiling rather than crossing it. At `0` every fallback is refused, so a broad block cannot turn into an unbounded bill | | `PROXY_RESIDENTIAL_COST_PER_GB` | `3` | No | Provider list price, used to price the cap | | `PROXY_RESIDENTIAL_MB_PER_REQUEST` | `1.2` | No | Measured average response size. With the price per gigabyte this estimates month to date spend | | `SIGNUP_CAPACITY_MAX_UTILIZATION` | `0` | No | `0` disables the gate. Above `0`, registration is refused with `503` once tracked collection passes this share of daily capacity. Applied only when `BILLING_ENABLED` is `true`, so a self hosted instance is never gated | ## Alerts See [Send alerts](/guides/alerts). | Variable | Default | Required | Effect | | - | - | - | - | | `ALERT_DELIVERY` | `batched` | No | `batched` sends up to two scoped reports per channel after daily processing completes. `instant` sends one notification per event. Warns in production when `instant` has no SMTP configured. Webhook subscriptions live in the database, so configuration validation cannot see them | | `ALERT_RANK_DROP_THRESHOLD` | `5` | No | Positions a primary app must move before `rank.dropped` or `rank.improved` fires | | `ALERT_REVIEW_SCORE_MAX` | `2` | No | Highest star rating treated as a negative review. Accepts 1 to 4 | | `ALERT_ACTIONS_MIN_PRIORITY` | `high` | No | Lowest priority that fires `action.opened`. One of `critical`, `high`, `medium`, `low` | | `WEBHOOK_ALLOW_PRIVATE_TARGETS` | `false` | No | `false` keeps alert webhooks on public addresses. Loopback, private, carrier grade NAT, link local and cloud metadata targets are refused when the webhook is registered and again when the connection is made, so a hostname that resolves to an internal address cannot reach it either. `true` opts a self hosted instance in to delivery on its own network, including a receiver named by a bare hostname such as a Compose service, or by `localhost`. Refuses to boot when `true` with `BILLING_ENABLED` set to `true`, because the url belongs to the customer, and warns in production otherwise | | `WEB_PUBLIC_URL` | Empty | No | Public web origin, `http` or `https` only, and any other scheme refuses to boot. Builds every link in an email (confirmation, password reset, invitation, the button back into the app, the logo and the unsubscribe link), deep links inside alert payloads and the Stripe return address, and is the single origin CORS allows. One click unsubscribe headers are added only when it is `https`. Unset leaves an alert link `null`, sends emails without buttons, logo or unsubscribe links, closes checkout, sends no CORS headers, refuses account recovery with `503` because its link would carry no host, and refuses to boot when `BILLING_ENABLED` is `true` with SMTP configured, because confirmation is mandatory there. A new email alert recipient always waits for confirmation, and without this no confirmation email can be sent, so the recipient never receives an alert. Warns in production when SMTP is configured and this is empty, or names this machine (`localhost`, `127.0.0.1` or `[::1]`), because no recipient elsewhere can open its links. The Compose stacks set it themselves, see [Compose only](#compose-only) | ## Email delivery Email stays disabled until both `SMTP_HOST` and `SMTP_FROM` are set. Email is optional on a self hosted instance and mandatory on a metered one. With `BILLING_ENABLED=true` in production and no mail path, the API warns at boot that it can confirm no address, warn nobody before a trial ends, and recover no forgotten password. See [Hosting behind TLS and a reverse proxy](/security/hosting). | Variable | Default | Required | Effect | | - | - | - | - | | `SMTP_HOST` | Empty | No | SMTP server host. Required to enable email | | `SMTP_PORT` | `587` | No | Use `465` with `SMTP_SECURE=true`, otherwise 587 or 25 | | `SMTP_SECURE` | `false` | No | `true` wraps the connection in TLS from the start, which port 465 expects. Warns in production when `true` on any other port | | `SMTP_USER` | Empty | No | Leave empty for an unauthenticated relay | | `SMTP_PASSWORD` | Empty | No | Paired with `SMTP_USER` | | `SMTP_FROM` | Empty | No | From address such as `asobeast `. Required to enable email | | `SMTP_DKIM_DOMAIN` | Empty | No | Signs every email with DKIM for this domain, which should be the `SMTP_FROM` domain or its parent so DMARC aligns. The three `SMTP_DKIM_` variables are set together or not at all, and the API refuses to boot when only some are set | | `SMTP_DKIM_SELECTOR` | Empty | No | DKIM selector. Publish the public key at `._domainkey.` | | `SMTP_DKIM_PRIVATE_KEY_PATH` | Empty | No | RSA private key in PEM, for example `keys/dkim-private.pem`. A relative path resolves against `apps/api`, which both Compose stacks mount read only at `apps/api/keys`. The API refuses to boot when the three are set and the file cannot be read. Its contents are never logged | ## Action Center See [Work the Action Center](/guides/action-center). | Variable | Default | Required | Effect | | - | - | - | - | | `ACTIONS_MAX_OPEN_PER_APP` | `20` | No | New actions one generation run may open per app, highest impact first. Existing open and snoozed actions always refresh | | `ACTIONS_SNOOZE_MAX_DAYS` | `90` | No | Furthest a snooze may be set into the future | ## Screenshot captions See [Read screenshot captions](/guides/screenshot-captions). | Variable | Default | Required | Effect | | - | - | - | - | | `SCREENSHOT_OCR` | `true` | No | Reads the large caption text printed on App Store screenshots on this machine. `false` still records the screenshot list and detects replaced or reordered screenshots, but downloads and reads nothing | | `SCREENSHOT_OCR_LANGUAGES` | `eng,deu,fra,spa,por,ita,jpn,kor,chi_sim,chi_tra,rus,ara,tha,pol` | No | The bundled language packs the reader may use. A storefront reads English plus its own language when that pack is listed. The API refuses to boot on a name that is not bundled | ## Data retention `0` means keep forever. Pruning runs on `CRON_RETENTION`. | Variable | Default | Required | Effect | | - | - | - | - | | `RETENTION_RANKINGS_DAYS` | `365` | No | Prunes keyword positions older than N days | | `RETENTION_SERP_DAYS` | `90` | No | Prunes SERP entries older than N days | | `RETENTION_SNAPSHOTS_DAYS` | `180` | No | Prunes app snapshots older than N days. The newest snapshot per app and market is always kept | | `RETENTION_CATEGORY_RANKS_DAYS` | `365` | No | Prunes category ranks older than N days | | `RETENTION_CHARTS_DAYS` | `365` | No | Prunes top chart captures older than N days. `0` keeps forever | | `RETENTION_CHANGE_EVENTS_DAYS` | `0` | No | Prunes metadata change events older than N days | | `RETENTION_DELIVERIES_DAYS` | `30` | No | Prunes alert delivery log rows older than N days | | `RETENTION_ALERT_EVENTS_DAYS` | `30` | No | Prunes completed alert claims older than N days. Unfinished claims are always retained | | `RETENTION_AUDIT_SCORES_DAYS` | `0` | No | Prunes audit score history older than N days | | `RETENTION_ACTIONS_DAYS` | `180` | No | Prunes closed action items only. Open and snoozed actions are never pruned by age | | `RETENTION_AI_CALLS_DAYS` | `400` | No | Days the AI call ledger is kept. Never prunes the current month, which the monthly AI allowance still counts. `0` keeps forever | | `RETENTION_STORE_REPORTS_DAYS` | `35` | No | Days a downloaded store console report file is kept so it can be read again after a parser change. The funnel rows built from it are never pruned by age. `0` keeps forever, which is not recommended because the files grow with every app and day | | `RETENTION_BILLING_EVENTS_DAYS` | `90` | No | Days to keep stored Stripe webhook payloads, which carry customer billing details. `0` keeps them forever | ## Runtime | Variable | Default | Required | Effect | | - | - | - | - | | `NODE_ENV` | `development` | No | `development`, `test` or `production`. The Docker image sets `production`, which turns on the configuration guards below | Two combinations are individually valid and jointly unsafe, so the API refuses to start on them rather than logging a warning nobody reads. Five more warn at startup. See [Hosting and network exposure](/security/hosting). ## Authentication See [Authentication and accounts](/security/authentication). | Variable | Default | Required | Effect | | - | - | - | - | | `AUTH_SESSION_DAYS` | `7` | No | Session cookie lifetime in days | | `AUTH_ALLOW_REGISTRATION` | `false` | No | `true` keeps signups open. `false` closes registration once the first account exists. The first account always bootstraps as owner | | `AUTH_REGISTRATION_WORKSPACE` | `own` | No | Where a self registration lands. `own` gives every sign up a workspace of its own, so someone who registers never sees the apps, keywords and rankings already tracked on the instance. `shared` puts every sign up in the bootstrap workspace as a member, which suits an instance whose sign ups are all trusted with that data. Refuses to boot when `shared` and `BILLING_ENABLED` is `true`. Warns in production when `shared` and `AUTH_ALLOW_REGISTRATION` is `true`. See [Authentication and accounts](/security/authentication) | | `STRIPE_SECRET_KEY` | empty | No | Enables billing. Absent, every Stripe path is inert and the API boots normally. Never logged. Give every environment its own sandbox, because a sandbox shared with another stack posts that stack's events here. Warns in production when `BILLING_ENABLED=true` on a sandbox key (`sk_test_` or `rk_test_`) | | `STRIPE_WEBHOOK_SECRET` | empty | No | Verifies [the webhook endpoint](/operations/billing#the-webhook-endpoint). Absent, the endpoint refuses every delivery, because an unverified endpoint hands out subscriptions, and checkout stays closed so nobody pays for access that could never be provisioned | | `STRIPE_PORTAL_RETURN_URL` | empty | No | Where the customer portal returns to. Falls back to `WEB_PUBLIC_URL` plus `/settings` | | `STRIPE_TAX_ENABLED` | `false` | No | Adds automatic tax to every checkout session. Needs Stripe Tax active with its registrations and prices that carry a tax behaviour, or Checkout refuses to open. Address and tax id are collected either way. Refuses to boot together with `STRIPE_MANAGED_PAYMENTS=true`. See [Billing](/operations/billing) | | `STRIPE_MANAGED_PAYMENTS` | `false` | No | Sells every checkout through Stripe Managed Payments, so Stripe is the merchant of record and calculates, collects and remits the tax. Needs Managed Payments activated in the Stripe dashboard. Refuses to boot together with `STRIPE_TAX_ENABLED=true`. Warns in production when `BILLING_ENABLED=true` runs on a live key with neither this nor `STRIPE_TAX_ENABLED`. See [Billing](/operations/billing) | | `STRIPE_PRICE_INDIE_MONTHLY` | empty | No | Price id for Indie monthly. Empty resolves it from the lookup key `asobeast_indie_month`, which `pnpm --filter api stripe:catalog` creates, at boot and before every reconciliation. Setting any of the four `STRIPE_PRICE_*` replaces the lookup key catalog with the ids they name, so set all four or none | | `STRIPE_PRICE_INDIE_YEARLY` | empty | No | Price id for Indie annual. Empty resolves it from `asobeast_indie_year` | | `STRIPE_PRICE_ULTIMATE_MONTHLY` | empty | No | Price id for Ultimate monthly. Empty resolves it from `asobeast_ultimate_month` | | `STRIPE_PRICE_ULTIMATE_YEARLY` | empty | No | Price id for Ultimate annual. Empty resolves it from `asobeast_ultimate_year` | | `CRON_BILLING_RECONCILE` | `0 7 * * *` | No | Daily reconciliation of local subscription state against Stripe, UTC | | `CRON_TRIAL_NOTICES` | `0 9 * * *` | No | Daily sweep that sends the trial milestone emails, UTC | | `AUTH_COOKIE_SECURE` | `false` | No | Marks the session cookie `Secure`. **Refuses to boot** when `false` and `NODE_ENV` is `production`. The Docker image sets `NODE_ENV=production`, so the Compose stack defaults this to `true` | | `TRUST_PROXY` | `0`, and `1` pinned in the Compose stack | No | How many proxies you control sit between the internet and the API, counting the web app as one. Compose pins it to `1` because the web app is always exactly one hop in front of the API there. `true` means 1 and `false` means 0. The API reads that many entries back from the right of `X-Forwarded-For`, so auth throttling keys on the real client address. Both apps warn at startup in production when `0`. See [Hosting](/security/hosting) | ## Entitlements | Variable | Default | Required | Effect | | - | - | - | - | | `BILLING_ENABLED` | `false` | No | `true` starts every new account on a trial and answers `402` once it lapses, until the plan is `indie` or `ultimate` | | `TRIAL_DAYS` | `7` | No | Trial length in days when `BILLING_ENABLED=true`. **Refuses to boot** at `0` with billing enabled, because every new account would be locked out immediately | ## Published store status An opt-in outbound integration, off by default. When `STORE_STATUS_URL` is empty **no request is ever made** and the poll is not even scheduled. See [What leaves your deployment](/legal/data-collection) and [Recover from a broken store parser](/operations/scraper-breakage). | Variable | Default | Required | Effect | | - | - | - | - | | `STORE_STATUS_URL` | Empty | No | Url of a published store status document. Setting it opts this deployment in to an hourly bare `GET` of that url, so a store breakage the maintainer already knows about can be announced without waiting for the local canary. Empty means no request is ever made. Give the final url: a redirect is not followed | | `CRON_STORE_STATUS` | `17 * * * *` | No | When the poll runs, UTC. The odd minute is deliberate, so installations do not all arrive at the top of the hour. Never scheduled while `STORE_STATUS_URL` is empty. An empty value removes the schedule | The poll goes through the same public only address guard that alert webhooks use, so a url resolving to a loopback, private, link local or cloud metadata address is refused at both configuration and connection time. A failed or stale poll never changes what the local canary reported. ## Optional AI See [Enable the optional AI features](/guides/ai-features). | Variable | Default | Required | Effect | | - | - | - | - | | `OPENAI_API_KEY` | Empty | No | Empty disables the AI audit, metadata drafts and action explanations | | `AI_MODEL` | `gpt-5.6-luna` | No | Must support vision and structured outputs, because the audit reads screenshots | | `AI_CALLS_PER_MONTH` | Empty | No | AI calls each workspace may make per calendar month (UTC) on a self hosted instance. Empty means unlimited and `0` turns AI calls off. Ignored when `BILLING_ENABLED` is true, where each plan sets its own allowance. Warns in production when empty while `OPENAI_API_KEY` is set and registration is open | ## Apple search popularity Optional, off by default. With an Apple Ads API key, asobeast reads Apple's own weekly search popularity for the App Store terms it covers instead of estimating them. See [Keyword scoring](/concepts/scoring). | Variable | Default | Required | Effect | | - | - | - | - | | `APPLE_ADS_CLIENT_ID` | Empty | No | Client id from the API page of your Apple Ads account settings. The four credentials are set together or not at all, and a partial set refuses to boot | | `APPLE_ADS_TEAM_ID` | Empty | No | Team id from the same page | | `APPLE_ADS_KEY_ID` | Empty | No | Key id Apple returned when you uploaded the public key | | `APPLE_ADS_PRIVATE_KEY_PATH` | Empty | No | Path to the `prime256v1` private key PEM. A relative path resolves against `apps/api`, and both Compose stacks mount `apps/api/keys` there read only, so `keys/apple-ads-private.pem` works with and without Docker. When the four credentials are set and the path is not a readable file, the API refuses to boot. Its contents are never logged | | `APPLE_ADS_AD_ACCOUNT_ID` | Empty | No | Ad account to read through. Empty uses the first one the credentials can read | | `CRON_APPLE_POPULARITY` | `0 9 * * 1` | No | When the weekly sync runs, UTC. Never scheduled while the credentials are unset | ## Store consoles Optional, off by default. Owners connect their store consoles in Integrations; these keys protect what they paste. See [Read impressions and conversion from your console](/guides/store-funnel). | Variable | Default | Required | Effect | | - | - | - | - | | `STORE_CONNECT_ENCRYPTION_KEY` | Empty | No | Base64 of 32 random bytes, generated with `openssl rand -base64 32`. Seals the App Store Connect and Play Console keys owners connect in Integrations. Empty keeps every store connection off, and nothing contacts Apple or Google. A value that is not 32 bytes refuses to boot. Never logged | | `STORE_CONNECT_ENCRYPTION_KEY_PREVIOUS` | Empty | No | The key being rotated away from. A stored credential sealed with it is opened and resealed with the current key on first use. Refuses to boot when set without `STORE_CONNECT_ENCRYPTION_KEY` | | `GOOGLE_PLAY_OAUTH_CLIENT_ID` | Empty | No | Client ID of a Google Cloud OAuth client of type Web application whose authorized redirect URI is `WEB_PUBLIC_URL` followed by `/integrations/google-play`. With the secret and `WEB_PUBLIC_URL` it lets owners connect Google Play by signing in with Google instead of a service account key. Set both `GOOGLE_PLAY_OAUTH_` variables or neither; one alone refuses to boot | | `GOOGLE_PLAY_OAUTH_CLIENT_SECRET` | Empty | No | Client secret of that OAuth client. Publish its consent screen to production, or Google expires every sign in after seven days. Never logged | | `CRON_STORE_REPORTS` | `30 5 * * *` | No | When the daily download of the reports each connected console published runs, UTC. Never scheduled while `STORE_CONNECT_ENCRYPTION_KEY` is empty | ## Admin surfaces See [The queue dashboard and the OpenAPI surface](/security/admin-surfaces). | Variable | Default | Required | Effect | | - | - | - | - | | `BULL_BOARD_ENABLED` | `true` | No | Serves the queue dashboard at `/admin/queues`. `false` removes it entirely | | `API_DOCS` | `owner` | No | Who may read `/docs`, `/docs-json` and `/docs-yaml`. `owner`, `public` or `off`. Warns in production when `public` | | `METRICS_CACHE_SECONDS` | `30` | No | How long one `/metrics` scrape is reused before the collectors run again. `0` collects on every scrape | | `BACKUP_MAX_AGE_HOURS` | `0` | No | Hours a backup may go without reporting a completed run before `backup.stale` fires. `0` expects no backup and never alerts. See [Backups](/operations/backups) | | `DISK_BUDGET_BYTES` | `0` | No | Bytes the database may occupy before `storage.headroom.low` fires, which is what the partition can spare rather than what it holds. `0` declares no budget and never alerts. See [Capacity and limits](/operations/capacity) | ## Compose only These live in the root `.env` and are read by Compose, not by the API directly. | Variable | Default | Required | Effect | | - | - | - | - | | `POSTGRES_PASSWORD` | None | Yes | Sets the PostgreSQL role password and is interpolated into `DATABASE_URL` | | `AUTH_SECRET` | None | Yes | Passed through to the API container | | `WEB_PUBLIC_URL` | `http://localhost:3001` | No | Passed to the API container in place of any value in `apps/api/.env`, which `pnpm dev` reads on port 3000. Set it to the origin a person would type when a proxy or another host serves the stack. The tunnel overlay replaces it with `https://` and `ASOBEAST_DOMAIN` | | `TRUST_PROXY` | `0` | No | Hops you control in front of the web app. The tunnel overlay pins `1` for the web app whatever this file says, because the tunnel is always exactly one hop. Set it yourself when you run `cloudflared` on the host or bring your own proxy | | `TUNNEL_TOKEN` | None | With the tunnel overlay | Token of the Cloudflare Tunnel you created in the dashboard. Read only by `docker-compose.tunnel.yml` | | `ASOBEAST_DOMAIN` | None | With the tunnel overlay | Public hostname attached to that tunnel. Becomes `WEB_PUBLIC_URL` | See [Hosting behind TLS and a reverse proxy](/security/hosting) for the overlay and for how to verify the hop count against the real chain. ## Retired variables `BULL_BOARD_USER` and `BULL_BOARD_PASSWORD` are ignored. The queue dashboard now requires a session or a personal API token of the platform operator instead of basic authentication, and the API logs a warning at boot if either variable is still set. Remove them. ## Keeping this page correct This page and the `.env.example` files change in the same commit. If a default here disagrees with `apps/api/.env.example`, the file is right and this page is a bug. # Web app configuration Source: https://docs.asobeast.com/configuration/web The web app reads API_INTERNAL_URL and TRUST_PROXY at runtime and proxies browser traffic to the API, so one image can target any API without a rebuild. The web app reads five variables, all at runtime. Nothing about the API address is baked into the image, so the same image can point at a different API by changing an environment variable and restarting. ## Variables | Variable | Default | Required | Effect | | - | - | - | - | | `API_INTERNAL_URL` | `http://localhost:4000` | No | API base URL. Server rendered pages call it directly, and browser requests reach it through the `/api/backend/*` proxy | | `API_PROXY_TIMEOUT_MS` | `30000` | No | How long the proxy waits for the API to start answering. A response that has started streams until it ends. A timeout returns a `504` error envelope instead of hanging | | `TRUST_PROXY` | `0` | No | How many proxies you control sit between the internet and the web app. `true` means 1 and `false` means 0. Decides which `X-Forwarded-For` entry the proxy passes to the API, and therefore what auth throttling keys on. An unparseable value is refused rather than interpreted. See [Hosting](/security/hosting) | | `STATUS_PAGE_URL` | Empty | No | An `http` or `https` status page hosted away from this machine. Set it and every error boundary offers a link to it; leave it empty and nothing is shown. A value that is not an http origin is ignored rather than linked, so a misconfiguration cannot turn into a `javascript:` link. See [Uptime probes and the status page](/operations/uptime) | | `SENTRY_DSN` | Empty | No | Sentry dsn for scrubbed browser and server error reports. Ignored unless `NODE_ENV` is `production`. No Compose file sets it, so a self hosted deployment reports nothing until you opt in with a dsn of your own, and the reports go to that project. Read at runtime and served to the browser by `/api/health`, never baked into the image. See [Hosted observability](/operations/hosted-observability#error-reporting) | Compose sets `API_INTERNAL_URL` to `http://api:4000`, which resolves on the Docker network rather than on your machine. ## How the proxy works The browser only ever talks to the web origin. A call to `/api/backend/apps` is handled by a Next.js route handler, which forwards the request, the session cookie and any bearer header to `API_INTERNAL_URL` and streams the answer back. Two consequences follow from that, and both are the reason it works this way. * The browser never makes a cross origin request, so no CORS headers are involved on this path at all. The API still keeps its own allowlist for callers that do reach it directly. See [Hosting](/security/hosting). * The API never needs to be published. In the Compose stack it stays on a private network and only host port 3001 is exposed to port 3000 in the web container. ## What the proxy does with the client address The proxy never passes a caller supplied `X-Forwarded-For` or `X-Real-IP` through to the API, because a caller can write either one and the API keys authentication throttling on what it reads there. Instead it sends exactly one address, on the requests it forwards for the browser and on the ones it makes while rendering a page: the one `TRUST_PROXY` says it can trust, taken that many entries back from the right of the incoming header. At `0` it sends no address at all, so the API sees the web container as the client and throttles every browser client as one connection. That is blunt but safe, and it is why the documented deployment sets `TRUST_PROXY=1` on both apps. See [How asobeast works](/concepts/architecture). ## What a timeout looks like When the API does not start answering within `API_PROXY_TIMEOUT_MS`, the proxy returns a `504` in the standard error envelope rather than leaving the request open. Once the API has started answering, the response streams until it ends, so a long download or an MCP event stream is never cut by this bound. Raise the value if you run a slow upstream, but a proxy timeout usually points at a saturated API rather than at a bound that is too tight. The MCP endpoint gives up on a tool call after 25 seconds and answers with a tool error that warns a change may or may not have been applied. Keep this value above `25000` so an agent connected through the web origin reads that error instead of the proxy's `504`. Actions that wait on a store while you wait (importing an app, refreshing a listing, adding a competitor and keyword suggestions) stop after 20 seconds and answer `502` with a sentence naming the store. A market check stops at the same moment and reports the market as unknown. The store request is cancelled then, so nothing is saved afterwards. Keeping this value above `25000` keeps that answer inside the proxy's budget too. ## Related > **[Configuration reference](/configuration/reference)** > > Every API variable with its default. > **[Hosting behind TLS and a reverse proxy](/security/hosting)** > > What to set before exposing host port 3001. # Glossary Source: https://docs.asobeast.com/glossary Definitions of the ASO and asobeast terms used across this documentation, from home storefront and keyword field to opportunity and fingerprint. One definition per term, in the wording the rest of this site uses. ## ASO App Store Optimization: the practice of improving how an app listing ranks and converts in store search and charts. asobeast measures it from public store data, and from your own console's reports when you connect one. ## Storefront One country's version of a store, identified by a two lowercase letter code such as `us`, `gb` or `de`. Catalogs, search results and rankings differ between storefronts. ## Home storefront The single storefront recorded on an app row, taken from the imported URL or from `DEFAULT_COUNTRY`. It drives the home listing, category ranks and reviews. See [Countries and markets](/concepts/countries). ## Market A storefront you track keywords in. An app has one home storefront but can track keywords in many markets. ## Listing market A storefront whose listing asobeast has captured for your app. Your home storefront always has one, and every market where you track a keyword gets one. See [Countries and markets](/concepts/countries). ## Tracked keyword The link between one app and one keyword row, carrying the source it came from and an optional relevance override. See [Track keywords](/guides/track-keywords). ## Keyword field The private 100 character App Store field that never appears on the public listing. Each App Store localization has its own, and a storefront reads the fields of every localization it supports. It cannot be collected, so you type it on the metadata page. Google Play has no equivalent. See [App Store and Google Play](/concepts/stores). ## Shipped localization An App Store localization your app has, on the list asobeast keeps because App Store Connect is private. Your home storefront's default localization is always shipped, and any other is added by you, by saving a keyword field for it or by detection. See [Which localizations does an app ship?](/concepts/countries#which-localizations-does-an-app-ship). ## Reader A market of your app whose storefront reads a localization, with a role: **Default** when the localization is first on the storefront's list, **Additional** otherwise. See [Which markets read a localization?](/concepts/countries#which-markets-read-a-localization). ## Subtitle The 30 character indexed App Store field below the title. App Store only. Its audit weight is 0 on Google Play. ## Short description The 80 character indexed Google Play field, called `summary` in the store payload. Google Play only, and asobeast tracks its keywords automatically with the source `DESCRIPTION`. ## Position An app's 1 based rank for one keyword on one UTC date. `null` means checked and not found within that row's depth. See [Positions and rank depth](/concepts/positions). ## Depth How far a position check looked, stored on each observation. The default is 200, which is why an unranked keyword renders as `>200`. ## SERP The search results page for one keyword. asobeast stores the top 10 results per keyword and date, which is what powers volatility, entrants and movers. ## Volatility A 0 to 100 measure of how much a keyword's top 10 changes membership, averaged across consecutive observed days over an eight day window. Reordering alone scores 0. ## Mover An app whose position on a tracked keyword changed materially between SERP snapshots. ## Entrant An app that appeared in a keyword's top 10 without having been there before. Entrants fire the `serp.entrant` alert. ## Rank milestone An owned app entering or leaving the top 10, the top 3 or first place for a keyword between two consecutive checks. Fires the `rank.milestone` alert. See [Send alerts](/guides/alerts#milestones-first-rankings-and-overtakes). ## First ranking The first check in which an owned app ranks for a keyword it had no ranked check for. Fires the `rank.first` alert. ## Overtake A tracked competitor moving from behind an owned app, or from not ranked, to ahead of it on a keyword between two consecutive checks. Fires the `rank.overtaken` alert. ## Popularity score A persisted 0 to 10 estimate of a keyword's search demand, shown on a 0 to 100 scale as volume, stored with its evidence source, formula version, capture date and confidence. See [Popularity, difficulty and opportunity](/concepts/scoring). ## Difficulty A persisted 0 to 10 estimate of how hard a keyword's current top results are to displace, shown on a 0 to 100 scale. See [Popularity, difficulty and opportunity](/concepts/scoring). ## Opportunity A 0 to 100 score for one keyword: the share of the page left open by difficulty, scaled down when the volume is under 30, so either one can veto the keyword. It is computed on read and never stored. See [Popularity, difficulty and opportunity](/concepts/scoring). ## Suggest reach How early a store offers the exact keyword while it is being typed: after how many characters and in which position, only for the whole phrase, or never. It is the main demand signal behind Google Play popularity, and one of the signals the App Store popularity model reads next to the search results. See [Popularity, difficulty and opportunity](/concepts/scoring#how-is-popularity-estimated). ## Score signals The evidence returned with each stored score: suggest reach, how much of the first page targets the keyword, page flags such as `brand` or `padded`, and Apple's own popularity when it was used. See [Popularity, difficulty and opportunity](/concepts/scoring#what-are-score-signals). ## Visibility A number from 0 to 100 rolling up an app's positions across its tracked keywords, each weighted by its popularity. Compare it with itself over time rather than between apps. See [Visibility](/concepts/visibility). ## Category rank An app's position in a store chart rather than in search. asobeast captures free, paid and grossing charts daily for the home storefront. See [Follow category ranks](/guides/category-ranks). ## Top chart One day of the free, paid or grossing chart of one store, storefront, category and device. Each row carries its position and how far it moved since an earlier capture. See [Read top charts](/guides/top-charts). ## Chart capture A stored day of a top chart. It is fetched once a day and shared by every workspace, whether the daily run, a follow or **Capture today** asked for it. ## Chart follow One chart of a category a workspace asked the daily run to capture every day. The plan's followed category limit counts categories, so the three kinds of one category are one unit. See [Read top charts](/guides/top-charts#where-do-captures-come-from). ## Chart category One store, storefront, category and device: the unit a follow counts and the top charts page shows side by side. See [Read top charts](/guides/top-charts). ## Snapshot A captured copy of a listing's metadata at one moment, stored with the raw store payload so a corrected parser can reprocess it. ## Screenshot text The large caption text printed on an App Store screenshot, read on the machine that hosts asobeast. It is a weak signal that Apple has not confirmed, so it is shown beside keyword coverage and never counted as an indexed field. See [Read screenshot captions](/guides/screenshot-captions). ## Change event A field level difference between two consecutive snapshots, recorded for your own apps and for competitors. See [Watch listing changes](/guides/listing-changes). ## Change impact How the positions of your tracked keywords and your visibility moved 7, 14 and 28 days after a change to your own listing, compared with the last rank check before it. See [Watch listing changes](/guides/listing-changes#measure-the-impact-of-a-change). ## Audit score The weighted result of the ten factor audit rubric, out of 100. Weights differ per store. See [Run an ASO audit](/guides/aso-audit). ## Action item One recommendation from the Action Center, carrying a rule, a priority, an estimated impact, its evidence and a lifecycle state. See [Work the Action Center](/guides/action-center). ## Fingerprint The stable identity of an action, derived from its rule, app, store, country, keyword and discriminator. It excludes every volatile magnitude, which is what lets a snooze or a dismissal survive the next run. ## Linked app group A group holding at most one app per store, used to compare the App Store and Google Play listings of the same product. See [Compare a portfolio](/guides/portfolio). ## Portfolio Every tracked app in the workspace, rolled up into shared totals and trends. ## Workspace The tenant boundary on every owned row, enforced by PostgreSQL row level security. The first account joins the default workspace, and every later sign up gets a workspace of its own unless the operator opts in to sharing. See [Where a self registration lands](/security/authentication#where-a-self-registration-lands). ## Owner The account that owns a workspace and manages its plan, its team and its deletion. The owner of the default workspace, the first account created on a fresh instance, is also the platform operator: the only account that reaches the admin area, the queue dashboard and the OpenAPI surface. See [Who the platform operator is](/security/admin-surfaces#who-the-platform-operator-is). ## Personal API token A revocable credential prefixed with `asob_`, minted in Integrations and sent as a bearer header. Stored as a SHA-256 hash, shown once. See [Personal API tokens](/security/api-tokens). ## Daily pipeline The scheduled run that enqueues position checks, snapshots, reviews and category ranks, then generates actions and flushes batched alerts. It starts at `CRON_DAILY`, defaulting to 03:00 UTC. ## Request budget The estimated daily store request fan out against the capacity set by `SCRAPE_ITUNES_RPM` and `SCRAPE_GPLAY_RPM`. `GET /jobs/budget` returns it. See [The daily pipeline and rate limits](/concepts/pipeline). # Work the Action Center Source: https://docs.asobeast.com/guides/action-center Seventeen deterministic rules run over your stored history and return a prioritized queue, each recommendation carrying the exact numbers behind it, its history and, once done, its measured outcome. The Action Center answers one question from data asobeast already collected: what should you do this week, and why. Every recommendation is a pure function of stored data with named constants, so any of them can be reproduced by hand from the evidence it carries. It reads only the database. Generation makes zero store requests and consumes none of your request budget. ## Reading the queue ![The Action Center at desktop width: the header with its status line, four summary tiles, the toolbar, the queue grouped by priority and the progress rail beside it](https://mintcdn.com/asobeast/ui4i44dXUdFMO1y0/images/action-center/queue-light.png?fit=max\&auto=format\&n=ui4i44dXUdFMO1y0\&q=85\&s=93bf146f325ba993b357340add2bc269) The header states how many actions are open, how many of them are critical or high, when the queue was last generated and how many new actions the per app cap withheld. **Generate now** queues an extra run. Four tiles sit under it: | Tile | What it counts | | - | - | | Open | Open actions, with the critical and high share | | New this week | Actions opened in the last seven days, and how many resolved on their own | | Confirmed fixed | Done actions whose rule stopped firing in the last 30 days, against how many you marked done | | Snoozed | Snoozed actions, and when the next one wakes | The toolbar filters the queue without a round trip. Status tabs switch between to do (open and snoozed), done, dismissed and resolved. Search matches the headline, the keyword, the app and the market. Priority toggles, the rule, category, app, market and store facets, the grouping (priority, app or category) and the sort all live in the URL, so a filtered view can be bookmarked and shared. Each row leads with its subject: the keyword, market, competitor or listing field the finding is about, and the numbers that made it fire. Below the headline sit the category, app, store, market and first seen date. A tag marks a snoozed action with its wake date, a done action that is still being verified or was confirmed fixed, a reopened action and a stored action whose evidence could not be read. On the right, **Progress** charts 30 days of activity: actions opened, closed by you and resolved on their own. **Where the work is** splits the open work by app, or by market on an app's own page, and each bar filters the queue to it. ## Opening an action Selecting a row opens the action in a sheet without leaving the queue. The sheet has these sections: * **Why this** lists the evidence as graded facts and lists, with one sentence summarizing it. * **How to fix** gives the steps for this rule and store, ending with how asobeast confirms the fix. * **Trend** charts the rule's metric from at least 14 days before the action first appeared, with markers where it opened and where you marked it done. * **Outcome** appears once you marked the action done. See [Outcomes](#outcomes). * **History** lists every recorded transition. See [History](#history). * **Note** keeps a note on the action that everyone in the workspace sees. * **AI summary** appears only when AI is configured. See [AI explanations are optional](#ai-explanations-are-optional). The open action lives in the URL as `?action=`, so a link to one opens the sheet directly, and closing it returns focus to its row. ## The lifecycle An action is a finding with a life, not a row in a report. A stable fingerprint identifies it across runs, so re running generation refreshes the same action instead of minting a new one. | Status | Meaning | How it is reached | | - | - | - | | `OPEN` | Needs attention | The rule fired, or a snooze expired | | `SNOOZED` | Deferred until a date | You set a date, capped by `ACTIONS_SNOOZE_MAX_DAYS` | | `DONE` | You acted | You closed it | | `DISMISSED` | You rejected it | You closed it. It never reopens on its own, however long it stays true | | `RESOLVED` | The condition went away | The system closed it, because the rule stopped firing | The difference between `RESOLVED` and `DONE` is the point. Only that distinction tells you whether your fix is what removed the problem. ### Confirmed fixed A `DONE` action is confirmed fixed the first run its rule stops firing, and the page shows **Confirmed fixed** from then on. If a confirmed action's rule fires again, the action reopens at once, because the problem came back after it was gone. While the rule keeps firing after you marked it done, the action shows **Still detected**. It reopens once the rule has kept firing for 14 more days, counted from when you closed it, which leaves room for a listing change to reach the store and move the numbers. ## How are duplicate actions prevented? An action is identified by a fingerprint over its rule, app, store, country, keyword and a rule specific discriminator. It deliberately excludes every volatile magnitude: positions, scores, counts and observation dates are all left out. That exclusion is the whole design. If a position were part of the fingerprint, tomorrow's check would produce a different fingerprint, mint a brand new action, and your snooze and dismiss decisions would evaporate every night. Excluding magnitudes is what makes the lifecycle mean anything. The fingerprint is unique per workspace, so the same finding cannot open twice. ## Outcomes Once you mark an action done, asobeast measures what happened to the rule's metric. The measurement compares the last checked value on or before the day you closed it with the latest checked value. | Metric | Rules | Better when | | - | - | - | | Position | `keyword.add_uncovered`, `keyword.defend`, `keyword.prune`, `serp.hold_volatile`, `keyword.push_to_top10`, and `competitor.investigate_overtake` for its first keyword | Lower | | Visibility in the action's market | `rank.investigate_drop`, `rank.investigate_unexplained_drop`, `market.improve_country` | Higher | | Overall audit score | `audit.fix_factor`, `metadata.fix_lint` | Higher | | Review score, a trailing 14 day mean | `reviews.investigate_theme`, `reviews.investigate_rating_decline`, `reviews.reply_negative` | Higher | | Days since the last store update | `listing.ship_update` | Lower | | Conversion in the action's market, or of the named product page | `conversion.investigate_drop`, `listing.cpp_underperforms` | Higher | The verdict is **Improved**, **Worsened** or **No change**, with a small tolerance per metric so noise does not count as movement. It stays **Measuring** until there are three days of data after you marked the action done. A keyword that was not ranked before counts as one position below the checked depth, so moving from unranked into the results reads as an improvement. An outcome is a measured value, not proof that your change caused it: a store algorithm update, a competitor's release or seasonality move the same numbers. asobeast has no download or revenue data, so an outcome never claims either. ## History Every transition is recorded as an event on the action: opened, reopened, snoozed, woke, done, dismissed, resolved and verified. Each event records whether the system or a person caused it, the name of the person, the status, priority and impact at that moment, the snooze date and, for a dismissal, the reason. Sending the same change twice, or at the same moment, records it once. The second request finds the action already in that state and records nothing. Two different changes sent together are both recorded, one after the other. History is kept with its action and pruned with it after `RETENTION_ACTIONS_DAYS`. Actions that existed before this history was recorded carry a minimal backfilled history: when they opened and, if they are closed, when and how. ## Working faster Select several rows with their checkboxes, or a whole group with its heading checkbox, and a bar offers **Done**, **Snooze** and **Dismiss** for all of them at once. Every change, one action or many, offers **Undo** in its confirmation, and asobeast accepts the undo for ten minutes. Dismissing asks for a reason: not relevant to this app, already handled elsewhere, or the data looks wrong. The reason is kept in the history. The queue works from the keyboard. Press `?` anywhere on the page to see this table. | Key | Action | | - | - | | `j` or `↓` | Focus the next action | | `k` or `↑` | Focus the previous action | | `Enter` or `o` | Open the focused action | | `d` | Mark the focused action done | | `s` | Snooze the focused action | | `Shift` `D` | Dismiss the focused action | | `x` | Select the focused action | | `/` | Search actions | | `Esc` | Clear the selection | `j`, `k`, `/` and `?` work from anywhere on the page, so `j` on a freshly loaded page focuses the first action. `Esc` clears the selection from anywhere in the queue, and the other keys act on the focused action. Shortcuts pause while you type, while a menu or dialog is open and while `Ctrl`, `Alt` or `Cmd` is held. ## The rules Seventeen rules, with fixed constants. | Rule | Fires when | Resolves when | | - | - | - | | `keyword.add_uncovered` | A tracked home market keyword scores opportunity at or above 35, relevance at or above 60 and volume at or above 20, and no indexed field contains it | Metadata covers it, it drops below threshold, or it is deactivated or untracked. While its score is being recalculated with a newer formula the action stays as it is | | `keyword.defend` | At least 2 apps in your niche sit in the top 10 today that were not in it when the 7 day window opened, with at least 4 observed days, and at least 1 sits at or above you while you rank in the top 20, or held the top 10 during the window if unranked now | No new entrants for a full window, or every entrant falls below you or leaves the top 10 | | `keyword.prune` | A keyword ranked on at most 5 percent of at least 30 checks, carries volume below 20 and relevance at most 50, and pruning would help | It ranks, gains volume, is deactivated, or the budget pressure disappears | | `keyword.push_to_top10` | A relevant home market keyword with volume at or above 20 sits at 11 to 20, spent at least 5 of the last 7 days there, has not slid 2 or more places from the median of its first 3 checked days to the median of its last 3, and appears only in a weaker field than the title and subtitle, or the title and short description on Google Play | It reaches the top 10, leaves the band, slides 2 or more places by that median, or a strong field covers it | | `metadata.fix_lint` | A home market listing field has at least one error level lint issue, such as exceeding its limit, a policy term or an emoji | The field has no error level issue left | | `rank.investigate_drop` | An indexed field changed within 14 days and visibility fell by at least 5 points, or at least 3 tracked keywords fell by the rank alert threshold | Visibility recovers to within 2 points of its pre change value | | `rank.investigate_unexplained_drop` | In a market with at least 5 keywords, the last three days average at least 5 visibility points below the days 8 to 13 days ago, or at least 3 of its keywords fell by the rank alert threshold over 7 days, with no indexed change of yours in 14 days | Visibility recovers to within 2 points, or the fall no longer qualifies | | `competitor.investigate_overtake` | A tracked competitor changed its title, subtitle, short description or description within 14 days and has stayed ahead of you on at least 2 checks since, on a relevant keyword with volume at or above 20 where you ranked in the top 20 before | You are back ahead, or the change leaves the window | | `serp.hold_volatile` | A keyword's SERP volatility over the last 8 days is at least 45 over at least 5 observed days, and it actually damped another rule this run | Volatility falls below threshold, or it damped nothing | | `audit.fix_factor` | A factor weighing at least 10 scores below 5 of 10 in an audit snapshot no older than 7 days, other than the outcome factors Keyword rankings, Ratings & reviews and Freshness and trust | The factor scores 5 or above in a newer snapshot, or leaves the rubric | | `listing.ship_update` | The store has shown no new update for at least 90 days | The store shows a newer update | | `reviews.investigate_theme` | A phrase appears in at least 3 negative reviews of the latest version, which has at least 5 reviews in the last 30 days, and at least twice as often as in the previous version's | The version is superseded without recurrence, or mentions fall below the minimum | | `reviews.investigate_rating_decline` | At least 5 reviews in each window, and the last 14 days average at least 0.5 below the 21 days before them | The gap closes below 0.5, or either window has too few reviews | | `reviews.reply_negative` | At least 3 reviews of 3 stars or fewer from the last 14 days were checked for a reply and have none | Fewer than 3 remain unanswered | | `market.improve_country` | A non home market with at least 5 keywords and at least 7 observed days sits at least 15 visibility points behind home | The gap closes, or the market drops below the keyword minimum | | `conversion.investigate_drop` | Over the last 7 complete days of the home market, Get taps a day fell at least 20 percent and at least 20 taps in all against the 14 complete days before, while product page views a day stayed within 10 percent | Taps recover above the threshold. While page views move more than 10 percent, a day of the window is missing or the console stops reporting the app, the action is held as it is and never confirmed | | `listing.cpp_underperforms` | Over the last 14 complete days of the home market, a custom product page with at least 100 product page views turns them into Get taps at under 0.8 of the rate of the default product page, which itself has at least 100 product page views | The page converts at 0.8 of the default or better, or is renamed. While the page or the default page has under 100 product page views, or the console stops reporting the app, the action is held as it is and never confirmed | The two conversion rules read the [store funnel](/guides/store-funnel): they need a connected store console, read complete days of the home market only and open nothing for an app the console does not report. A day is complete three days after it ends on the App Store and seven days after on Google Play, which posts later. On Google Play the home market reads the store listing visitors of every market, because the listing is one listing worldwide and Google files the countries of a small app under Other. Both are advisory, so their impact never passes 50, because the numbers describe what the funnel did, not why. A product page action is one per page and named after the page, so a renamed page opens a new action and the old one resolves. An entrant counts toward `keyword.defend` only when it is plausibly in your niche: a tracked competitor, an app that also appeared in the top 10 of another of your active keywords in the same storefront during the window, or an app whose title shares a word with the keyword. The test is where the app appears, not what kind of app it is. A popular app that a store uses to pad the result pages of two or more of your keywords, such as a social network or a maps app, therefore counts, while one that pads a single keyword and shares no word with it does not. A paused keyword and a keyword in another storefront add no evidence. The outcome factors measure results rather than a field you edit: the keyword rules are already the lever for rankings, so `audit.fix_factor` leaves them to the audit page. Unanswered audit checks never count toward `audit.fix_factor`, and Apple only surfaces are skipped on Google Play rather than recommended impossibly. A factor less than half measured opens no action, and each audit action lists the checks that failed or warned. `reviews.reply_negative` runs on Google Play only, because only Google Play replies are recorded. Its link opens the reviews page filtered to the reviews that need a reply, lowest first, where the composer drafts each reply. The rule keeps reading the reply Google Play shows, so a review you mark as replied closes it only once the store shows the reply the next day, while a reply posted from the reviews page counts on the next run. See [Reply to reviews](/guides/reviews#reply-to-reviews). When a more specific rule explains the same finding, the broader one stays quiet without closing: a review theme holds back a rating decline, `keyword.defend` or a volatile result holds back a push into the top 10, and your own recent change hands a drop to `rank.investigate_drop`. ## Priority and impact Impact is a stored, versioned estimate on a 0 to 100 scale. ```text theme={null} impact = round(100 × (0.45 × reach + 0.35 × severity + 0.20 × confidence)) ``` Each term is a documented 0 to 1 value per rule. Reach is how much addressable traffic or listing weight is at stake, severity is how bad the deviation is now, and confidence is how complete the evidence is. | Priority | Impact | | - | - | | `critical` | 80 and above | | `high` | 60 and above | | `medium` | 35 and above | | `low` | Below 35 | The two advisory rules, `serp.hold_volatile` and `keyword.prune`, are capped at impact 50, so hygiene can never outrank growth. Impact is stamped with `formulaVersion: actions-v1` and is never a promised outcome. asobeast has no download or revenue data, so it never presents one. ## Home market rules and market rules Several rules are worded carefully because of what asobeast can and cannot see. `keyword.add_uncovered`, `keyword.push_to_top10`, `metadata.fix_lint` and `listing.ship_update` read the home listing. asobeast captures market listings too, but these rules do not yet judge them, so they never claim a keyword is missing from a localized listing. `keyword.prune` is the one rule that weighs the relevance of keywords in every market, and a keyword's relevance follows the listing of its own market. The first capture of a market listing can therefore open or resolve a prune action for that market's keywords. `keyword.add_uncovered`, `keyword.push_to_top10` and `competitor.investigate_overtake` weigh relevance for home market keywords only. `market.improve_country` says investigate this market rather than fix your localization, for the same reason. It reports a visibility gap, which is a fact, and leaves the diagnosis to you. See [Countries and markets](/concepts/countries). ## Limits | Setting | Default | Effect | | - | - | - | | `ACTIONS_MAX_OPEN_PER_APP` | 20 | New actions one run may open per app, highest impact first. Existing open and snoozed actions always refresh, and the run reports how many it withheld | | `ACTIONS_SNOOZE_MAX_DAYS` | 90 | Furthest a snooze may be set into the future | | `RETENTION_ACTIONS_DAYS` | 180 | Prunes closed actions and their history only. Open and snoozed actions are never pruned by age | Per rule caps also apply where a rule could flood the queue: at most 5 prune actions, 5 pushes into the top 10 and 3 review theme actions per app. ## Running it Generation runs automatically inside the daily pipeline completion barrier, before alerts flush, so a newly opened priority action rides the same batched report. `POST /actions/run` queues an extra run and returns `202`. **Generate now** on the Action Center calls it, and you can press it again at any time: a request joins a run that is still waiting or running, and starts a new one once the last run finished. It reads stored data only, so it costs no store requests, and the page updates when the run finishes. A run that opens nothing still records when it ran, so the page shows when the queue was last generated. A failing generation is logged and never blocks alert delivery. ## AI explanations are optional With `OPENAI_API_KEY` set, an action gains an explain button that turns its typed evidence into two to four plain sentences. AI may only summarize an action that already exists. It can never create, rank, re order or suppress one, and the entire feature works with the key unset, which is the default. See [Enable the optional AI features](/guides/ai-features). ## Get notified about new actions `ALERT_ACTIONS_MIN_PRIORITY` sets the lowest priority that fires an `action.opened` event, and defaults to `high`. Refreshed, touched and resolved actions never fire. Existing alert subscribers are unaffected until they opt into the event. See [Send alerts](/guides/alerts). ## Troubleshooting * **A dismissed action came back.** It was a different finding. Check the keyword and country: the fingerprint includes both, so the same rule on a different market is a separate action. * **Fewer actions than you expected.** `ACTIONS_MAX_OPEN_PER_APP` caps new actions per run. The run reports how many it withheld. * **A rule never fires.** Most rules need a minimum number of observed days. A recently added keyword or market has not accumulated them yet. * **An action says still detected.** You marked it done and the rule still fires. A listing change takes time to reach the store and move rankings, so give it a few days: the action is confirmed fixed the first run the rule stops firing, and reopens if it keeps firing for 14 days. * **An outcome says measuring.** asobeast needs three days of data after you marked the action done, and at least one measurement on each side of that day. ## Related > **[Run an ASO audit](/guides/aso-audit)** > > Where `audit.fix_factor` gets its evidence. > **[Optimize listing metadata](/guides/metadata-workbench)** > > Where `keyword.add_uncovered`, `keyword.push_to_top10` and `metadata.fix_lint` are resolved. # Enable the optional AI features Source: https://docs.asobeast.com/guides/ai-features Set OPENAI_API_KEY to enable the AI creative analysis, metadata drafts, action explanations, review reply drafts and review translations. Every other feature works with the key left unset. AI in asobeast is garnish, not foundation. Every score, rule and recommendation is deterministic and works with no key configured, which is the default. ## What does the key enable? Five features, all bring your own key. | Feature | Where | What it does | | - | - | - | | AI creative analysis | Audit tab | Reads the icon, up to six screenshots and up to five competitor icons, and reports captions, caption language, message type, text in the icon, element count, contrast and similarity to a competitor icon. The audit rules score what it reports | | Metadata drafts | Metadata workbench | Proposes field values grounded in your tracked keywords, coverage gaps and competitor titles, for the primary listing or for an additional language one of your storefronts reads | | Action explanations | Action card | Turns typed evidence into two to four plain sentences | | Review reply drafts | Reviews page | Drafts the developer reply to one review in its language, in the tone you pick and within the store's limit, following the app's reply guidelines | | Review translations | Reviews page | Translates the title and text of one review into your browser's language and keeps the translation on the review | Each feature is a single completion per run. The creative analysis runs in the background after you start it, so leaving the page does not cancel it. It is reused while the icon, the screenshots, the competitor icons and `AI_MODEL` are unchanged, so analyzing an unchanged listing again costs nothing, and a finished analysis records today's audit score. The nightly score snapshot reads the stored observations and never calls OpenAI. ## How many AI calls can a workspace make? Every AI call spends one call from a monthly allowance that the whole workspace shares. The allowance of each plan is listed on [Pricing](/legal/pricing#rate-limits). It renews at 00:00 UTC on the 1st of every month, and unused calls do not carry over. | Counts as one call | Costs nothing | | - | - | | Explaining an action, including Regenerate | A creative analysis of a listing that has not changed since the last one | | One set of metadata drafts | Starting an analysis while one is already queued or running | | One creative analysis | A request refused before it reaches the model, such as an unsupported field | | One review reply draft, including Draft again | A reply you write by hand, copy or mark | | One review translation | Showing a translation already kept for the same language | | An answer the model gave that could not be used, such as a refusal or an incomplete set of drafts | A call OpenAI never answered: a timeout, an outage, a rejected key | Two clicks on the same button while the first is still working spend one call. A creative analysis that retries in the background is still one call. The settings page shows the calls used this month and the renewal date, and every AI button shows how many are left. When none are left the buttons are disabled until the 1st, and the API answers `429` with an `aiAllowance` object; see [Errors](/api-reference/errors#a-spent-ai-allowance). Upgrading applies the larger allowance at once and keeps what was already used. Moving to a smaller plan keeps the usage too, so a workspace already past the smaller allowance waits for the 1st. ### Self hosted A self hosted instance has no allowance unless you set `AI_CALLS_PER_MONTH`, which applies to every workspace on the instance. Set it if registration is open, because each account that registers gets a workspace of its own and spends your OpenAI key. See [Configuration](/configuration/reference). ## What happens without a key? The product is fully usable. Precisely four things differ. | Surface | With no key | | - | - | | AI endpoints | Answer `409` | | Metadata drafts card | Hidden | | Audit tab | Shows a one line setup hint instead of the analyze button | | Reply composer | Works without AI. The Draft with AI button and the reply guidelines are hidden | Every other audit check still scores. The creative checks are listed as unanswered rather than guessed, and the audit weights each factor by how much of it was measured, so they neither raise nor lower the score. See [Run an ASO audit](/guides/aso-audit). ## Configure it | Variable | Default | Notes | | - | - | - | | `OPENAI_API_KEY` | Empty | Empty disables all four features | | `AI_MODEL` | `gpt-5.6-luna` | Must accept image input and structured outputs, because the creative analysis reads images and answers in a fixed schema. `gpt-4o` also qualifies | | `AI_CALLS_PER_MONTH` | Empty | Calls each workspace may make per month on a self hosted instance. Empty is unlimited | All three live in `apps/api/.env`. Restart the API after changing them. ## What is AI never allowed to do? It may only summarize an action that already exists. It can never create, rank, re order or suppress one, and the explanation prompt is told it may not change the recommendation. The stored summary is labelled as an AI summary of deterministic evidence, so a reader can always tell which part of the card is computed and which part is prose. See [Work the Action Center](/guides/action-center). A review reply draft is a suggestion the composer holds until you copy it, and the kept reply records that it came from the model. Metadata drafts are constrained the same way. Every draft is linted again on the server and clamped to the store field limit, and asobeast has no write path to the stores, so a draft ends at a copy button rather than being published. ## What data leaves your deployment? > [!WARNING] > > Setting `OPENAI_API_KEY` sends data to OpenAI. Only the app being worked on is sent, plus the public icons of its competitors for the creative analysis. Never other workspaces' data, never review text in the action explanation prompt, and never secrets. With the key unset, no request leaves your deployment for any AI feature. The creative analysis sends the least data that answers its checks. | Sent | Not sent | | - | - | | Store name, market code and title | Description, subtitle, short description and release notes | | URLs of the app icon and up to six screenshots | Ratings, reviews, keywords and rankings | | URLs of up to five competitor icons | Anything about other workspaces | | | The OpenAI key, in any message or log | OpenAI downloads the images from the store CDNs itself; asobeast never proxies image bytes. Competitor icon URLs are public store assets. The action explanation prompt receives the app's store, country and title, the rule, the priority, the impact and the evidence object, and nothing else. The metadata draft prompt receives the store, the listing's current metadata, up to 25 tracked keywords of the home market with their scores and coverage, competitor titles and your instructions. A draft for an additional language also sends the language and the codes of your markets that read it. The review translation prompt receives the target language and the title and text of the one review being translated, and nothing else. The review reply prompt receives the store, the app name, the one review being answered (its score, title, text, version, date, author and the developer reply Google Play shows), the listing's current version and release notes, the app's reply guidelines, up to three of the app's most recent Google Play replies as examples of your voice, the tone and your instruction line. Never another review and never other workspaces' data. ## Troubleshooting * **The AI endpoints answer 409.** No key is configured, or the API was not restarted after setting it. * **The audit button is missing but the key is set.** Confirm the key reached the API container. `docker compose config` shows the resolved environment. * **The model rejects the request.** `AI_MODEL` must accept images and structured outputs. A text only model fails the creative analysis while drafts still work. A failed creative analysis shows one of these messages. Only transient failures are retried, once. | Message | Fix | | - | - | | OpenAI rejected the API key. Check OPENAI\_API\_KEY. | Set a valid key and restart the API | | The OpenAI key cannot use the model | Give the key's project access to `AI_MODEL`, or pick another model | | OpenAI does not offer the model. Check AI\_MODEL. | Correct the model name and restart the API | | OpenAI refused the request, followed by OpenAI's reason | Usually a model without image input or structured outputs; pick one that has both | | The OpenAI account has no remaining quota. | Add credit to the OpenAI account | | OpenAI is rate limiting requests. | Retried automatically; try again later if it persists | | OpenAI had an internal error. | Retried automatically; try again later | | OpenAI did not answer in time. | Retried automatically; try again | | Could not reach OpenAI. | Check the API container's outbound network | | OpenAI request failed. | Retried automatically when OpenAI reports a timeout, a conflict or a server error; otherwise check the API logs | | OpenAI refused to analyze this listing. | The model declined the images; try again or another model | | The model ran out of output tokens. | Try again; a model with a larger output limit avoids it | | OpenAI filtered the response. | The images tripped OpenAI's content filter | | OpenAI returned an unreadable response. | Retried automatically; try again | | The model returned observations that do not match the schema. | Retried automatically; a model with structured outputs avoids it | | The analysis did not finish. Try again. | The run took more than 10 minutes, usually because the API restarted; start it again | | The analysis could not be queued. Try again. | Redis did not accept the job; check the Redis container, then start it again | | This listing has no icon or screenshots to analyze yet. Refresh the app, then try again. | Wait for the daily pipeline or refresh the app | ## Related > **[Run an ASO audit](/guides/aso-audit)** > > What the AI pass adds and what it leaves alone. > **[Optimize listing metadata](/guides/metadata-workbench)** > > Where drafts appear. # Send alerts Source: https://docs.asobeast.com/guides/alerts asobeast delivers rank, SERP, metadata, review and action events by signed webhook or SMTP email. Batched delivery is resumable and deduplicated. Alerts fan out to two channel types, webhooks and email, and both carry the same events. Every attempt is recorded in a delivery log so a failing endpoint is visible instead of retrying silently. ## Choose a delivery mode `ALERT_DELIVERY` has two values and defaults to `batched`. | Mode | Behaviour | Suits | | - | - | - | | `batched` | Up to two scoped reports per channel, sent after the daily pipeline reports every child job settled | Almost everyone, because the underlying facts have daily granularity | | `instant` | One notification per event | A consumer that needs a per event stream, or an existing integration built on it | In batched mode each subscribed channel receives the owned app report first and the competitor report second. Subscription filters apply to both scopes independently, so an owned only channel receives one report and a competitor only channel receives the other. An empty scope produces no notification at all. Batched delivery is durable. A flush claims a fixed snapshot of pending rows before sending, so a queue or process failure leaves that claim resumable with the same rows, timestamp and delivery identifiers. Later facts wait for a later claim rather than joining the retry. Re runs and multi market checks deduplicate the same fact before it is claimed, with the latest values winning. There is no separate alert schedule. `CRON_DAILY` controls when processing starts, while workload and retry duration decide when the report goes out. ## The nudge on the overview Alerts are not part of the guided setup, because nothing can move before positions exist. Once an app has two daily captures and your workspace has no webhook and no email alert, the app overview shows a card offering to add one: an email alert when SMTP is configured, a webhook otherwise. **Not now** hides the card in that browser, and adding any channel hides it everywhere. ## Subscribe to events | Event | Fires when | Threshold setting | | - | - | - | | `rank.dropped` | An owned app position falls | `ALERT_RANK_DROP_THRESHOLD`, default 5 | | `rank.improved` | An owned app position rises | `ALERT_RANK_DROP_THRESHOLD`, default 5 | | `rank.milestone` | An owned app enters or leaves the top 10, the top 3 or first place | None | | `rank.first` | An owned app ranks for a keyword it has never ranked for | None | | `rank.overtaken` | A tracked competitor moves above an owned app | None | | `review.negative` | A review lands at or below the rating cutoff | `ALERT_REVIEW_SCORE_MAX`, default 2, range 1 to 4 | | `action.opened` | An action opens or reopens at or above the priority floor | `ALERT_ACTIONS_MIN_PRIORITY`, default `high` | | `metadata.changed` | A snapshot differs from the previous one | None | | `serp.entrant` | A new app enters a tracked keyword's top 10 | None | | `digest.weekly` | The weekly portfolio digest | `CRON_DIGEST` | A new event never joins an existing subscription. To opt in, open **Edit events** on the channel in Settings, tick the events and save. A webhook keeps its url and its secret. ## Milestones, first rankings and overtakes `rank.dropped` and `rank.improved` report moves of at least `ALERT_RANK_DROP_THRESHOLD` places. These three events report what a move means, whatever its size. Each compares today's check with the previous check of the same app and keyword, so a keyword checked for the first time fires none of them, and adding keywords never floods a channel. | Event | Rule | | - | - | | `rank.milestone` | The app entered or left the top 10, the top 3 or first place. One move reports one milestone: rising from 15 to 2 enters the top 3 rather than also the top 10, and falling from 2 to 15 leaves the top 10. | | `rank.first` | The app ranks today, did not rank in the previous check and has no earlier ranked check. It replaces `rank.milestone` for that keyword and check. | | `rank.overtaken` | A tracked competitor that was behind the app, or not ranked, in the previous check is ahead of it now, also when the app no longer ranks. One event per competitor. | `rank.dropped` and `rank.improved` still fire alongside, so a move from 14 to 8 brings both `rank.improved` and `rank.milestone`. All three belong to your own app and arrive in the owned app report; `rank.overtaken` names the competitor inside it. "Never ranked" looks back as far as position history is kept, `RETENTION_RANKINGS_DAYS`, 365 days by default. See [what is retained](/concepts/pipeline#what-is-retained). A keyword that moves between 10 and 11 fires each time it crosses. In batched delivery, the default, every fact is deduplicated per keyword and day and folded into the daily report, so a noisy boundary costs a line in that report rather than a message per check. Instant delivery sends each check that crosses a boundary as it happens. ## Webhook delivery A webhook receives a JSON POST with two headers, plus a signature header when the subscription has a secret. | Header | Value | | - | - | | `X-Asobeast-Event` | The event name, for example `alerts.batch` | | `X-Asobeast-Signature` | `sha256=`, an HMAC-SHA256 of the exact request body | Verify the signature by computing an HMAC-SHA256 of the raw request body with your subscription secret and comparing it, in constant time, with the header value. Compute it over the bytes you received rather than over a re serialized object. ### Which endpoints a webhook may reach A webhook url must be `http` or `https`, must not embed credentials, and must resolve to a public address. Loopback, private, carrier grade NAT, link local and cloud metadata ranges are refused, in both their IPv4 and their IPv6 mapped forms. When the subscription is saved, a literal address in a refused range and a name that can only be local, such as one without a dot or one ending in `.local` or `.internal`, are refused at once. Every address a hostname resolves to is checked on the socket as it connects, so a hostname that resolves to an internal address, at save time or only later, is refused at delivery and recorded as not delivered. Redirects are never followed, which keeps the payload and its signature on the origin you registered. A name that only resolves inside a network is refused the same way. That covers a bare hostname such as `hooks` or `n8n`, `localhost`, and any name ending in `.local`, `.localhost`, `.internal` or `.home.arpa`. The error names the host and says it resolves inside this network. Set `WEBHOOK_ALLOW_PRIVATE_TARGETS` to `true` to deliver to your own network from a self hosted instance. The url may then name a host instead of an address, for example `http://n8n:5678/webhook/asobeast` for another service in the same Compose project, which keeps working when the container is recreated with a new address. Inside the api container `localhost` is the container itself, so name the receiver's service instead. Credentials in the url and schemes other than `http` and `https` stay refused. See the [configuration reference](/configuration/reference) for what that changes. A granular event looks like this. ```json theme={null} { "event": "action.opened", "occurredAt": "2026-07-30T03:10:00.000Z", "app": { "id": "cl…", "name": "Habit Tracker", "store": "APP_STORE", "country": "us", "iconUrl": "https://is1-ssl.mzstatic.com/image/thumb/…/512x512bb.jpg" }, "action": { "id": "cl…", "rule": "keyword.add_uncovered", "category": "metadata", "priority": "high", "impact": 71, "firstSeenAt": "2026-07-30T03:10:00.000Z", "reopened": false }, "keyword": { "id": "cl…", "text": "budget planner" }, "evidence": { "rule": "keyword.add_uncovered", "opportunity": 66.5, "volume": 62, "relevance": 80, "indexedFields": ["title", "subtitle", "keywordField"], "uncoveredFields": ["title", "subtitle", "keywordField"], "keywordFieldCharsFree": 18 }, "link": "https://aso.example.com/actions?action=cl…" } ``` A milestone and an overtake look like this. `rank.first` carries `position` and `depth` instead of the move. ```json theme={null} { "event": "rank.milestone", "occurredAt": "2026-09-26T03:14:07.000Z", "app": { "id": "cm1a", "name": "My App" }, "keyword": { "id": "cm1k", "text": "habit tracker", "store": "APP_STORE", "country": "us" }, "tier": 10, "direction": "entered", "from": 14, "to": 8, "fromDepth": 200, "toDepth": 200 } ``` ```json theme={null} { "event": "rank.overtaken", "occurredAt": "2026-09-26T03:14:07.000Z", "app": { "id": "cm1a", "name": "My App" }, "keyword": { "id": "cm1k", "text": "habit tracker", "store": "APP_STORE", "country": "us" }, "competitor": { "id": "cm1r", "name": "Rival Focus", "from": 9, "to": 4 }, "from": 5, "to": 6, "fromDepth": 200, "toDepth": 200 } ``` Every `app` object of a granular event may also carry `iconUrl`, `store` and `country`, read from the app when the alert is sent. `store` and `country` name the app's store and home storefront, and `iconUrl` is the address of the icon the store shows today, or `null` when no listing capture has recorded one. A consumer that ignores unknown fields needs no change. A batched report wraps events in a scoped envelope. Branch on the top level `scope`, which is `owned_apps` or `competitors`. The flat `events` array stays granular and contains only events for that scope, so a consumer that already handles granular events needs one extra branch rather than a rewrite. ```json theme={null} { "event": "alerts.batch", "scope": "owned_apps", "occurredAt": "2026-07-30T04:12:00.000Z", "window": { "from": "2026-07-29", "to": "2026-07-30" }, "totals": { "events": 12, "apps": 2 }, "apps": [], "events": [] } ``` Each entry of `apps` groups one owned app's facts in arrays: `rankDrops`, `rankImprovements`, `rankMilestones`, `firstRankings`, `overtakes`, `serpEntrants`, `changes`, `negativeReviews`, `actions` and `competitors`. An array is empty when nothing happened or when the channel does not list its event. The `app` of an entry, and of each entry of its `competitors`, carries `id`, `name`, `store`, `country` and `iconUrl`. Branch on `event` and ignore values and fields you do not recognise. New events and fields arrive as additive changes within a major version. `link` is present only when `WEB_PUBLIC_URL` is set. When it is unset the link is `null` rather than a broken `localhost` guess. The Compose stacks always set it, see [Hosting behind TLS and a reverse proxy](/security/hosting). Slack, Discord and ntfy all accept a plain webhook endpoint, and asobeast formats for the first two. ## Email delivery Email stays disabled until both `SMTP_HOST` and `SMTP_FROM` are set. Neither one alone enables it. | Variable | Default | Notes | | - | - | - | | `SMTP_HOST` | Empty | Required to enable email | | `SMTP_FROM` | Empty | Required to enable email, for example `asobeast ` | | `SMTP_PORT` | 587 | Use 465 with `SMTP_SECURE=true`, otherwise 587 or 25 | | `SMTP_SECURE` | `false` | `true` wraps the connection in TLS from the start, which is what port 465 expects | | `SMTP_USER` | Empty | Leave empty for an unauthenticated relay | | `SMTP_PASSWORD` | Empty | Paired with `SMTP_USER` | ### Confirm a recipient Adding an email alert sends one confirmation email to the recipient, naming the account that added the address. The alert shows **Awaiting confirmation** in **Settings**, **Email alerts** and receives nothing, not even a test email, until the recipient presses **Confirm email address** on the page the link opens. That page asks before it acts, so a mail scanner that opens the link confirms nothing. A recipient is confirmed at once only when the address is the verified account email of a member of the workspace. Account emails are verified only where billing and SMTP are both on, so on a self hosted instance every recipient, the owner included, confirms through the mailbox. The confirmation link points at the web app, so it needs `WEB_PUBLIC_URL`. Without it no confirmation email can be sent, the recipient stays **Awaiting confirmation** and receives nothing, and the API logs a warning at startup in production. When a confirmation email cannot be sent, for that reason or because the SMTP relay refused it, **Settings** says so when you add the recipient, and **Resend confirmation** tries again. | Action | Effect on confirmation | | - | - | | Change the address of an alert | The new address must confirm, and links sent to the old one stop working | | Change the events or pause and resume | None | | Unsubscribe from an alert email | The alert pauses and its confirmation is withdrawn. This alert never asks that address again, so **Resend confirmation** is refused until the address changes | | Press **Do not ask this address again** in a confirmation email | The same as an unsubscribe. The alert shows **Unsubscribed** and asks that address nothing more | | Press **Resend confirmation** | A new email goes out and the previous link stops working. One alert can be resent once an hour | A confirmation link works for 7 days, for the address it was sent to and for the latest email sent for that alert. When you upgraded, alerts that were active counted as confirmed. Paused alerts did not, because a pause may have been an unsubscribe, so each needs **Resend confirmation** before it delivers again. Three limits keep confirmation emails from being used to mail strangers: | Limit | Value | | - | - | | Email alerts in a workspace | 25, of which at most 5 may be waiting for confirmation | | Confirmation emails to one mailbox | 5 a day in total, whichever workspace asks | | Confirmation emails from one workspace | 20 a day | Adding an alert, changing its address, resending a confirmation and sending a test are each limited to 10 requests a minute per workspace. A refused request answers `429` with a `Retry-After` header, or `403` for the two alert counts. The recipient limit counts every workspace together on purpose: it protects the person receiving the email, however many workspaces ask. It counts one mailbox, not one spelling: addresses that reach the same mailbox share the allowance, whatever their capitalization, a `+tag` after the name, a domain written with accents or in its `xn--` form, and for Gmail dots and `googlemail.com`. The [One trial per mailbox](/security/authentication#one-trial-per-mailbox) section lists every rule. The plus tag is dropped on every domain, so on a provider that treats `+` as an ordinary character `name+a@example.com` and `name+b@example.com` also share one allowance. The email is still sent to the address you typed, and only the count is shared. A workspace that spends it delays confirmations to that mailbox from other workspaces until the next UTC day. A workspace past its own limit spends none of a recipient's. ### What an alert email looks like Every alert email uses the AsoBeast layout: a header with the logo, the details of what happened, a button back into the app and a footer saying why the recipient gets it. The button opens the app page, the action for `action.opened`, or the portfolio for the weekly digest. Instant delivery sends one email per event, and batched delivery sends the daily app update and the competitor watch. Subjects start with `[asobeast]` and never change, so mail filters keep working. Every email also has a plain text part. The button, the logo, the settings link and the unsubscribe link are built from `WEB_PUBLIC_URL`. Without it the email shows text only and no link. A daily update lists up to 20 lines per section. When the report would grow past what Gmail shows before clipping, the HTML part shows fewer lines per section and fewer competitors per app, each with a note of how many more there are, while the plain text part keeps the full lists. Open the app from the card for everything. ### Unsubscribe from an email Every alert email carries a one click unsubscribe that mail apps show as their own button, and an **Unsubscribe** link in its footer. Either one pauses that email alert only, while webhooks and other email alerts keep running. It also withdraws the recipient's confirmation, so switching the alert back on under **Settings**, **Email alerts** sends nothing to that address until the address changes and the new one confirms. A link works for the address it was sent to, so changing an alert's address retires the links in earlier emails. The one click headers need `WEB_PUBLIC_URL` on `https`. On plain `http` only the footer link is offered. See [Email deliverability](/operations/email-deliverability) for the sender side. ### Try it locally Run a throwaway SMTP sink, and preview every template without sending anything. See [Set up a development environment](/install/local-development#preview-email-templates). ## Read the delivery history Every attempt on every channel is logged with its outcome and surfaced per channel on the settings page. Rows follow `RETENTION_DELIVERIES_DAYS`, which defaults to 30 days. Completed batch claims follow `RETENTION_ALERT_EVENTS_DAYS`, also 30 days by default. Claims that have not finished are retained regardless, so a stuck flush is never pruned out from under its retry. ## Troubleshooting * **The webhook endpoint returns a non 2xx status.** The delivery is logged as failed with the status. Fix the endpoint and use flush now to make an early claim of the facts currently pending. * **The signature does not verify.** Compute the HMAC over the raw request body bytes. Re serializing the parsed JSON changes whitespace and key order, which changes the digest. * **Email never sends.** Check that both `SMTP_HOST` and `SMTP_FROM` are set. One without the other leaves email disabled. * **The relay rejects the sender.** Many relays require the envelope sender to match an authorized domain. Set `SMTP_FROM` to an address on the domain the relay accepts. * **Deep links are missing.** `WEB_PUBLIC_URL` is unset, so `link` is `null` by design. * **The email shows no buttons or logo.** `WEB_PUBLIC_URL` is unset. Set it to the public web origin. * **Mail apps show no unsubscribe button.** The instance is not on `https`, or the relay does not sign with DKIM. See [Email deliverability](/operations/email-deliverability). * **A recipient stopped receiving alerts.** They unsubscribed, and the email alert shows **Unsubscribed** in Settings. That alert does not ask the address to confirm again, so a recipient who wants alerts back is added as a new email alert and confirms it. * **A new recipient receives nothing.** The alert shows **Awaiting confirmation**. Ask them to open the confirmation email, check the spam folder, or press **Resend confirmation**. Without `WEB_PUBLIC_URL` no confirmation email can be sent at all. * **Adding a recipient answers 429.** The recipient's mailbox already had 5 confirmation emails today under any spelling of its address, or the workspace had 20. The message says which, and the limit resets at midnight UTC. * **Resend confirmation answers 429.** A confirmation email went to that alert less than an hour ago. The message says how long to wait. * **Reports arrive at an unpredictable hour.** In batched mode the flush waits for every child job to settle, including retries. `CRON_DAILY` sets the start, not the delivery time. ## Related > **[Work the Action Center](/guides/action-center)** > > Where `action.opened` comes from. > **[Compare a portfolio](/guides/portfolio)** > > Where the weekly digest comes from. # Run an ASO audit Source: https://docs.asobeast.com/guides/aso-audit The audit scores a listing against a deterministic, store specific rubric, weights every factor by how much of it could be measured, and turns what fails into a ranked plan. The audit turns a listing into a score out of 100 with a letter grade, shows how much of that score was actually measured, and ranks the fixes by the points they would add. ## Run an audit Open the audit tab on an app. The audit runs on every visit and needs no button: every check is a rule over stored data, so the same listing and the same data always produce the same score. A change in the number means a change in the listing, its rankings or its reviews. The optional AI only reads images. It reports what is visibly on the icon and the screenshots, such as a caption, the language of that caption or whether the icon carries text, and the rules score what it read. It never gives a score of its own. See [Enable the optional AI features](/guides/ai-features). The check Keywords in captions answers from the text asobeast reads on your own machine when there is no AI analysis. See [Read screenshot captions](/guides/screenshot-captions). ## Read the score Each factor carries a store specific weight. Google Play has no subtitle or keyword field, so those factors are not listed there, and the short description takes their place. | Factor | App Store weight | Google Play weight | | - | - | - | | Title | 20 | 20 | | Subtitle | 15 | not listed | | Keyword field | 15 | not listed | | Short description | not listed | 15 | | Description | 5 | 15 | | Screenshots | 15 | 15 | | Preview video | 5 | 5 | | Ratings and reviews | 15 | 15 | | Icon | 5 | 5 | | Keyword rankings | 10 | 10 | | Freshness and trust | 5 | 5 | | **Total** | **110** | **105** | The description weighs 15 on Google Play because Google Play indexes it. On the App Store, preview videos are not visible in public store data, so that factor is reported as not measurable rather than scored. Every factor is a group of checks scored 0 to 10. A check that cannot be answered yet, for example because it needs competitors or the creative analysis, is listed as unanswered with what would answer it. The share of a factor's checks that are answered is its confidence, and the overall score weights each factor by its weight times its confidence. A factor measured by one light check therefore moves the overall far less than a factor measured completely, and missing data neither inflates nor lowers the score. The overall confidence is the share of the rubric that was measured. The page shows it next to the score. | Grade | Overall | Label | | - | - | - | | A | 85 to 100 | Excellent | | B | 70 to below 85 | Good | | C | 50 to below 70 | Fair | | D | 30 to below 50 | Weak | | F | below 30 | Poor | A grade is called provisional while less than 60% of the rubric is measured. The factors are also split into two groups, search visibility and conversion, each with its own score. ## Checks and what they read Each check reads one kind of data: the store listing, your tracked keywords, your competitors, your reviews, your rankings or the AI creative analysis. The exact thresholds live in [the check sources on GitHub](https://github.com/AsoBeast/asobeast/tree/main/apps/api/src/audit/checks), so this page names the checks without repeating numbers that could drift from the code. ### Search visibility | Check | Store | Reads | | - | - | - | | Title keyword, length, policy and uniqueness | Both | Keywords, listing, competitors | | Subtitle keyword, repetition and length | App Store | Keywords, listing | | Keyword field length, hygiene and relevance | App Store | Your saved keyword field | | Short description keyword, length, repetition and policy | Google Play | Keywords, listing | | Description keyword coverage, frequency and placement above the fold | Google Play | Keywords, listing | | Search visibility, top 10 share and weekly trend | Both | Rankings | | Keyword gap to competitors | Both | Competitors, rankings | ### Conversion | Check | Store | Reads | | - | - | - | | Description hook, call to action, social proof and formatting | Both | Listing | | Screenshot count | Both | Listing | | iPad screenshots | App Store, iPad apps | Listing | | Feature graphic | Google Play | Listing | | Screenshot captions, first message, style consistency | Both | AI creative analysis | | Keywords in screenshot captions | App Store | Read screenshot text, or the AI creative analysis when one exists, keywords | | Caption localization | App Store | AI creative analysis | | Promo video linked | Google Play | Listing | | Icon text, simplicity, contrast and distinctness | Both | AI creative analysis, competitors | | Average rating and rating volume | Both | Listing, competitors | | Recent review sentiment | Both | Reviews | | Current version rating | App Store | Listing | | Replies to negative reviews | Google Play | Reviews | | Update recency and release notes | Both | Listing | | Localizations | App Store | Listing | | Privacy policy linked | Google Play | Listing | Caption localization compares captions with the storefront's language only when your app declares that language, so an English only app in a non English storefront is not penalised. ## Follow the plan Every check that warns or fails carries a title, the evidence behind it and a fix. The plan ranks them in three buckets. | Bucket | Horizon | What lands there | | - | - | - | | Quick wins | Today | Text edits you make in the store console in minutes | | High impact | This week | Creative work, such as screenshots or the icon, that adds at least one point | | Strategic | This month | Work that needs releases, time or users, and creative work that adds less than a point | * **Lift** is how many points the overall score would gain if that one check reached 10. * **Impact** is high from 3 points of lift, medium from 1 and low below that. * **Effort** is minutes, hours or weeks. * **Potential** is the overall score with every advised check fixed. Each recommendation links to where the fix happens: the metadata workbench, the keywords page, the rankings, the competitors or the reviews. Fixes made in App Store Connect or Play Console, such as new screenshots, carry no link. ## Unlock more of the audit An unanswered check says what would answer it. The page lists these unlocks ranked by how much of the rubric they would measure. | Unlock | What to do | | - | - | | Keyword field | Type your keyword field from App Store Connect into the localization's editor on the [metadata page](/guides/metadata-workbench#edit-the-ios-keyword-field) | | Keywords | Score your tracked keywords so the audit knows your primary keywords | | Competitors | Add competitors to compare titles, icons, ratings and keyword gaps | | History | Wait for the daily checks; the weekly trend needs 7 days of positions | | Reviews | Recent review checks need 5 reviews from the last 30 days | | AI analysis | Run the creative analysis to read the icon and screenshots | ## What the audit cannot see Public store data leaves some things out. The audit lists them on every run instead of guessing. ### App Store | Limitation | Why | | - | - | | App previews | asobeast cannot see App Store previews. Check them in App Store Connect. | | Promotional text | Not in the data asobeast reads, and not indexed. | | In-app events | Not visible to asobeast, although events can appear in search. | | Custom product pages | Not visible to asobeast, although pages with assigned keywords appear in organic search. | | Replies to reviews | The App Store review feed carries no developer replies. | | Rating prompts | Happen inside your app. | | Product page conversion | Read on the overview's [store funnel](/guides/store-funnel) once App Store Connect is connected. The audit does not score it. | ### Google Play | Limitation | Why | | - | - | | Promo video content | asobeast sees whether a video is linked, not what it shows. | | Promotional content | Offers and events published from Play Console are not visible. | | Custom store listings | Not visible to asobeast. | | Store listing experiments | Run and read in Play Console. | | Android vitals | Crash and ANR rates affect visibility but are Play Console only. | | Rating prompts | Happen inside your app through the In-App Review API. | | Store listing conversion | Play Console only. | ## Follow the history A score snapshot is written daily on `CRON_AUDIT`, which defaults to `0 6 * * *`, meaning 06:00 UTC, after the daily pipeline. A finished creative analysis also records today's point, so the chart moves as soon as the checks it answers are scored. Snapshots recorded before this rubric are kept, and the chart marks the day the rubric changed with a "Rubric v2" line so a jump on that day is not mistaken for a change in the listing. Audit score rows follow `RETENTION_AUDIT_SCORES_DAYS`, which defaults to `0` and therefore keeps them forever. ## Troubleshooting * **The score changed after upgrading.** The rubric changed to v2: new checks, confidence weighting and store specific factors. Compare points after the "Rubric v2" marker with each other, not with the points before it. * **A factor says not measured.** None of its checks can be answered yet. Follow the unlock it shows, or read the limitation if public data cannot measure it. * **The analysis is outdated.** Your icon, screenshots, competitor icons or `AI_MODEL` changed since the last analysis. Its checks stay unanswered until you analyze again. * **The analysis failed.** The page shows the reason. See the error table in [Enable the optional AI features](/guides/ai-features#troubleshooting). * **The score differs between an iOS and an Android listing of the same product.** Expected. The factors and weights differ by store. ## Related > **[Optimize listing metadata](/guides/metadata-workbench)** > > Fix what the audit found. > **[Work the Action Center](/guides/action-center)** > > Weak audit factors you can edit become action items automatically. # Follow category ranks Source: https://docs.asobeast.com/guides/category-ranks asobeast captures free, paid and grossing chart positions daily for the home storefront, so a chart move reads alongside keyword positions. Category ranks are your position in a store chart rather than in a search result. They move for reasons keywords do not, which makes them a useful control when reading a ranking change. ## What is captured? The daily run captures the free, paid and grossing charts of the app's category and of the overall chart. The app's position is recorded in its own free or paid chart, depending on its price, and in the grossing chart, which is why a paid app shows no free series. Each observation records the app, the date, the collection and the genre. | Field | Meaning | | - | - | | Collection | Free, paid or grossing | | Genre | The store category the chart belongs to | | Date | The UTC date of the observation | Capture stays on the home market, like reviews. See [Countries and markets](/concepts/countries). The headline on the overview card links to the chart it was read from, so you can read every app in that chart. See [Read top charts](/guides/top-charts). ## Read the chart Chart positions use the same convention as keyword positions: 1 based, with an absent position meaning the app was not found within the captured chart depth rather than zero. See [Positions and rank depth](/concepts/positions). The App Store chart feed answers 100 positions and Google Play answers 200, so the captured depth is 100 for an App Store chart and 200 for a Google Play chart. An App Store app outside its chart reads `Not in top 100`. The overview card headlines the chart the app competes in: the paid chart for an app whose latest snapshot has a price above zero, the free chart otherwise. The headline names the chart it read, as in `#2 in Games · paid chart`. When that chart was captured but the app is outside it, the headline reads the grossing chart if the app ranks there and says so. When the app's own chart has no capture on the latest capture date, for example just after a price change, it reads the grossing chart too. Only series captured on that latest date are read, so an older position on the app's own chart never stands in for the current one. The overall chart is read only when no genre chart exists. The legend and the tooltip keep listing every series the chart captured. Read a chart movement against your keyword positions on the same dates. A chart fall with stable keyword positions usually points at conversion or at a competitor's promotion rather than at your metadata. ## What does it cost? Three charts for the app's category and three for the overall chart, each one store request a day, shared by every app and workspace in that chart, because the same capture serves the whole instance. See [Read top charts](/guides/top-charts) for the whole chart behind a position and [The daily pipeline and rate limits](/concepts/pipeline) for the budget. Category jobs remain the smallest slice of the daily budget, and the budget card in settings, shown to the platform operator, reports the real number. See [The daily pipeline and rate limits](/concepts/pipeline). ## How long are they kept? `RETENTION_CATEGORY_RANKS_DAYS` defaults to 365 days. Set it to `0` to keep chart history forever. ## Troubleshooting * **No chart position at all.** The app is outside the captured chart depth for that category, which is common outside the top categories. * **The app appears on the grossing chart but not the free chart.** A paid app is charted against paid and grossing, not free. * **A gap in the history.** The daily job for that date failed and its retries were exhausted. Check the queue dashboard at `/admin/queues`. * **A free game has no overall free position.** On the App Store the free overall chart holds no games, so the position reads `Not in top 100`. Read the grossing overall chart or the game genre chart. See [Read top charts](/guides/top-charts#why-does-the-app-store-overall-chart-differ-by-kind). ## Related > **[Read rankings and SERPs](/guides/read-rankings)** > > Keyword positions over the same dates. > **[Compare a portfolio](/guides/portfolio)** > > Roll several apps up into one view. # Track competitors Source: https://docs.asobeast.com/guides/competitors Add a competitor by name, from a store URL, from a stored SERP snapshot or from the suggestions in the guided setup. Competitors cost no extra store requests on keywords you already track. A competitor is a listing asobeast tracks alongside yours so you can compare metadata and see where it ranks on the keywords you care about. ## Add a competitor Four paths, the first three on the competitors tab of an app. | Path | When to use it | | - | - | | By name | You know the app but not its URL. The form searches your app's store and storefront and lists the results with their icon | | From a store URL | You already know which app you want to watch | | From a stored SERP snapshot | You want the apps that actually outrank you on a keyword | | From the suggestions in the guided setup | Right after an import, the apps that rank in your keyword results and the apps the store calls similar are listed with a checkbox each and added together | The second path is usually better, because it names the apps the store already puts in front of your users rather than the ones you assume are competitors. One listing in one storefront is either an app you track or a competitor of one app, never both and never two at once. Adding a listing you already track as an app, importing one you already watch as a competitor, or attaching a competitor that already belongs to another app all answer `409` and change nothing. Remove the listing from where it is first. Adding a competitor this app already tracks returns that competitor without reaching the store and without writing a snapshot, for the same reason a repeat import does. See [Importing twice costs nothing extra](/guides/import-an-app#importing-twice-costs-nothing-extra). ## Suggested competitors The competitor step of the guided setup suggests up to ten apps from two sources, in this order. | Source | What it is | Shows | | - | - | - | | Keyword results | Untracked apps that appear in your own keyword results over the last 30 days | Their icon, how often they appeared and their best position | | Similar apps | The apps the store lists as similar to yours | Their icon | A keyword result counts every storefront you track keywords in, so an app that ranks in your German results is suggested with those appearances. Its title, developer, rating and icon are the ones your home storefront shows when the app ranked there during the window, and the newest result of another storefront when it did not. An app is therefore named the same way every day, in the language of your own listing. Google Play lists popular apps from unrelated categories among the similar apps, so a Google Play suggestion is kept only when its title and description share at least half as many of the words and phrases in your title and short description as the closest similar app does, and the ones sharing the most come first. The apps you already track and the keyword results are left out before the closest one is chosen. The App Store list is kept in the store's order. Your own app and the competitors you already track are never suggested, and an app both sources name is listed once, as a keyword result. Reading the keyword results costs nothing, because they come from the searches the daily run already made. The similar apps lookup reaches the store while you wait and spends one store lookup of the hourly allowance. When the store does not answer or the hourly allowance is spent, the keyword result apps are still listed and the similar apps are left out. `GET /apps/{id}/competitors/suggestions` answers the same list, and so does the `competitor_suggestions` MCP tool. ## Why are competitors free to track? A position check searches the keyword's storefront once and records positions for the primary app and for every competitor attached to it from that single search. So adding a competitor costs zero extra store requests on keywords you already track. Ten competitors on a keyword are still one search per day. Adding a *keyword*, or a market, is what costs budget. See [Countries and markets](/concepts/countries). ## Compare listings The comparison view puts your listing beside each competitor field by field, with character counts against the store limits. The gap analysis is the useful half: it shows phrases competitors index that you do not, so you can decide whether they belong in your own title, subtitle, keyword field or short description. Adding one from there tracks it with the source `COMPETITOR`, which starts its relevance lower than a keyword drawn from your own listing. See [Popularity, difficulty and opportunity](/concepts/scoring). ## Collapse a section The competitors tab has three sections: the add form together with the competitors you track, **Apps you don’t track yet**, and the comparison matrix. Each section collapses behind its title. The window tabs of the discovery section and the only gaps switch of the matrix stay beside their titles while the section is collapsed. The choice is remembered in this browser and is not part of the link. When you print the page, a collapsed section prints its title and nothing else. ## Read the comparison matrix The matrix lists every tracked keyword with your position and each competitor's. Click any header to sort by it, your own column and each competitor included, and click again to reverse. Search narrows the keywords by text, and the result filter keeps the rows where you are **winning** (you rank best, or alone), **losing** (a competitor ranks better, or ranks where you do not) or **tied**. The only gaps switch still asks the server for gap rows alone. When you track keywords in more than one market, the first column names the market of each row, so the same phrase tracked in two storefronts is two rows you can tell apart. On a phone the column is hidden to leave room for the positions, and the market appears under the keyword instead. **Columns** shows or hides the column on any screen, and the choice is remembered in this browser. The **Market** filter beside **Result** keeps the rows of one market, is part of the link, and clears with **Clear all**. See [Countries and markets](/concepts/countries). Each position is shaded by its grade and the best position in a row stays underlined. Popularity and difficulty carry their grade too. See [How are scores graded?](/concepts/scoring#how-are-scores-graded). A dash in place of a position means no check has run yet for that app and keyword, which is how a competitor you just added looks until the next keyword check. `>200` means the check ran and did not find the app within the captured depth. **Export CSV** in the matrix writes the rows shown, in the order shown, and the competitor list above it exports as well. See [Export the keyword comparison to CSV](/guides/export-data#export-the-keyword-comparison-to-csv). Discovery sorts the same way, by appearances, keywords, best position, average position or rating, and searches by app name. To find searches around what competitors target, use **Competitor searches** in **Suggestions** on the keywords page. See [Get keyword suggestions](/guides/track-keywords#get-keyword-suggestions). ## Why does a competitor rank above you? Press a keyword in the matrix to open its breakdown. Your app comes first, then every competitor, each with its position on the latest daily check. For each app the panel reads every listing the keyword's market searches. On the App Store that is the title and subtitle of each localization the storefront reads, plus the keyword fields you saved for those localizations. A competitor's keyword field is private and is never shown. On Google Play it is the title, the short description and the long description, and the text of the long description is not shown. See [App Store and Google Play](/concepts/stores) and [Which localizations does a storefront read?](/concepts/countries#which-localizations-does-a-storefront-read). The keyword's words are marked where they appear. Each field says whether it holds the whole phrase, every word or some of them, which is the same verdict the coverage table uses. See [Check keyword coverage](/guides/metadata-workbench#check-keyword-coverage). Words of two localizations never combine into one phrase, so each localization is judged on its own. See [What does each store index?](/concepts/stores#what-does-each-store-index). A word that matches only when accents are ignored, such as `guidees` for `guidées`, has a dotted mark and does not count as a match. Stores usually treat the two spellings alike, but neither store documents it. **Not read** names the localizations the market searches whose listing has not been captured for that app. For a competitor only the native localizations of an English default storefront are captured, so the others stay unread rather than guessed. When the keyword's market has no stored listing of an app, the panel reads its home listing and says so. A competitor that ranks above you carries one note, from the first rule that applies: | Note | When it applies | What it suggests | | - | - | - | | Placement | Its strongest placement beats yours. The order is the title, then the subtitle or short description, then your keyword field, then the long description, then every word spread over fields, then some of the words | Move the keyword to a stronger field | | Spelling | It spells a word exactly as searched where your listings match only with accents ignored | Consider the exact spelling | | Ratings | Both ratings come from the keyword's market, and it has at least three times your ratings and at least 100 more | Ratings may be the difference | | Not read | No listing it was read in holds every word of the keyword, and a localization the market searches was not captured | The missing listing may hold the keyword | | Words not all visible | App Store only, and no listing it was read in holds every word of the keyword | It may rank through its private keyword field | | Similar | None of the above | Nothing visible explains the gap | These are rules of thumb. Installs and engagement, the strongest signals, are not visible for other apps, and neither store publishes how it weighs a field. For **Not read** and **Words not all visible** the card says whether none of the words show or only some of them, the same count the field badges give. Your own card says how many competitors rank above you, which words none of your listings holds, which match only with accents ignored, and whether your listing changed or you saved a keyword field this market reads in the last 28 days, because ranks usually take two to four weeks to settle. The panel reads stored data only and makes no store request. ## Watch competitor metadata changes Competitor listings are snapshotted on the same schedule as your own, and a field level change appears in the changes feed marked as a competitor change. Alerts can carry them in a separate competitor report. See [Watch listing changes](/guides/listing-changes). ## Troubleshooting * **A competitor shows no positions.** It only gets positions on keywords you track, in the storefronts you track them in. A dash means its first check has not run yet; it fills in on the next keyword check. * **A competitor's ratings column is empty on Google Play.** Google Play search results carry no rating counts. * **You added the wrong app.** Removing a competitor removes its position history with it, so confirm the store id before adding. ## Related > **[Read rankings and SERPs](/guides/read-rankings)** > > Where the competitor list comes from. > **[Optimize listing metadata](/guides/metadata-workbench)** > > Turn a gap into a metadata change. # Export data Source: https://docs.asobeast.com/guides/export-data The keyword monitor, rankings view, competitors page, reviews list and top 10 sheet export CSV, and the overview and the rankings view print as a report you can save as PDF. Each file follows the filters you applied. The keyword monitor, the rankings view, the competitor list and the comparison matrix on the competitors page, the reviews list and the top 10 sheet each export a CSV file that follows the filters you have applied. `` in a file name is the UTC day of the export. The app overview and the rankings view also print as a report, which is the quickest way to share a view with someone who has no account. ## Export keywords to CSV The export writes one row per tracked keyword, named `keywords--.csv`. | Column group | Columns | | - | - | | Identity | `keyword`, `source`, `active`, `bucket`, `relevance` | | Position | `position`, `delta1d`, `delta7d`, `volatility` | | Scores | `popularity`, `difficulty`, `opportunity` | | Provenance | `scoredAt`, `scoringSource`, `formulaVersion`, `confidence`, `capturedAt` | | Evidence | `suggestReach`, `serpFlags` separated by spaces, `officialPopularity`, `scoreOutdated` | | Caveat | `scoreComparability` | | Owner notes | `tags` separated by semicolons, `note` | | Market | `country`, the storefront of each keyword, as the last column | The provenance columns travel with the scores on purpose. A spreadsheet that carries `formulaVersion` and `confidence` can still be audited months later, while one that carries only a number cannot. The file can be imported again: the keyword, `country`, `tags` and `note` columns are read and the rest are ignored. See [Import keywords from a CSV file](/guides/track-keywords#import-keywords-from-a-csv-file). The `scoreComparability` column repeats, on every row, that App Store and Google Play scores use different public signals and are not directly comparable. That caveat is easy to lose once data leaves the product, so it is written into the file. See [App Store and Google Play](/concepts/stores). `position` is formatted the same way the table shows it, so an unranked keyword exports as `>200` rather than as an empty cell or a zero. See [Positions and rank depth](/concepts/positions). ## Export rankings to CSV The rankings view exports the charted window as one row per date and one column per keyword, named `rankings----.csv`, where `` and `` are the first and last day of the window. ## Export competitors to CSV The competitors page exports one row per tracked competitor from its latest snapshot, named `competitors--.csv`. Your own app is not in the file. | Column | What it holds | | - | - | | `competitor` | The competitor's name | | `store` | `APP_STORE` or `GOOGLE_PLAY` | | `title`, `subtitle`, `summary` | The listing text of the latest snapshot, where `summary` is the Google Play short description | | `rating`, `ratings` | The average rating, unrounded, and the number of ratings | | `installs` | The install count | | `price` | The price, `0` for a free app | | `version` | The latest version, empty on Google Play when the version varies by device | | `capturedAt` | When the snapshot was taken, in UTC | A column the store does not publish stays empty: `subtitle` on Google Play, `summary` and `installs` on the App Store. See [App Store and Google Play](/concepts/stores). ## Export the keyword comparison to CSV The comparison matrix exports the rows it shows, in the order shown, after the search, the market filter and the result filter. The file is named `keyword-comparison--.csv`, or `keyword-gaps--.csv` when **only gaps** is on, and then it holds the gap rows alone. | Column | What it holds | | - | - | | `keyword` | The tracked phrase | | `popularity`, `difficulty` | The scores on the 0 to 100 scale the matrix shows, empty when unscored | | `you` | Your position, empty until your app has been checked for the keyword | | One column per competitor | That competitor's position, headed by its name and empty until its first check | | `result` | `winning`, `losing` or `tied`, empty when nobody ranks | | `gap` | `true` on the rows the matrix marks as a gap, otherwise `false` | | `country` | The two letter code of the market the keyword is tracked in | The file always ends with `country`, like the keyword export, whether the matrix shows one market or several. Positions are written the way the matrix shows them: an app that does not rank exports as `>200`, and an app with no check yet exports an empty cell. Two competitors with the same name get `(2)`, `(3)` after the name so every column header stays unique, and a competitor without a name is headed `Competitor`. What winning, losing, tied and a gap mean is explained in [Read the comparison matrix](/guides/competitors#read-the-comparison-matrix). ## Export reviews to CSV The reviews list exports up to 200 reviews that match the star, version, period and reply state filters, in the order the page shows them, newest or lowest first, and keeps only the reviews the search matches, named `reviews--.csv`. The reviews endpoint returns at most 200 reviews in one request and does not page, so older reviews are not in the file. When more reviews match the filters than the file holds, a notice after the download says how many it holds. With a search, the file holds the reviews that match it among the ones loaded, up to 200, and the notice says how many it found, how many were searched and how many match the filters in all, because a search cannot reach reviews that did not load. Narrow the filters to reach a different set, for example one version at a time. | Column | What it holds | | - | - | | `reviewedAt` | The review's date in UTC, empty when the store sent none | | `score` | 1 to 5 stars | | `title`, `text` | The review, with `title` empty when it has none | | `version` | The app version the store attached to the review, when it sent one | | `author` | The reviewer's display name | | `reviewId` | The store's own id for the review | | `replyState` | `needs_reply`, `drafted`, `replied` or `updated` | | `replyText` | The reply kept on the review, or the reply Google Play shows when none is kept | ## Export a SERP snapshot to CSV The top 10 sheet on the keyword monitor exports the snapshot it shows, one row per result, named `serp----.csv`, where `` is the day the snapshot was taken. | Column | What it holds | | - | - | | `keyword`, `capturedOn` | The phrase and the snapshot's day, repeated on every row | | `position` | 1 to 10 | | `app`, `developer` | The result's title and developer | | `rating`, `ratings` | Its average rating and rating count | | `role` | `you`, `competitor` for an app tracked as a competitor in the workspace, as the sheet's badge shows, or `other` | | `storeAppId` | The store's id for the app | `ratings` stays empty for Google Play keywords, whose search results carry no rating counts. See [App Store and Google Play](/concepts/stores). ## Print a report or save it as PDF The app overview and the rankings view print as a report. Select **Print report**, on the overview next to the snapshot facts and on the rankings view next to **Export CSV**, or use the browser's print command. To get a file, choose **Save as PDF** as the destination. The printout leaves out the navigation, the header controls, banners and notifications, and every button. The keywords charted on the rankings view print as a row of labels with their colours. It opens with the app name, its store and home storefront, the page, each range the charts show, and the UTC date it was printed. Range pickers print as the range they are set to, so set them before you print. | On screen | On paper | | - | - | | Dark theme | Always the light theme | | Range tabs | The selected range, such as `Last 30 days` | | Charts side by side | One chart per row, scaled to the page width | | Grade colours | The colour and the grade word, such as `3 strong` | Charts keep the shape they had on screen and shrink to fit the paper, so a chart drawn across a wide window prints with smaller text. For larger chart text, print the overview in landscape or from a narrower window. Grade colours, badges and chart legends print even with background graphics turned off. Other pages print without the navigation and in the light theme, but without a report layout. ## Are the files safe to open? Yes. Every field that begins with `=`, `+`, `-`, `@`, a tab or a carriage return is prefixed with an apostrophe before it is written, so a spreadsheet treats it as text rather than as a formula. Fields containing quotes, commas or newlines are quoted and escaped, and the file carries a byte order mark so spreadsheets read it as UTF-8. ## Use the API instead For anything scripted, read the API rather than parsing an export. It returns typed JSON and it does not depend on which filters happened to be applied in a browser. See [asobeast API](/api-reference/introduction). ## Related > **[Track keywords](/guides/track-keywords)** > > The table the keyword export is taken from. > **[Read rankings and SERPs](/guides/read-rankings)** > > The chart and the snapshots the rankings and SERP exports are taken from. > **[Track competitors](/guides/competitors)** > > The list and the matrix the competitor exports are taken from. > **[Sync reviews and mine keyword ideas](/guides/reviews)** > > Where the exported reviews come from. # Import an app Source: https://docs.asobeast.com/guides/import-an-app Search a store by name or paste an App Store or Google Play URL, and asobeast captures the first snapshot, extracts keywords and tracks them in the home storefront. When you finish this guide you have one tracked app, its first metadata snapshot and a set of keywords already tracking in its home storefront. ## Import by name or store URL The import dialog takes either the name of the app or its store URL in one field. A name searches the store you pick, App Store or Google Play, in the storefront you pick, and lists up to ten listings with their icon, developer and rating. Picking one imports it by its store id, exactly as if you had pasted its URL. Each name search reaches the store while you wait and spends one store lookup of the hourly allowance, so a search is only sent once you stop typing. A name can be up to 80 characters. A longer name is not sent: the field marks itself invalid and says how many characters it has, and the same rule applies to the name search of the competitor form. See [Rate limits and quotas](/api-reference/rate-limits#a-spent-on-demand-allowance-names-its-action). A URL, a bare App Store id or a Google Play package name never searches. It imports as described below. **1. Type the app's name or copy its store URL** Any public listing URL works, for either store. ```text theme={null} https://apps.apple.com/de/app/example/id123456789 https://play.google.com/store/apps/details?id=com.example.app ``` The import dialog accepts four forms of the same thing: | Form | Example | | - | - | | A listing URL | `https://apps.apple.com/de/app/example/id123456789` | | A listing URL without `https://` | `apps.apple.com/de/app/example/id123456789` | | A bare App Store id | `123456789` | | A bare Google Play package name | `com.example.app` | An App Store id with leading zeros names the same listing, so `0123456789` imports `123456789`. **2. Paste it into the import dialog** asobeast parses the store and the storefront from the URL. **3. Confirm the home storefront** The country segment in the URL becomes the home storefront. `apps.apple.com/de/...` imports a `de` home market. A bare id, or a URL with no country segment, falls back to `DEFAULT_COUNTRY`, which defaults to `us`. The dialog lets you override it before importing. The home storefront is not just a label. It decides where the first snapshot, category ranks and reviews are collected; market listings are added for the markets you track keywords in. See [Countries and markets](/concepts/countries). ## What happens on import? Four things, in order. 1. **The listing is fetched and snapshotted.** asobeast stores the parsed fields and the raw payload, so a later parser fix can reprocess history rather than recollect it. The App Store subtitle is read from the public product page, and when Apple does not answer that page in time, the snapshot is stored without it and a backfill fills it in within a few minutes. 2. **Keywords are extracted from the indexed fields and auto tracked.** Each one carries the source it came from, and each one is queued for its first popularity and difficulty score. Spaceless languages are split into words first. See [How are auto tracked keywords chosen?](/concepts/stores#how-are-auto-tracked-keywords-chosen) 3. **A review backfill is queued.** The first three pages of reviews are collected so ratings history starts with something in it. 4. **The first pass is scheduled.** Every auto tracked keyword is queued for a rank check. The action queue for your workspace is generated when those first rank checks finish, so the first recommendations read real positions instead of an empty database. The import then opens the guided setup, which walks through competitors, keywords and countries. See [Complete the guided setup](/quickstart#complete-the-guided-setup). The first pass is what fills the listing. Positions and scores arrive as the queue drains rather than waiting for the next scheduled run, so a fresh instance with an empty queue shows real numbers within minutes on the App Store and within tens of minutes on Google Play. See [The daily pipeline and rate limits](/concepts/pipeline) for the throughput those waits come from. ### Importing twice costs nothing extra Each piece of first pass work carries an identifier, so importing the same app twice collects nothing twice. | Work | Identifier | What deduplicates against it | | - | - | - | | Rank check | `first-run~~~` | Another first pass for the same app the same UTC day | | Subtitle backfill | `subtitle~` | Another import of the same app while its backfill is still waiting or running | | Action run | `actions~` | **Generate now** while an earlier run is still waiting or running | Each import waits for its own first rank checks before it generates actions, so importing a second app never leaves that app out of its first recommendations. **Generate now** joins a run that is still waiting or running, and starts a new run once the last one has finished. The rank check identifier names the app on purpose. Keywords are shared: two apps tracking the same phrase in the same storefront are one keyword row, and one search records positions for every app tracking it. That sharing is what makes a scheduled run cheap, but it only helps while the search is still queued. A search that already ran cannot record a position for an app that started tracking afterwards, so a first pass that deduplicated against it would be dropped in silence and the new app would show nothing until the next daily run. Naming the app keeps each import's promise, and costs at most one extra search per shared keyword on the day it is imported. The nightly pipeline builds its own `daily~` prefixed identifiers so its flow can keep track of its own children, and it regenerates actions in place rather than queueing a job, so it is not on that list either. Auto tracked sources differ by store, because the stores index different fields. | Source | App Store | Google Play | | - | - | - | | `TITLE` | Yes | Yes | | `SUBTITLE` | Yes | Not present | | `DESCRIPTION` | Not indexed | Yes, from the 80 character short description | | `KEYWORD_FIELD` | Only when you paste it | Not present | See [App Store and Google Play](/concepts/stores) for the full field comparison. ## Refresh a listing Refreshing fetches the listing again, writes a new snapshot and returns the difference against the previous one, so the response is meant to be read rather than discarded. It lists every field the refresh recorded in the changes feed, replaced or reordered screenshots, the icon, the price and What's New included. Edited screenshot captions are not in it, because captions are read after the refresh answers; they appear in the changes feed once the new screenshots are read. See [Screenshot captions](/guides/screenshot-captions). In a storefront that defaults to English, an import and a refresh also read the native localizations of the listing and a refresh lists their changes with the language. See [Countries and markets](/concepts/countries#what-about-storefronts-that-default-to-english). A refresh that cannot read the App Store product page keeps the subtitle from the previous snapshot instead of storing an empty one, so a throttled page never shows up as a removed subtitle in the changes feed or fires a change alert. Refresh is the only way to ask for a listing on demand, and it is rate limited per plan. Refresh while a market is selected on the metadata, overview or changes page refreshes that market's listing instead, and the API takes `?country=` for the same. Importing a url you already track returns the app you have without reaching the store: a second import would otherwise write a snapshot that quietly became the new baseline, and the change it captured would never appear in the changes feed. Field level changes also land in the changes feed, where they are kept alongside changes asobeast detected on its own schedule. See [Watch listing changes](/guides/listing-changes). ## Troubleshooting * **The URL is rejected.** The parser accepts App Store listing URLs, Google Play `details` URLs, bare App Store ids and bare Google Play package names. A search result URL, a developer page, an album, a bundle, an editorial story or a shortened link is refused at once with the input echoed back, before any store request. Open the listing in a browser and copy the address bar. * **The import answers 422.** The App Store listing is not available on iPhone, so it can never rank in the search asobeast reads. That covers apps sold only on iPad, the Mac App Store, Apple Watch or Apple Vision Pro. The same applies when you add it as a competitor. See [App Store and Google Play](/concepts/stores). * **The import answers that the store did not answer.** The App Store or Google Play did not answer within 20 seconds, so nothing was imported. Try again in a few minutes. Importing an app that is already in the workspace returns it instead of importing it twice, so a retry is always safe. * **The app exists in one storefront but not another.** Storefronts carry different catalogs. Import using a URL for a storefront where the app is actually published, then track keywords in the other market. * **The import fails with a parse error.** A store response shape changed. The job fails and BullMQ retries it with backoff, so check the queue dashboard at `/admin/queues` for the retry state. Request handling is unaffected. See [The daily pipeline and rate limits](/concepts/pipeline). * **Positions are empty right after importing.** Expected for a moment. The rank checks are queued at import and drain at the store rate limit, so watch the first run panel on the app overview page, or the run pill beside **Run daily**, rather than the clock. If they stay empty, check the queue dashboard at `/admin/queues` for failed jobs. * **The subtitle is empty right after importing.** Apple sometimes answers the App Store product page with HTTP 429, and the subtitle only exists on that page. The import still succeeds, the API logs `subtitle scrape failed`, and a `resolve-subtitle` job retries for up to about eight minutes. The first run panel shows **Listing captured** as waiting until it lands, and the overview reloads the listing when it does. If the job exhausts its retries, it shows as failed on `/admin/queues` and the next refresh fills the subtitle. * **The first pass did not run at all.** The import itself succeeds even when the queue refuses the work, because the listing, its snapshot and its keywords are already stored and only the head start is missing. The API logs why, and the next daily run collects what the first pass would have. Check `/admin/queues` if it keeps happening. * **Trend based actions stay thin for the first week.** Expected. The rules that compare a before and an after need several days of captures before they can fire at all, so the action queue grows over the first week rather than arriving full. ## Related > **[Track keywords](/guides/track-keywords)** > > Add keywords, import a list and add markets. > **[Track competitors](/guides/competitors)** > > Competitors cost no extra store requests on keywords you already track. # Watch listing changes Source: https://docs.asobeast.com/guides/listing-changes Every snapshot is compared with the previous one, so a changed title, subtitle or description becomes a dated event for your apps and competitors, and each change to your own listing is measured against your keyword positions. The changes feed is the answer to "what did they change, and when". It records field level differences between consecutive snapshots for your own apps and for every competitor you track. For your own changes, the card above the feed also shows how your positions moved afterwards. ## Read the changes feed Each event names the app, the field, the previous value, the new value and the date it was detected. | Column | Meaning | | - | - | | App | Yours or a competitor's | | Field | The metadata field that changed | | From and to | The two values, so you can read the edit itself | | Detected | The UTC date the change was observed | Detection is snapshot based, so the date is when asobeast noticed rather than when the store published. A refresh you trigger yourself narrows that gap. See [Import an app](/guides/import-an-app). ## Screenshot and caption changes Three kinds of event describe screenshots. All of them are recorded for your apps and for every competitor. | Field | When it appears | What it shows | | - | - | - | | Screenshots | The number of screenshots changed | The two counts, exactly as before. When the images are known the event also shows which screenshots were added or removed | | Screenshot images | The count is the same but a screenshot was replaced or the order changed | Before and after thumbnails with the changed positions marked | | Screenshot captions | A caption was edited, added or removed | The captions that disappeared and the ones that appeared | Each listing is compared only with its own previous snapshot, so a market listing's screenshot and caption changes appear in that market's timeline and never against the home listing. A reordered or replaced screenshot used to leave no trace, because only the count was compared. Caption events need the screenshot text reader and appear after the snapshot has been read, so they trail the image events by a few minutes. They are skipped when a screenshot of either snapshot could not be read. See [Read screenshot captions](/guides/screenshot-captions). ## Measure the impact of a change A change to your own listing is only worth repeating if it worked. The card at the top of the changes page answers that for each change: how the positions of your tracked keywords and your visibility moved 7, 14 and 28 days later. ### What counts as one change Every edit asobeast detects on your listing on one UTC day is one change, whatever the fields: a new title and a new subtitle found on the same day are measured together, and the card lists both. Version, price, screenshots, screenshot images, screenshot captions, icon and What's New count too. On the App Store a new keyword field only takes effect with a new version, so a version change is where a keyword field edit lands. The dashboard tile counts the same way, once per app per UTC day, for your competitors as well. Competitor changes are never measured here. They stay in the feed below the card. ### How a window is measured Each change is compared with a baseline: the most recent rank check before the day of the change, at most 3 days before it. Each window then reads the most recent rank check between 2 days before its target date and the target date itself, where the target is the day of the change plus 7, 14 or 28 days. | The window says | What it means | | - | - | | Measured on a date | both rank checks exist and at least one keyword was checked on both days | | Measurable on a date | the target date has not arrived yet | | No rank check near a date | no usable rank check was recorded in the 3 days up to the target | A change with no rank check in the 3 days before it cannot be measured at all, and the card says so instead of showing its windows. That happens to an app imported just before its first change, or after a pause in the daily pipeline. ### Which keywords count A keyword counts in a window when it was checked on both days, whether or not it was found. A keyword you started tracking after the change has no baseline, so it drops out instead of appearing as a new ranking. Paused keywords and keywords of other markets never count. ### Read the numbers | Number | Meaning | | - | - | | Improved, declined | keywords ranked on both days whose position went up or down | | Started ranking | not found within the checked depth before, ranked after | | Stopped ranking | ranked before, not found after | | Checked on both days | every keyword the window measured, including those found on neither day | | Median position | the middle position change over keywords ranked on both days; up means the typical keyword climbed | | Visibility | before and after, over the measured keywords, each weighted by its popularity on the baseline date for both numbers | Holding the popularity weights at the baseline means a weekly rescore between the two dates cannot move the visibility change; only positions do. It is also why the number can differ slightly from the overview chart, which weights each day by the popularity current on that day and covers every active keyword in every market. The formula itself is explained in [Visibility](/concepts/visibility). ### When changes overlap A window lists every later change to your listing that falls inside it, up to the day it was measured. Its effect is then mixed with that later change. Nothing is hidden or discounted: read the two changes together, or wait for a change with a clean window. ### Markets and windows The report measures one market at a time. It opens on your home storefront; pick another market in the card when the app tracks keywords in more than one. The changes timeline shows one market at a time too: pick the market in the card to read what changed on that storefront's listings. Market changes are recorded and shown, and never sent as alerts. Changes to a native localization appear on the market's timeline with the language next to the field, and send no alert either. See [Countries and markets](/concepts/countries). The card follows the window of the changes page, 30, 90 or 365 days, and measures the 12 most recent changes in it. When there are more, it says how many it measured out of how many. Rank checks older than `RETENTION_RANKINGS_DAYS` (365 days by default) are pruned, so a change older than that cannot be measured. ### Conversion after a change With a store console connected, each window also reads the [store funnel](/guides/store-funnel) of the market the card shows. It adds up impressions, product page views, Get taps and conversion over the same number of complete days before the change and from the day of the change on, so a 7 day window compares the 7 days before with the 7 days that start on the change. The day of the change counts as after. A Google Play app reads the store listing visitors of every market in its home market, because its listing is one listing worldwide and Google files the countries of a small app under Other, which no market can read. A window whose market the console reported nothing for in the days before the change says there is no console data instead of comparing zeros. | The store funnel says | What it means | | - | - | | Get taps and conversion, before and after | every day of both periods is complete, so the two are compared | | Final in a number of days | some days after the change are not final yet, because Apple finishes a day within three days and Google within seven | | No console data before this change | the console reports nothing for the whole period before the change | The changes timeline marks the day of a change whose 7 day window is measured with its conversion change, for the market the card shows. Without a connection, the card and the timeline look exactly as before, and the report carries no store funnel. > [!WARNING] > > The report shows what moved after a change, not what the change caused. Store algorithm updates, seasonality, competitors' own edits and ordinary movement from one day to the next all shift positions too. Treat a window as evidence to weigh, and compare changes made under similar conditions. The same report is available from the API as `GET /apps/{id}/changes/impact` with optional `days` and `country` parameters (see [asobeast API](/api-reference/introduction)), and to an agent as the `change_impact` tool (see [MCP tool reference](/mcp/tools)). ## Why competitor changes matter most Your own changes you already know about. A competitor rewriting a title is the interesting row, because it usually precedes a position shift on the keywords that title covers. Pair a competitor change with a position drop on the same keywords and you have the beginning of an explanation. The Action Center formalises this in `rank.investigate_drop`, which looks for an indexed field change within 14 days of a visibility fall. See [Work the Action Center](/guides/action-center). ## Get notified Subscribe a channel to `metadata.changed`. In the default batched delivery mode, owned app changes and competitor changes arrive as two separate reports, so a channel can subscribe to one scope without the other. See [Send alerts](/guides/alerts). ## How long are changes kept? `RETENTION_CHANGE_EVENTS_DAYS` defaults to `0`, which keeps change events forever. See [Configuration reference](/configuration/reference). That default is deliberate: change history is small, and it is the record you most want years later when explaining a ranking trend. ## Troubleshooting * **A change you made is not listed.** No snapshot has been captured since the store published it. Refresh the listing. * **A change appears with no visible difference.** Stores sometimes reformat whitespace or punctuation. The diff shows exactly what differed. * **Competitor changes stopped.** Confirm the competitor is still tracked and that its listing has not been removed from the storefront. * **Every window says Measurable on a date.** The change is recent. The first window arrives a week after it. * **A window says No rank check near a date.** The daily pipeline did not check your keywords around that day. See [The daily pipeline and rate limits](/concepts/pipeline). * **A change is missing from the impact card.** It is older than the page window, or at least 12 more recent changes came after it. ## Related > **[Track competitors](/guides/competitors)** > > Add the listings whose changes you want to see. > **[Work the Action Center](/guides/action-center)** > > Where a change plus a drop becomes an investigation. # Optimize listing metadata Source: https://docs.asobeast.com/guides/metadata-workbench The workbench lints each indexed field against its store limit, reports keyword coverage, and holds the App Store localizations of an app with one private iOS keyword field each. The workbench is where you see your listing the way search sees it: field by field, against the limits, and against the keywords you claim to be targeting. ## Lint your metadata Each field is checked against the limit for its store, and the lint reports characters used, characters left and anything wasteful such as a duplicated word across indexed fields. | Field | App Store | Google Play | | - | - | - | | Title | 30 | 30 | | Subtitle | 30 | Not present | | Keyword field | 100 | Not present | | Short description | Not present | 80 | | Description | 4000, not indexed | 4000, indexed | | Promotional text | 170, not indexed | Not present | Fields a store does not have are hidden rather than shown empty. See [App Store and Google Play](/concepts/stores). On Google Play, a short description word that is also in the title is a warning, not an error. Google's metadata policy asks for succinct text without repeated keywords, but it does not forbid reusing a title word, so the lint points out the wasted room without calling it a rule break. On the App Store a subtitle or keyword field word that Apple already indexes from the title stays an error, and only errors open an Action Center item. A keyword field word is flagged as a competitor brand only when it reads as one competitor's brand. A word in your own title or subtitle, a keyword you track, a generic word such as `free`, and a word two or more competitor titles share are category vocabulary, so a geo guessing game keeps `guess` and `location` while an untracked `geoguessr` is still flagged. ## See which localizations your app ships On an App Store app, the **App Store localizations** section lists the localizations your app ships, your home storefront's default first. Each one is an item that holds its own keyword field and shows the markets that read it as chips, **Default** or **Additional**. When the app has two markets or two shipped localizations, a matrix above the items crosses them, so you see at a glance which market reads which localization. Each shipped localization is another title, subtitle and keyword field for search in every storefront that reads it, and the section counts that room for you. The rules, their source and their limits are in [Which localizations does a storefront read?](/concepts/countries#which-localizations-does-a-storefront-read) and [Which localizations does an app ship?](/concepts/countries#which-localizations-does-an-app-ship) The **Listing market** control above the fields shows what each of those storefronts actually serves. These controls manage the list. * **Add localization** adds a language you ship in App Store Connect. The dialog lists the languages your markets read first, then every other language, and searches both names and codes. * **Find localizations** asks the store which of those languages your listing really serves and adds the ones it proves, marked **Detected**. It spends one refresh from your on demand allowance and the answers arrive as the queue drains, so the list fills in over a few minutes. It cannot see a localization whose text equals the default listing or your home listing; add that one by hand. See [Which localizations does an app ship?](/concepts/countries#which-localizations-does-an-app-ship) * **Not listed yet**, under the items, names every localization one of your markets reads that is not on the list, with the markets that read it and an **Add** button that adds it and opens its keyword field. A localization the store serves as a market's own language arrives on its own after the next capture of that market, so this list is for a language you ship with the same text as your home listing, or for an additional language such as Spanish (Mexico) in the United States. * **Remove localization**, at the foot of an item, deletes the localization's field and stops tracking its phrases as keyword field phrases. Phrases you track for another reason stay tracked. The home localization has no remove button. ## Check keyword coverage Coverage answers one question per tracked keyword: does any indexed field of this listing contain it? A field contains a keyword when it has the keyword as whole words. Chinese, Japanese, Thai, Lao, Khmer and Burmese do not put spaces between words, so a keyword in one of those scripts counts wherever it appears in the field, and a character of those scripts ends a word written next to it as a space would. The subtitle `AIが3行要約` therefore contains `ai` and `iPhone用カメラ` contains `iphone`, while `SmartNewsアプリ` does not contain `news`. Korean does put spaces between words but attaches particles to their end, so a Korean keyword counts as a whole word or as a word followed by a particle: `운동` is found in `운동을`, but not in `운동화`, and `계부` is not found in `가계부`. A keyword that mixes scripts, such as `line 漫画`, counts when each of its words is found by its own rule. | Store | Fields that count toward coverage | | - | - | | App Store | Title, subtitle, keyword field | | Google Play | Title, short description, description | A keyword with high opportunity and no coverage is the single most actionable state in the product, which is why the Action Center has a rule for exactly that. See [Work the Action Center](/guides/action-center). On the App Store, a Screenshot text column appears once the screenshots of the listing you look at, or of a listing that judges one of the keywords, were read. It marks the keywords a caption contains, reading the captions of the same listing that judged each keyword, and is labelled as a weak signal, because Apple has not confirmed that it reads screenshot text. It never changes whether a keyword counts as covered. See [Read screenshot captions](/guides/screenshot-captions). Search the coverage table by keyword, sort it by keyword or bucket, and switch on **Uncovered only** to keep the keywords no indexed field contains. ## Switch markets When you track keywords in more than one market, a **Listing market** control sits above the fields. It lists your home storefront and every market whose listing asobeast has captured. Choose one to see the title, subtitle, description and screenshot captions that storefront serves, with the coverage of the keywords tracked there. The URL keeps the choice as `?market=`, so a link opens on the same market. When the market has a native localization, a **Localization** control next to it shows that localization's fields and captions, and the URL keeps it as `?localization=`. A keyword covered only by a native localization says which one, for example in Title (Polish). See [Countries and markets](/concepts/countries#what-about-storefronts-that-default-to-english). Each keyword is judged against the listing of its own market, and against the home listing until its market has one. The keyword field card shows the field you saved for the localization on screen, linted against that localization's title and subtitle, and the suggestion below the coverage table names the localization it is for. The AI drafts stay on the home market. The API takes the same choice as the `country` parameter of `GET /apps/{id}/metadata/audit`. See [What is a market listing?](/concepts/countries#what-is-a-market-listing) ## Edit the iOS keyword field The 100 character field is private and cannot be collected, so you enter it here, once per localization. This is the only place that edits a keyword field; the keywords page reports what each market reads. See [See what each market reads](/guides/track-keywords#see-what-each-market-reads). **1. Open the localization** Click its item in the **App Store localizations** section. The URL keeps the open item as `?fieldLocale=`, so an **Edit** link from the keywords page or the **Edit this keyword field** link on a suggestion opens the right one. An app with a single localization opens it for you. **2. Type or paste the field** Separate the keywords with commas. The counter shows the characters used of 100 and turns to a warning when you pass the limit, and **Save keyword field** stays disabled while the field is over the limit, or empty and untouched. Saving an unchanged field is how you track its phrases in a market that does not track them yet. **3. Save and choose where to track** asobeast previews the save. If it adds and drops nothing, it saves at once and tracks in every market that reads the localization. Otherwise a dialog lists the new phrases, the phrases that leave the field and a checkbox for each of your markets that reads the localization, all ticked. It sums, for the ticked markets, the keyword markets the save adds and the store requests they cost each day, and on a plan with a keyword market limit it shows how many you will use after saving. **Save and track** tracks every phrase of the field, with the source `KEYWORD_FIELD`, in each ticked market. **Save** with nothing ticked stores the field and tracks nothing new, and every market that reads the localization still counts the field for coverage. Phrases that left the field stop being tracked as keyword field phrases in every market that reads the localization, whatever you tick, unless another field there holds them. A save that would pass your plan's keyword market limit is refused with nothing written, the field included, and the dialog links to the plans instead. See [How does asobeast keep a keyword field per localization?](/concepts/countries#how-does-asobeast-keep-a-keyword-field-per-localization) Come back to the page and the field shows what asobeast tracks rather than the string you pasted. See [How do you read and write the keyword field?](/concepts/stores#how-do-you-read-and-write-the-keyword-field). > [!WARNING] > > Leaving the field empty is not neutral. Coverage reports every keyword as uncovered by the keyword field, because asobeast has no evidence about what it contains, and the audit scores keyword field hygiene as unset. ## Group keywords into buckets Buckets classify scored keywords so a long list stays readable and so you can see whether your targeting is balanced. | Bucket | Definition | | - | - | | `primary` | The five highest opportunity keywords that are not aspirational | | `aspirational` | Volume at or above 70 and difficulty at or above 70 | | `longtail` | Three words or more, or volume below 30 | | `secondary` | Every other scored keyword | A listing whose indexed fields cover only aspirational keywords will rank for none of them. A listing that covers only longtail will rank for many things nobody searches. The buckets make that visible in one glance. ## Generate a metadata draft With `OPENAI_API_KEY` set, a drafts card proposes title, subtitle and keyword field values on the App Store, or title, short description and description values on Google Play, grounded in your tracked keywords, your coverage gaps and competitor titles. Every draft is linted again on the server and clamped to the field limit, so a draft can never exceed a store limit. asobeast has no write path to the stores: a draft ends at a copy button. With no key the card is hidden entirely. See [Enable the optional AI features](/guides/ai-features). On an App Store app the drafts card has a **Localization** menu. **Primary listing** drafts the listing asobeast reads, as before. An additional language of one of your storefronts drafts that localization instead: its title, subtitle and keyword field, written in that language from the meaning of your tracked keywords rather than copied from them, preferring words your current listing does not use. A draft for a localization is told the keyword field you saved for it. The keyword field is held to its 100 character limit, and a Chinese, Japanese or Korean character counts once. See [How do you read and write the keyword field?](/concepts/stores#how-do-you-read-and-write-the-keyword-field) Each localization item has a **Draft with AI** button that picks it in the menu and moves the focus there. To fill a localization's keyword field with keywords in another language, a common tactic for Spanish (Mexico) in the United States, say so in the instructions. Which storefronts read which localization is explained in [Which localizations does a storefront read?](/concepts/countries#which-localizations-does-a-storefront-read) ## Troubleshooting * **A keyword you added to the title still shows as uncovered.** Coverage reads the latest snapshot. Refresh the listing after the store publishes the change. * **The subtitle card is missing.** You are looking at a Google Play app. It has no subtitle. * **Character counts differ from App Store Connect.** asobeast counts Unicode code points of the stored value, so a Chinese, Japanese or Korean character counts once even when it lies outside the Basic Multilingual Plane, and an emoji made of several parts, such as a flag or a family, counts once for each part. Apple does not document how App Store Connect counts those, so a field with emoji sequences can read differently there. * **The Localization menu is missing.** The app is on Google Play, none of your storefronts lists an additional language (the United Kingdom lists none), or asobeast could not read your keyword markets when the page loaded. * **Find localizations found nothing.** The store proves only a localization whose text differs from the default listing of the market it is asked in and from your home listing, and only for languages your home listing says the app supports. Add a localization with the default text by hand. * **I track a market and see its listing but no keyword field for its language.** The localization is added after the next capture of that market. Press **Refresh** with that market chosen, or add it from **Not listed yet**. * **A localization came back after I removed it.** **Find localizations** adds every localization the store serves again. Remove it once more after the run, or leave it: a localization the store serves is one your app ships. ## Related > **[Run an ASO audit](/guides/aso-audit)** > > Score the listing across ten weighted factors. > **[Track competitors](/guides/competitors)** > > Find phrases competitors index and you do not. # Compare a portfolio Source: https://docs.asobeast.com/guides/portfolio Read the dashboard, link the App Store and Google Play listings of one product into a group, compare visibility across it and receive a weekly portfolio digest. A portfolio view exists because most products ship on both stores, and reading two disconnected app pages hides the thing you actually want to know: whether the product is gaining visibility overall. ## Read the dashboard The dashboard is the first page after you sign in. It leads with what needs you and what moved, then lists every app you track. ### The status line Under the title, one line counts your apps, tracked keywords and competitors. A second line says when your portfolio was last collected, and whether the daily run is in progress, complete or delayed. For the [platform operator](/security/admin-surfaces#views-only-the-operator-sees) it also shows what share of the daily request budget the tracking uses, linked to settings. What spends that budget is explained in [Track keywords](/guides/track-keywords). ### The four tiles | Tile | What it measures | | - | - | | Keywords in top 10 | Tracked keywords ranking in the top 10 across every app, with the change against seven days earlier and the number tracked | | Keyword movement | Keywords that climbed and fell over the last seven days, and how many entered the results or dropped out of them | | Open actions | Open Action Center items, with the critical and high counts | | Changes this week | Listing changes over the last seven days, split into your apps and your competitors. Edits found on one UTC day count as one change per app, the same unit as the changes page | The tiles add counts across apps and never average visibility, ratings or audit scores, because scores from different stores are not comparable. ### Top actions and keyword movers Top actions lists the highest impact open actions with the app each one belongs to, and each row opens that action in the Action Center. See [Work the Action Center](/guides/action-center). Keyword movers lists the largest position changes of the week across every app. Each row names its app and storefront and opens that keyword on the app's rankings. See [Read rankings](/guides/read-rankings). ### The app list Each card shows visibility with its seven day change, a visibility sparkline, a bar of rank bands with the number of keywords in the top 10, the rating and the audit score with a grade word, open actions and new low ratings, the negative reviews written in the last seven days. How they are counted is explained in [Sync reviews and mine keyword ideas](/guides/reviews). A value the app does not have yet is left out rather than shown as zero, and an app whose first positions are still being collected shows only that. What unranked means is explained in [Positions](/concepts/positions), and audit scores in [Run an ASO audit](/guides/aso-audit). An app whose guided setup is in progress carries a chip such as **Setup 1 of 3** that opens its setup page, and a banner above the dashboard names the app you touched last with its next step, how many other apps are still in setup, and buttons to resume or dismiss. See [Complete the guided setup](/quickstart#complete-the-guided-setup). Sort the list by visibility, seven day change, keywords in the top 10, rating, open actions, name or last collected. With more than six apps a search box filters by name, store, country or group. The table view shows the same apps as rows with one column per value, and its column headers sort the same way. The sort, the search, the view and the changes filter are kept in the page address, so a bookmark or a shared link opens the dashboard exactly as you left it. | Parameter | Values | | - | - | | `sort` | `visibility`, `change`, `top10`, `rating`, `actions`, `name`, `updated` | | `dir` | `asc` or `desc`; absent means the natural direction of the sort | | `q` | the search text | | `view` | `cards` or `table` | | `changes` | `all`, `yours` or `competitors` | ## Group linked listings A linked app group holds at most one app per store, so the App Store and the Google Play listing of the same product belong in exactly one group together. Grouping does not merge their data. Scores stay store specific and are never averaged across stores, because Apple and Google Play scores come from different public evidence. See [App Store and Google Play](/concepts/stores). What grouping gives you is a single row for the product, with each store's numbers side by side. ## Compare group visibility Visibility rolls a set of keyword positions into one number, so a group's visibility is comparable with itself over time rather than with another group. How the number is built is explained in [Visibility](/concepts/visibility). Use it as a trend, not as a score out of 100. The Action Center reads visibility per market, as [Visibility](/concepts/visibility#where-else-is-visibility-used) explains. See [Work the Action Center](/guides/action-center). ## Follow trends Two levels of trend are available. | Level | Answers | | - | - | | Per app | Did this listing gain or lose visibility over the window | | Portfolio | Did the whole set move, or only one listing | The portfolio level is what separates a market wide shift from a problem with one listing. If every app fell on the same day, look at the store rather than at your metadata. ## Receive the weekly digest The digest is sent to webhooks and email alerts on `CRON_DIGEST`, which defaults to `0 8 * * 1`, meaning 08:00 UTC on Monday. It has its own schedule and bypasses the batched alert outbox entirely, so it arrives on time regardless of how long the daily pipeline ran. Subscribe a webhook or an email alert to `digest.weekly`. See [Send alerts](/guides/alerts). Its negative review count follows the same rule as the dashboard, over the digest window. ## Troubleshooting * **A group refuses a second app.** A group holds one app per store. Two App Store listings cannot share a group. * **Visibility is missing for a market.** Visibility needs observed days. A market added this week does not have them yet. * **The digest never arrives.** The channel is not subscribed to `digest.weekly`. Subscribe a webhook or an email alert to it. ## Related > **[Follow category ranks](/guides/category-ranks)** > > Chart positions alongside search positions. > **[Send alerts](/guides/alerts)** > > Where the digest is subscribed. # Read rankings and SERPs Source: https://docs.asobeast.com/guides/read-rankings The ranking chart uses a reversed Y axis so position 1 sits at the top. SERP snapshots keep the top 10 results per keyword and per day. The rankings view answers two questions: where you rank over time, and who else is on the page. The first comes from position history, the second from stored SERP snapshots. ## Read the ranking chart The Y axis is reversed, so position 1 is at the top and a rising line is an improvement. The chart covers every market the app tracks, so each series is named with its storefront, as in `habit tracker (US)`. The same phrase in two markets is two keyword rows checked by two searches, and the labels are what keeps them apart on the chart, in its legend and in the CSV export. Rank alerts name the market the same way. | What you see | What it means | | - | - | | A line rising toward the top | Position improving | | A gap with a hollow marker on the depth line | The app was checked and not found within the captured depth | | A gap with no marker | No check finished that day, and the tooltip says **Not checked** | | A single dot | One checked day between two days without a position | | `>200` in the table | Checked and not found, expressed as a bound from that row's own depth | Neither gap is zero. See [Positions and rank depth](/concepts/positions) for why treating it as zero inverts every trend. Use the range picker to change the window. Dates are UTC, so a chart point labelled with a date is the observation for that UTC day. To share the chart with someone who has no account, print the rankings view or save it as PDF. See [Print a report or save it as PDF](/guides/export-data#print-a-report-or-save-it-as-pdf). ## Mark events on the charts An annotation is a named event pinned to one UTC day: a release, a listing change you shipped, the start of a campaign. The rank history chart and the visibility chart draw its annotations as plain vertical dashed lines; the charts stay clean and the hover tooltip of the day carries the label, the tag and the market. Add annotations on the rankings page of an app: pick a date, write a short label and save. A tag groups the markers of one campaign, and the list keeps every marker with its date for one click removal. The metadata change lines asobeast draws on its own show what the store reports; annotations show what you did. Both on one chart is usually the whole story. Agents write annotations too. A token with the write scope carries `add_annotation` and `delete_annotation`, so an agent that ships a listing change can leave the marker itself. See [MCP tool reference](/mcp/tools). Annotations are plain rows: they never change a position, a score or an alert, and deleting one removes only the line. ## Inspect a SERP snapshot For every tracked keyword and day, asobeast keeps the top 10 search results with the app, its position and the listing signals the store returned. That snapshot is what makes the rest of the page possible. | Derived signal | What it measures | | - | - | | Entrants | Apps that appeared in the top 10 that were not there before | | Movers | Apps whose position changed materially between snapshots | | Volatility | How much of the top 10 changed hands over the recent window | Google Play search results carry no rating counts, so the rating columns stay empty for Google Play keywords. That is a store limitation rather than a missing sync. Open a keyword's snapshot from the keyword monitor with its top 10 button. **Export CSV** in that sheet saves the snapshot. See [Export a SERP snapshot to CSV](/guides/export-data#export-a-serp-snapshot-to-csv). SERP entries are pruned on `RETENTION_SERP_DAYS`, which defaults to 90 days. ## Understand volatility Volatility scores how much a keyword's top 10 changes membership, on a 0 to 100 scale. asobeast averages the difference between consecutive observed days over an eight day window, so 0 means the same ten apps every day and 100 means a completely different ten each day. It measures which apps are present rather than the order they sit in, so a top 10 that reorders without anyone entering or leaving scores 0. High volatility means the result page turns over on its own, which makes a single day's change weak evidence. The Action Center uses this directly. A volatile keyword damps rules that would otherwise fire on a one day movement, and `serp.hold_volatile` tells you when that happened. See [Work the Action Center](/guides/action-center). ## Discover competitors from a SERP Any app in a stored SERP snapshot can be added as a competitor in one step, which is usually a better starting list than guessing. See [Track competitors](/guides/competitors). ## Troubleshooting * **The chart is empty for a new keyword.** No daily run has covered it yet. * **A line stops and restarts.** The app left and re entered the captured depth. The gap is real data. * **Volatility is missing.** It needs several observed days in the window. A keyword added yesterday has none. ## Related > **[Positions and rank depth](/concepts/positions)** > > What 1 based, null and depth mean precisely. > **[Follow category ranks](/guides/category-ranks)** > > Free, paid and grossing chart positions. > **[MCP tool reference](/mcp/tools)** > > Every tool, including the annotation writes. # Sync reviews, reply to them and mine keyword ideas Source: https://docs.asobeast.com/guides/reviews Reviews sync in the daily pipeline for the home storefront. See what people mention, translate a review, draft and track a reply to each one, and turn repeated language in negative reviews into keyword ideas and action items. Reviews give asobeast two things a listing cannot: the words your users actually use, and an early signal that something in the latest release is going wrong. ## Sync reviews Reviews sync as part of the daily pipeline, which starts at `CRON_DAILY` and defaults to 03:00 UTC. Each review is stored once, keyed by the app and the store review id, so re running a sync does not duplicate rows. A reviewer on Google Play can edit a review after it was posted, and the store keeps the same review id. The daily sync takes the newer score, title, text, version and date of an edited review it already stored, and sends no alert for it. When the store also shows a developer reply that is older than the edit, the review is **Updated after reply**, described under [Reply to reviews](#reply-to-reviews). A Google Play review can carry a star rating and no text. It is stored like any other review, shows as "No written review" on the reviews page, counts in the star filters and the negative review totals, and sends a `review.negative` alert that reads "no written review" when its rating is at or below `ALERT_REVIEW_SCORE_MAX`. A review whose text is only white space is shown the same way. It contributes nothing to the keyword ideas mined from review text. Review sync stays on the home market, like category ranks and the ratings history. See [Countries and markets](/concepts/countries). Stored reviews are listed on the app's reviews page, filtered by star, version, period and reply state, sorted, searched, and exported to CSV. The period filter keeps the reviews written in the last 30, 90 or 180 days, the same windows as the ratings chart, so it answers what came in since a release. The version filter lists the newest version first and compares the numbers in each version, so `3.123.0` ranks above `3.95.0` and `450.0.0.24.77` above `6.12.2`. See [Export reviews to CSV](/guides/export-data#export-reviews-to-csv). ## See what people mention Above the list, **What people mention** shows the phrases that stand out in two rows. **Complaints** come up mostly in reviews at or below `ALERT_REVIEW_SCORE_MAX` stars, such as `server problem` or `credit card`. **Praise** comes up mostly in the other reviews. A phrase that reviews of every rating use is neither, so it is not shown. The strip shows up to six complaints and four praise phrases, the strongest first, and is hidden when nothing stands out. Each phrase is compared with the newest 500 reviews in the version and period filters, so the strip still names complaints when the star filter keeps only one star reviews. When most of those reviews are high rated, a complaint has to stand out clearly against them, while a phrase counts as praise as soon as it leans as positive as they do. When most are low rated, the roles swap, so a bad release still lists its complaints. The number on a phrase counts the first 200 reviews that match every filter in the list's order, the same reviews a search reaches, and a phrase needs at least two of them. Pressing a phrase searches the list for it in quotes, so the list holds the reviews the number counted, and pressing it again clears the search. A phrase holds up to four words and never starts or ends with the app's own name, a word such as `app`, `good`, `love`, `use` or `time`, or a function word of the review's language, so a Spanish review in a United States storefront adds no `que`. asobeast skips the function words of English and of the 15 languages listed under [How are auto tracked keywords chosen?](/concepts/stores#how-are-auto-tracked-keywords-chosen), so a review in another language, such as Russian, keeps its function words as phrases. When a longer phrase covers most mentions of a shorter one, only the longer is shown, so `encountered a fatal error` replaces `fatal error`. The phrases come from the same extraction as the keyword ideas described under [Mine reviews for keyword ideas](#mine-reviews-for-keyword-ideas). No AI is involved and no store request is made. ## Reply to reviews Every review on the reviews page has a **Reply** button that opens a composer under it. Write the reply yourself or, with `OPENAI_API_KEY` set, press **Draft with AI**. With the store connected under [Store connections](/guides/store-funnel), press **Post to App Store** or **Post to Google Play** to send it, as described under [Post a reply](#post-a-reply). Without a connection, press **Copy**, open the store console with the link beside it, paste the reply there and press **Mark as replied**. What a draft sends and what it costs is on [Enable the optional AI features](/guides/ai-features). The text is saved on the review as you leave the field and whenever you press a button, so a reload or a teammate sees the same draft and a draft is never paid for twice. Emptying the field does not remove a saved reply, because an empty text is never saved, and the composer says so under the counter. **Discard** removes the text and the replied mark. The counter under the text measures it against the store's limit without the spaces and line breaks around it, which are not saved or copied, so a reply pasted with a trailing line break still fits, and the buttons wait until the text fits. The limits and the two sources of a replied review are explained once, under [Developer replies](/concepts/stores#developer-replies). Each review shows one of four states. **Needs reply** has no reply anywhere, **Draft saved** has a reply kept here that is not marked, and **Replied** is a reply the store shows, a reply posted from asobeast or a review you marked. The badge says which: **Pending publication**, **Posted** or **Edited since posting** for a reply posted from asobeast, **Replied on Google Play** for a reply Google Play shows, or **Marked replied**. **Updated after reply** is a Google Play review the reviewer edited after the developer reply the store shows, often with a new rating, so it is worth reading again. That state appears in the counts and the filter only while a review is in it. The counts at the top of the list filter it by state, and pressing **Needs reply** also sorts the lowest scores first, because a reply queue is worked worst first. The toolbar sorts the newest or the lowest scores first and searches the titles, texts, authors and replies of the reviews loaded on the page. A search in quotes, such as `"dark mode"`, matches whole words in a single sentence of the title or of the text, the way a phrase is counted, so `"update"` leaves out `updated` and a reply that mentions an update. Emoji are searched as written, so `🎉` lists the reviews that hold it and `🎉🎉` the ones with two in a row, while punctuation such as `(*)` is ignored and lists every review. The page loads 50 reviews, and **Show all** or a search raises that to 200. ### Post a reply With the store connected, the composer's main button is **Post to App Store** or **Post to Google Play**. It saves the text first, then asks you to confirm: the dialog shows the exact text and the store's rules. A reply is public under the review, a review has one reply, and a reply must not ask for a rating or offer anything in return. Only a click posts, and only through a signed in session, so a personal API token or an agent never posts. Members can post as well as the owner. Once the store accepts the reply, the review counts as replied everywhere: the badge, the counts and the filter read it at once, and the Action Center on its next run. On the App Store the badge reads **Pending publication**, because Apple publishes a response within 24 hours. On Google Play it reads **Posted**, and the next daily sync reads the same reply back from the store. Editing a posted reply shows **Edited since posting** and turns the button into **Update on App Store** or **Update on Google Play**. Posting again replaces the reply on the store. On the App Store, **Withdraw from App Store** deletes the response after a confirmation and keeps the text here, and **Discard** appears again once the response is withdrawn, so a live response is never left without a way to remove it. Google's API offers no way to remove a reply, so remove it in Play Console if you need to. **Mark as replied without posting** stays for a reply you sent another way. The App Store review feed and App Store Connect name a review differently, so the first post of an App Store reply looks the review up in App Store Connect by its storefront, rating, title, text and author, and remembers what it found for every later post and the withdraw. A key that may not reply is named on the composer and on the connection card, with the role or permission it needs. ### Translate a review With AI configured, a review written in a language other than your browser's shows **Translate** next to its author. Pressing it spends one AI call and shows the title and text in your language with the language it was translated from. **Show original** and **Show translation** switch back and forth without another call, because the translation is kept on the review, so a reload or a teammate who reads the same language sees it for free. Translating into another language replaces the kept translation, and a reviewer's edit clears it. A translation that adds a link or an address the review does not contain is refused, and the call stays counted. asobeast guesses the language of a review from its words and script, and falls back to the storefront's language when a review is too short to tell. What a translation sends is on [Enable the optional AI features](/guides/ai-features). ### Reply guidelines With AI configured, the **Reply guidelines** button on the toolbar keeps up to 1,000 characters for the app: who signs, the support address, what never to promise. Every draft for the app follows them. ### What a draft does A draft is written in the review's language, addresses the reviewer by name when there is one and stays on the one thing the review says. It never asks for a better rating, never promotes, and names a link or an address only when your guidelines or your instruction contain it; asobeast checks the draft for that and refuses one that adds any other, which still counts as a call. The text of the review is handed to the model as data, so a review that tries to give the model instructions does not steer the draft. It suggests updating only when the review was left on an older version than the listing shows, and describes a fix only when the release notes say so. Pick a tone, friendly, neutral or formal, and add a one line instruction for this review if you want to steer it. A draft that would run past the store's limit is shortened on a sentence boundary and the composer says so. The Action Center's unanswered reviews action opens this page filtered to the reviews that need a reply, lowest first. See [Work the Action Center](/guides/action-center). ## Read the ratings history The ratings chart tracks the average rating and the rating count over time, taken from each snapshot rather than recomputed from individual reviews. It is the fastest way to see whether a release damaged your rating, and it feeds the ratings factor of the audit. See [Run an ASO audit](/guides/aso-audit). ## Mine reviews for keyword ideas asobeast extracts repeated phrases from review text and offers them as keyword suggestions, alongside store suggest and competitor derived ideas. Adding one tracks it like any other keyword. A phrase is one to three words from a single sentence of the title or of the text, never both. It never starts or ends with a stopword, a function word of the storefront's languages, which skips [a few more words in reviews than in listings](/concepts/stores#how-are-auto-tracked-keywords-chosen), or a filler such as `just`, `please`, `now` or `also`, so `crashes on launch` is offered and `to use` or `the driver` is not. A contraction reads as the word it contracts, so `don't` and `it's` add nothing on their own, and a version number such as `0.78.8` separates words rather than becoming one. The same phrases feed the review theme of the ASO audit and the review theme actions. These ideas are worth a look because they are phrased the way users search rather than the way you describe your own product. ## Watch for a growing theme A phrase becomes an action item when it appears in at least 3 negative reviews of the latest version and at least twice as often as it did in the previous version's reviews. The rule needs at least 5 reviews on the version before it will fire, so a single angry review is never enough. The action resolves when the version is superseded without the theme recurring, or when mentions fall below the minimum. At most 3 review themes open per app per run. See [Work the Action Center](/guides/action-center). ## Alert on negative reviews `ALERT_REVIEW_SCORE_MAX` sets the highest star rating treated as negative. It defaults to `2` and accepts 1 to 4, so the default fires on one and two star reviews. Subscribe a webhook or an email channel to `review.negative` to receive them. See [Send alerts](/guides/alerts). The review backfill queued at import, described in [Import an app](/guides/import-an-app), stores its reviews without sending alerts, so an import does not alert on reviews that were already on the store. Every later sync alerts for each new review at or below `ALERT_REVIEW_SCORE_MAX`, so when the backfill comes back empty, the first daily sync that finds reviews alerts on them. The dashboard's new low ratings and the weekly digest's negative review count answer a different question. They count reviews at or below `ALERT_REVIEW_SCORE_MAX` that were written in the window, using the date the store gives each review, so reviews stored by the backfill count only when they were written that week. A review with no store date counts from the day asobeast stored it. ## Troubleshooting * **No reviews appear.** The store returns reviews for the home storefront, and a new or low volume app may genuinely have none in the window the store exposes. * **The rating count moves without new reviews.** Ratings and reviews are different things. Users can rate without writing. * **A theme did not fire.** Check that the version has at least 5 reviews and that the phrase reached 3 negative mentions with twice the previous version's rate. * **Translate is missing.** No `OPENAI_API_KEY` is set, or the review is already in your browser's language. A kept translation still shows without a key. * **A review moved to Updated after reply.** The reviewer edited it on Google Play after the developer reply. Read it again and update the reply in Play Console if needed. * **Draft with AI is missing.** No `OPENAI_API_KEY` is set. With the monthly AI allowance spent the button stays but is disabled until the 1st. * **The badge says replied but I never replied here.** Google Play shows a reply someone posted in Play Console, and asobeast reads it with the next daily sync. * **The post says the review was not found in App Store Connect.** The public feed and App Store Connect can disagree for a few days after a review is written or edited. Try again later, or post it in App Store Connect and press **Mark as replied without posting**. * **The post says the key cannot respond to reviews.** The connected App Store Connect key needs the **Admin** or **Customer Support** role, and a Google Play service account or Google account needs **Reply to reviews** in Play Console. A Google sign in also needs to have allowed Play Console access; sign in with Google again to allow it. * **The post says the Google Play Android Developer API is not enabled.** Google refused the reply because the API is off in the Google Cloud project behind the connection: the OAuth client's project for a Google sign in, the service account's project for a key. Enable the API in the project the message names, wait a few minutes and post again. Reconnecting does not help, and the connection keeps its reply access. See [Use a service account key](/guides/store-funnel#use-a-service-account-key). * **Withdraw is missing on a Google Play reply.** Google's API offers no way to remove a reply. Edit it here and post again, or remove it in Play Console. * **Drafting fails with "added a link or an address".** The model put a link or an email address in the draft that your guidelines do not contain. Draft again, or add the address to the guidelines if it is yours. * **My reply is cut.** It ran past the store's limit. Shorten it or edit it before you copy it. * **I cleared the reply and it came back after a reload.** Emptying the field saves nothing, so the saved reply stays. Press **Discard** to remove the text and the replied mark. * **A review has stars and no text.** Google Play lets users rate without writing, and a few old reviews on the store carry only the rating. They are kept so the star counts stay right. ## Related > **[Track keywords](/guides/track-keywords)** > > Turn a mined phrase into a tracked keyword. > **[Send alerts](/guides/alerts)** > > Get told about a negative review the day it lands. # Read screenshot captions Source: https://docs.asobeast.com/guides/screenshot-captions asobeast reads the large caption text printed on App Store screenshots on your own machine, shows it per screenshot, reports it beside keyword coverage and records screenshot and caption edits as change events. Screenshots are the biggest part of an App Store listing and the only part asobeast could not read. This feature reads the caption text printed on them, keeps it with the snapshot it came from, and uses it in three places: the metadata page, keyword coverage and the change feed. It runs on the machine that hosts asobeast. It needs no OpenAI key, no account and no language data from the internet. ## Does Apple read screenshot text? Probably, a little, and Apple has not confirmed it. ASO practitioners reported in early June 2025 that apps started ranking for words that appear only in their screenshot captions, first measured by Appfigures and later repeated by Phiture and AppTweak. Apple has not documented screenshot text as an indexed field. A controlled test by ConsultMyApp of 64 caption phrases across eight apps found one phrase that ranked without an explanation in the title, subtitle or keyword field. Treat it as a weak, secondary signal. That is why asobeast shows it beside keyword coverage and never inside it: a keyword that only a screenshot caption mentions still counts as uncovered. The evidence and its limits are in [App Store and Google Play](/concepts/stores#is-screenshot-text-indexed). ## What does it read? | Included | Not included | | - | - | | The large marketing text of each iPhone screenshot on the App Store, in store order | The small interface text inside the device frame | | Up to the first 10 screenshots of each snapshot | iPad screenshots and app preview videos | | Your apps and your competitors, on the home listing and on every market listing asobeast captures | Google Play screenshots | Each screenshot is read twice: once in grey, and once with its colours inverted so that white lettering on a coloured band turns dark. The two reads are merged, keeping the more confident one where they found the same line. The reader then groups the large, confident lines into blocks of neighbouring lines and keeps the heaviest block, giving less weight to the middle of the image where the device frame usually sits. A lone line smaller than 3 percent of the image height is treated as an interface title, so a screenshot without a caption reads as having none. Every snapshot records its screenshots, so the caption belongs to the snapshot rather than to the app. When a screenshot has the same address as one already read, in your workspace or in anyone else's, the reader reuses the result instead of reading the image again. Screenshots are public store data, so the reads are shared the same way keyword searches are. ## How do market listings read their screenshots? Every listing asobeast captures records its own screenshots: the home listing and each market listing, for your app and for every competitor. A market listing is captured when you track a keyword in a new market, in the daily run and when you refresh it, and while reading is on each capture queues its screenshots for reading like the home one. See [What is a market listing?](/concepts/countries#what-is-a-market-listing) | What | Market listing | | - | - | | Language | English plus the language of that storefront, so the German listing of a US app reads English and German | | Screenshot captions card | Shows the screenshots of the market chosen in the **Listing market** control | | Screenshot text column | Reads the captions of the listing that judged the keyword: its own market listing, or the home listing until that market has one | | Changes | Compared with the previous snapshot of the same market listing only, and shown in that market's timeline. Like every market change, they are never sent as alerts | | ASO audit | Not used. The audit and its check Keywords in captions read the home listing | The extra reads cost CPU on the `screenshots` queue only. They spend no store request budget, because the images come from Apple's image server, and a screenshot shared between markets is read once per language set. ## What state can a screenshot be in? | State | Meaning | | - | - | | Reading | Queued or being read. The card refreshes by itself | | Caption read | A caption was found and is shown | | No caption | The image was read and carries no large text | | Could not read | The store no longer serves the image, or it is not a readable image. The other screenshots are unaffected | | Not read | Reading is switched off, the store is Google Play, or no language pack applies to the storefront | A temporary problem, such as the image server being busy, is retried with growing delays and then recorded as could not read. ## Which languages does it read? A storefront reads English plus the language of the storefront: Japanese for `jp`, Korean for `kr`, Simplified Chinese for `cn`, Traditional Chinese for `tw`, `hk` and `mo`, Thai for `th`, Russian for `ru`, `by` and `kz`, Arabic for the Arabic speaking storefronts, Polish for `pl`, and German, French, Spanish, Portuguese and Italian for the storefronts that speak them. Any other storefront reads English alone. A native localization is read with English plus its own language when a pack ships for it, so a French localization in Belgium reads French and a Dutch one reads English alone. The language packs ship inside the API image, so reading never downloads anything from the internet. Reading is most reliable on large, high contrast type on a plain or gradient background, which is what captions usually are. Decorative scripts, text on busy photographs and heavily rotated text may be missed or misread. Small differences between two reads of similar images do not create change events. ## Where do you see the result? * **Metadata page.** The Screenshot captions card shows the screenshots in store order with the caption under each, and follows the **Localization** control when the market has a native localization. Each screenshot is shown whole at its own aspect ratio, a tall iPhone screenshot narrow and an iPad or landscape one wide, so the caption printed at its top is never cut off. See [Optimize listing metadata](/guides/metadata-workbench). * **Keyword coverage.** A Screenshot text column marks the tracked keywords found in a caption and lists nothing else. A dashed circle marks a keyword whose listing has not been read yet, so it is never shown as missing from the captions. It does not change the Uncovered filter, the keyword field suggestion or the Action Center. * **Changes.** Replaced, added, removed and reordered screenshots, and edited captions, appear for your apps and your competitors. See [Watch listing changes](/guides/listing-changes). * **ASO audit.** The check Keywords in captions answers from the read text when no AI analysis exists. See [Score your listing](/guides/aso-audit). The same data is available as `GET /apps/{id}/screenshots`, with `country` for a market listing that answers `404` until that listing was captured (see [asobeast API](/api-reference/introduction)), and the optional `screenshotText` fields of `metadata_audit` and the `detail` field of `changes_timeline` carry it to an agent (see [MCP tool reference](/mcp/tools)). ## What does it cost? | Resource | Cost | | - | - | | Time | About 1 to 2 seconds per new screenshot, for the two reads together. An app is read once per distinct image, and an unchanged listing is never read again | | Memory | Up to about 190 MB while the reader works, returned after five idle minutes. The API container ceiling is 1024 MB for this reason | | Disk | About 95 MB more in the API image: the OCR engine, the language packs and the image library | | Network | One image download of about 300 KB per new screenshot from Apple's image server, from your own address. It does not use the proxy pool or the store request budget | | OpenAI | None. The allowance for AI calls is not touched | ## How do you turn it off or narrow it? Set `SCREENSHOT_OCR=false` to stop downloading and reading. Screenshots are still recorded with every snapshot and replaced or reordered screenshots are still detected, because that needs no reading. Set `SCREENSHOT_OCR_LANGUAGES` to a shorter list, such as `eng,jpn`, to read fewer languages. See [Configuration reference](/configuration/reference#screenshot-captions). ## Retention Screenshot records leave with their snapshot, so `RETENTION_SNAPSHOTS_DAYS` governs them. The shared cache of read text is pruned nightly when no read has used an entry for 90 days. ## Troubleshooting * **Every screenshot says Not read.** Check `SCREENSHOT_OCR`, and that the storefront has an enabled language pack. Google Play apps are never read. * **The card says Reading for a long time.** The reader runs one screenshot at a time. Open the queue dashboard at `/admin/queues` as the operator and look at the `screenshots` queue. * **A caption is wrong or missing.** Open the screenshot in the store at full size. Thin, decorative or low contrast lettering is the usual cause. The reader never changes your store listing, so nothing needs repair. * **No caption change appeared after you edited captions.** Changes are recorded after the snapshot is fully read, one snapshot later than the first import. They are skipped when a screenshot of either snapshot could not be read, because a missing read would otherwise look like a removed caption. * **Captions changed right after an upgrade.** A new version of the reader reads each listing again on its next refresh. The first read with the new version becomes the baseline and records no caption change, so improved readings never appear as edits. * **Every read fails right after an upgrade or a rebuild.** A language pack in the image may be damaged. The reads fail with the reason in the `screenshots` queue at `/admin/queues` and the API keeps running. Rebuild or pull the image again, or set `SCREENSHOT_OCR=false` until you can. * **The API container restarts.** See [Capacity and limits](/operations/capacity) for the memory ceiling. ## Related > **[Optimize listing metadata](/guides/metadata-workbench)** > > Coverage across the indexed fields, and where screenshot text fits. > **[Watch listing changes](/guides/listing-changes)** > > Screenshot and caption edits for your apps and your competitors. # Read impressions and conversion from your console Source: https://docs.asobeast.com/guides/store-funnel Connect an App Store Connect key or a Google Play service account once and see the store's own funnel by market beside your rankings: impressions, page views, Get taps and conversion on the App Store, store listing visitors, acquisitions and installs per search term on Google Play. Rankings say where your listing shows. The store funnel says whether people take it. Connect an App Store Connect key in Integrations and every App Store app the key covers gains a **Store funnel** card on its overview with four numbers Apple reports for it. Connect a Google Play service account and every Google Play app it can read gains the same card with three numbers Google reports. See [Google Play](#google-play). ## What you get | Number | What it counts | | - | - | | Impressions | Times the app appeared in the App Store, on every page type | | Product page views | Times someone opened the product page | | Get taps | Taps on Get, the closest daily figure to a download Apple publishes in this report | | Conversion | Get taps divided by unique impressions | The card shows the last 28 complete days, with the change against the 28 days before and the change of the last 7 complete days against the 7 before, the same windows the [change impact](/guides/listing-changes) uses. It also says what share of the impressions came from App Store search. The card follows the market switcher like every other overview card, so **All markets** adds every storefront and a market reads that storefront alone. An app the connection does not cover and a competitor show no numbers. A member sees the card and the connection, and only the owner connects, verifies or disconnects. ## Create the key **1. Open the team keys** In App Store Connect, open **Users and Access**, then **Integrations**, then **Team Keys**. The issuer ID is shown above the list. **2. Generate a key with the Admin role** Generate a key, name it and choose the **Admin** role. Apple lets only an Admin key ask for reports, so this one key does everything asobeast needs, including posting review replies. **3. Download the key once** Apple offers the `.p8` file a single time. Keep it, and note the ten character key ID beside the key. > [!NOTE] > > For least privilege, a **Sales and Reports** key works too, but only once the report requests exist: after an Admin key has made them, or when another tool already asked Apple for the same reports. Connect the Admin key first and replace it later if you prefer. An App Store app imported after the switch needs the Admin key again, because its requests do not exist yet. | Role | Reports | Review replies | | - | - | - | | Admin | Asks for and reads them | Posts and withdraws them | | Sales and Reports | Reads them once an Admin key asked for them | No | | Customer Support | No | Posts and withdraws them | The connection card's **Review replies** line says whether the connected key may reply. It reads **Not tried yet** until the first post, then **Allowed**, or names the role to add when Apple refused one. ## Connect it Open **Integrations** in the sidebar and the **Store connections** section, press **Connect App Store Connect**, then paste the issuer ID, the key ID and the contents of the `.p8` file, or choose the file. asobeast asks Apple whether the key works before it stores anything, so a mistyped ID or a revoked key is refused on the spot. | Control | What it does | | - | - | | **Verify** | Asks Apple again whether the stored key works. A key Apple refuses is marked rejected; a key it accepts again clears that and queues a sync | | **Sync now** | Queues the download of the reports Apple published since the last sync. A sync already waiting is not queued twice | | **Replace key** | Connects another key in place of this one | | **Disconnect** | Deletes the stored key and stops the downloads. The numbers already stored are kept, hidden while no key reports them, and appear again once you connect a key | When the connected key cannot ask for reports, the section says that an Admin key is needed and **Replace key** becomes the main button. Connect an Admin key there; connecting queues a sync, which asks Apple for the reports. What asobeast stores and how is on [Store console credentials](/security/store-credentials). ## How the numbers are read asobeast asks Apple for the **App Store Discovery and Engagement** report of each app twice: once as a one time snapshot of the history Apple keeps, back to 1 January 2024, and once as an ongoing request for every new day. Apple produces the first files a day or two after the request, so the card waits for them before it shows numbers. Every day at 05:30 UTC, after the daily run has started, asobeast downloads the files Apple published since the last sync and adds their rows to the funnel. Apple finishes a day within three days and sometimes sends a corrected file for an earlier day; the newest file always wins, and the card counts a day as complete once it is three days old and Apple has delivered it, so a sync that falls behind never shows missing days as zeros. Apple drops rows under five users and adds a small amount of noise, so a number is close rather than exact. The card adds unique counts across markets and sources, which reads slightly high for the same person seen in two places. ## Product pages When the key may read it, the sync also downloads the detailed version of the report, which names the custom product page behind each page view and Get tap. Apple files every impression under no page, so a page has no impressions of its own and its conversion is its Get taps divided by its product page views. The funnel endpoint then lists each page of the last 28 complete days with its counts, the default product page first and the others by page views, and its conversion against the default page as a ratio. A ratio under 1 converts below the default. The [Action Center](/guides/action-center) opens `listing.cpp_underperforms` for a page with at least 100 product page views that converts under 0.8 of the default. ## Markets Apple names a market by its territory. asobeast matches each territory to an App Store storefront, so a market on the switcher reads the rows of that storefront. A territory it cannot match still counts in **All markets** and is named in the API log of the sync, so the list can grow. ## Google Play Google publishes no daily analytics API for store listings. Play Console writes monthly report files to a Cloud Storage bucket instead, and asobeast reads them either with your own Google account or with a service account you invite. | Number | What it counts | | - | - | | Store listing visitors | People without the app who opened the store listing | | Acquisitions | People who installed the app after opening the store listing | | Conversion | Acquisitions divided by store listing visitors | Google reports no impressions, so the card has three tiles. ### Copy the bucket id Open **Download reports** in Play Console and copy the Cloud Storage URI. The bucket id is the part after `gs://` that starts with `pubsite_prod`. Both ways of connecting need it, and asobeast also accepts the whole URI. ### Sign in with Google The quicker way, offered once the instance has a Google OAuth client (see [Set up Google sign in](#set-up-google-sign-in)). **1. Start the sign in** In **Integrations**, **Store connections**, press **Connect Google Play**, keep **Sign in with Google**, paste the bucket id and press **Sign in with Google**. **2. Allow read only Cloud Storage access** Choose the Google account that owns the Play Console developer account, or one that has **View app information and download bulk reports** on the **Account permissions** tab of **Users and permissions**. Keep **View your data in Google Cloud Storage** ticked. asobeast also asks to manage your Play Console, which it uses only to post the review replies you send from the reviews page. Leave that unticked and the connection still reads reports, but cannot post replies. **3. Return to asobeast** Google sends you back to asobeast, which lists the bucket with your account before it stores anything. When your account can open the reports in Play Console, the connection works at once. asobeast keeps only the refresh token Google issues, sealed like every console key. To revoke it, remove asobeast under **Third party connections** in your Google account; the connection is then marked refused until you sign in again. A sign in posts replies when it allowed Play Console access and the account has **Reply to reviews** in Play Console. A sign in made before asobeast asked for that access, or one that left it unticked, shows **Review replies** as refused on the connection card; sign in with Google again and allow it. ### Use a service account key **1. Create a service account** In the Google Cloud console, open **IAM and Admin**, then **Service accounts**, create an account in any project and add a key of type **JSON**. Google downloads the key file once. **2. Invite it in Play Console** In Play Console, open **Users and permissions**, invite the service account's email address and grant **View app information and download bulk reports** on the **Account permissions** tab. Google applies the reports bucket permission only from that tab, not from the permissions of single apps, and can take up to two days to apply it. Grant **Reply to reviews** as well to post review replies from asobeast. **3. Connect it** In **Integrations**, **Store connections**, press **Connect Google Play**, choose **Service account key**, paste the bucket id and choose the JSON key file or paste its contents. asobeast lists the bucket with the key before it stores anything, so a refused account or a wrong bucket id is answered on the spot. Reading reports needs no Google Cloud IAM role, project link or extra API: the bucket belongs to Google, and Play Console alone decides who may read it. Posting review replies goes through the Google Play Android Developer API, so enable that API in the Google Cloud project of the service account, or of the OAuth client for a sign in. **Verify**, **Sync now**, **Reconnect** and **Disconnect** work as they do for App Store Connect. ### When Google refuses access | What asobeast says | What it means | What to do | | - | - | - | | Google Cloud Storage has no bucket | The bucket id is wrong. Cloud Storage answers this even to an account without access | Copy the Cloud Storage URI again from **Download reports** | | Google refused an account access to the bucket | The bucket exists, and the account may not read it | Grant **View app information and download bulk reports** on the **Account permissions** tab, wait up to two days for a service account, or sign in with Google | | Your Google account cannot read the bucket | You signed in with an account without report access | Sign in with the account that owns the developer account | | Google refused the key | The key was deleted or disabled in Google Cloud | Create a new JSON key and connect it | | Google no longer accepts the sign in | You revoked it, or the OAuth app is still in testing | Sign in with Google again, and publish the consent screen to production | | Google refused the OAuth client of this instance | The client ID, the secret or the redirect URI the operator set does not match the OAuth client | Check the variables and the authorized redirect URI in [Set up Google sign in](#set-up-google-sign-in) | ### Set up Google sign in An operator turns sign in with Google on once for the instance. **1. Create the OAuth client** In the Google Cloud console, open **APIs and services**, then **Credentials**, and create an OAuth client ID of type **Web application**. Add the authorized redirect URI `WEB_PUBLIC_URL` followed by `/integrations/google-play`, for example `https://aso.example.com/integrations/google-play`. **2. Publish the consent screen** Add the scopes `https://www.googleapis.com/auth/devstorage.read_only` and `https://www.googleapis.com/auth/androidpublisher` to the consent screen, enable the Google Play Android Developer API in the same project, and publish the app to production. Google expires every sign in of an app left in testing after seven days. Until Google verifies the app, the consent screen warns that it is unverified, which is enough for a team that knows the instance. **3. Set the variables** Set `GOOGLE_PLAY_OAUTH_CLIENT_ID`, `GOOGLE_PLAY_OAUTH_CLIENT_SECRET` and `WEB_PUBLIC_URL`, described in the [configuration reference](/configuration/reference#store-consoles), and restart the API. ### How the Play numbers are read The daily sync lists the files of every Google Play app of the workspace in the bucket and reads two files of the current and the previous month: the country file and the traffic source file under `stats/store_performance/`. A file is downloaded only when Google wrote a new version of it since the last sync, so a sync without news downloads nothing. A file over 20 MB is skipped and named in the section, since a real monthly file is a few kilobytes. An app with no file in the bucket is shown as **No files in this bucket**, and one whose files Google refuses to the service account as **No access to this app**. A file that cannot be read waits for the next sync without holding back the others, and the section says so. A market on the switcher reads the country file. **All markets** reads the traffic source file, which splits the visitors by Google Play search, explore and third party referrers, so the card states the search share there only. The two views never add rows of both files together. Google posts a day three to seven days after it ends, later than Apple, so the newest days of the card fill in over the following week. Google also changed how it counts store listing visitors and acquisitions on 10 July 2026, so compare periods that fall on the same side of that date. ### Installs per search term The traffic source file also names the Google Play search terms that brought visitors. Only rows of Google Play search count, and the placeholder Google writes when it names no term is left out. The keywords page of a Google Play app gains a **Play installs** column that shows, for each tracked keyword, the acquisitions and visitors of the newest complete month in which Google reported that term (the month in progress only when it is the only one), with the month in its tooltip. A term matches a keyword when both read the same after asobeast normalizes them, so case, spacing and punctuation do not matter. Google reports the terms for all markets together, so the column is the same in every market, and a dash means Google reported no visitors for that term. See [Track keywords](/guides/track-keywords#see-play-installs-per-search-term). ## Limits * One App Store Connect key and one Google Play connection, a service account key or a Google sign in, per workspace. * Apple allows each key 3,500 requests an hour. A sync downloads at most 200 daily files and leaves the rest for the next day, so a long history arrives over a few days. * When Apple answers an error for one app, or sends a report asobeast cannot read, the other apps and reports still sync and the section says that some were left for the next sync. A request Apple stopped for inactivity is asked for again on the next sync. * When Apple refuses the key during a sync, the connection is marked rejected and nothing is downloaded until you verify it or connect a new one. The numbers already stored are kept and the card shows them again once the key is accepted. A refused service account, a revoked Google sign in or a bucket that disappeared is handled the same way. * The downloaded files are kept for `RETENTION_STORE_REPORTS_DAYS` so a parser change can read them again. The funnel rows built from them are kept. The variables are on the [configuration reference](/configuration/reference#store-consoles). # Read top charts Source: https://docs.asobeast.com/guides/top-charts Read the free, paid and grossing charts of any storefront and category side by side, see who climbed, entered, fell and dropped out, follow a category and capture today on demand. The top charts page shows the whole chart an app is ranked in, not just your own position. Open **Top charts** in the sidebar, pick a category and a day, and read every app in it with how far it moved. The page opens on the free, paid and grossing charts of one category side by side, the top 25 of each. **Open table** reads the whole chart of one kind with search, sort, columns and the row actions, and the **Layout** switch moves between the two. The layout, the kind and every filter live in the page address. ## What is a chart? A chart is one store, one storefront, one category, one kind and one device. | Part | Values | | - | - | | Store | App Store or Google Play | | Country | Any storefront of that store | | Category | Overall, or one of the 25 app categories and 16 game genres Apple charts. On Google Play, the categories the Play chart lists, grouped as apps, games and family | | Kind | Free, paid or grossing | | Device | iPhone on the App Store. Google Play charts have no device, and iPad charts are not captured yet. The device control appears once a store charts more than one device | The App Store chart feed answers 100 apps and Google Play answers 200, so a chart ends there. An app outside that depth is not in the chart, the same rule [category ranks](/guides/category-ranks) follow. Each row keeps the title, developer and icon its storefront showed on that day, so an older capture reads the way the chart looked then. Every filter lives in the page address, so a link to a chart on a day opens that chart on that day. On a phone, the country, category and comparison sit behind **Filters**. ## Why does the App Store overall chart differ by kind? On the App Store the free overall chart holds no games, while the paid and grossing overall charts do. In a US capture of the top 100, the free chart holds none, the grossing chart about a third and the paid chart over half. asobeast stores exactly what Apple's chart feed answers for each kind, and that feed leaves games out of the free overall list only. Apple's own web charts can differ from the feed, so a position here may not match the one Apple shows. Read games in a games chart. Pick the **Games** category, or one of the game genres, and all three kinds list games. Google Play answers the same apps list for all three kinds, so this applies to the App Store only. The same rule shapes [category ranks](/guides/category-ranks): a free game has no position in the overall free chart, while the same game can hold a real position in the overall grossing chart. ## Where do captures come from? A chart is captured once a day, and one capture serves every workspace on the instance. * **Your apps.** The daily run captures the free, paid and grossing charts of every category a tracked app sits in, and of the overall chart, for its home storefront. Your [category ranks](/guides/category-ranks) are read from the same capture, and an app is still judged only against its own price class and grossing. * **Followed categories.** Press **Follow category** and the daily run captures the three charts of that category every day from tomorrow, even when none of your apps is in it. Following writes nothing to the store until the next run. The plan counts a category once however many of its kinds the workspace follows. **Followed charts** lists one entry per category against the plan's limit, opens any of them and stops following one. * **Capture today.** When today has no capture, **Capture today** fetches that chart while you wait and shows it. Each column and the table carry their own button, and the empty state says what a capture costs on your plan. A chart the store lists no apps in is captured as empty once it is known to be empty, and the confirmation says so. The day picker enables only the days a capture exists for. The arrows beside it step to the previous and next captured day. ## What does an empty chart mean? Some charts are empty by nature. Google Play lists no paid apps in the Shopping category of several storefronts, for example. The daily run asks the store, and when a chart that listed no app in the last seven days answers empty on every attempt of its job, AsoBeast stores the answer as a capture with no rows. The column and the table then read **No apps in this chart** with the store, the category and the day, the day appears in the day picker, and **Capture today** is not offered for a day that is already captured. From then on the chart is stored on the first attempt, once a day like any other, and adds no failed job. **Capture today** has no second attempt to wait for. On a chart the daily run has not yet found empty, an empty answer is reported ("Google Play lists no apps in this chart today") but not stored, so the day stays open and the daily run settles it. On a chart that was already stored empty, the capture is stored at once. A chart that listed apps within the last seven days and suddenly answers empty is treated as a store that did not answer, because that is how a blocked request looks. The answer is not stored, the daily job fails and the queue retries it, and **Capture today** says the store did not answer. After a week without a listed app, the empty answer follows the rules above. See [What happens when a job fails?](/concepts/pipeline#what-happens-when-a-job-fails) ## How do you read movement? Each row compares its position with an earlier capture. Pick the window with **Compare**: the previous day, 7 days ago or 30 days ago. The comparison is the newest capture at or before the chosen day minus the window, and no older than one more window, so a 7 day comparison reads a capture between 7 and 14 days earlier. | Change | Meaning | | - | - | | Up or down | The app moved by that many places. The previous position is shown beside it | | No change | The app held its position. The change column shows a dash | | New | The app is in this chart but was not in the comparison capture | | No earlier capture | No capture exists inside the window, so nothing can be compared | The trending panel lists the climbers, the new entries, the fallers and the apps that dropped out of the chart since the comparison, up to ten each. Beside the three columns it switches between the free, paid and grossing charts. In the table it sits beside the chart on a laptop and stays in view while you scroll. Sort the table by **Change** to read the new entries first, then the biggest climbs. ## How do you act on a row? * A row that is one of your apps is badged **Yours** and links to its overview. * A row you track as a competitor is badged with the app it competes with and links to that app's competitors. * Any other row offers a plus button that adds it as a competitor to one of your apps on the same store, with a menu when you track more than one. The competitor is checked in the searches its app already runs. See [Track competitors](/guides/competitors). * Every row opens its listing in the store. The table shows a **Price** column on the paid and grossing charts only, because every row of a free chart is free. ## What does it cost? | Action | Cost | | - | - | | A chart in the daily run | One store request a day, on either store, shared by every app and workspace in that chart. A category of a tracked app is three charts, and so is the overall chart | | Capture today | One store request, and one chart capture of the daily allowance: 30 a day on Indie, 150 on Ultimate, unmetered on a self hosted instance. It is spent once per chart kind. A chart already captured today, or being captured by another request at that moment, costs nothing | | Follow a category | One unit of the plan's follow limit and three more charts in the daily run. Indie follows up to 10 categories, Ultimate 50, and a self hosted instance has no limit. After a move to a smaller plan, the daily run keeps capturing only the oldest categories the plan has room for, each with every kind it holds | A refused capture or follow costs nothing. See [Rate limits](/api-reference/rate-limits) for how the allowance is counted and [The daily pipeline and rate limits](/concepts/pipeline) for the budget. ## How long are captures kept? `RETENTION_CHARTS_DAYS` defaults to 365 days. Set it to `0` to keep chart history forever. See [Configuration reference](/configuration/reference). ## Troubleshooting * **A day has no capture.** Nothing asked for that category that day: no app of yours sat in it and nobody followed it, or the run shed category work under pressure. Follow the category so the daily run keeps it. * **A column says the store listed no apps.** The store answered and the chart had no rows that day. A category can have free apps and no paid ones, so open another kind or another day. **Capture today** cannot fill a day that is already captured, and on a chart not yet known to be empty it reports the empty answer without storing it until the daily run has confirmed it. * **Only the first 25 rows show in a column.** A column lists the top 25. Open the table for the rest of the chart. * **An app you expected is missing.** It is outside the depth the store answers: 100 on the App Store, 200 on Google Play. * **A category is missing from the list.** The list follows the categories Apple and Google Play chart today. Kids is an age band on the App Store, not a chart. * **A game is missing from the free overall chart.** The App Store free overall chart holds no games. Open the Games category instead. See [Why does the App Store overall chart differ by kind?](#why-does-the-app-store-overall-chart-differ-by-kind). ## Related > **[Follow category ranks](/guides/category-ranks)** > > Your own position in the charts over time. > **[Track competitors](/guides/competitors)** > > Compare titles, keywords and positions with the apps you add. > **[The daily pipeline](/concepts/pipeline)** > > What the daily run captures and what it costs. # Track keywords Source: https://docs.asobeast.com/guides/track-keywords Add keywords per storefront on the keyword monitor, import a list from a CSV file, see which keyword fields each market reads, and know what every added market costs in daily store requests. The keyword monitor is where you decide what asobeast checks every day. Each keyword you add belongs to one storefront, and every keyword in every market adds one store search to the daily run. ## Before you start You need an imported app. See [Import an app](/guides/import-an-app). ## Add a keyword **1. Select the market** The country filter at the top of the keyword monitor sets the storefront every add applies to, and shows the keyword count per market. **2. Add one keyword or paste several** A single add takes one phrase. Bulk add accepts a list, one phrase per line, and creates them all in the selected market. To add a list that carries countries, tags or notes, [import it from a CSV file](#import-keywords-from-a-csv-file). **3. Read the first position** A keyword added by hand, by CSV import, by a market seed or by **Save and track** on a keyword field is checked within minutes, ahead of its popularity and difficulty, so its first position does not wait for the daily run. Until it lands the row says **Checking** and the table refreshes itself. Every added keyword carries a source, which sets its starting relevance. A keyword you type is `MANUAL`. See [Popularity, difficulty and opportunity](/concepts/scoring). ## Seed a market from its listing The countries step of the guided setup suggests storefronts that read a language your listing ships. Tick one, leave **Seed with its listing** on and press the add button: asobeast captures that storefront's listing and tracks up to 15 phrases from its title and subtitle, with the sources `TITLE` and `SUBTITLE`, so the market starts with keywords from its own text. Each storefront reports its outcome on its row. The same seed is one request, `POST /apps/{id}/market-seed` with a `country`, and it answers: | Field | Meaning | | - | - | | `seeded` | Keywords this request started tracking in that market | | `skipped` | Phrases the keyword limit of your plan could not hold | | `status` | `seeded`, or `unavailable` when the app is not published in that storefront and nothing was stored | A seed spends one refresh from your on demand allowance. See [How do you add a market?](/concepts/countries#how-do-you-add-a-market) ## Track the same keyword in a second market Switch the country filter and add the phrase again. That creates a second keyword row, checked by a second search. The two rows are genuinely independent: positions, popularity and difficulty all differ per storefront. They also cost twice as much, because each market multiplies daily search volume against one fixed budget. See [Countries and markets](/concepts/countries) and [The daily pipeline and rate limits](/concepts/pipeline). ## Import keywords from a CSV file **Import CSV**, beside **Add keywords** on the keyword monitor, tracks a list from a file. Use it to bring keywords over from a spreadsheet or another tool, to track the same list across several markets in one go, or to copy the keywords of one app to another with the file the keyword monitor exports. **1. Choose a file** Pick a file of up to 500 rows, one keyword per row. CSV, TSV and plain text files are read, saved as UTF-8 or UTF-16, separated by commas, semicolons, tabs or pipes, so the file Excel writes on any regional setting works. **Download template** gives you a file with the right header. The file is read in your browser; only the rows are sent. **2. Check the columns** The dialog shows which column it read as the keyword, the country, the tags and the note, and whether it treated the first row as a header. Correct any of them in place. Rows without a country use the market selected in the dialog, which starts as the market you were looking at. **3. Review every row** The table lists each row with a status and, for a refused row, the reason. Filter it by status. Nothing is saved yet. **4. Import** The button says how many keywords it will track. Their first positions are checked within minutes, as for any new keyword. ### Columns A file needs a keyword column and nothing else. Headers are matched ignoring case, spaces and punctuation, so `Search Term` and `search_term` are the same header. Every other column, such as position or popularity, is ignored. | Column | Required | Headers that are recognised | | - | - | - | | Keyword | Yes | `keyword`, `keywords`, `keyphrase`, `keyword phrase`, `phrase`, `term`, `search term`, `search query`, `query`, `text` | | Country | No | `country`, `country code`, `cc`, `storefront`, `market`, `region`, `location` | | Tags | No | `tags`, `tag`, `labels`, `label`, `groups`, `group`, `keyword group`, `list`, separated by semicolons, pipes or commas | | Note | No | `note`, `notes`, `comment`, `comments`, `remark`, `remarks` | The separator is read from the header row, so a file typed by hand whose later rows leave trailing cells empty, or carry an extra one, still splits into the same columns. Without a recognised header, the separator is used only when every row has the same number of cells. A file with no header row is read with its first column as the keyword. If a header is not recognised, switch **First row is a header** on and choose the column yourself. A country is the two letter storefront code, such as `us`, `gb` or `pl`; it must be a storefront of the app's store. Upper case is fine. The keyword export is a valid import file. It ends with a `country` column, so a file exported from the `pl` market tracks in `pl` whatever market the dialog starts on. Export from one app and import into another app of the same store to copy a keyword set, tags and notes included. ### What each status means | Status | Meaning | | - | - | | New | The keyword will be tracked in that market, with its tags and note | | Resume | The app tracked it before and it is paused, so the import resumes it. Its tags and note are left as they are | | Already tracked | Skipped. Nothing changes | | Duplicate | The same phrase and market already appear on an earlier line, ignoring case and punctuation. Skipped | | Invalid | Refused, with the reason: empty, longer than 100 characters, more than five words, not a storefront of this store, a tag that breaks the tag rules, more than 8 tags, or a note over 500 characters. Fix the row and import the file again | | Over limit | Importing it would pass the keyword limit of your plan. Skipped | The import tracks the new and resumed rows together, or none of them. Rows that are skipped or refused never stop the others, and the dialog says how many were skipped. When rows are over the limit, the review states how many keyword markets your plan uses out of its limit and, on a hosted plan with a larger tier, links to the plans. Importing the same file twice is safe: the second time every row is already tracked. Imported keywords are tracked like keywords you typed (source `MANUAL`). The paused state of a keyword in the file is not imported, and the tags and note of a keyword that is already tracked are never overwritten; change those on the row menu. See [Tag keywords and keep notes](#tag-keywords-and-keep-notes). A phrase that another app of your workspace already tracks in the same market costs no extra keyword market and no extra store request, because the search is shared. ### What an import costs Every keyword market an import opens adds the same daily store requests as adding it by hand, one a day on the App Store and eight a day on Google Play, and each new keyword is scored once. A market the app tracks no keywords in yet also adds the daily refresh of its listing, one request a day for the app and one for each competitor. See [What is a market listing?](/concepts/countries#what-is-a-market-listing) The dialog states the requests a day the import adds, listing refreshes included. The [platform operator](/security/admin-surfaces#who-the-platform-operator-is) also sees what share of the store's daily capacity that brings it to, and a warning from 85 percent. See [Watch your request budget](#watch-your-request-budget). An import takes up to 500 rows. For a longer list, split the file and import the parts one after the other, previewing each. On a hosted plan, rows beyond your keyword limit are listed as over limit and skipped, so you can see what fits before you confirm. ### Import through the API The dialog sends JSON rows, so a script can do the same: `POST /apps/{id}/keywords/import/preview` answers, for every row, what an import would do and writes nothing, and `POST /apps/{id}/keywords/import` runs it. The preview counts against the read budget and works with a read only token. A row's own `country` may be in either case, but the request level `country`, the default market for rows without one, must be a lower case storefront code such as `us`. See [Rate limits and quotas](/api-reference/rate-limits#request-size-limits-apply-everywhere) and the API reference. Both answers carry the same fields. `summary` and `results` describe the plan, row by row. On the import, `imported` counts the keywords that request actually tracked or resumed, which is lower than `new` plus `resume` only when another request tracked some of the same keywords in between. `quota.used` counts the keyword markets in use when the answer was made, so after an import it already includes the rows just tracked. ## See Play installs per search term The keywords page of a Google Play app shows a **Play installs** column: the acquisitions and the store listing visitors that the keyword brought as a Google Play search term, from the newest complete month Google reported it, or the month in progress when it is the only one. The tooltip names the month. A dash means Google reported no visitors for that term, or that the workspace has not connected the Google Play Console. The column sorts like the others, hides on a phone and can be hidden from the **Columns** menu. See [Installs per search term](/guides/store-funnel#installs-per-search-term). ## See what each market reads The 100 character iOS keyword field never appears on the public listing, so asobeast cannot read it. You type it on the metadata page, once for each localization your app ships. See [Edit the iOS keyword field](/guides/metadata-workbench#edit-the-ios-keyword-field). The keywords page only reports. Its last card shows what the market you selected reads. **1. Choose the market** Pick a market in the country filter. The card is named after it, for example **What US search reads**, and its header counts the unique words in the fields that market reads. **2. Read the rows** Each row is a localization your app ships and that storefront reads, default first, with its role, **Default** or **Additional**, and the characters its field uses of 100. An empty field says **Empty**. **3. Edit where it belongs** **Edit** opens that localization's editor on the metadata page. The card also folds the localizations the storefront reads that your app does not ship into a line you can open, with a link to add one. Below the rows, the card warns about words read twice and phrases split across localizations. They are hints about where characters are wasted and never change coverage. See [What does a market read?](/concepts/countries#what-does-a-market-read) A link to the keywords page that still carries `?fieldLocale=` opens the page as usual and the parameter does nothing there. The metadata page uses it to open an editor. The keyword field is App Store only. Google Play has no keyword field, so the card does not appear for a Play app. See [How does asobeast keep a keyword field per localization?](/concepts/countries#how-does-asobeast-keep-a-keyword-field-per-localization) ## Get keyword suggestions Suggestions come from four places. | Source | Where it comes from | | - | - | | Store suggest | A live lookup against the store suggestion endpoint | | Competitor listings | Phrases that competitors index and you do not | | Competitor searches | A live lookup of what people type around phrases taken from your competitors' listings in the market | | Review mining | Language that appears repeatedly in your own reviews | When one of several store lookups fails, the suggestions that answered are kept. **Competitor searches** read the title and subtitle of each competitor's listing in the market shown (the title and short description on Google Play) and keep the phrases of two words or more, up to five, as seeds. Phrases that several competitors use come first. A phrase that only one competitor uses seeds too, unless it is only that competitor's brand name, the first part of its title. The store's search suggestions then say what people type around each seed. A search must contain the words of its seed, and asobeast leaves out phrases you already track in that market, competitor titles and app names. Each row names its seed and how many competitors use it, so a row from one competitor is a weaker signal than a row from several. When a competitor has no listing in that market yet, its home listing is read. One lookup spends one keyword suggestion. An app with no competitors, whose competitors have no listing yet, or whose listings hold no phrase of two words or more spends none. A suggestion lookup is the one user initiated exception to the rule that store requests only happen in the queue. It is bounded by a person clicking, so it stays small, but a lookup that reaches the store spends one request of the hourly suggestions allowance. The Search tab with no active tracked keyword and the More by developer tab with no developer id in the newest listing have nothing to ask the store, so they answer an empty list and cost nothing. See [Rate limits and quotas](/api-reference/rate-limits). Deep search runs the store suggestion endpoint against a seed phrase and every one letter extension of it, queued as separate probes. It belongs to the market selected on the keyword monitor, so the same seed in two storefronts is two independent deep searches with their own results and their own budget. See [Countries and markets](/concepts/countries). Starting a deep search spends one of the plan's daily deep searches, and a term whose 27 lookups are all stored for today, or still waiting in the queue, costs nothing. The lookups queue behind the daily run's own jobs, so during the daily run a deep search can take longer than its usual two minutes. See [Rate limits and quotas](/api-reference/rate-limits). ### Seasonal suggestions The API and the MCP `keyword_suggestions` tool also offer a `seasonal` strategy. The web app does not show it. It reads a fixed calendar and suggests the keywords of every event that is running or starts within 14 days, in UTC. The keywords are English, so an event is offered only in the English language storefronts (Australia, Canada, India, Ireland, New Zealand, the Philippines, Singapore, South Africa, the United Kingdom and the United States) that observe it. Any other storefront gets an empty list, as does an English one between events. The storefront is the app's home market, or the `country` you pass. | Event | Runs | Offered in | | - | - | - | | New Year | 26 December to 7 January | every English storefront | | Valentine's Day | 1 to 14 February | every English storefront | | Spring and Easter | 1 March to 30 April | Canada, Ireland, the United Kingdom, the United States | | Mother's Day | 1 to 14 May | Australia, Canada, India, New Zealand, the Philippines, Singapore, South Africa, the United States | | Summer | 1 June to 31 August | Canada, Ireland, the United Kingdom, the United States | | Back to School | 15 July to 10 September | Canada, Ireland, the United Kingdom, the United States | | Halloween | 1 to 31 October | Australia, Canada, Ireland, New Zealand, the United Kingdom, the United States | | Black Friday | 20 to 30 November | every English storefront | | Christmas | 1 to 26 December | every English storefront | | End of Year | 27 to 31 December | every English storefront | Mother's Day is the second Sunday of May where it is offered. The United Kingdom and Ireland keep Mothering Sunday in March, which the calendar does not cover. The End of Year event suggests `goals` with the coming year, so `goals 2027` while 2026 ends. ## Track combinations from your listing The **Keyword combinations** card, below the suggestions, lists every phrase of one to three words that the words of your listing can form: the title, subtitle and keyword field on the App Store, and the title and short description on Google Play. Open it to see the list; the card stays closed until you do, and the address remembers that it is open. Words are lowercased, stopwords such as app, free and best are left out, and so are words of one character. A phrase is a set of words, shown in the order its words first appear in the listing, because word order does not change what the App Store matches. See [What does each store index?](/concepts/stores#what-does-each-store-index) | Status | Meaning | | - | - | | Tracked | An active tracked keyword in your home storefront has the same words, in any order and ignoring stopwords, so `sleep notes app` counts for `sleep notes`; the card names that keyword | | Paused | Only a paused keyword has those words, matched the same way; tracking it again resumes that keyword | | Not tracked | No tracked keyword has those words | Combinations and coverage answer different questions. Coverage on the metadata workbench asks whether a tracked keyword appears word for word inside one field. Combinations ask which phrases your listing's words can form across fields, so a phrase can be a combination without being covered. Tick phrases and choose **Track selected**, or choose **Track all shown** to track every phrase the filters keep that is not tracked yet. A confirmation states how many keywords that is. Each tracked keyword adds one store search a day in that storefront and counts against your plan's keyword limit, so read [Watch your request budget](#watch-your-request-budget) before tracking hundreds. The list comes from your home listing. Coverage and relevance of a keyword read its own market's listing, but the combinations card stays on the home listing, and in another market it points you back to it. A listing forms at most 2,000 phrases in the list, shortest first; when it forms more, the card says how many and that the first 2,000 are listed. ## Bulk manage keywords Select rows to untrack them, deactivate them or queue a rescore. Deactivating keeps the history and stops the daily check, which is the right move for a keyword you may want back. Untracking removes the link between the app and the keyword. Buckets classify scored keywords automatically so a large list stays readable. | Bucket | Meaning | | - | - | | `primary` | The five highest opportunity keywords that are not aspirational | | `aspirational` | Volume at or above 60 and difficulty at or above 65 | | `longtail` | Three words or more, or volume below 25 | | `secondary` | Everything else that has been scored | An unscored keyword has no bucket until the weekly scoring run reaches it. ## Tag keywords and keep notes Tags and notes record what asobeast cannot work out for itself: which keywords are your brand, which carry the business, which are an experiment, and why each one is tracked. Open the row menu of a keyword and choose **Edit tags and note**. Type a tag and press Enter or a comma, or pick one of the suggestions: `brand`, `core`, `testing`, then the tags already used in this market. Backspace in the empty field removes the last tag. Save writes both at once. | Rule | Limit | | - | - | | Tags per keyword | 8 | | Tag length | 24 characters, starting with a letter or number; letters, numbers, spaces, hyphens and underscores | | Tag case | stored in lowercase, with extra spaces removed, so `Core` and `core ` are the same tag | | Note | 500 characters of plain text; an empty note is removed | To tag many keywords at once, select them and choose **Tag** in the bar that appears. Type or pick one tag, then add it to the keywords that do not have it yet or remove it from the keywords that do. A keyword already at 8 tags is skipped and named. The **Tags** filter keeps the keywords that carry any of the tags you tick. Tags and notes belong to the keyword in its storefront, so the same phrase tracked in two markets has its own tags and note in each. ## Read the table Click a column header to sort by it, and click it again to reverse the order. The arrow in the header shows the direction, and the page address carries both as `sort` and `dir`, so a copied link opens the same view. Scores and weekly change sort best first, position and names sort from the top, and a keyword with no value sorts last either way. A visit that names no sort of its own opens with the last sort you chose on this table, which the browser remembers. A link that names a sort or a direction always opens in that order. The controls above the table narrow the rows without reloading them. | Control | What it keeps | Address key | | - | - | - | | Search keywords | Phrases that contain the text you type | `q` | | Source | Keywords added from the chosen sources | `source` | | Bucket | Keywords in the chosen buckets | `bucket` | | Status | Active or paused keywords | `status` | | Popularity, Difficulty, Opportunity | Keywords whose score has the chosen grade | `pop`, `diff`, `opp` | | Position | Top 3, 4 to 10, 11 to 30, beyond 30, or not ranking | `pos` | | Tags | Keywords carrying any of the chosen tags | `tag` | Every active filter shows as a chip under the controls. Remove one chip to drop that filter, or use **Clear all**. The count next to the controls reads how many keywords are shown out of how many are tracked in the market, and **Export CSV** exports exactly the rows shown, in the order shown. The grade words are defined in [How are scores graded?](/concepts/scoring#how-are-scores-graded). The **Tags** column shows up to three tags and how many more a keyword carries. A note icon next to the keyword shows its note when you point at it or focus it. The **Position** column carries an info button when the keyword has rank history. It opens the best recorded position, how far the keyword sits below it and how many days the keyword has held its current tier, which is one of first place, top 3 or top 10. Best means the best position kept within ranking retention (`RETENTION_RANKINGS_DAYS`, 365 days by default), not the all time best. The streak counts checked days only: a day without a check, a drop out of the tier or a paused keyword restarts it, and it shows only while the keyword is active and its newest check is from today or yesterday UTC. When an active keyword's newest check is older than yesterday UTC, the info button beside its position turns into a clock, which opens the day of that check, and the change since yesterday is hidden. The position is the one from that day. See [How do I know a position is current?](/concepts/positions#how-do-i-know-a-position-is-current) for what stops a check. **Columns** hides and shows columns. The choice is remembered in this browser and is not part of the link. On a phone the table starts with the keyword, position and opportunity columns, and the filters move into a **Filters** panel. ## Watch your request budget Every keyword and every market adds daily requests against a fixed capacity set by `SCRAPE_ITUNES_RPM` and `SCRAPE_GPLAY_RPM`. The suggested markets in the guided setup show what each market would add before you track it. The [platform operator](/security/admin-surfaces#who-the-platform-operator-is) sees a daily request budget card in the **Capacity** section of the settings page, with the estimate broken down by kind, the capacity and a utilization meter, and the dashboard warns them at 85 percent of capacity. `GET /jobs/budget` returns the same numbers. A customer of a hosted instance watches their keyword markets on the plan card instead. It counts the keyword markets the workspace tracks against its plan, and says since when the workspace has been over its keyword limit. ## Troubleshooting * **A new keyword says Checking for long.** Its check waits in the store queue behind the other work of the day, at the store rate limit. The row updates on its own for ten minutes and after a reload. If it stays, look for a failed `check-keyword` job on `/admin/queues`. * **A keyword shows `>200` every day.** It was checked and the app was not in the top 200. See [Positions and rank depth](/concepts/positions). * **Popularity and difficulty are empty.** Scoring runs weekly on `CRON_SCORING`, which defaults to Sunday 04:00 UTC. * **The budget card warns the operator the instance is over capacity.** Remove keywords or markets, or raise the per store rate limit and accept the risk of the store throttling your IP address. * **The whole file shows as one column.** The separator is read from a header row that names a keyword column, else only when every row has the same number of cells. Add a header such as `keyword,country`, or save the file again from the spreadsheet so each row has the same columns. * **The dialog says the file could not be read.** The browser could not open it, often because it moved or is still syncing. Choose it again, or save a copy and choose that. * **Letters look wrong in the review.** The file is not UTF-8, so it was read as Windows-1252. Save it as CSV UTF-8 and choose it again. * **A row says it is not a storefront.** Use the two letter code of the storefront, such as `gb` for the United Kingdom. Google Play and the App Store have different lists. See [Countries and markets](/concepts/countries). * **A row is over limit.** Your plan's keyword limit is full. Remove keywords you no longer need, or move to a larger plan, then import the file again; the rows that were imported are skipped as already tracked. ## Related > **[Read rankings and SERPs](/guides/read-rankings)** > > Charts, SERP snapshots, volatility and movers. > **[Export data](/guides/export-data)** > > Take the keyword table out as CSV. # AsoBeast documentation Source: https://docs.asobeast.com/index AsoBeast is an App Store Optimization (ASO) tool and keyword rank tracker for the Apple App Store and Google Play. Use the hosted service at asobeast.com or self host the open source edition. AsoBeast is an App Store Optimization (ASO) tool and keyword rank tracker for the Apple App Store and Google Play. Use the hosted service at [asobeast.com](https://asobeast.com), or self host the open source edition, which runs the same code. Point it at an App Store or Google Play URL and it imports the listing, snapshots the metadata, extracts and tracks keywords, checks positions daily, scores each keyword for popularity and difficulty, and turns the accumulated history into a prioritized queue of work. Every store request runs on the machine that hosts AsoBeast as an ordinary public search or page request. Beyond those store requests, your data stays in your deployment unless you explicitly enable an outbound integration such as webhook alerts, email, OpenAI assistance, Apple Ads search popularity or store status updates. See [What leaves your deployment](/legal/data-collection). > **[Quickstart](/quickstart)** > > Run asobeast with Docker Compose and import your first app in about five minutes. > **[How asobeast works](/concepts/architecture)** > > The daily pipeline, the queue, the provider layer and where every number comes from. > **[API reference](/api-reference/introduction)** > > Every endpoint, generated from the API's own OpenAPI document. > **[MCP server](/mcp/introduction)** > > Point Claude Code or Claude Desktop at your instance and ask it about your positions. ## What does asobeast do? > **Tracks two live stores** > > Import Apple App Store and Google Play URLs, capture the first metadata snapshot, refresh on demand and review field level changes. See [App Store and Google Play](/concepts/stores) for the differences that matter. > **Monitors keywords per market** > > One app is a single tracking entity. Keyword tracking carries its own country, so one app tracks keywords across many storefronts. See [Countries and markets](/concepts/countries). > **Scores keywords transparently** > > Popularity and difficulty carry their evidence source, calculation version, capture date and confidence. See [Popularity, difficulty and opportunity](/concepts/scoring). > **Reads your console when you connect it** > > Optional. Connect an App Store Connect key and the overview shows impressions, product page views, Get taps and conversion by market from Apple's own report. Without a key asobeast reads public store data only. See [Read impressions and conversion from your console](/guides/store-funnel). > **Produces an explainable work queue** > > Seventeen deterministic rules run over stored history and return recommendations with a priority, an estimated impact, the exact numbers behind them and, once done, their measured outcome. See [Work the Action Center](/guides/action-center). ## Who is asobeast for? asobeast is built for people who ship apps and want their ASO data to stay theirs. It fits if any of the following is true. * You want ASO tracking without a per keyword subscription. * You need the data on your own infrastructure for policy or privacy reasons. * You want to query your own ASO history from an agent or a script instead of a dashboard. * You track a handful of apps across several storefronts and want one place to see them. asobeast is not a replacement for a large commercial ASO suite. It collects from the public store endpoints at a deliberately modest rate, so it suits a portfolio measured in apps rather than hundreds. ## Where should you go next? > **[Install](/install/docker-compose)** > > Docker Compose, Railway, published images or a development environment. > **[Guides](/guides/import-an-app)** > > One guide per surface, from importing an app to sending alerts. > **[Configuration](/configuration/reference)** > > Every environment variable with its default and its effect. # Self host with Docker Compose Source: https://docs.asobeast.com/install/docker-compose Clone the repository, generate the two required secrets and run docker compose up. The API applies migrations and seeds the default workspace on first boot. Docker Compose is the supported way to run asobeast. Three commands take you from a clone to a running instance, because the API applies its own database migrations and seeds the default workspace when it boots. ## Install asobeast **1. Clone the repository** ```bash theme={null} git clone https://github.com/AsoBeast/asobeast.git cd asobeast ``` **2. Generate the two required secrets** ```bash theme={null} printf 'POSTGRES_PASSWORD=%s\nAUTH_SECRET=%s\n' "$(openssl rand -hex 32)" "$(openssl rand -hex 32)" > .env chmod 600 .env ``` Both values are URL safe and stay in the ignored root `.env`. Compose refuses to start when either one is missing or empty, so a half configured instance never boots. `chmod 600` keeps the file readable only by its owner. **3. Start the stack** ```bash theme={null} docker compose up --build -d --wait ``` `--wait` returns only once every health check passes, so a successful exit means the stack is actually serving. **4. Create the owner account** Open `http://localhost:3001`. A fresh installation redirects to registration. The first account becomes the owner, and registration closes automatically afterwards unless you set `AUTH_ALLOW_REGISTRATION=true`. Safari does not keep the session cookie over plain `http://localhost`, so use Chrome or Firefox, or put TLS in front of the stack. See [Signing in returns to the sign in form](/operations/troubleshooting#signing-in-returns-to-the-sign-in-form). ## Run the published images instead `docker compose up --build` compiles both applications from your clone. `docker-compose.pull.yml` runs the images published for each release instead, which needs neither a clone nor a build: ```bash theme={null} curl -fsSLO https://raw.githubusercontent.com/AsoBeast/asobeast/main/docker-compose.pull.yml printf 'POSTGRES_PASSWORD=%s\nAUTH_SECRET=%s\n' "$(openssl rand -hex 32)" "$(openssl rand -hex 32)" > .env chmod 600 .env docker compose -f docker-compose.pull.yml up -d --wait ``` Both files declare the same project name, the same volumes and the same limits, so an installation can move between them without touching its data. See [Run a published release](/install/published-images) for the tag shapes and for `ASOBEAST_IMAGE_TAG`. ## What does each service do? | Service | Role | Notes | | - | - | - | | `postgres` | Durable system of record | Volume `pgdata`. Back this up | | `redis` | BullMQ queue state | Volume `redisdata`. Reconstructible, so it needs no application backup | | `api` | NestJS API, queue workers, daily pipeline | Applies Prisma migrations and seeds the default workspace on boot | | `web` | Next.js dashboard and same origin proxy | The only service that publishes a port | There is no manual migration step. The API waits for PostgreSQL and Redis to report healthy, applies every pending forward migration, seeds the default workspace and then listens. ## What is exposed? Only host port 3001, published to port 3000 in the `web` container. The API, PostgreSQL and Redis stay on Docker networks that are not reachable from the host, and the `backend` network is marked `internal`. The browser never talks to the API directly. It calls `/api/backend/*` on the web origin, and the Next.js proxy forwards those requests to `API_INTERNAL_URL`, which Compose sets to `http://api:4000`. The web image reads that value at runtime, so pointing it at a different API needs no rebuild. Every service has a health check and a restart policy, and the application containers run as non root users. ## Verify the installation | URL | What it tells you | | - | - | | `http://localhost:3001` | The dashboard is serving | | `http://localhost:3001/api/health` | The web container is alive | | `http://localhost:3001/api/backend/health` | The API is alive and reachable through the proxy | | `http://localhost:3001/admin/queues` | The queue dashboard, for the signed in platform operator | | `http://localhost:3001/docs` | The OpenAPI surface, for the signed in platform operator under the default `API_DOCS=owner` | > [!WARNING] > > `/admin/queues` and `/docs` answer 404 to anyone who is not a signed in owner. That is deliberate. A 403 would confirm the surface exists, so the gate answers as though it does not. ## Customize the installation The root `.env` carries only `POSTGRES_PASSWORD` and `AUTH_SECRET`. Everything else has a working default, including `WEB_PUBLIC_URL`, which builds every link in an email and defaults to `http://localhost:3001`. Set it in the root `.env` when a person reaches the stack at another origin. See [Hosting behind TLS and a reverse proxy](/security/hosting). Copy `apps/api/.env.example` to `apps/api/.env` only when you want an optional feature such as email alerts, the AI assisted audit, a different collection schedule or different retention windows. Compose loads that file when it exists and ignores it when it does not. See [Configuration reference](/configuration/reference). Values set directly in `docker-compose.yml`, including `DATABASE_URL`, `REDIS_HOST`, `REDIS_PORT` and `WEB_PUBLIC_URL`, override anything an older `apps/api/.env` sets for the same keys. Run `docker compose config` to inspect the resolved topology. ## Common operations ```bash theme={null} docker compose ps docker compose logs --no-color --tail=200 api web docker compose down ``` Never paste resolved Compose configuration into an issue, because it contains the database password. ## Next steps > **[Import an app](/guides/import-an-app)** > > Paste a store URL and start tracking. > **[Authentication and accounts](/security/authentication)** > > The owner bootstrap, sessions and registration. > **[Back up PostgreSQL](/operations/backups)** > > The only thing in the stack that needs backing up. > **[Upgrade and roll back](/install/upgrade)** > > Forward migrations, pinned tags and what a rollback really means. # Set up a development environment Source: https://docs.asobeast.com/install/local-development Start PostgreSQL and Redis with the development Compose file, install with pnpm and run the API, the web app and the shared package together with pnpm dev. Development runs the applications on your machine and only the datastores in Docker. `pnpm dev` starts the shared packages and the MCP server in watch mode, the API and the web app together through Turborepo. ## Prerequisites | Requirement | Value | | - | - | | Node.js | 22 or newer | | pnpm | 12.4.1, pinned by `packageManager` in the root `package.json` | | Docker | For PostgreSQL and Redis only | ## Start the development services ```bash theme={null} docker compose -f docker-compose.dev.yml up -d ``` This starts `postgres:18-alpine` on host port 5433 and `redis:8-alpine` on host port 6380, both bound to `127.0.0.1` so a locally running API can reach them without exposing either service on the network. Their container ports remain 5432 and 6379. The development database uses the password `asobeast`, which is fine because nothing is exposed beyond your machine. Never reuse it in a deployment. These services run as the `asobeast-dev` Compose project, with their own containers and their own volumes. A self hosted stack started from `docker-compose.yml` on the same machine keeps its data separately, so starting, resetting or removing the development services never touches it. ## Install and run **1. Install the workspace** ```bash theme={null} pnpm install ``` **2. Create the local environment files** ```bash theme={null} cp apps/api/.env.example apps/api/.env cp apps/web/.env.example apps/web/.env ``` Set `AUTH_SECRET` in `apps/api/.env` to at least 32 characters. Generate one with `openssl rand -hex 32`. The API refuses to boot without it. **3. Apply migrations and seed** ```bash theme={null} pnpm --filter api db:migrate pnpm --filter api db:seed ``` **4. Run everything** ```bash theme={null} pnpm dev ``` The API listens on 4000, the web app on 3000, and `@asobeast/shared` rebuilds on change. ## Commands Every command runs from the repository root. | Command | Purpose | | - | - | | `pnpm dev` | Run the shared packages, the MCP server, the API and the web app together | | `pnpm --filter api dev` | Run only the API | | `pnpm --filter web dev` | Run only the web app | | `pnpm build` | Build every package, respecting the dependency graph | | `pnpm lint` | Lint every package | | `pnpm test` | Run the unit suites | | `pnpm --filter api test:e2e` | API end to end suite with mocked store providers | | `pnpm --filter web test:e2e` | Playwright suite against the typed mock API | | `pnpm --filter api db:migrate` | Apply migrations | | `pnpm --filter api db:seed` | Seed the default workspace | | `pnpm --filter api db:studio` | Open Prisma Studio | | `pnpm format:check` | Check formatting without writing files | ## Browser tests Install the browsers once before the first Playwright run. ```bash theme={null} pnpm --filter web exec playwright install chromium webkit ``` The WebKit browser runs one spec, `engine-hydration.spec.ts`, which loads every signed in route in Safari's engine and fails on a hydration error. Dates and country names are worded by each engine's own ICU data, so only a real second engine notices a difference. The browser suite is hermetic. A typed `node:http` mock API runs on port 4100 and serves both server side prefetching and browser fetches, so no PostgreSQL, Redis or store request is involved. Playwright reuses a running dev server locally and builds the production bundle in continuous integration. ## Test email alerts locally Run a throwaway SMTP sink and point the API at it. ```bash theme={null} docker run -p 1025:1025 -p 8025:8025 axllent/mailpit ``` Then set `SMTP_HOST=localhost`, `SMTP_PORT=1025`, `SMTP_FROM="AsoBeast "` and `WEB_PUBLIC_URL=http://localhost:3000` in `apps/api/.env`, so the logo and buttons appear, add an email alert on the settings page, and read the messages at `http://localhost:8025`. Mailpit's HTML check tab reports client support for a delivered message. ## Preview email templates Every email is a React Email template. Start the preview server and open `http://localhost:3030`. ```bash theme={null} pnpm --filter api email:dev ``` Previews live in `apps/api/src/mail/previews` and render the same fixtures the specs use. The logo loads from the web app, so run `pnpm --filter web dev` alongside to see it. ## Repository layout ```text theme={null} apps/ api/ NestJS + Prisma + BullMQ backend mcp/ local MCP server over the HTTP API web/ Next.js App Router frontend packages/ shared/ @asobeast/shared: contract types, Store union, url parser, helpers typescript-config/ shared base tsconfigs docs/ Mintlify documentation site docker-compose.dev.yml PostgreSQL + Redis for development docker-compose.yml full self hosted stack ``` Every request and response shape the frontend consumes lives in `@asobeast/shared`. Prisma generated types never leave `apps/api`, and packages never import each other by relative path. All store traffic crosses the `StoreProvider` boundary in `apps/api/src/store-providers/`. No other module imports a scraper library, which contains a parser breakage to one file. ## Contributing Commits follow Conventional Commits in the form `type(scope): subject`, enforced by a `commit-msg` hook. Feature work is open on the `1.x` line, and what constrains it is the compatibility promise rather than a freeze: the HTTP contract, the `@asobeast/shared` contract types and the MCP tool surface stay compatible throughout `1.x`. See [Upgrade and roll back](/install/upgrade) for what that covers. Read [CONTRIBUTING.md](https://github.com/AsoBeast/asobeast/blob/main/CONTRIBUTING.md) before opening a pull request. # Run a published release Source: https://docs.asobeast.com/install/published-images Every GitHub release publishes multi architecture API and web images to GitHub Container Registry, tagged by version, minor, major, commit and latest. Every release publishes a `latest` tag that follows the newest version, so a deployment that names no tag tracks the current release. Pin an explicit version when you want an upgrade to be a decision rather than a restart. A release can carry a schema migration, and `latest` moving underneath an unattended `docker pull` applies that migration on the next restart. ## Which images are published? Each GitHub release builds both applications for `linux/amd64` and `linux/arm64`, signs a build provenance attestation and pushes them to GitHub Container Registry. | Image | Contents | | - | - | | `ghcr.io/asobeast/asobeast-api` | NestJS API, queue workers, Prisma migrations | | `ghcr.io/asobeast/asobeast-web` | Next.js dashboard and the same origin proxy | Five tag shapes are published for every release. Pick the narrowest one your upgrade policy allows. | Tag shape | Example for release `v1.0.0` | Moves when | | - | - | - | | Version | `1.0.0` | Never | | Minor | `1.0` | A patch release ships | | Major | `1` | Any release in that major ships | | Commit | `sha-` | Never | | Latest | `latest` | Any release ships | ## Run the stack The sequence below creates the networks and volumes, starts PostgreSQL and Redis on an internal network, attaches the API to both networks and publishes only the web app. Replace `` with the release to run, such as the newest on the [releases page](https://github.com/AsoBeast/asobeast/releases). ```bash theme={null} export ASOBEAST_IMAGE_TAG= export ASOBEAST_POSTGRES_PASSWORD="$(openssl rand -hex 32)" export ASOBEAST_AUTH_SECRET="$(openssl rand -hex 32)" docker network create --internal asobeast-backend docker network create asobeast-frontend docker volume create asobeast-pgdata docker volume create asobeast-redisdata docker run -d --name asobeast-postgres --restart unless-stopped \ --network asobeast-backend \ --volume asobeast-pgdata:/var/lib/postgresql \ --env POSTGRES_USER=asobeast \ --env POSTGRES_PASSWORD="$ASOBEAST_POSTGRES_PASSWORD" \ --env POSTGRES_DB=asobeast \ postgres:18-alpine docker run -d --name asobeast-redis --restart unless-stopped \ --network asobeast-backend \ --volume asobeast-redisdata:/data \ redis:8-alpine docker run -d --name asobeast-api --restart unless-stopped \ --network asobeast-backend \ --env DATABASE_URL="postgresql://asobeast:$ASOBEAST_POSTGRES_PASSWORD@asobeast-postgres:5432/asobeast" \ --env REDIS_HOST=asobeast-redis \ --env REDIS_PORT=6379 \ --env PORT=4000 \ --env DEFAULT_COUNTRY=us \ --env AUTH_SECRET="$ASOBEAST_AUTH_SECRET" \ --env AUTH_COOKIE_SECURE=true \ --env TRUST_PROXY=1 \ "ghcr.io/asobeast/asobeast-api:$ASOBEAST_IMAGE_TAG" docker network connect asobeast-frontend asobeast-api docker run -d --name asobeast-web --restart unless-stopped \ --network asobeast-frontend \ --publish 3001:3000 \ --env API_INTERNAL_URL=http://asobeast-api:4000 \ --env HOSTNAME=0.0.0.0 \ "ghcr.io/asobeast/asobeast-web:$ASOBEAST_IMAGE_TAG" ``` The API image applies database migrations and seeds the default workspace when it starts. Add any optional API setting as a further `--env` flag on the API container. ## Why two networks? Separating them is what keeps the datastores unreachable. `asobeast-backend` is created with `--internal`, so containers on it have no route off the host. | Container | `asobeast-backend` | `asobeast-frontend` | | - | - | - | | `asobeast-postgres` | Yes | No | | `asobeast-redis` | Yes | No | | `asobeast-api` | Yes | Yes, attached after creation | | `asobeast-web` | No | Yes | The web container therefore reaches the API and nothing else, while PostgreSQL and Redis are reachable only from the API. This mirrors what `docker-compose.yml` does with its `frontend` and `backend` networks. ## Run the same images from Compose `docker-compose.pull.yml` is the supported Compose path for published images. It declares the identical stack as `docker-compose.yml`, with the same project name, volumes, memory limits, log limits and health checks, and differs only in running the published images instead of building them. An installation can move between the two without touching its data. ```bash theme={null} curl -fsSLO https://raw.githubusercontent.com/AsoBeast/asobeast/main/docker-compose.pull.yml printf 'POSTGRES_PASSWORD=%s\nAUTH_SECRET=%s\n' "$(openssl rand -hex 32)" "$(openssl rand -hex 32)" > .env chmod 600 .env docker compose -f docker-compose.pull.yml up -d --wait ``` Downloading from `main` gets the current file, and that file defaults to the release it was published with, so the copy on your disk is pinned even though the download URL is not. Release Please bumps that default on every release and continuous integration fails when it drifts, so the file you fetch today runs today's release and keeps running it until you say otherwise. Two variables in the root `.env` change what it pulls. `ASOBEAST_IMAGE_TAG` selects a different release, which is how you pin an older one or roll back. `ASOBEAST_IMAGE_OWNER` selects the registry namespace and exists for forks. Both are read only by this file. The file declares no pull policy, so Compose fetches an image it does not already have and otherwise starts what is on disk. Changing the tag is therefore enough to upgrade, and a restart with the tag unchanged never needs the registry, which matters on a machine that reboots without network. Only a moving tag such as `latest` needs an explicit fetch: ```bash theme={null} docker compose -f docker-compose.pull.yml pull docker compose -f docker-compose.pull.yml up -d --wait ``` The Cloudflare Tunnel overlay composes onto it exactly as it does onto the build from source stack: ```bash theme={null} docker compose -f docker-compose.pull.yml -f docker-compose.tunnel.yml up -d --wait ``` > [!NOTE] > > The checked in `docker-compose.yml` builds both images from source and declares no `image`, so `docker compose pull` cannot move it onto a published tag. Use `docker-compose.pull.yml` for pinned images, or the `docker run` sequence above when you want no Compose file at all. ## Next steps > **[Self host with Docker Compose](/install/docker-compose)** > > The build from source path, with health checks and volumes handled for you. > **[Upgrade and roll back](/install/upgrade)** > > Moving a pinned tag forward, and what to do when it goes wrong. # Deploy on Railway Source: https://docs.asobeast.com/install/railway Deploy asobeast on Railway in one click. The template runs the published images with PostgreSQL and Redis and generates every secret for you. The Railway template is the fastest way to a hosted instance. It runs the same published images as [Run a published release](/install/published-images) and generates every secret at deploy time, so there is nothing to clone, build or configure before the first sign in. [![Deploy on Railway](https://railway.com/button.svg)](https://railway.com/deploy/asobeast?referralCode=GAJ6fQ\&utm_medium=integration\&utm_source=template\&utm_campaign=asobeast-docs) ## What does the template create? One Railway project with four services on its private network. | Service | Image | Public domain | | - | - | - | | `web` | `ghcr.io/asobeast/asobeast-web:1` | Yes, the only entry point | | `api` | `ghcr.io/asobeast/asobeast-api:1` | No | | `Postgres` | `postgres:18-alpine` | No | | `Redis` | `redis:8-alpine` | No | The `web` service is health checked on `/api/health` and the `api` service on `/health`. `Postgres` keeps its data on a volume mounted at `/var/lib/postgresql`, and `Redis` keeps its queue state on one mounted at `/data` and starts with the same memory ceiling and eviction policy as the Compose stack. The template also sets what depends on Railway's edge proxy. `TRUST_PROXY` is `1` on `web`, for the edge, and `1` on `api`, for the web hop. `AUTH_COOKIE_SECURE` is `true`, and `WEB_PUBLIC_URL` is `https://${{web.RAILWAY_PUBLIC_DOMAIN}}`. See [Verify the hop count](/security/hosting#verify-the-hop-count) for why the hop count matters. Redis has no password and relies on the private network. Keep public networking off for `api`, `Postgres` and `Redis`, so `web` stays the only service reachable from the internet, exactly as the Compose stack publishes only the web container. See [Which ports does asobeast publish?](/install/requirements#which-ports-does-asobeast-publish) ## Deploy asobeast **1. Start the deploy** Click **Deploy on Railway** above and sign in to Railway. The template fills in every required variable, including the database password and `AUTH_SECRET`, so you can deploy without editing anything. **2. Wait for the health checks** The API applies every database migration and seeds the default workspace when it boots. The instance is serving once all four services show as active. **3. Create the owner account** Open the public domain of the `web` service as soon as the deploy finishes. The domain is public from the first minute, and the first account to register becomes the owner, after which registration closes. See [Authentication and accounts](/security/authentication). Railway serves the domain over HTTPS, so the secure session cookie works in every browser, Safari included. ## Optional variables Set optional variables on the `api` service, under its **Variables** tab. Railway stages a variable change and applies it when you deploy the staged changes. Without these the instance runs fully, with email alerts and AI assistance switched off. | Variable | Turns on | | - | - | | `SMTP_HOST`, `SMTP_FROM` | Email alerts. Both are needed, plus `SMTP_USER` and `SMTP_PASSWORD` for an authenticated relay | | `OPENAI_API_KEY` | The AI assisted audit, metadata drafts and action explanations | Every other variable, with its default, is in the [configuration reference](/configuration/reference). ## Use a custom domain Open the `web` service, then **Settings**, then **Networking**, and add a custom domain. Railway shows the DNS record to create at your registrar and issues the certificate once the record resolves. Add the domain to `web` only. The API is reached through the web origin at `/api/backend/*`, so it never needs a domain of its own. Then set `WEB_PUBLIC_URL` on `api` to `https://` followed by the new name, because it still points at the Railway domain and builds the alert, confirmation and recovery links. See [Set WEB\_PUBLIC\_URL](/security/hosting#set-web_public_url). ## Upgrade The `1` image tags move with every `1.x` release, and Railway shows an update button in a service's **Settings** when a newer image is published under its tag. Read the release notes and take a backup first, then update `api` and wait for it to become active before you update `web`. The API applies every pending migration on boot. Leave Railway's automatic image updates off, because they skip the backup and update the two services independently. To choose exactly which release runs, pin an exact version. Open each application service, then **Settings**, and change the source image to a version tag such as `ghcr.io/asobeast/asobeast-api:`, using the same version for `api` and `web`. See [Run a published release](/install/published-images) for the tag shapes, and [Upgrade and roll back](/install/upgrade) for what the compatibility promise covers and what a rollback means once a migration has run. ## Back up PostgreSQL PostgreSQL is the only service that needs backing up, as on any other host. Open the `Postgres` service, then **Backups**, to take a backup by hand before an upgrade and to schedule daily, weekly or monthly volume backups. Volume backups stay inside Railway. For an archive you hold yourself, enable a TCP proxy on `Postgres` under **Settings**, then **Networking**, run `pg_dump` against the address it shows with a PostgreSQL 18 client, and remove the proxy afterwards so the database is private again. [Back up PostgreSQL](/operations/backups) covers the archive format and how to verify that it restores before you rely on it. ## Next steps > **[Import an app](/guides/import-an-app)** > > Paste a store URL and start tracking. > **[Capacity and limits](/operations/capacity)** > > How many keyword markets one instance carries before the store rate limits bind. # System requirements Source: https://docs.asobeast.com/install/requirements asobeast needs Docker with Compose, roughly 2.5 GB of memory and an outbound internet connection. PostgreSQL 18 and Redis 8 ship with the stack. Docker with the Compose plugin is the only hard requirement. PostgreSQL, Redis, the API and the web app all ship inside the stack, so there is nothing else to install. ## What do you need to run asobeast? | Requirement | Value | Why | | - | - | - | | Docker Engine | Current release with the Compose plugin | Runs every service. `docker compose version` must succeed | | Memory | Roughly 2.5 GB free | Four containers with memory ceilings summing to about 2.1 GB, of which the API is the largest. See [Capacity](/operations/capacity) | | Disk | Roughly 5 GB free | The PostgreSQL volume grows with ranking history. Retention settings bound it, and container logs are capped at 30 MB per service. The API image is about 95 MB larger than before the screenshot reader: the OCR engine, the language packs and the image library | | Network | Outbound HTTPS | Store collection runs from this machine | | Architecture | `linux/amd64` or `linux/arm64` | Published images are multi architecture | A single instance tracking a handful of apps runs comfortably on the smallest cloud instance most providers sell. ### What one host actually carries Memory and disk are not what runs out first. The store rate limits are, and they bind at very different points depending on which store your keywords are on. These come from a measured run against the shipped memory ceilings; see [Capacity and limits](/operations/capacity). | Store mix | Keyword markets a day | Disk after a year of history | | - | - | - | | Apple only | Roughly 20,000 | Grows with captures. 8,000 markets at 90 days measured 227 MB | | Even split | Roughly 3,600 | The same, for a quarter of the markets | | Play only | Roughly 1,800 | The same again | A workload of 75,000 keyword markets with 90 days of history measured 2 GB of database and took PostgreSQL to 94 percent of its 384 MB ceiling, so plan disk from your own market count rather than from the 5 GB above once you pass a few thousand markets. ## What ships with the stack? You install none of these separately. Compose pulls or builds all four. | Service | Image | Role | | - | - | - | | `postgres` | `postgres:18-alpine` | Durable system of record for every app, keyword, position and action item | | `redis` | `redis:8-alpine` | BullMQ queue state, which is reconstructible and never needs an application backup | | `api` | Built from `apps/api/Dockerfile` | NestJS API, the queue workers and the daily pipeline | | `web` | Built from `apps/web/Dockerfile` | Next.js dashboard and the same origin proxy to the API | Published releases replace the two built images with `ghcr.io/asobeast/asobeast-api` and `ghcr.io/asobeast/asobeast-web`. See [Run a published release](/install/published-images). ## What do you need only for development? Working on the source needs the toolchain as well as Docker. | Requirement | Value | Source | | - | - | - | | Node.js | 24.9 or newer | The images build on `node:24-alpine`, and Jest loads the ESM-only NestJS packages only from 24.9 | | pnpm | 12.4.1 | `packageManager` in the root `package.json` | | Docker | For PostgreSQL and Redis only | `docker-compose.dev.yml` | See [Set up a development environment](/install/local-development). ## Which hosts does asobeast reach? Every store request runs on the machine that hosts asobeast, so that machine needs outbound HTTPS to the Apple and Google endpoints. There is no asobeast cloud service in the path and no telemetry. A host behind a strict egress policy needs the Apple iTunes endpoints and the Google Play endpoints allowed. Without them imports, ranking checks and scoring jobs fail while the rest of the application keeps serving requests. Outbound connections to your own SMTP relay, your own webhook endpoints and the OpenAI API happen only when you configure those optional features. ## Which ports does asobeast publish? One. The web container publishes host port `3001`, mapped to its port `3000`, and nothing else is reachable from outside the Docker network. | Port | Service | Published | | - | - | - | | 3001 on the host, 3000 in the container | `web` | Yes, this is the only entry point | | 4000 | `api` | No, private to the Compose network | | 5432 | `postgres` | No, the backend network is marked internal | | 6379 | `redis` | No, the backend network is marked internal | The browser reaches the API through `/api/backend/*` on the web origin, which forwards to `API_INTERNAL_URL`. The development Compose file is different on purpose: it publishes `5433` and `6380` on `127.0.0.1` so a locally running API can reach them. > [!WARNING] > > Publishing host port 3001 to the internet without TLS in front of it sends session cookies in the clear. Terminate TLS at a reverse proxy. `AUTH_COOKIE_SECURE` is already `true` in the Compose stack and must stay so, because the Docker image refuses to boot without it. ## Next steps > **[Self host with Docker Compose](/install/docker-compose)** > > The install path for almost everyone. > **[Run a published release](/install/published-images)** > > Pinned images from GitHub Container Registry without cloning. > **[Deploy on Railway](/install/railway)** > > The published images on Railway in one click, with no server to manage. # Upgrade and roll back Source: https://docs.asobeast.com/install/upgrade Pull the new image tag, restart the stack and let the API apply forward migrations on boot. Roll back by restoring a database backup taken before the upgrade. Every schema change ships as a forward Prisma migration. There are no down migrations, so a rollback means restoring a database archive taken before the upgrade, not reversing one. That single rule shapes the whole page. Take the backup first, because after the migration runs it is the only way back. ## Before you upgrade **1. Record what you are on** ```bash theme={null} git rev-parse HEAD ``` Note the Compose project name as well. It defaults to the repository directory name, and volumes are prefixed with it, such as `production_pgdata`. Keep it stable across upgrades. **2. Take a verified backup** A backup is not proven until it restores. Create the archive and confirm PostgreSQL can read its table of contents before you continue. See [Back up PostgreSQL](/operations/backups). ## Upgrade ```bash theme={null} git pull docker compose config --quiet docker compose build --pull docker compose up -d --wait docker compose ps docker compose logs --no-color --tail=200 api web ``` The API applies every pending migration on boot, so `--wait` returning success means the schema is current and both services are healthy. When it fails instead, see [If a migration fails](/operations/upgrades#if-a-migration-fails). When you run pinned images instead of building from source, upgrade with `docker-compose.pull.yml`. Set the new tag from the [releases page](https://github.com/AsoBeast/asobeast/releases) and bring the stack up; the API applies every pending migration on boot exactly as it does from source. ```bash theme={null} ASOBEAST_IMAGE_TAG= docker compose -f docker-compose.pull.yml up -d --wait ``` Put the tag in the root `.env` instead of the command line when you want it to persist across restarts. The checked in `docker-compose.yml` builds both applications from source and declares no `image`, so `docker compose pull` cannot move that file onto a published tag. See [Run a published release](/install/published-images) for the tag shapes and for the `docker run` sequence when you want no Compose file at all. ## Verify the upgrade ```bash theme={null} curl --fail --silent --show-error http://127.0.0.1:3001/api/health curl --fail --silent --show-error http://127.0.0.1:3001/api/backend/health ``` Then check representative row counts against what you expect. ```bash theme={null} docker compose exec -T postgres psql --username asobeast --dbname asobeast --command 'select count(*) from "App";' docker compose exec -T postgres psql --username asobeast --dbname asobeast --command 'select count(*) from "Keyword";' ``` ## Roll back A migration can make older application code incompatible with the migrated schema. Restoring only the previous image is therefore not a rollback. Restore both. On published images, run every Compose command below and in the restore block with `-f docker-compose.pull.yml`. Plain `docker compose` in a clone reads `docker-compose.yml` instead and starts the checked out source rather than the tag you set. **1. Stop the applications** ```bash theme={null} docker compose stop web api ``` **2. Switch to the previous version** Check out the previous code revision and run `docker compose build`, or set the previous `ASOBEAST_IMAGE_TAG` in the root `.env`. Do this before the restore, because the restore starts the stack with whatever image is current, and the newer one would apply its migrations to the restored data again. **3. Restore the pre upgrade archive** Drop and recreate the database, restore the archive you took before the upgrade and verify both health URLs. See [Restore PostgreSQL](/operations/restore). ## What does 1.0.0 promise? `1.0.0` promises compatibility for three surfaces throughout the `1.x` line. Breaking any of them requires `2.0.0`. | Surface | Covered by the promise | | - | - | | The HTTP contract | Yes | | `@asobeast/shared` contract types | Yes | | The MCP tool surface | Yes | | Environment variables | No | | The database schema | No, changes ship as forward migrations | | Internal modules | No | The promise starts at `1.0.0` and holds for every later `1.x` release. Additive change is allowed inside it, so a new endpoint, a new optional response field or a new MCP tool can arrive in a minor release. Removal, renaming and narrowing cannot. Release Please owns tags, versions and `CHANGELOG.md`. Read the [release notes](https://github.com/AsoBeast/asobeast/releases) before every upgrade, because an operator facing change appears in a commit subject or a breaking change footer. # How asobeast collects data Source: https://docs.asobeast.com/legal/data-collection What the service reads from Apple and Google, what it refuses to do, why the self hosted and hosted cases differ, and what happens if a store objects. asobeast is built on public store data. A product built that way and unwilling to discuss how looks evasive, so this page states the position plainly and in advance. The code is open, so anyone can check that the position matches the behaviour. ## What the service does It collects **publicly visible store search results and listing metadata** on a customer's behalf: the same search result pages and app pages any visitor sees, read on a schedule and stored so that changes over time become visible. Concretely, per tracked keyword and market, one search per day. Per tracked app, a listing snapshot, its public reviews and its category positions. ### The one request that is not a store request A deployment can opt in to an hourly poll of a store status document the maintainer publishes, by setting `STORE_STATUS_URL`. It exists so a store breakage the maintainer already knows about reaches every opted in installation by editing one file, rather than arriving as issues one at a time. **It is empty by default, and while it is empty no request is ever made.** The poll is not even scheduled. `apps/api/test/no-outbound.e2e-spec.ts` asserts exactly that, and it is one of five opt in outbound integrations alongside webhook alerts, email, OpenAI assistance and Apple Ads search popularity. The last one is a weekly authenticated read of Apple's published search popularity, made only when the operator configures their own Apple Ads API key. See [Connect Apple Ads](/operations/apple-ads). When it is set, what leaves the deployment is a bare `GET` of that url. No request body, no query string, no identifying header: no instance id, no version, no workspace count, no telemetry of any kind. The maintainer's server therefore learns the requesting IP address, the same thing any web server learns, and nothing else. Changing that would be a new decision, a new paragraph here and a new consent, never an implementation detail. ## What the service does not do These are commitments, not aspirations, and each is visible in the code. * **No authenticated access.** Nothing signs in to a store, uses a developer account, or reads anything behind a login. * **No private data.** The one private field asobeast handles, the iOS keyword field, is typed in by the app's owner because it cannot be read from anywhere. That is the exception that proves the rule. * **No circumvention of access controls.** No paywall bypass, no token forging, no undocumented internal endpoint that a store protects. * **No CAPTCHA solving.** If a store challenges a request, the request fails and the job retries later. There is no solver, no third party solving service and no plan to add either. * **No republishing.** Collected data is shown to the customer who asked for it. It is not sold, syndicated or offered as a dataset, and the terms forbid customers doing so. ## Rate limiting is good citizenship, not just cost control Collection is deliberately slow. The App Store worker runs one job at a time behind a limiter defaulting to 15 requests per minute; Google Play runs at 10 job starts per minute, and a Play job fans out to several requests, so its effective ceiling is lower still. Those limits exist because a store serving a request for free should not have to absorb a burst. They also happen to protect the collection, since an address that gets throttled stops producing data. The two interests point the same way, which is the comfortable case. The daily run sheds load rather than pushing harder. When demand passes capacity it drops the least valuable work first and lets the run finish late, and registration on the hosted service is gated before capacity is exceeded rather than after. ## Self hosted and hosted are not the same thing Pretending they are would not be credible. | | Self hosted | Hosted | | - | - | - | | Who is asking | One developer, about their own apps and their competitors | A service, on behalf of many customers | | Where requests leave from | The operator's own address | A pool of datacenter addresses | | Volume | The volume of one small portfolio | The sum of every customer | | Who is responsible | The operator | Us | A developer checking their own app's rank from their own machine is an ordinary use of a public page. A service making a large volume of requests through a proxy pool is a different thing, and it carries obligations that the first case does not: keeping the rate down, keeping the volume attributable, and answering for it. The hosted service uses datacenter addresses, not residential ones. Residential egress routes requests through other people's connections, and beyond the cost, borrowing a stranger's address to make a request they did not ask for is not a position worth defending. It stays configured as a small emergency fallback with a hard monthly cap, never as the primary path. ## If a store objects The honest answer is that Apple and Google decide what they serve, and neither owes this service anything. * If a store blocks the collection, collection stops. There is no escalation, no evasion and no rotation designed to defeat a block. * If a store asks the service to stop, it stops, and customers are told what changed and what it means for their plan. * If a store changes what it serves, the service changes with it, which may mean reduced depth or a store that is no longer covered. That is also why the [terms of service](https://asobeast.com/terms#accuracy-of-store-data) disclaim accuracy and state the dependence on Apple and Google directly rather than in a footnote. A product whose input can be withdrawn should say so before a customer depends on it. ## Why the licence matters here The code is [AGPL-3.0-only](https://github.com/AsoBeast/asobeast/blob/main/LICENSE). Every claim on this page is checkable against the source, including the ones that would be convenient to break quietly. It also means the collection is available without paying anyone: a developer who wants only their own data can run the whole thing themselves, from their own address, at their own volume. Charging for hosting rather than for features keeps that path real, and a real self hosted path is what makes this posture something other than a marketing statement. # Data export, deletion and retention Source: https://docs.asobeast.com/legal/data-retention Export every row your workspace owns, delete the workspace with a reversible grace period, and read the two honest limits on what deletion can reach. Your data is yours. This page says exactly how to take it out, how to erase it, and where erasure stops. ## Export everything `GET /account/export` streams every row your workspace owns as newline delimited JSON. No support ticket, no waiting, no partial archive. ```bash theme={null} curl --silent --location \ --header 'Authorization: Bearer asob_your_token' \ --output asobeast-export.ndjson \ https://app.asobeast.com/api/backend/account/export ``` The first line is a manifest naming every table and its row count. Every line after it is one row: ```json theme={null} {"manifest":{"workspaceId":"ws_...","exportedAt":"2026-08-15T09:00:00.000Z","format":"ndjson","tables":[{"table":"app","rows":5}]}} {"table":"app","row":{"id":"...","store":"APP_STORE","name":"Focus Timer"}} ``` What the archive contains: | Included | Excluded | | - | - | | Workspace, users, personal API tokens, invites | Password hashes | | Apps, groups, snapshots, tracked keywords with their phrases | Personal API token hashes | | Rankings, category ranks, followed top charts, reviews, listing changes | Webhook signing secrets | | Audits, audit scores, action items | Password recovery and email confirmation state of every member | | Webhooks, email alerts, delivery log, alert events | Session versions and billing checkout identifiers | | | Anything owned by another workspace | Credential material and the state that goes with it stays in the database. A member's pending recovery link, its expiry, the counter that signs their sessions out, and the identifiers of an open billing checkout are never written to the archive, whoever asks for it and whichever credential they use. The archive is the workspace's data; a parser should not expect those columns on the `user` and `workspace` rows. Export stays available while a workspace is suspended, unentitled or scheduled for deletion. Losing access to capacity never means losing access to your data. The response is sent in chunks as the rows are read. The manifest counts each table when the download starts, and the rows are then read in pages. A complete download ends normally and holds every row that existed for the whole download, each exactly once. A row written or deleted while it runs, for example by the daily run or the retention job, can add or remove a line, so the line count of a table matches the manifest exactly when nothing wrote to the workspace during the download. If the server cannot finish, for example because the client stops reading for a minute, it closes the connection instead of ending the response, so a client reports a broken transfer rather than a short file that looks whole. When a script depends on a complete archive, check that the download ended normally, compare the line counts with the manifest, and download again at a quiet time if they differ. > [!NOTE] > > A large workspace produces a large stream. Call the API directly with a personal API token rather than through the web origin proxy, which applies a per request timeout. Per resource CSV and JSON export is still available in the application. See [Export your data](/guides/export-data). ## Delete the workspace Deletion is confirmed, delayed and reversible. An immediate irreversible delete behind one click is a support incident waiting to happen. **1. Request it** As the workspace owner, open **Settings**, go to **Workspace** and press **Delete workspace**. The dialog enables its action only once you type `DELETE`. From the API, send `POST /account/deletion` with `{"confirm":"DELETE"}`. Anything other than the literal `DELETE` is refused. The workspace of the platform operator answers `409 Conflict` while other accounts exist, as [Deleting the operator workspace](/security/admin-surfaces#deleting-the-operator-workspace) explains. **2. Nothing happens yet** The workspace keeps running for the grace period, seven days by default and set by `ACCOUNT_DELETION_GRACE_DAYS`. Every member sees a banner on every page naming the earliest erasure date, and **Settings** shows who asked and when. `GET /account/deletion` reports the same. **3. Change your mind** The owner presses **Cancel deletion** in **Settings**, or sends `DELETE /account/deletion`. Nothing was removed, so nothing needs restoring. A member sees the status but cannot cancel it. Once the retention run has started releasing the workspace at Stripe, cancelling answers `409 Conflict`: the subscription may already be gone, so erasure finishes. If Stripe could not be reached, the run lets go and cancelling works again. **4. Erasure** On the first retention run after the grace period ends, the workspace row is deleted and every tenant owned table cascades with it. The due date is therefore the earliest erasure, not an exact time. A workspace that pays is released at Stripe first: its Stripe customer is deleted, which cancels every subscription so nothing is charged again. If Stripe cannot be reached, the workspace waits for the next run rather than being erased while its subscription still bills. ### What erasure removes Everything the workspace owns: the workspace, its users, personal API tokens and invites, apps and groups, snapshots, tracked keywords, rankings, category ranks, followed top charts, reviews, listing changes, audits, audit scores, action items, webhooks, email alerts, the delivery log and alert events. An end to end test seeds a row in **every** tenant owned table, deletes the workspace, and asserts each one is gone. The same test fails if a future table is added without being covered, which is what keeps this list true rather than aspirational. ### What erasure does not remove Five things survive, deliberately, and pretending otherwise would be dishonest. | Survives | Why | For how long | | - | - | - | | Stored billing events | The link to your workspace is removed, but the payment provider's event record is kept for accounting and idempotency | 90 days, then pruned | | Support access records | An audit trail of operator access that expired with the account would not be an audit trail | Indefinitely. It holds an operator email, an action and a reason, never your data | | Keyword rows | One search serves every workspace tracking that phrase and market. The row is shared and carries no owner | While anyone tracks it | | Top chart captures | One capture of a public store chart serves every workspace reading it. The rows are shared and carry no owner | `RETENTION_CHARTS_DAYS`, 365 days by default | | Trial claims | One free trial per mailbox is only enforceable if the record outlives the workspace that took it. The row is a keyed hash of the mailbox (an HMAC under the instance secret), not the address, and it holds no workspace or account reference | Until the instance secret is rotated. Hosted retention is stated in the privacy policy | Beyond the application: * **Your payment provider keeps its own records.** Stripe retains payment and invoice data under its own legal obligations, and we cannot delete what a subprocessor is required to keep. When a plan was sold through Link, Link keeps the receipt and invoice as the seller of record. Ask them directly. * **Backups contain deleted data until they rotate.** This is the real deletion horizon and it is worth stating plainly: erasure removes your data from the live database immediately, and from backups after the backup retention window passes. The shipped schedule keeps 14 daily and 8 weekly archives, and the offsite copies default to the same window, so that window is 56 days. See [Backups](/operations/backups), which is where a deployment records its own window if it changed it. * **Server logs** carry workspace identifiers, never your keywords or listings, and rotate with the hosting platform. ## Retention while the workspace is alive Every retention window is listed in the [privacy policy](https://asobeast.com/privacy#how-long-we-keep-it) and configured by the `RETENTION_*` variables in the [configuration reference](/configuration/reference). A daily job prunes rows past their window. Retention prunes by age. Deletion removes everything at once. They are separate paths, and neither one rewrites history: a pruned day is gone, never replaced by an estimate. ## Self hosted Everything above describes the code, so it applies to your own installation too. The endpoints are the same, the grace period is your `ACCOUNT_DELETION_GRACE_DAYS`, and the backup horizon is whatever your own backup schedule makes it. Nobody else holds a copy, because a self hosted deployment reports to nobody. # Pricing Source: https://docs.asobeast.com/legal/pricing Two hosted plans, the exact limits the code enforces, and the self hosted option that costs nothing and has the same features. Two hosted plans, or run it yourself for free. **Both paths have exactly the same features.** You pay for hosting, never for a feature that is locked away. ## Plans | | Self hosted | Indie | Ultimate | | - | - | - | - | | Price | Free, forever | **\$10** per month or **\$100** per year | **\$99** per month or **\$990** per year | | Annual saving | | Two months | Two months | | Apps | Unlimited | 5 | 50 | | Keyword markets | Unlimited | 1,000 | 10,000 | | Competitors per app | 10 | 10 | 25 | | [Followed chart categories](/guides/top-charts#what-does-it-cost) | Unlimited | 10 | 50 | | Countries | All | All | All | | Daily rank tracking | Yes | Yes | Yes | | Keyword research and gap finder | Yes | Yes | Yes | | Reviews, audits, action center | Yes | Yes | Yes | | REST API and MCP | Yes | Yes | Yes | | CSV and JSON export | Yes | Yes | Yes | | Who runs it | You | Us | Us | The trial is **7 days, no card**, with Indie limits and 25 AI calls, and each email mailbox gets one. When it ends nothing is charged and nothing is deleted. ## A keyword counts once per market This is the limit most easily misread, so it is stated exactly: **One phrase tracked in five countries counts as five keyword markets.** That is not an accounting trick. Each market is a separate search against a separate storefront every day, so five markets is five times the collection. An Indie plan can therefore track 1,000 phrases in one country, 200 phrases in five countries, or any combination that adds to 1,000 pairs. Apps are counted the same way for competitors: each competitor you add to an app is checked in the same searches as that app, which is why competitors cost a per app cap rather than a share of the keyword budget. ## Google Play costs more, and is not charged more A Google Play rank check costs roughly eight store requests where an App Store check costs one, so a Play heavy workspace is several times more expensive to run at the same price. **We do not weight Play keywords against your quota.** One keyword market counts as one whichever store it belongs to. The cost difference is ours to carry, and the capacity planning behind the plans already accounts for it. ## Rate limits Limits are per workspace, not per token, and every response tells you where you stand. | | Indie | Ultimate | | - | - | - | | Requests per minute | 300 | 900 | | Writes per minute | 60 | 200 | | Requests per day | 40,000 | 400,000 | | Concurrent requests | 16 | 48 | | MCP requests per minute | 60 | 300 | | Manual app refresh | 50 per day | 500 per day | | Manual daily run | 5 per day | 20 per day | | Manual keyword score | 100 per day | 1,000 per day | | Keyword suggestions | 60 per hour | 300 per hour | | AI calls | 200 per month | 2,000 per month | Self hosted has no rate limits at all, because there is no shared capacity to protect. AI calls are unlimited too unless you set `AI_CALLS_PER_MONTH`. See [Rate limits](/api-reference/rate-limits) for the headers and error shapes. ## Self hosting is a real option, not a footnote The whole application is [AGPL-3.0-only](https://github.com/AsoBeast/asobeast/blob/main/LICENSE) and runs on a small server with Docker Compose. Every feature the hosted service has, a self hosted install has, including the API, the MCP server, AI features and alerts. > **[Install it yourself](/install/docker-compose)** > > PostgreSQL, Redis and two containers. Around fifteen minutes. Showing you the free path is the point. A company that hides it is asking you to trust the parts you cannot check. ## Questions people actually ask > **What happens when I hit a limit?** > > A capacity limit, such as apps or keyword markets, answers `403` and names the resource. You keep everything you already track; you cannot add more until you remove something or upgrade. A rate limit answers `429` with `Retry-After`, and the daily run is unaffected. > **What if I track more keyword markets than my plan allows?** > > Nothing is deleted. The daily run covers a rotating subset within your limit so no keyword goes stale forever, and the application tells you that you are over. > **Can I switch plans?** > > Yes, in either direction. An upgrade applies at once. A downgrade applies at the end of the paid period, with a warning first if your usage is above the smaller plan. > **What happens to my data if I stop paying?** > > You keep it. Every read and every export keeps working after access lapses. What stops is capacity: writes, on demand actions and new collection. See [Data export, deletion and retention](/legal/data-retention). > **Why is Google Play different from the App Store?** > > Play has no subtitle and no keyword field, so those Apple concepts are hidden rather than faked. Its indexed surfaces are the title, the 80 character short description and the long description. Play also costs about eight times as much to collect per keyword, which is a cost we absorb rather than pass on. See [Stores](/concepts/stores). > **Is there a free hosted tier?** > > No. There is a 7 day trial and a free self hosted path. A permanently free hosted tier would be paid for by the customers who do pay, and the daily collection cost is real. # Privacy policy Source: https://docs.asobeast.com/legal/privacy The privacy policy of the hosted asobeast service is published at asobeast.com/privacy. A self hosted installation collects nothing and reports to nobody. The privacy policy that covers the website, this documentation and the hosted service is published on the asobeast website. That page is the only version, so this documentation keeps no copy that could drift from it. > **[Privacy Policy](https://asobeast.com/privacy)** > > What we collect and why, the legal basis for each use, cookies, every provider that receives data, international transfers, retention, export and deletion, and your rights. ## A self hosted installation If you run asobeast yourself, the policy does not describe your deployment, and **we are not a processor of your data in any capacity**. * The software collects nothing about you and phones home to nothing. * Error tracking is ignored unless billing is enabled, which self hosting does not use. * No outbound integration runs until you configure it: SMTP, a proxy provider, webhooks and AI are each off until you turn them on. * You are the data controller for everything the deployment holds, including the public review text it collects. The end to end suite boots an installation with no cloud configuration and asserts that no connection is attempted beyond its own database and queue. That test exists so this claim stays true as the hosted code grows, rather than being a promise nobody checks. ## Related pages * [Subprocessors](/legal/subprocessors) shows what reaches each third party, and which of them a self hosted installation uses. * [Data export, deletion and retention](/legal/data-retention) describes how export, deletion and pruning work in the code. * [Backups](/operations/backups) explains why 56 days is the longest an archive can hold a deleted row. # Subprocessors Source: https://docs.asobeast.com/legal/subprocessors Every third party the hosted asobeast service sends data to, what reaches each one, and which of them a self hosted installation uses. A subprocessor is a third party that processes data on our behalf. This page states what reaches each one the **hosted** service uses. The binding list is in the [privacy policy](https://asobeast.com/privacy#who-receives-your-data) on asobeast.com, and where the two differ the policy applies. ## The hosted service | Subprocessor | Purpose | What reaches it | | - | - | - | | Hosting provider | Runs the application, the PostgreSQL database and the Redis queue | Everything the service stores, at rest and in transit inside the deployment | | Stripe and Link | Subscriptions, payments, the customer portal and, as reseller and merchant of record, tax collection, receipts and payment support | Your billing name, email and address, any tax id you enter, payment details you enter in Stripe's own interface, and a workspace identifier. **We never see or store card details**. Link uses what you give it at checkout under its own privacy policy, as the seller of record | | Webshare | Datacenter network egress for store requests | The store request itself, which carries the keyword phrase and the storefront. No account data | | SMTP provider | Alert emails, email confirmation and billing notices | Recipient address and the message body, which can name your apps and keywords | | Object storage provider | Offsite backup archives | An encrypted database archive. The decryption key is not stored on the server that produced it and is never sent to the provider, so the provider holds ciphertext it cannot read | | Error tracking provider | Server errors, only when configured | Error type, a secret scrubbed message and stack, workspace and correlation identifiers, and the request method with a masked path. Never headers, cookies, query strings or request bodies | | OpenAI | Optional AI audit, metadata drafts and action explanations | The listing metadata of the app you run the feature on, at the moment you run it. Nothing is sent unless you invoke an AI feature | ## What never leaves * **Your password.** It is stored as an Argon2 hash and is never transmitted anywhere beyond the sign in request. * **Your personal API tokens.** Only a hash is stored, and the plaintext is shown once at creation. * **Your app's users.** The service never receives them, so there is nothing to pass on. See the [privacy policy](https://asobeast.com/privacy). ## A self hosted installation A self hosted installation uses **none** of the above by default. | Subprocessor | Used when self hosted | | - | - | | Hosting provider | Your own infrastructure, not ours | | Stripe and Link | No, unless you deliberately enable billing. Link only when you also enable Managed Payments | | Webshare | No, unless you configure a proxy provider. Store requests otherwise leave from your own address | | SMTP provider | Only the server you configure, and email stays disabled until you do | | Object storage provider | Only the destination you configure, and archives stay on the host until you do | | Error tracking provider | **Never.** Error tracking is ignored unless billing is enabled, which a self hosted installation does not use | | OpenAI | Only when you supply your own key. AI features are otherwise hidden and the endpoints refuse | The end to end suite boots an installation with no cloud configuration and asserts it opens no connection beyond its own database and queue. That test is what keeps this table honest as the hosted code grows. ## Changes New subprocessors are added here before they are used, and noted in the changelog. If a change materially affects what leaves the deployment, we will say so directly rather than only editing this page. # Terms of service Source: https://docs.asobeast.com/legal/terms The terms of the hosted asobeast service are published at asobeast.com/terms. A self hosted installation is governed by AGPL-3.0-only alone. The terms that apply to the hosted service, its REST API and its MCP server are published on the asobeast website. That page is the only version, so this documentation keeps no copy that could drift from it. > **[Terms of Service](https://asobeast.com/terms)** > > Accounts, the trial, billing, cancellation and refunds, acceptable use, limits, suspension, data ownership, availability and governing law. ## A self hosted installation A copy you run on your own server is governed by [AGPL-3.0-only](https://github.com/AsoBeast/asobeast/blob/main/LICENSE) alone. The terms do not apply to it, because there is no service between you and your own deployment. ## What the terms rely on The terms state their promises in plain words and leave the exact figures to these pages: * [Pricing](/legal/pricing) lists every plan limit and rate limit. The terms make those limits part of the agreement. * [Rate limits](/api-reference/rate-limits) describes the headers and the `429`, `403` and `402` answers. * [Data export, deletion and retention](/legal/data-retention) describes what deletion reaches and when. * [How asobeast collects data](/legal/data-collection) sets out the dependence on Apple and Google in full. ## The hosted service in numbers The terms promise best effort availability from a single server. This is what that means: | Promise | Value | What it means | | - | - | - | | Availability target | None | Best effort. We publish what happens rather than a percentage we have not earned | | Planned downtime | Minutes, announced | Upgrades stop the stack, apply migrations and start it again | | Recovery point objective | Up to 24 hours | One backup a day. Losing the machine loses at most a day of collection, and never your account or your app list, which change rarely | | Recovery time objective from the local archive | Minutes | Restoring on the same machine is a database restore and a restart | | Recovery time objective from the offsite archive | Hours | Rebuilding on another machine is bounded by transferring the archive, not by the restore | | Backup retention | 56 days | 14 daily and 8 weekly archives, encrypted, held both on the machine and offsite | Incidents that affect collection are posted on a status page hosted away from the service at [status.asobeast.com](https://status.asobeast.com). See [Backups](/operations/backups) for how the archives are taken and restored. ## Contact Questions about the terms belong in [support](/support). Security reports follow [SECURITY.md](https://github.com/AsoBeast/asobeast/blob/main/SECURITY.md). # The asobeast MCP server Source: https://docs.asobeast.com/mcp/introduction asobeast ships a Model Context Protocol surface that reads by default and changes things only for a token you allow. Connect to the hosted endpoint or run the stdio server yourself, both with the same tools. asobeast ships a Model Context Protocol surface. It authenticates with a personal API token and it reads by default: thirty-seven read tools cover apps, keywords, competitors, positions, top charts, annotations, SERPs, audits, reviews, analytics and the Action Center queue. Seven more tools change keywords, competitors, actions and annotations, and a connection sees them only when its token has the write scope. That means you can point any MCP client, such as Claude Code, Claude Desktop, Codex, Cursor, VS Code, Gemini CLI or Windsurf, at your asobeast and ask it questions in plain language instead of clicking through the dashboard. ## Two transports, one tool surface Your client can connect straight to `POST /mcp` on your instance, or launch the stdio server on your own machine. Both read one catalog of tool definitions, so the names, schemas and descriptions are identical and neither transport has a tool the other lacks. The hosted endpoint runs inside the API, so it inherits the same authentication, the same workspace isolation and the same per workspace rate limits as every REST call, rather than reimplementing them. The stdio server is an HTTP client because it runs somewhere else. See [Connect an agent](/mcp/setup). ## Which protocol revision do they speak? Both transports serve MCP `2026-07-28` and the 2025 revisions from the same tool definitions. A client that probes for the newer revision is served it, and a client that opens with the 2025 handshake keeps negotiating the revision it always did, so nothing you have already configured needs to change. One thing on the wire did change when both transports moved to version 2 of the MCP TypeScript SDK. Every tool now advertises its parameters as JSON Schema 2020-12 rather than draft-07, and the `execution` hint the previous SDK attached to each tool is gone. The parameters themselves are identical, so a client that reads a schema without validating its dialect needs no change, while a client that validates needs a validator that understands 2020-12. Both revisions receive the same schemas, so neither era is served a narrower contract than the other. Neither transport holds a session. The hosted endpoint builds one server instance per request and keeps nothing between requests, and `2026-07-28` removes the session concept from the protocol outright, so there is no session identifier to send, store or expire. For what the revisions differ on, see the [MCP TypeScript SDK documentation](https://ts.sdk.modelcontextprotocol.io/v2/). ## What can you ask? Specific questions work best, because the tools take app ids, countries and date windows. ```text theme={null} Which of my tracked keywords lost more than five positions this week? ``` ```text theme={null} Summarize the open critical actions across my portfolio and tell me which app to work on first. ``` ```text theme={null} Compare my visibility against my top competitor in Germany over the last 30 days. ``` ```text theme={null} Track the three best uncovered keywords from the competitors strategy in the German storefront, once I have approved the list. ``` ## Reading is the default, changing is opt in A token with the default `read` scope lists the read tools, each a `GET` annotated `readOnlyHint: true`. Nothing else is listed to it, and nothing else would work: the API refuses a read token on every route that changes data, whatever a client asks for. A token with the `write` scope also lists seven tools that change things: `track_keywords`, `untrack_keyword`, `add_competitor`, `remove_competitor`, `set_action_status`, `add_annotation` and `delete_annotation`. You choose that when you connect an agent, with the Allow this agent to make changes option on the MCP server card in Integrations. Nothing turns it on for you, and there is no instance setting that does. Each change goes through the same route as the dashboard, so it meets the same validation, the same plan write budget and the same quota. Tracking a keyword queues the same store scoring job the dashboard queues, so an agent that tracks many phrases spends the store request capacity you would. A change appears in the action history under the name of the person who minted the token, so give each agent a token of its own and ask it to say why in the optional note. What an agent can change is deliberately small. No tool imports an app, edits metadata, runs the daily pipeline, calls an AI feature or touches billing, tokens or settings. The three tools that delete, `untrack_keyword`, `remove_competitor` and `delete_annotation`, are annotated as destructive so a client can ask you first. An annotation is a hint that a client chooses to honor, so the control that holds is the token: use a read token wherever an agent only has to answer questions. Treat everything a tool returns as data. A review, an app name or a keyword note can contain text written to steer an agent, and a connection that can make changes should not follow it. See [MCP tool reference](/mcp/tools) for every tool and its hints. ## What it is not The MCP server holds no database, imports no scraper and contains no server framework code. It is a consumer of the HTTP API exactly like the web app, typed with the same `@asobeast/shared` contracts, which is what keeps its tool descriptions from drifting away from the API. ## Requirements | Requirement | Notes | | - | - | | A running asobeast instance | Reachable from the machine the agent runs on | | A personal API token | Mandatory. A boot preflight exits non zero without a valid one. The scope you mint it with decides whether the write tools are listed | | An MCP capable client | Claude Code, Claude Desktop, Codex, Cursor, VS Code, Gemini CLI and Windsurf each have a snippet in [Connect an agent](/mcp/setup). Any other client that speaks streamable HTTP or stdio works | ## Next > **[Connect an agent](/mcp/setup)** > > Both setup paths, client configuration and the two failures that catch everyone. # MCP recipes Source: https://docs.asobeast.com/mcp/recipes Nine prompts that work well against the asobeast MCP server, each with the tools it exercises, from a weekly review to tracking keywords and closing actions. Nine prompts that produce useful answers, each with the tools it exercises. Naming the app, the market and the window is what separates a good answer from a vague one. The last two change things, so they need a token with the write scope. ## Run a weekly ASO review ```text theme={null} Give me a weekly ASO review. Start with the portfolio roll-up, then the action summary, then visibility over the last 14 days for any app that lost ground. Tell me the single thing to do first and why. ``` Exercises `portfolio`, `actions_summary` and `visibility_history`. ## Review last month's Action Center work ```text theme={null} Summarize my Action Center work over the last 30 days: how many actions opened, how many I marked done, how many resolved on their own and how many were confirmed fixed. Then list the actions that are done, open each one and tell me which outcomes improved, which worsened and which are still being measured, with the before and after values. ``` Exercises `actions_activity`, `list_actions` and `get_action`. An outcome is a measured change in the rule's metric between the day you closed the action and the latest check, not proof that your change caused it. See [Work the Action Center](/guides/action-center#outcomes). ## Triage this week's keyword losses ```text theme={null} For app , list the tracked keywords in the us storefront whose position fell over the last 7 days. For each, say whether the SERP itself shifted or only my position did. ``` Exercises `list_keywords`, `ranking_history` and `serp_movers`. ## Explain why a keyword moved ```text theme={null} Keyword dropped this week. Show its position history for the last 30 days, the most recent SERP snapshot, and any change events on the app in that window. Then give me the two most likely explanations. ``` Exercises `ranking_history`, `serp_snapshot` and `changes_timeline`. ## Compare against a competitor in one market ```text theme={null} Compare app with the top ranked competitor on keyword in Germany. Use the SERP snapshot for who is ahead, and visibility history for the trend over 30 days. ``` Exercises `app_summary`, `serp_snapshot` and `visibility_history`. ## Ask why a competitor outranks you ```text theme={null} For app , find the keyword gaps in the comparison, then explain for the top one why the competitor ranks above me and which of my listings could hold the keyword. ``` Exercises `keyword_comparison` and `keyword_breakdown`. Pass the `keywordId` of a gap row to `keyword_breakdown` and read `standing` for your app and `insight` for each competitor above you. The breakdown reads stored data only. See [Why does a competitor rank above you?](/guides/competitors#why-does-a-competitor-rank-above-you). ## Choose which competitors to track ```text theme={null} For app , list the suggested competitors and explain in one line why each one is worth watching. Recommend three and wait for my approval, then add them. ``` Exercises `competitor_suggestions` and, with a write token, `add_competitor`. Listing the suggestions spends one store lookup for the similar apps, and each add spends one competitor add. See [Suggested competitors](/guides/competitors#suggested-competitors). ## Draft the next metadata iteration ```text theme={null} For app , show the metadata audit and the latest ASO audit, then pull keyword suggestions from the competitors strategy. Propose a title and subtitle that stay inside the character limits and cover the highest opportunity uncovered keywords. ``` Exercises `metadata_audit`, `app_audit` and `keyword_suggestions`. The server cannot apply a draft, and neither can asobeast. Every metadata change is published by you in App Store Connect or Google Play Console. See [Optimize listing metadata](/guides/metadata-workbench). ## Track the best suggestions in one market ```text theme={null} For app , pull keyword suggestions from the competitors strategy, drop the ones I already track in the de storefront, and show me the five with the best opportunity. Wait for my approval, then track them in de. ``` Exercises `keyword_suggestions`, `list_keywords` and, with a write token, `track_keywords`. Each phrase you start tracking queues a first position check ahead of its store scoring job and joins the daily ranking run, so add the few that matter. Read `app_run_status` until its `state` is `complete` before you read the new positions. Tracking counts against your plan's keyword market limit. See [Countries and markets](/concepts/countries). ## Decide where to track next ```text theme={null} For app , list the markets its listing languages suggest that I do not track yet, with what each would cost a day. Recommend two and wait for my approval, then track the five phrases of my title that fit best in each. ``` Exercises `market_suggestions`, `get_app` and, with a write token, `track_keywords`. A suggestion says which storefronts read a language your listing ships, not whether the app is published there. To seed a market from its own listing, use the countries step of the guided setup instead. See [How do you add a market?](/concepts/countries#how-do-you-add-a-market) ## Close the actions you have already handled ```text theme={null} List the open actions for app . For each one that my last release already covers, say why and wait for my yes. Then mark those done with a note naming the release. ``` Exercises `app_actions`, `get_action` and, with a write token, `set_action_status`. Marking an action done starts the measurement of its outcome, and the change appears in the history under the name of the person who minted the token. See [Work the Action Center](/guides/action-center#outcomes). ## Writing better prompts Three habits do most of the work. * Name the app and the market. Keywords are per storefront, so an unqualified question makes the agent guess. * Give a window. `last 30 days` maps directly onto the `from` and `to` parameters, which must be real dates in order. See [API conventions](/api-reference/conventions#dates-are-utc). * Ask for the reasoning. The tools return evidence, so asking the agent to show its arithmetic makes a wrong answer visible rather than plausible. ## Related > **[MCP tool reference](/mcp/tools)** > > Every tool and its parameters. > **[Work the Action Center](/guides/action-center)** > > Where the prioritized queue comes from. # Connect an agent Source: https://docs.asobeast.com/mcp/setup Connect straight to the hosted endpoint, or run the stdio server yourself. Both transports expose the same tools, and the scope of your token decides whether they include the tools that make changes. There are two transports and they expose the same tools. | Transport | What runs where | Use it when | | - | - | - | | Hosted endpoint | Your client connects to `POST /mcp` on your instance. Nothing to install | You want the shortest path, and your instance is reachable from the agent | | Local stdio server | Your client launches `apps/mcp/dist/index.js` on your own machine, which calls the HTTP API | You are running from a checkout, or your client has no remote transport | The tool catalog is one set of definitions consumed by both, so there is no hosted only tool and no self hosted only tool. See [MCP tool reference](/mcp/tools). Both transports serve MCP `2026-07-28` and the 2025 revisions, and none of the commands below carry a protocol version, so none of them change with the revision your client picks. See [The asobeast MCP server](/mcp/introduction). Either way the MCP server card in Integrations writes the configuration for you. The manual path is the same thing typed by hand. ## Connect to the hosted endpoint **1. Mint a token** Open the MCP server card in Integrations and click connect an agent. The token is read only, which is all any read tool needs. Tick Allow this agent to make changes only when you want the agent to track keywords, manage competitors and update actions, and mint a separate token for that agent. **2. Copy the hosted snippet** The card shows the endpoint and a ready to paste command, pre filled with the origin you are browsing. **Claude Code command** Run in a terminal. ```bash theme={null} claude mcp add --transport http asobeast https://your-host/api/backend/mcp --header "Authorization: Bearer asob_…" ``` **Claude Code JSON** Run in a macOS or Linux terminal. `add-json` takes one server entry, not a whole configuration file. ```bash theme={null} claude mcp add-json asobeast '{"type":"http","url":"https://your-host/api/backend/mcp","headers":{"Authorization":"Bearer asob_…"}}' ``` **Shared .mcp.json** Put this in `.mcp.json` at the project root and commit it. It holds no secret: export `ASOBEAST_API_TOKEN` in your shell before you start `claude`, which expands the variable. ```json .mcp.json theme={null} { "mcpServers": { "asobeast": { "type": "http", "url": "https://your-host/api/backend/mcp", "headers": { "Authorization": "Bearer ${ASOBEAST_API_TOKEN}" } } } } ``` **Claude Desktop** Claude Desktop's configuration file only runs local commands, and its Connectors screen has no field for a token. The entry below runs `mcp-remote`, a small local bridge that connects to the endpoint with your token. Open Settings, Developer, Edit Config, and merge the entry into the existing `mcpServers` object. It needs Node 18 or newer. ```json claude_desktop_config.json theme={null} { "mcpServers": { "asobeast": { "command": "npx", "args": [ "-y", "mcp-remote@0.14.2", "https://your-host/api/backend/mcp", "--header", "Authorization:${ASOBEAST_AUTH_HEADER}" ], "env": { "ASOBEAST_AUTH_HEADER": "Bearer asob_…" } } } } ``` The header value lives in `env` because no argument may contain a space. For a plain `http://` origin other than `localhost` or `127.0.0.1`, add `"--allow-http"` directly after the endpoint. **Codex** Codex reads `~/.codex/config.toml`, which the CLI, the IDE extension and the app share. ```toml ~/.codex/config.toml theme={null} [mcp_servers.asobeast] url = "https://your-host/api/backend/mcp" http_headers = { Authorization = "Bearer asob_…" } ``` The command form keeps the token out of the file and reads it from `ASOBEAST_API_TOKEN` whenever Codex starts. Export the variable in your shell profile. A Codex app launched from the dock does not see shell exports, so use the file above there. ```bash theme={null} codex mcp add asobeast --url https://your-host/api/backend/mcp --bearer-token-env-var ASOBEAST_API_TOKEN ``` **Cursor** Put this in `~/.cursor/mcp.json`, or in `.cursor/mcp.json` inside a project. ```json ~/.cursor/mcp.json theme={null} { "mcpServers": { "asobeast": { "url": "https://your-host/api/backend/mcp", "headers": { "Authorization": "Bearer asob_…" } } } } ``` **VS Code** Put this in `.vscode/mcp.json`. The top level key is `servers`, not `mcpServers`, and `"type": "http"` is required. VS Code asks for the token the first time the server starts and keeps it in its secret storage, so the file holds no secret. ```json .vscode/mcp.json theme={null} { "inputs": [ { "type": "promptString", "id": "asobeast-token", "description": "asobeast personal API token", "password": true } ], "servers": { "asobeast": { "type": "http", "url": "https://your-host/api/backend/mcp", "headers": { "Authorization": "Bearer ${input:asobeast-token}" } } } } ``` **Gemini CLI** Gemini CLI names streamable HTTP `httpUrl`. A `url` key selects the older SSE transport and never connects to asobeast. ```json ~/.gemini/settings.json theme={null} { "mcpServers": { "asobeast": { "httpUrl": "https://your-host/api/backend/mcp", "headers": { "Authorization": "Bearer asob_…" } } } } ``` Or add it with one command: ```bash theme={null} gemini mcp add --transport http --scope user --header "Authorization: Bearer asob_…" asobeast https://your-host/api/backend/mcp ``` **Windsurf** Windsurf names the address `serverUrl`, and ignores a `url` key. ```json ~/.codeium/windsurf/mcp_config.json theme={null} { "mcpServers": { "asobeast": { "serverUrl": "https://your-host/api/backend/mcp", "headers": { "Authorization": "Bearer asob_…" } } } } ``` **Any other client** Point the client at the endpoint over streamable HTTP and send the token as a request header. | Setting | Value | | - | - | | Endpoint | `https://your-host/api/backend/mcp` | | Transport | Streamable HTTP | | Header | `Authorization: Bearer asob_…` | A client that only runs local commands can use the Claude Desktop entry, which bridges through `mcp-remote`. The endpoint authenticates with the same `asob_` token as the REST API, so there is no second credential to manage. Every request is authenticated, entitled and rate limited exactly like a REST call. See [Rate limits and quotas](/api-reference/rate-limits). A token is the only credential it takes. A browser session cookie is answered with `401` even when it belongs to a signed in owner, so the endpoint carries no ambient authority a page could borrow. ## The quick path for the stdio server **1. Open the MCP server card in Integrations** Sign in to your instance first. **2. Click connect an agent and name a token** asobeast mints the token and shows two ready to paste snippets, the Claude Code command and the Claude Desktop configuration, pre filled with the origin you are browsing. **3. Replace the entrypoint with an absolute path** The snippet cannot know where your checkout lives. Substitute the absolute path to `apps/mcp/dist/index.js`. The token is shown once. Copy it before you leave the page. ## The manual path **1. Build the server** ```bash theme={null} pnpm build ``` This produces `apps/mcp/dist/index.js`. **2. Mint a personal API token** Use the API tokens card in Integrations, and pick the read scope unless the agent has to make changes. See [Personal API tokens](/security/api-tokens). **3. Register the server with your client** Use the configuration below. ## Client configuration for stdio **Claude Code** ```bash theme={null} claude mcp add asobeast --env ASOBEAST_API_URL=http://localhost:4000 --env ASOBEAST_API_TOKEN=asob_… -- node "/absolute/path/to/asobeast/apps/mcp/dist/index.js" ``` **Claude Desktop** ```json claude_desktop_config.json theme={null} { "mcpServers": { "asobeast": { "command": "node", "args": ["/absolute/path/to/asobeast/apps/mcp/dist/index.js"], "env": { "ASOBEAST_API_URL": "http://localhost:4000", "ASOBEAST_API_TOKEN": "asob_…" } } } } ``` **Codex** ```toml ~/.codex/config.toml theme={null} [mcp_servers.asobeast] command = "node" args = ["/absolute/path/to/asobeast/apps/mcp/dist/index.js"] env = { ASOBEAST_API_URL = "http://localhost:4000", ASOBEAST_API_TOKEN = "asob_…" } ``` **Cursor** ```json ~/.cursor/mcp.json theme={null} { "mcpServers": { "asobeast": { "command": "node", "args": ["/absolute/path/to/asobeast/apps/mcp/dist/index.js"], "env": { "ASOBEAST_API_URL": "http://localhost:4000", "ASOBEAST_API_TOKEN": "asob_…" } } } } ``` **Any stdio client** ```bash theme={null} ASOBEAST_API_URL=http://localhost:4000 \ ASOBEAST_API_TOKEN=asob_… \ node "/absolute/path/to/asobeast/apps/mcp/dist/index.js" ``` > [!WARNING] > > Always use an absolute path to the entrypoint. Claude Desktop and Claude Code do not necessarily launch the process from your repository root, so a relative path fails with `MODULE_NOT_FOUND`. This is the most common setup failure. ## Choosing ASOBEAST\_API\_URL `ASOBEAST_API_URL` is resolved on the machine the agent runs on, not by a browser. That is the second most common failure, because the value that works in your address bar is often not the value the agent needs. | Where the agent runs | Value | | - | - | | The same host as the instance | `http://localhost:4000` | | Anywhere else | Your public web origin plus `/api/backend` | The proxy path is not a fallback. In a Compose deployment the API is on a private network and is never published, so `https://your-host/api/backend` is the only route in from another machine. The proxy forwards the bearer header to the API unchanged. See [How asobeast works](/concepts/architecture). It defaults to `http://localhost:4000`, and an empty value counts as unset, so a template that leaves it blank still gets the default. There is no default for the token. The value is the REST API, so it never ends in `/mcp`. The hosted endpoint is a different transport, and the server refuses a URL copied from its snippet with the value to set instead. Plain `http://` to a host other than this machine logs a warning at startup, because the token travels unencrypted. It still connects, since a private network you trust is a legitimate place to run it. Use `https://` everywhere else. ## Verify the connection At startup the server checks its configuration and calls `/auth/me` on your instance before it registers a single tool. Each of these outcomes ends the process with a non zero exit status and a message that names the variable to change. | Situation | What you see | | - | - | | `ASOBEAST_API_TOKEN` unset or empty | A message telling you to set it to a personal API token | | `ASOBEAST_API_TOKEN` does not start with `asob_` | A message that it must be a personal API token. A pasted `Bearer ` prefix or surrounding quotes are removed for you | | `ASOBEAST_API_URL` is not an absolute `http://` or `https://` address | A message showing the form it needs, such as `https://your-host/api/backend` | | `ASOBEAST_API_URL` ends in `/mcp` | A message giving the same value without `/mcp` | | `ASOBEAST_API_URL` has a query string, a fragment or a user name and password | A message giving the value without them. The token goes in `ASOBEAST_API_TOKEN` | | `ASOBEAST_API_URL` is your web address without `/api/backend` | A message giving the value with `/api/backend` appended | | `ASOBEAST_API_URL` answers with a redirect | A message naming the address it redirected to. The token is never sent across a redirect | | The token was rejected | A `401` message telling you to mint a fresh token in Integrations and to check that the URL is the instance the token was minted on | | The account is not entitled | A message that the trial expired or a plan is required | | The workspace is suspended | A message that the workspace is suspended, with the reason the operator recorded | Diagnostics go to stderr, because on stdio the JSON-RPC stream owns stdout. The token is never logged. When the preflight passes, the server logs the instance it connected to and the tools appear in your client. The log line also says whether the token is read only or write tools are enabled. The server asks `GET /auth/me` which scope the token has and registers the write tools only for `write`. An instance older than this release reports no scope, so the server treats it as read only. ## Allow an agent to make changes Changes are opt in, and the token is the switch. Nothing else in this guide changes: the endpoint, the snippets and the stdio environment are the same. **1. Mint a token of its own for the agent** In Integrations, click connect an agent, name the token after the agent, and tick Allow this agent to make changes. You can also pick the read and write scope on the API tokens card. **2. Reconnect the client** The tool list is decided on every request, but most clients read it once when they connect. Restart the client or reconnect the server, and the seven tools that change things appear after the read tools. **3. Approve what matters** Most clients ask before calling a tool that is not read only. Read the arguments before you approve, especially the three tools annotated as destructive. To take the power back, revoke the token or connect with a read token. A read token never lists the write tools, and the API refuses it on every route that changes data. See [MCP tool reference](/mcp/tools) for what each tool does and [Personal API tokens](/security/api-tokens) for scope and expiry. ## Troubleshooting > **MODULE\_NOT\_FOUND on startup** > > The entrypoint path is relative, or the server has not been built. Run `pnpm build` and register the absolute path to `apps/mcp/dist/index.js`. > **I minted a write token and see no tools that change things** > > The client keeps the tool list it read when it connected, so restart it or reconnect the server. Then check the token on the API tokens card: it must say Read and write. A stdio server connected to an instance older than this release reports read only, so upgrade the instance. > **Invalid configuration: : Invalid input from claude mcp add-json** > > The command was given a whole configuration file. Pass only the server entry, the object that starts with `"type": "http"`, as the Claude Code JSON tab shows. > **spawn npx ENOENT in Claude Desktop** > > Claude Desktop does not read your shell `PATH`, so it cannot find `npx` when Node was installed by a version manager. Install Node system wide, or set `command` to the absolute path that `which npx` prints in your terminal. > **The server shows but has no tools** > > The entry is nested one level too deep, usually an `mcpServers` object pasted inside an existing `mcpServers` object. Keep a single `mcpServers` key with `asobeast` directly inside it. > **spawn node ENOENT** > > Desktop clients such as Claude Desktop do not read your shell `PATH`, so they cannot find `node` when it was installed by a version manager. Set `command` to the absolute path that `which node` prints in your terminal. > **The process exits immediately** > > The preflight failed. Read the stderr output in your client's MCP log, which names the exact cause: a missing token, a rejected token, an unentitled account or a suspended workspace. > **The tools do not appear in the client** > > The client did not start the process, or it exited during preflight. Confirm the command runs on its own in a terminal with the same environment variables, which surfaces the error directly. > **The agent cannot reach localhost** > > The agent is on a different machine than the instance. `localhost` there means that machine. Set `ASOBEAST_API_URL` to your public web origin plus `/api/backend`, or use the hosted endpoint instead, which needs no local process at all. > **Connecting spends requests from the MCP budget** > > A client configured to probe for the newer protocol revision sends one `server/discover` request before anything else, and it counts against your workspace's `mcpRequestsPerMinute` budget like any other request. Connecting and listing the tools costs two requests, so a client that reconnects often spends more of the budget than one that stays connected. See [Rate limits and quotas](/api-reference/rate-limits). > **The hosted endpoint answers 401, 402, 403, 405 or 429** > > Each one is a different problem. A `401` means the token is missing, revoked or expired, and it carries a `WWW-Authenticate: Bearer` challenge. A `402` means the workspace has no live plan. A `403` means the workspace is suspended. A `405` means the client used `GET` or `DELETE`, which a stateless server does not offer. The response names `POST` in its `Allow` header, and a conforming client carries on over `POST`. A `429` means a rate limit is spent, and the response says when it reopens. See [Rate limits and quotas](/api-reference/rate-limits). > **The client asks you to sign in, or says OAuth discovery failed** > > The endpoint has no OAuth. The client did not send the token, so it answered `401` and the client went looking for OAuth metadata, which every `/.well-known` path on your instance answers with a JSON `404`. Check that the header is named `Authorization`, that its value starts with `Bearer ` followed by the `asob_` token, and, for a `.mcp.json` that reads the token from a variable, that the variable is exported in the shell that starts the client. ## Related > **[MCP tool reference](/mcp/tools)** > > Every tool and its parameters, generated from the catalog. > **[MCP recipes](/mcp/recipes)** > > Prompts that work well once you are connected. # MCP tool reference Source: https://docs.asobeast.com/mcp/tools 37 read only tools covering apps, keywords, competitors, top charts, annotations, rankings, SERPs, audits, reviews, analytics and the Action Center, plus 7 opt in tools that change keywords, competitors, actions and annotations. This page is generated from the tool catalog both transports share, so it cannot drift. 37 tools read and nothing else: each is a `GET` annotated `readOnlyHint: true`. 7 more tools change things and are listed only to a token with the write scope. Almost all of them take an `appId`, which you get from `list_apps`. ## Apps | Tool | Title | Returns | Parameters | | - | - | - | - | | `list_apps` | List apps | List every tracked app with its store, home country, latest visibility score and keyword count | None | | `get_app` | Get app | Full detail for one app: store metadata, home storefront, latest snapshot and category | `appId` (required), `country` | | `app_summary` | App summary | Headline metrics for one app: tracked keyword count, visibility, average position and recent movement | `appId` (required), `country` | | `app_run_status` | Today's run of an app | Where today's data collection stands for one app: the listings captured for the app and its competitors in the home storefront and every tracked market, the active keywords whose position was checked and the category charts read, each as ready of total and counted since the daily trigger, or since midnight UTC once the trigger has passed | `appId` (required) | | `portfolio` | Portfolio summary | Cross-app portfolio roll-up: totals and per-app visibility, keyword and movement figures across every tracked app | None | | `market_suggestions` | Suggest markets from the app's languages | App Store storefronts that read a localization in a language the app's home listing declares, the home storefront left out, each with the localization, its role in that storefront (default or additional), whether the app already tracks keywords there and the daily store requests the market would cost: the listing reads of the app and its competitors and the requests one tracked keyword adds | `appId` (required) | Start with `list_apps`. Nothing else works without an app id. Pass `country` to `get_app`, `metadata_audit` or `changes_timeline` to read one market's listing. `market_suggestions` lists the App Store storefronts that read a language the home listing declares, with the daily requests each one would cost. See [How do you add a market?](/concepts/countries#how-do-you-add-a-market). ## Keywords | Tool | Title | Returns | Parameters | | - | - | - | - | | `list_keywords` | List tracked keywords | Tracked keywords for one app with popularity (the traffic field) and difficulty on the stored 0 to 10 scale, volume and opportunity on a 0 to 100 scale, and the latest checked position, with the owner's tags and note | `appId` (required), `sort`, `country` | | `keyword_suggestions` | Keyword suggestions | Suggested keywords for one app from a chosen strategy (metadata, search, similar, developer, competitors, seasonal, reviews or competitorSearch); search, similar, developer and competitorSearch ask the live store | `appId` (required), `strategy` (required), `limit`, `country` | | `keyword_comparison` | Keyword comparison | One row per active tracked keyword of one app, in every market it is tracked in, with country (the storefront), its popularity (traffic) and difficulty, the app's latest position and the latest position of each competitor, keyed by competitor id | `appId` (required), `onlyGaps` | | `keyword_breakdown` | Keyword breakdown | Why the app and each competitor rank where they do for one tracked keyword, in the keyword's market: the latest check of each app (ranking null means never checked; position null means checked and not found within depth), and for every localization that market searches, each indexed field judged against the keyword (phrase true when the field holds the whole phrase, each word exact, folded when it only matches with accents ignored, or none) | `appId` (required), `keywordId` (required) | | `keyword_fields` | List saved keyword fields | The private App Store keyword fields the owner saved for one app, one per App Store localization: the phrases in submission order, the characters used against the 100 character limit, and the markets of the app whose storefront reads that localization | `appId` (required) | | `app_localizations` | List app localizations | The App Store localizations one app ships, the home localization first, each with its private keyword field and the markets of the app that read it | `appId` (required) | | `market_keyword_fields` | Read one market's keyword fields | What App Store search in one market reads for one app: every localization the storefront reads, in read order, with its role (default or additional), whether the app ships it, the phrases of the keyword field saved for it and the characters used | `appId` (required), `country` | Keywords are per market, so pass `country` to scope to one storefront. `strategy` chooses the suggestion source: metadata, search, similar, developer, competitors, seasonal, reviews or competitorSearch, which expands phrases from your competitors' listings through the store's search suggestions. The `seasonal` strategy offers English holiday keywords only in the English language storefronts that observe the event, so it is empty for any other storefront. See [Seasonal suggestions](/guides/track-keywords#seasonal-suggestions). `keyword_fields` lists the private App Store keyword fields the owner saved, one per localization, with the markets that read each one. `app_localizations` lists the localizations the app ships, each with its field and the markets that read it, and `market_keyword_fields` reports what one storefront's search reads, for the home storefront unless you pass `country`. See [Which localizations does an app ship?](/concepts/countries#which-localizations-does-an-app-ship). Every parameter is validated by the tool before a request leaves, against the same bounds the API enforces. A `country` is a lowercase two letter storefront code, a date is `YYYY-MM-DD`, and a `limit` or `days` window is refused rather than sent when it sits outside the range the endpoint accepts. ## Competitors | Tool | Title | Returns | Parameters | | - | - | - | - | | `list_competitors` | List competitors | The competitor apps tracked against one app, oldest first, each with its id, store, name, icon and the summary of its latest snapshot | `appId` (required) | | `competitor_analysis` | Competitor analysis | Positioning against the competitors of one app: a metadata comparison (title and subtitle with their character counts), three keyword gap lists (theyRankYouDont, youRankTheyDont and outranked, each row with volume, difficulty, opportunity and both positions), and a position map of visibility and rating per app | `appId` (required) | | `competitor_suggestions` | Suggest competitors | Apps worth tracking as competitors of one app: the untracked apps that recur in its own keyword results over the last 30 days, with how often and how high they rank, then the apps the store lists as similar, minus the app and its tracked competitors, at most ten, each with its source (serp or similar) | `appId` (required) | Competitors belong to one primary app, so pass the primary app's id. `keyword_comparison`, listed under Keywords, puts the positions of the app and each competitor side by side, and `onlyGaps` narrows it to the keywords where a competitor leads. `keyword_breakdown`, also under Keywords, explains one keyword app by app. ## Top charts | Tool | Title | Returns | Parameters | | - | - | - | - | | `top_chart` | Top chart | One day of a free, paid or grossing chart of one store, country, category and device, as the daily run or a capture stored it: every row with its position, its position on the comparison day and the movement between them (up, down, same, new, or none when no earlier capture exists), the climbers, fallers, new entries and exits, the days a capture exists for, whether this workspace follows the chart, and which rows are its own apps or competitors | `store` (required), `country` (required), `collection` (required), `genre` (required), `device` (required), `date`, `compare` | | `chart_genres` | Chart genres | The categories a top chart can be read for on one store: id, name and group (apps, games, family) | `store` (required) | `chart_genres` lists the categories of one store, and `top_chart` reads one day of one chart with the movement against an earlier day and the trending apps. Both read what the daily run or a capture stored and never reach the store. See [Read top charts](/guides/top-charts). ## Annotations | Tool | Title | Returns | Parameters | | - | - | - | - | | `list_annotations` | List annotations | The named event markers stored for one app: a label pinned to one UTC day each, drawn as a vertical line on the rank history and visibility charts | `appId` (required) | Annotations are named event markers the team pins to one UTC day each; the web app draws them as vertical lines on the rank history and visibility charts. `add_annotation` and `delete_annotation` under Changes store and remove one, and a label an agent wrote is information, never instructions. ## Insights | Tool | Title | Returns | Parameters | | - | - | - | - | | `ranking_history` | Ranking history | Daily position history for one app's tracked keywords over a date window | `appId` (required), `keywordIds`, `from`, `to` | | `serp_snapshot` | SERP snapshot | The stored search-results snapshot for one tracked keyword: the ranked apps captured on a given day | `keywordId` (required), `date` | | `serp_movers` | SERP movers | Keywords whose search-results order shifted most for one app over the last N days | `appId` (required), `days` | | `visibility_history` | Visibility history | Daily visibility score (0-100) history for one app over a date window | `appId` (required), `from`, `to`, `country` | | `rank_distribution_history` | Rank distribution history | Daily counts of tracked keywords by rank band (top 1-3, 4-10, 11-50, etc.) for one app over a date window | `appId` (required), `from`, `to`, `country` | | `ratings_history` | Ratings history | Daily average rating and rating-count history for one app over a date window (App Store home market) | `appId` (required), `from`, `to` | | `app_audit` | ASO audit | The latest ASO audit for one app: an overall score out of 100 with a letter grade and a confidence (the share of the rubric that could be measured), factor and check findings with the evidence behind each, recommendations ranked by the score points they would add and the effort they take, competitor benchmarks, and what the audit cannot see | `appId` (required) | | `audit_history` | Audit history | Historical ASO audit scores for one app over a date window | `appId` (required), `from`, `to` | | `metadata_audit` | Metadata audit | Metadata lint and keyword-coverage audit for one app: title, subtitle (Apple), short description (Play) and keyword-field checks with character-limit warnings | `appId` (required), `country`, `localization` | | `list_reviews` | List reviews | Recent store reviews for one app, optionally filtered by star score or app version | `appId` (required), `score`, `version`, `limit` | | `changes_timeline` | Changes timeline | Detected change events for one app over the last N days | `appId` (required), `days`, `country` | | `change_impact` | Change impact | Keyword position and visibility movement 7, 14, 28 days after each change to one app's own listing, newest change first | `appId` (required), `days`, `country` | | `category_ranks` | Category ranks | Daily chart positions of one app over a date window: one series per chart it was captured in, for the free, paid and grossing collections, in its own category and overall, each with the current position and the daily points | `appId` (required), `from`, `to` | `from` and `to` are inclusive UTC date strings in `YYYY-MM-DD` form. Omit `keywordIds` on `ranking_history` to get every tracked keyword, and omit `date` on `serp_snapshot` to get the most recent one. Omit `country` on `metadata_audit` and `changes_timeline` for the home storefront. `category_ranks` is the home storefront only and defaults to the last 90 days. On the App Store, `metadata_audit` returns an optional `screenshotText` summary for the listing of the `country` you pass and, per keyword, which screenshot positions of the listing that judged it contain it. `changes_timeline` events may carry a `detail` object for screenshot and caption changes of the market you pass. Both are optional and absent when there is nothing to report. See [Read screenshot captions](/guides/screenshot-captions). ## Actions | Tool | Title | Returns | Parameters | | - | - | - | - | | `list_actions` | List actions | The prioritized ASO work queue across every tracked app: what to do next and why | `status`, `priority`, `rule`, `category`, `appId`, `country`, `store`, `limit` | | `app_actions` | App actions | The prioritized ASO work queue for one tracked app | `appId` (required), `status`, `priority`, `rule`, `category`, `country`, `store`, `limit` | | `actions_summary` | Actions summary | Counts for the whole action queue: open and snoozed totals, counts per priority band and per category, and the most common rules | None | | `get_action` | Get action | One action with its evidence, its full history of lifecycle events, the daily series of the metric it is about and, once marked done, the measured before and after | `actionId` (required) | | `actions_activity` | Actions activity | Daily counts of actions opened, reopened, marked done, dismissed, resolved on their own and confirmed fixed, over the last 7 to 90 days | `days`, `appId`, `country`, `store` | `status` defaults to `OPEN` and `SNOOZED`, and `limit` defaults to 100 with a maximum of 200. Actions exist only for tracked primary apps, never for competitors, which appear only inside evidence. See [Work the Action Center](/guides/action-center). ## Changes These tools are listed only when the connection's token has the write scope. A read only token never sees them and cannot call them. Each wraps one existing route and is annotated `readOnlyHint: false` with its own destructive, idempotent and open world hints. | Tool | Title | Does | Parameters | Hints | | - | - | - | - | - | | `track_keywords` | Track keywords | Start tracking keywords for one app in one market | `appId` (required), `keywords` (required), `country` | does not delete, idempotent, uses the store | | `untrack_keyword` | Untrack keyword | Stop tracking one keyword for one app, in the market the keyword belongs to | `appId` (required), `keywordId` (required) | deletes, idempotent, no store requests | | `add_competitor` | Add competitor | Add a competitor to one app from its store listing URL, for example [https://apps.apple.com/us/app/rival/id1234567890](https://apps.apple.com/us/app/rival/id1234567890) | `appId` (required), `url` (required) | does not delete, idempotent, uses the store | | `remove_competitor` | Remove competitor | Remove one competitor from an app | `appId` (required), `competitorId` (required) | deletes, idempotent, no store requests | | `set_action_status` | Set action status | Change the status of one action in the Action Center | `actionId` (required), `status` (required), `snoozedUntil`, `reason`, `note` | does not delete, idempotent, no store requests | | `add_annotation` | Add annotation | Pin a named event to one UTC day on the app's charts, for example a release, a listing change you shipped or the start of a campaign | `appId` (required), `date` (required), `label` (required), `market`, `tag` | does not delete, not idempotent, no store requests | | `delete_annotation` | Delete annotation | Remove one event marker from the app's charts | `appId` (required), `annotationId` (required) | deletes, idempotent, no store requests | `track_keywords` takes a `country` and defaults to the app's home storefront. It accepts up to 50 phrases a call, queues the same store scoring job the web app queues for each new phrase, and counts against the plan's keyword market limit, so it spends store request capacity. In a market other than the home storefront it also starts the daily capture of that market's listing for the app and each competitor. `untrack_keyword` and `remove_competitor` delete, and take the ids that `list_keywords` and `list_competitors` return. `add_competitor` fetches the listing from the live store before it answers. `set_action_status` takes `snoozedUntil` as a UTC date and an optional `note`, and the change is recorded in the action history under the name of the person who minted the token. Every change goes through the same route, validation, plan write budget and quota as the web app, so a refused change reads the way the web app's refusal does. If a change fails with a server error, a timeout or an answer that cannot be read, it may or may not have been applied, and the message says so. Read the current state before retrying. ## Reading the results Two conventions matter more than the rest when an agent is interpreting output. A position is 1 based, and `null` means the keyword was checked and the app was not found within that row's depth. Never read `null` as zero. See [Positions and rank depth](/concepts/positions). Apple and Google Play scores come from different public evidence and are not comparable, so never rank one store's keyword against another's. See [App Store and Google Play](/concepts/stores). ## Every tool maps to an endpoint Each tool wraps one route on the HTTP API, so the data an agent can reach and the changes it can make are exactly what that route allows and nothing more. Both transports read the same catalog, so the hosted endpoint and the local stdio server expose an identical surface for a given token. See [asobeast API](/api-reference/introduction). ## What is deliberately missing No tool imports an app, edits metadata, writes the iOS keyword field, refreshes a listing, runs the daily pipeline, calls an AI feature or touches billing, tokens, webhooks or settings. Those either spend store capacity in bulk, spend your AI allowance, or publish a decision you should own. An agent proposes them and you decide. # Connect Apple Ads Source: https://docs.asobeast.com/operations/apple-ads Give asobeast an Apple Ads API key so App Store popularity uses Apple's own weekly search popularity where Apple publishes it. Optional and off by default. Without a key, App Store popularity is an estimate from public store signals. With one, asobeast reads Apple's weekly search popularity for the terms Apple publishes and uses it instead. See [Popularity, difficulty and opportunity](/concepts/scoring#can-asobeast-use-apples-own-numbers) for what changes in the scores. The key belongs to the instance, not to a workspace, and the data it reads is the same for every workspace on it. ## Create the key **1. Generate a private key** Apple Ads accepts an elliptic curve key on the `prime256v1` curve. ```bash theme={null} openssl ecparam -genkey -name prime256v1 -noout -out apple-ads-private.pem openssl ec -in apple-ads-private.pem -pubout -out apple-ads-public.pem ``` **2. Upload the public key** In Apple Ads, open Account Settings, then API, and paste the contents of `apple-ads-public.pem`. Apple shows a client id, a team id and a key id. Keep all three. **3. Keep the private key out of the repository** Move `apple-ads-private.pem` into `apps/api/keys/` beside your Compose files. Git ignores that directory, so the key is never committed. ## Give the API the key Both `docker-compose.yml` and `docker-compose.pull.yml` mount `apps/api/keys` read only into the API container, at the path the API resolves a relative key path against. Nothing else needs mounting, whether you start the stack with `docker compose` directly or through `pnpm compose:build:prod`. **1. Let the API read the key** Inside the container the API runs as user id 1000. On a Linux host, give that user the file: ```bash theme={null} sudo chown 1000 apps/api/keys/apple-ads-private.pem sudo chmod 400 apps/api/keys/apple-ads-private.pem ``` Docker Desktop on macOS and Windows maps file ownership itself, so there the file only has to be readable by you. **2. Set the four variables** In `apps/api/.env`: ```bash theme={null} APPLE_ADS_CLIENT_ID=SEARCHADS.your-client-id APPLE_ADS_TEAM_ID=SEARCHADS.your-team-id APPLE_ADS_KEY_ID=your-key-id APPLE_ADS_PRIVATE_KEY_PATH=keys/apple-ads-private.pem ``` Set all four or none. A partial set refuses to boot and names the missing ones, and so does a key file the API cannot read, which names the path it tried. `APPLE_ADS_AD_ACCOUNT_ID` is optional; see the [configuration reference](/configuration/reference#apple-search-popularity). The relative path works unchanged for `pnpm dev`, which runs the API from `apps/api`. **3. Recreate the API** ```bash theme={null} docker compose up -d --force-recreate api --wait ``` ## Confirm the sync ran The sync runs every Monday at 09:00 UTC by default, after Apple posts the week that ended on Saturday. To run it now, sign in as the [platform operator](/security/admin-surfaces#who-the-platform-operator-is), open `/admin/queues`, select the `pipeline` queue and promote the `apple-popularity` repeatable job. Then count what was stored per market and week: ```bash theme={null} docker compose exec postgres psql -U asobeast -d asobeast -c 'select country, week, count(*) from "SearchTermPopularity" group by 1, 2 order by 2 desc' ``` A keyword Apple lists shows the source "Apple Ads search popularity" and high confidence once it is next scored. Score one from its row actions to see it at once. To see how the estimate compares with Apple's number on your own keywords, run the calibration command from a source checkout whose `apps/api/.env` points at the same database: ```bash theme={null} pnpm --filter api scoring:calibrate ``` Below ten keywords with both numbers it says there is not enough data yet. ## Rotate the key Generate and upload a new key as above, replace the file in `apps/api/keys/` and `APPLE_ADS_KEY_ID`, recreate the API, then revoke the old key in Apple Ads. Stored popularity stays valid, because it is Apple's published data rather than anything tied to the key. ## Disconnect Remove the four variables and recreate the API. The weekly sync is unscheduled on the next start and scoring returns to the estimate. Stored weeks stay in the table but are no longer read. # Back up PostgreSQL Source: https://docs.asobeast.com/operations/backups PostgreSQL is the durable system of record, so it is the only thing that needs backing up. Redis holds reconstructible queue state only. Back up PostgreSQL and nothing else. Every app, keyword, position, score, review, change event and action item lives there. Redis holds queue state, which is reconstructible: the next daily run rebuilds it. Including Redis in an application data backup adds size and restores nothing you need. Discarding Redis does lose two things, and neither is application data. Jobs that were queued or in flight are gone, so that day's collection restarts at the next run. Alert flush claims that were taken but never completed are gone as well, which means a batch that was mid delivery is not retried. Both recover on the next daily run. ## Take a backup by hand For a one off archive, before an upgrade for instance, the block below writes a compressed custom format archive, fails on any error, and verifies that PostgreSQL can read its table of contents before declaring success. For anything repeating, use the script in [Automate it](#automate-it) rather than pasting this into cron. ```bash theme={null} set -eu mkdir -p "$HOME/asobeast-backups" chmod 700 "$HOME/asobeast-backups" umask 077 backup_file="$HOME/asobeast-backups/asobeast-$(date -u +%Y%m%dT%H%M%SZ).dump" docker compose exec -T postgres pg_dump --username asobeast --dbname asobeast --format=custom > "$backup_file" test -s "$backup_file" docker compose exec -T postgres pg_restore --list < "$backup_file" > /dev/null echo "$backup_file" ``` `umask 077` gives the archive restrictive permissions, which matters because the dump contains everything. Both commands run inside the `postgres` container, so the client always matches the server and there is no version to keep in step. Running `pg_dump` from the host instead needs a client at least as new as the server, which is PostgreSQL 18. An older host client stops with `server version mismatch` and writes nothing. Run it from the repository root with the same Compose project name the deployment uses. The default project name is the directory name. For a deployment started with `docker compose -p production`, add `-p production` to every command on this page. ## Where does the data live? In the named volume `pgdata`, mounted at `/var/lib/postgresql` in the `postgres` container. Compose prefixes it with the project name, so it appears as `production_pgdata` under a project called `production`. Copying the volume directory while PostgreSQL is running is not a backup. Use `pg_dump`, which produces a consistent snapshot. ## How long it takes Measured on a running stack, restoring an archive the scheduled script produced rather than one taken by hand for the drill. | Measure | Value | | - | - | | Database size | 11 MB | | Archive size | 158 KB | | Backup duration | 0.13 seconds | | Restore duration | 0.20 seconds | That instance holds 2 apps and 30 tracked keyword markets with 42 applied migrations. Both durations scale with row count rather than with database size, and the archive compresses well because the largest columns are repeated JSON payloads. A year of daily rankings for 20 apps at 200 keywords is roughly 1.5 million rows, so plan for minutes rather than seconds at that scale and measure your own instance before you rely on a maintenance window. The restore was verified with the full checklist in [Restore PostgreSQL](/operations/restore): the archive's table of contents read before anything was dropped, row counts matched the live database, all 42 migrations came back finished, no constraint was left unvalidated, both workspace scoped unique indexes were present, and an insert succeeded afterwards. A restore can report success and still refuse a write, which is why the write is on the list. ## How long are archives kept? The window is a decision, not a detail, because a deleted workspace is not deleted until it has aged out of the last archive that holds it. Write yours down before you schedule anything. The shipped script keeps two generations. | Generation | Kept | Taken from | | - | - | - | | Daily | 14 archives | Every run | | Weekly | 8 archives | The Sunday run, promoted rather than dumped twice | That makes **56 days** the longest an archive can hold a row, and therefore the real deletion horizon for the deployment. Erasure removes data from the live database at once and from the last archive up to 56 days later. See [Data export, deletion and retention](/legal/data-retention). Both live in `$HOME/asobeast-backups`, `daily/` and `weekly/`, on the same host by default. Change the window by editing `BACKUP_KEEP_DAILY` and `BACKUP_KEEP_WEEKLY` at the top of the script, and change the deletion horizon you publish along with it. A shorter live retention promise than the backup window is not a promise you keep. The offsite copies below carry their own window, `BACKUP_REMOTE_KEEP_DAILY` and `BACKUP_REMOTE_KEEP_WEEKLY`, which default to the local numbers so the published horizon does not move on its own. Lengthening the remote window lengthens the deletion horizon, and the horizon you publish has to be the longest one you actually keep. ## Copy the archive off the host A backup on the same disk as the database survives a mistake but not a disk. On a single machine the daily archives and the `pgdata` volume are the same SSD, so one failure loses the database and every copy of it in the same second. Theft, a wiped filesystem and ransomware have the same shape. Offsite means somewhere the loss of this machine cannot reach: object storage, a different machine on a different power supply, or an external disk that is not mounted between runs. It does not mean a second directory or a second partition. Set two variables and the shipped script does the rest. ```bash theme={null} BACKUP_AGE_RECIPIENT=age1... BACKUP_REMOTE=offsite:asobeast-backups \ COMPOSE_FILE=/srv/asobeast/docker-compose.yml /usr/local/bin/asobeast-backup.sh ``` The run then encrypts the verified archive with [age](https://age-encryption.org), uploads the encrypted file with [rclone](https://rclone.org), reads the object size back from the remote to prove the upload landed, promotes the Sunday object to the remote weekly generation with a server side copy, prunes the remote to its own window, and removes the encrypted file from the host. The plaintext archive stays local, because restoring from local is minutes and restoring from a remote is bandwidth. ### Encryption is not optional offsite `pg_dump` output contains every password hash, every session row, every billing identifier and every review the instance has collected. That is acceptable at `chmod 700` on a machine you control and unacceptable in somebody else's storage, so the script refuses to push anything unless `BACKUP_AGE_RECIPIENT` names a recipient. Generate the key pair somewhere that is **not** the machine being backed up, and put only the public half on the host. ```bash theme={null} age-keygen -o asobeast-backup-identity.txt ``` The public key on the line it prints is `BACKUP_AGE_RECIPIENT`. Keep the identity file off the host and off the remote: a compromised mini PC can then write its offsite history and never read it back. Losing that file loses every offsite archive, so store it the way you store the recovery codes for your accounts. ### What the failure path does | Failure | What happens | | - | - | | `age` or `rclone` missing | The run stops before dumping and names the missing tool | | `BACKUP_REMOTE` set with no recipient | The run stops before dumping, rather than pushing plaintext | | Upload reports success but reads back short | The short object is deleted and the run exits non zero, so pruning cannot mistake it for a backup | | Remote unreachable | The run exits non zero with the local archive already written and verified | A failed upload is a failed run. The exit code is what the scheduler reports, and the completion timestamp the API watches is written only after the upload lands. See [Hosted observability](/operations/hosted-observability). ## The schedule is watched A backup that silently stops running is discovered during a restore, which is the one moment it cannot be fixed. The exit code alone does not protect you, because it goes to a timer nobody reads. Every successful run therefore reports its completion time to Redis, and the API reads it back on every `/metrics` scrape. Set `BACKUP_MAX_AGE_HOURS` in the API environment to the age you consider stale, and `backup.stale` fires when the last run is older than that, escalating from `investigate` to `page` at twice the window. A run that never reported at all pages, rather than reading as fresh. Leave `BACKUP_MAX_AGE_HOURS` at `0` if you back up some other way. Nothing is watched and nothing alerts, which is better than an alert you learn to ignore. Reporting is best effort. A run that finished but could not reach Redis says so on stderr and still exits zero, because the archive is good; the instance then reports the schedule as stale until the next run gets through, which is the conservative direction to be wrong in. ### Restore from the offsite copy The offsite copy is only a backup once you have restored one. See [Restore PostgreSQL](/operations/restore), which restores an archive fetched from the remote with the host powered off, and records the recovery objectives. ## Automate it The repository ships the script, at `scripts/asobeast-backup.sh`. Use it rather than a copy of the block above, because a copy on the host and a block in a document drift apart and nobody notices until a restore. It dumps, verifies the archive with `pg_restore --list`, promotes the Sunday archive to the weekly generation, copies an encrypted copy offsite when one is configured, prunes every generation to its window, and exits non zero on any failure so the scheduler reports it. CI runs this same script from a directory that is not the repository, restores the archive it produces and compares it to the source, and then fetches the encrypted object back out of object storage and restores that too, so the scheduled path is the tested path. It writes to a `.partial` file and renames only after verification, so an interrupted run never leaves a truncated archive that pruning would later mistake for a good one. Install it and schedule it. ```bash theme={null} sudo install -m 700 scripts/asobeast-backup.sh /usr/local/bin/asobeast-backup.sh ``` ```bash theme={null} 0 2 * * * COMPOSE_FILE=/srv/asobeast/docker-compose.yml /usr/local/bin/asobeast-backup.sh >> /var/log/asobeast-backup.log 2>&1 ``` `COMPOSE_FILE` is the one variable you have to set, and it must be absolute. Compose locates a deployment through its file, not through its project name, so a run that starts anywhere but the repository has nothing to talk to. The script refuses to start without it rather than dumping whichever stack happens to be under the working directory. | Variable | Default | Effect | | - | - | - | | `COMPOSE_FILE` | Required | Absolute path of the deployment's compose file, passed to Compose as `-f`. Several paths joined by `COMPOSE_PATH_SEPARATOR`, `:` by default, work the same way Compose reads them, so a base file and an override both apply | | `BACKUP_ROOT` | `$HOME/asobeast-backups` | Where `daily/` and `weekly/` are written | | `BACKUP_KEEP_DAILY` | `14` | Daily archives kept | | `BACKUP_KEEP_WEEKLY` | `8` | Weekly archives kept | | `BACKUP_AGE_RECIPIENT` | Unset | age public key the archive is encrypted to. Unset means no encryption and no upload | | `BACKUP_REMOTE` | Unset | rclone destination for the encrypted archive, for example `offsite:asobeast-backups`. Requires `BACKUP_AGE_RECIPIENT` | | `BACKUP_REMOTE_KEEP_DAILY` | `BACKUP_KEEP_DAILY` | Daily objects kept offsite | | `BACKUP_REMOTE_KEEP_WEEKLY` | `BACKUP_KEEP_WEEKLY` | Weekly objects kept offsite | | `BACKUP_AGE` | `age` | Path to the age binary | | `BACKUP_RCLONE` | `rclone` | Path to the rclone binary | | `BACKUP_REDIS_SERVICE` | `redis` | Compose service the completion time is reported to | | `BACKUP_REDIS_DB` | `0` | Redis database index the API reads, which is `REDIS_DB` in the API environment | | `BACKUP_DATABASE` | `asobeast` | Database to dump, which the shipped Compose file names | | `BACKUP_USER` | `asobeast` | PostgreSQL role the dump runs as | | `COMPOSE_PROJECT` | Unset | Passes `-p` to Compose, for a deployment that named its project | | `WEEKLY_DAY` | `7`, Sunday | Day of the week whose archive is promoted, as `date +%u` reports it | Cron runs with a minimal environment and from a working directory that is probably not the repository. Give cron a `HOME` if `BACKUP_ROOT` depends on it, and set `COMPOSE_PROJECT` only if the deployment named its project, because the shipped Compose file already names it `asobeast`. ### Check the failure path before you trust it A schedule that fails silently is worse than no schedule, because it reports success by saying nothing. Verify the failure path once, on purpose, rather than discovering it during a restore. Stop PostgreSQL and run the script. ```bash theme={null} docker compose stop postgres && COMPOSE_FILE=/srv/asobeast/docker-compose.yml /usr/local/bin/asobeast-backup.sh; echo "exit=$?" ``` It must print the reason, exit non zero, and leave nothing behind. On the shipped script that is `service "postgres" is not running` and `exit=1`, with no file written under `daily/`, because the dump goes to a `.partial` path that is removed on any failure and renamed only after `pg_restore --list` reads it. A failed run also stops before pruning, so it can never delete a good archive on its way out. Then start PostgreSQL again and confirm the next run writes an archive. ```bash theme={null} docker compose start postgres ``` Cron mails the output of a failing job to the crontab owner. Confirm that mail actually arrives on your host, or send the log somewhere you read, because the exit code is only useful if something is listening for it. ## When to run it Back up shortly before the daily pipeline, not after it. `CRON_DAILY` defaults to `0 3 * * *`, so the hour before that is the quietest point in the day: the previous run finished long ago, the next has not started, and no store jobs are in flight. The schedule above uses 02:00 for that reason. Backing up during the run is not dangerous, because `pg_dump` takes a consistent snapshot, but it captures a day that is half collected. Restoring it later leaves a partial day that the next run will not revisit, since a store SERP is a point in time observation. If you change `CRON_DAILY`, move the backup with it. Keep tested generations off host. See [Copy the archive off the host](#copy-the-archive-off-the-host). > [!WARNING] > > Never commit a database dump or a `.env` file to version control. Both contain credentials, and a dump contains every account record. ## Verify a backup A backup that has never been restored is not a backup. Schedule a real restore into a disposable stack on a regular cadence and confirm the app and keyword counts match. See [Restore PostgreSQL](/operations/restore). ## Related > **[Restore PostgreSQL](/operations/restore)** > > The other half of the drill. > **[Upgrade and roll back](/install/upgrade)** > > Why the backup has to precede the upgrade. # Billing and the subscription lifecycle Source: https://docs.asobeast.com/operations/billing How a hosted workspace moves from trial to paid, what happens when a card fails, and what a downgrade or cancellation does to the data already collected. Billing is off unless `BILLING_ENABLED` is `true` and `STRIPE_SECRET_KEY` is set. A self hosted instance never reaches any of this. See [Configuration reference](/configuration/reference) for the variables. ## The catalog Two products and four prices, created from a committed script rather than by hand, so a test account can be rebuilt exactly: ```bash theme={null} pnpm --filter api stripe:catalog ``` The script creates each price with the lookup key `asobeast__`, `tax_behavior: exclusive` and each product with the software as a service tax code `txcd_10103001`, provisions the customer portal described under [Downgrade and cancellation](#downgrade-and-cancellation), and ends by printing the settings only the dashboard holds for the mode `STRIPE_MANAGED_PAYMENTS` selects. It is idempotent: a second run finds everything it created and changes nothing. The [go live checklist](/operations/billing-go-live) repeats the dashboard settings. The API resolves the four prices from their lookup keys when it boots, before every nightly reconciliation, and on the next checkout or catalog read whenever the catalog is still empty, at most once a minute. No price id has to be copied into the environment. The four `STRIPE_PRICE_*` variables remain as an override: set any of them and the catalog comes from the environment alone and never asks Stripe. A subscription on a price that has lost its lookup key, for example one archived after a price change, is still placed by the plan in the price's `asobeast_plan` metadata, which the catalog script sets on every price. A price that names no plan either way is refused loudly rather than falling back to a free plan, because a silent fallback means a paying customer gets nothing. ## Checkout A checkout session always carries: * the billing address, required; * an explicit `flexible` billing mode, rather than whatever the pinned API version defaults to; * the workspace id as `client_reference_id` and in the subscription metadata. Who collects the tax decides the rest, and exactly one of three modes applies: | Mode | Set | Who is the seller | What the session adds | | - | - | - | - | | No tax | neither variable | this business | a tax id field and `customer_update` for the name and address, so a later invoice can be corrected; no tax is charged | | Stripe Tax | `STRIPE_TAX_ENABLED=true` | this business, registered for tax where it must be | the same, plus automatic tax | | Managed Payments | `STRIPE_MANAGED_PAYMENTS=true` | Stripe, through Link | `managed_payments`, and nothing Stripe controls in that mode | The two variables refuse to boot together. A production instance with billing on and a live key but neither of them logs a warning at startup, because it sells untaxed while this business stays liable. The success URL carries `{CHECKOUT_SESSION_ID}`, which Stripe replaces with the id of the completed session. The app posts it to `POST /billing/reconcile`, and the API adopts that session's subscription at once, provided the session names this workspace as its `client_reference_id`. A session that names another workspace, holds no subscription yet or is unknown to Stripe falls back to the customer's subscriptions, so the return never depends on the id. ## Selling through Stripe Managed Payments With `STRIPE_MANAGED_PAYMENTS=true` every checkout session asks Stripe to act as the merchant of record. The customer buys from Link, Stripe's reseller, and Stripe calculates, collects, files and remits sales tax, VAT and GST in the countries it covers, handles fraud and disputes, and answers payment questions. The service still owns the product, the plan, the portal and every webhook. What changes for the customer: * Checkout shows the price in their local currency at a rate guaranteed for a day. The subscription itself stays in US dollars. * The card statement reads `LINK.COM*` followed by the statement descriptor. * Receipts and invoices come from Link. Customers can also cancel or update the subscription at link.com, which reaches this service as the same subscription events as a change made in the billing portal. What changes for the operator: * Managed Payments has to be activated in the Stripe dashboard, and its terms accepted, before the variable is turned on. A session sent without it is refused by Stripe. * Stripe controls the tax, tax id, customer detail, payment method and currency parameters of the session, so the API stops sending them. * Stripe escalates payment questions to the support email in the dashboard's business details. A question left unanswered for 48 hours can be refunded by Stripe without asking. * Stripe can refund a purchase within 60 days to prevent a chargeback, and handles every dispute itself. * Sales to a country Managed Payments does not cover stay this business's tax liability. Keep Stripe Tax threshold monitoring on; it costs nothing for these transactions and says when a registration becomes necessary. * Payouts show the withheld tax and Stripe's fees separately in the balance reports, under `withheld_tax` and `fee_net_of_withheld_tax`. Only subscriptions bought through a Managed Payments checkout are sold by Link. Turning the variable on changes nothing about a subscription that already exists. ## The webhook endpoint Stripe posts every subscription event to one endpoint, and the URL is the web origin followed by the same `/api/backend/` prefix every other API call uses: ```text theme={null} https://your-host/api/backend/billing/webhook ``` Register that in the Stripe dashboard, put the signing secret it gives you in `STRIPE_WEBHOOK_SECRET`, and send one test delivery before you take a payment. Without the secret the endpoint refuses every delivery and checkout stays closed, so nobody pays for access that could never be provisioned. The endpoint is the one billing route that is public, because Stripe has no session. It authenticates the delivery instead: each request carries a `stripe-signature` header computed over the exact bytes of the body, and a delivery whose signature does not verify is refused with 400. The web app forwards that header and the body untouched, so the API verifies what Stripe actually signed. A refused delivery is logged. Look for `rejected a webhook delivery` in the API log, which names the reason, and check the endpoint's own delivery history in the Stripe dashboard. Stripe retries a failed delivery, so a secret you fix within the retry window costs nothing. ### What happens to an event this instance cannot place Every verified delivery is stored once and processed from a queue, and each stored event records how it settled: | Outcome | Meaning | | - | - | | `applied` | The subscription was written onto the workspace it belongs to | | `ignored` | Nothing to do: an event type asobeast does not act on, an event that names no subscription, or one older than the state the workspace already holds | | `orphaned` | The subscription names a workspace this instance does not have, or a workspace that belongs to a different Stripe customer | An orphaned event settles on its first attempt. Waiting cannot make it placeable, so it is never retried and never pages. It keeps its payload for `RETENTION_BILLING_EVENTS_DAYS`, the API logs a warning naming the event, the subscription and the customer, and the nightly reconciliation reports the subscription. The count of events settled as orphaned in the last seven days is `asobeast_billing_events_orphaned`, and while it is above zero the `billing.orphan.events` alert fires at investigate. See [Billing incident](/operations/runbooks/billing-incident) for what usually causes one. An event that fails for a reason that can pass, such as Stripe or the database being unreachable, still retries, and while it waits for a fix it is counted by `asobeast_billing_events_failed`, which pages. An event whose subscription is on a price the catalog cannot place fails once and stops. The catalog refreshes from Stripe first, so a price created since the last refresh retries instead. When the price is still unknown, retrying cannot teach the catalog about it, so the job fails at its first attempt, the stored event keeps its failure and no processed time, and `asobeast_billing_events_failed` pages. Once the cause is fixed, usually by running the catalog script or giving the price its `asobeast_plan` metadata, replay the event from support with `POST /admin/support/workspaces//billing-events//replay`. The replay clears how the event settled and queues it again under a fresh job, and it is audited like every other support action. While the catalog has not resolved at all, the same failure keeps retrying, because it can pass once Stripe answers. Upgrading to the release that introduced these outcomes settles, as orphaned, every stored event that had failed for one of the two orphan reasons, so those rows stop counting as failed. ## One sandbox per environment Stripe posts every event of a sandbox to every endpoint registered in that sandbox. A laptop and production that share one sandbox therefore share their events: a checkout on the laptop arrives at production as a subscription for a workspace production has never seen. Give every environment its own sandbox. | Environment | Stripe account | Webhook | | - | - | - | | Development on a laptop | A sandbox of its own, for example `asobeast-local` | Forwarded by the Stripe CLI, see below | | A disposable QA stack | The development sandbox, while no laptop uses it at the same time | Forwarded by the Stripe CLI to the stack's web origin | | CI | None. No automated test reaches Stripe | None | | Production before launch | A sandbox no other stack uses | An endpoint registered in the dashboard | | Production at launch | Live mode | A new live endpoint, with rotated keys | A stack that does not bill leaves `BILLING_ENABLED` false and holds no Stripe keys at all. Production warns at boot while billing runs on a sandbox key, as a reminder to switch to live keys before a customer pays. Create a sandbox in the dashboard from the account menu, then log the Stripe CLI in to it and forward its events to the local API: ```bash theme={null} stripe login --project-name asobeast-local stripe listen --project-name asobeast-local --all-snapshot --forward-to localhost:4000/billing/webhook ``` `stripe listen` prints a signing secret of its own. That is the `STRIPE_WEBHOOK_SECRET` of the stack it forwards to, not the secret of any registered endpoint. Run `pnpm --filter api stripe:catalog` once against each new sandbox. ## States a workspace moves through | State | Entitled | Buying again | What runs | | - | - | - | - | | Trial | Yes | Checkout | Everything, at Indie limits with 25 AI calls a month | | Paying, while the first payment confirms | No | Refused until it settles | Reads and exports only | | Active | Yes | Portal, to change plan | Everything, at the plan limits | | Past due | Yes | Portal, to change plan | Everything, while Stripe retries the charge | | Unpaid or paused | No | Portal, to start collecting | Reads and exports only | | Canceled or expired | No | Checkout | Reads and exports only | | Free, never subscribed | No | Checkout | Reads and exports only | The plan in force is derived rather than trusted: a paid plan past its period end falls back to the trial if one is still running, and to free otherwise. Every Stripe status lands in one of four outcomes. It **entitles** the workspace, it is **recoverable**, it is **pending**, or it is **gone**. Recoverable is the row that used to strand a customer: an unpaid or paused subscription pays for nothing, and a second one would be a double charge, so the only way forward is the portal. A workspace in that state is told its subscription stopped collecting and is offered a resume, never a checkout that would be refused. Pending is an `incomplete` subscription: the first payment needs authentication or is still processing, and Stripe gives it about a day to settle. It entitles nothing yet, and a second checkout is refused with a message that names the pending payment, because both attempts could otherwise complete and charge the customer twice. The paywall and the plan card say the payment is being confirmed and offer nothing to click. The subscription becomes active when the payment lands, or expires and frees the customer to buy again. A trial that ends with no payment method on file pauses or cancels the subscription according to the trial setting on the Stripe side. Pausing keeps the same subscription and asks for a card; cancelling frees the customer to buy again. Either is coherent, so pick one in the dashboard and leave it alone. ## Trial Seven days, Indie limits with 25 AI calls a month, no card. With SMTP configured the trial starts when the customer confirms their email, not at registration. A workspace gets one trial ever, recorded by `trialStartedAt`, and so does a mailbox: `person+2@gmail.com` and `p.erson@googlemail.com` are `person@gmail.com`, and an address on a throwaway domain starts none, privacy relay aliases such as `duck.com` or `mozmail.com` included, because one relay account mints unlimited aliases. A returning customer resumes on a paid plan rather than a second free week. A refused workspace records `trialDeclinedAt` and shows the plans straight away. See [One trial per mailbox](/security/authentication#one-trial-per-mailbox). A workspace that holds a paid plan, or ever held a subscription, never starts one, so paying before confirming an email keeps the paid plan. Milestone emails go out on days 0, 3, 5, 7 and 8 through the same SMTP transport and delivery log the product alerts use. The day 3 message is the one that matters: three days of positions is the first point at which the keyword monitor shows movement rather than a single reading. ## Dunning A failed charge moves the subscription to `past_due`, which **stays entitled**. Revoking on the first decline turns an expired card into churn. asobeast emails the owner once, points them at the billing portal, and then follows Stripe's own retry schedule. Recovery is automatic: the next `invoice.paid` clears the warning and restores the plan with no manual step. ## Downgrade and cancellation Both are self service through the Stripe customer portal. A cancellation takes effect at period end, so the customer keeps what they paid for. A downgrade, or a switch from annual to monthly, is scheduled at period end as well. An upgrade applies at once and invoices the difference. A plan change is a portal change, never a second checkout: a workspace that already names a live subscription is refused a checkout session with 409 and sent to the portal, so one workspace is never charged twice. A workspace also gets at most one checkout session it can complete. Opening a session claims a short lease, so two requests in flight together cannot both create one, and the previous session is expired at Stripe before a new one opens, so a page left open in another tab cannot be paid afterwards. Stripe itself is asked for the customer's subscriptions before any session is created, which closes the window between a completed payment and the webhook that records it. A subscription found that way is reconciled onto the workspace before the checkout is refused, so the customer keeps what they already pay for, and a refusal that rests on stored state reconciles first as well rather than trusting a row a webhook may have missed. A live subscription on the customer whose metadata names a different workspace refuses the checkout without adopting it: one customer is never billed twice, and the mismatch is logged for support. Turn on **Limit customers to one subscription** in the Stripe dashboard as well: it is the only thing that stops two checkout tabs both completing. Checkout and the billing portal first ask Stripe whether the workspace's stored customer still exists. A customer Stripe has deleted, or never had because the secret key now points at another Stripe account, is replaced by a new one before anything is charged, so moving from a sandbox to live mode needs no change to the database. A refusal carries the recovery in the error body, not only in its message, so the web app can open the portal for the customer rather than showing them a dead end. * asobeast reads the scheduled phase from Stripe and records the plan the workspace will land on. * Within a week of the effective date, if the workspace would be over the smaller plan's limits, the owner is emailed with the exact numbers. * At the effective date the daily run covers the first items in a stable order and leaves the rest untouched. **Nothing is deleted.** The catalog script provisions the portal rather than leaving it to the dashboard. It finds the default portal configuration and updates it, or creates one and says so. It allows cancellation at period end with a reason prompt, price changes across both products and all four prices with downgrades and switches to a shorter interval scheduled at period end, payment method updates, invoice history, and updates to the name, email, address, phone and tax id. It sets the terms and privacy links, `WEB_PUBLIC_URL` plus `/settings` as the default return, and turns the public portal login page off, because every portal visit starts from the API. Pausing is not offered: the portal of the pinned API version has no pause feature to configure, and a paused status is reserved for a trial that ended without a card. ## Reconciliation Webhooks are reliable but not guaranteed, and stale local state means either a paying customer locked out or a churned one still served. A daily job compares every workspace against Stripe, treats Stripe as authoritative, corrects the difference and logs every correction at error level, because a reconciliation that quietly fixes drift hides a webhook bug. It also reports subscriptions Stripe knows about that belong to no workspace. The last nightly report is kept, and the number of those orphan subscriptions is exported as `asobeast_billing_orphan_subscriptions`. While it is above zero the `billing.orphan.subscriptions` alert fires at investigate and lists up to five of the ids. A reconciliation of one workspace says nothing about orphans and never replaces the report. Reconciliation looks up a subscription two ways. The stored subscription id comes first, and when there is none, when Stripe has forgotten it, or when the one it names is already cancelled, the workspace customer is asked what it holds. A subscription there that names no other workspace in its metadata is adopted, which is what rescues a customer whose checkout completed and whose webhook never landed. Only a workspace that claims something Stripe cannot back is revoked, so a running trial is left to expire on its own rather than being reset every night. The revoke also requires the workspace to still hold the plan and subscription the run read, so a checkout that completes while a run is in progress is never undone by it; that workspace is left for the next run. The same repair runs the moment a customer comes back from checkout: the return carries a marker and the completed session id, which the app reads once, and it reconciles the workspace by that session there and then rather than waiting for a webhook that may never arrive. The marker is added to `STRIPE_PORTAL_RETURN_URL` as well when that is set, so the repair does not depend on the default return page. It is only cleared once the reconciliation succeeds, so a return that arrives while Stripe is unreachable is retried on the next load. Only an authoritative answer revokes a plan. A subscription Stripe reports as missing is gone and the workspace drops to free, and so is one whose Stripe customer Stripe reports as missing, which is what every stored id looks like after the secret key moves to another Stripe account, such as from a sandbox to live mode. A timeout, a rate limit, a rejected key, an outage or a price the local catalog cannot name leaves the workspace exactly as it is and is reported as unreconciled, because a provider that cannot answer is not the same as a customer who stopped paying. An owner can reconcile their own workspace immediately with `POST /billing/reconcile`, so support can resolve a single stuck customer without waiting for the next run. That call answers 503 rather than a report when Stripe cannot be reached. ## Related > **[Authentication and accounts](/security/authentication)** > > Sessions, workspaces and what an unentitled workspace keeps. > **[Configuration reference](/configuration/reference)** > > Every Stripe variable and its default. > **[Take billing live](/operations/billing-go-live)** > > The dashboard checklist and the ordered steps from sandbox to live mode. # Take billing live Source: https://docs.asobeast.com/operations/billing-go-live The dashboard settings the API cannot reach, the ordered steps from a sandbox to live mode, and how to roll back if the first live payment goes wrong. Everything the API can set about Stripe is set by `pnpm --filter api stripe:catalog`: the products, the prices, their tax behaviour, the product tax code and the customer portal. What is left lives only in the Stripe dashboard. This page is the checklist for it and the order to switch from a sandbox to live mode. How billing behaves once it runs is on [Billing](/operations/billing). Choose the tax mode first: [selling through Stripe Managed Payments](/operations/billing#selling-through-stripe-managed-payments) removes every tax registration from the steps below. ## Dashboard settings Set every row in every sandbox and again in live mode. The catalog script prints the same list when it finishes. Under Managed Payments the catalog script prints a different list; follow the script's output instead of this table. | Setting | Where | Value | Why | | - | - | - | - | | Limit customers to one subscription | Settings, Checkout and Payment Links, Subscriptions | On | The only thing that stops two checkout tabs both completing | | Manage failed payments | Settings, Billing, Subscriptions and emails | Smart Retries on, then mark the subscription unpaid | `past_due` stays entitled while Stripe retries; `unpaid` stops collection without losing the subscription, and the customer resumes it from the portal | | Customer emails | Settings, Billing, Subscriptions and emails | Failed payments, expiring cards and payments requiring authentication on, with the hosted invoice link | asobeast sends one dunning email and nothing when a renewal needs authentication; Stripe's emails carry the link | | Trial end behaviour | Settings, Billing, Subscriptions and emails | Leave as it is | asobeast trials run in the app and Checkout never starts a Stripe trial, so the setting never applies | | Payment methods | Settings, Payment methods | Card, Link, Apple Pay and Google Pay on; SEPA Direct Debit and Cash App Pay off | A delayed method leaves a subscription pending for days | | Adaptive pricing | Settings, Checkout | Off | The catalog and the web app are in USD | | Public business details | Settings, Public details | Legal name, support email `hello@asobeast.dev`, terms and privacy URLs | Checkout and the portal print them | | Radar | Radar, Rules | The default rules | | | Webhook endpoint | Developers, Webhooks | One endpoint per environment with the ten handled events on the pinned API version | See [Billing](/operations/billing#the-webhook-endpoint) | ## Going live **1. Choose who collects the tax** Under Managed Payments, Stripe is the merchant of record and nothing needs registering. Otherwise add the Stripe Tax registrations in the dashboard, Poland, OSS for the EU and any US state past its nexus threshold, and keep `STRIPE_TAX_ENABLED` at `false` until they exist. **2. Activate Managed Payments** Only for Managed Payments. In live mode open Settings, Managed Payments, activate it and accept its terms. Stripe reviews eligibility first; wait for the approval before switching the variable on. **3. Provision live mode** Run `pnpm --filter api stripe:catalog` with the live secret key in `STRIPE_SECRET_KEY`, the production `WEB_PUBLIC_URL` and `STRIPE_MANAGED_PAYMENTS` set as production will run, then read the checklist it prints for that mode, after the four lookup keys with their price ids and the portal configuration id. **4. Set the dashboard** Set every row of the table above in live mode, or under Managed Payments every item of the checklist the catalog script printed. **5. Create the live endpoint** Register `https://app.asobeast.com/api/backend/billing/webhook` with the ten handled events on the pinned API version, and copy its signing secret. **6. Switch the keys** Replace `STRIPE_SECRET_KEY` and `STRIPE_WEBHOOK_SECRET` on the host, remove the four `STRIPE_PRICE_*` variables, set either `STRIPE_MANAGED_PAYMENTS=true` or `STRIPE_TAX_ENABLED` to what the first step allows, never both, and redeploy. The boot log reports four prices resolved from lookup keys and no longer warns about a sandbox key. **7. Send a test event** Send one test event from the endpoint's page. It names no subscription, so the stored event settles as `ignored`. **8. Buy once with a real card** Buy Indie monthly on a throwaway workspace, confirm the plan card shows Indie, then cancel in the portal and refund the charge from the dashboard. Under Managed Payments, check that the receipt says sold through Link and that the tax line matches the billing country. **9. Rotate the sandbox keys** Rotate every sandbox key that was ever pasted anywhere outside the host. **10. Retire the old sandbox endpoint** Delete the sandbox endpoint that pointed at production, so the old sandbox can serve a staging stack or be deleted. ## Rolling back Put the sandbox keys and the sandbox webhook secret back on the host and redeploy, then delete the live endpoint so Stripe stops posting live events to an instance that cannot verify them. A workspace that paid in live mode in the meantime keeps its subscription at Stripe; refund it from the dashboard and reconcile the workspace from support once live mode returns. ## Related > **[Billing](/operations/billing)** > > The catalog, checkout, the webhook endpoint and reconciliation. > **[Billing incident](/operations/runbooks/billing-incident)** > > What to do when an event fails or a customer lands on the wrong side. # How much can one instance collect? Source: https://docs.asobeast.com/operations/capacity Measured orchestration cost and projected daily wall clock at three scales, the configuration where a run stops finishing inside a day, and what the per market multiplier really costs. Collection is bounded by rate limits, not by hardware. asobeast asks the stores for one thing at a time on purpose, so the honest question is not how fast your server is, it is how many store requests a day you have room for. Every number here was measured on a deterministic fixture and can be reproduced with `pnpm --filter api run bench:target`. ## What the pipeline costs to orchestrate This is the code's own overhead: building the target list, estimating the budget, and enqueueing the whole fan-out with a provider that returns instantly. | Scale | Apps | Tracked keywords | Ranking rows | Budget query | Fan-out | Jobs enqueued | Redis growth | | - | - | - | - | - | - | - | - | | `small` | 3 | 75 | 525 | 12 ms | 11 ms | 81 | 0.4 MB | | `target` | 20 | 8,000 | 720,000 | 91 ms | 468 ms | 8,040 | 9.5 MB | | `stress` | 50 | 75,000 | 6,750,000 | 547 ms | 4,029 ms | 75,100 | 77 MB | Orchestration is not the constraint and never becomes one. Enqueueing 75,100 jobs takes four seconds, against a run that will take days. Budget Redis at roughly 1 KB per queued job. ## What the pipeline costs in wall clock These are measurements, taken against the memory ceilings the stack ships rather than against an unconstrained runner. The full record, including how to reproduce them, is in `apps/api/test/load/MEASUREMENTS.md`. | Scale | Keyword markets | App Store requests | At 15 rpm | Google Play requests | At 10 rpm | Utilization | | - | - | - | - | - | - | - | | `small` | 75 | 81 | 0.1 hours | 0 | 0 hours | 0.4% | | `target` | 8,000 | 4,020 | 4.5 hours | 32,020 | 53.4 hours | 222% | | `stress` | 75,000 | 37,550 | 41.7 hours | 300,050 | 500.1 hours | 2,084% | Both columns count **requests**, which is what the store sees and what risks throttling. A depth 200 Google Play keyword search costs about eight requests where the Apple equivalent costs one, so a workload split evenly between the stores spends roughly eight times as long on Play as on Apple for the same number of keywords. The `target` row does not finish inside a day. Apple's share does, comfortably; Play's does not, by a factor of two. That is the measured answer to whether one host carries 20 apps at 200 keywords across two storefronts and two stores: **it does not**, and the reason is `SCRAPE_GPLAY_RPM`, not the machine. Orchestration is not what you wait for. Building the daily job set at target scale costs 110 ms to price the day and 443 ms to enqueue it, against a window measured in hours. ### What the guard does when the day is too small The daily run does not simply overrun. At 222% pressure it sheds review syncing, and past 150% it sheds app refreshes as well, keeping the keyword captures that are the product. Both were observed in the measurement runs. See [The daily pipeline and rate limits](/concepts/pipeline). ## Where the day runs out A run stops finishing inside 24 hours at: | Store | Ceiling per day | What that is | | - | - | - | | App Store | **21,600 requests** | `SCRAPE_ITUNES_RPM` of 15, times 60, times 24 | | Google Play | **14,400 job starts** | `SCRAPE_GPLAY_RPM` of 10, times 60, times 24 | Keywords dominate the count, and a keyword is counted once per market. What that ceiling buys depends entirely on which store the keywords are on. | Store mix | Keyword markets one host carries a day | Why | | - | - | - | | Apple only | Roughly **20,000** | One request a search against 21,600 requests, minus the app, review and category work. A category costs up to three requests a day, one per chart kind, each chart fetched once for every workspace in it | | Even split | Roughly **3,600** | The Play half costs 8 requests a search, so 1,800 Play markets exhaust 14,400 requests while 1,800 Apple markets use a tenth of theirs | | Play only | Roughly **1,800** | 14,400 requests at 8 requests a search | Some Apple only shapes that reach the ceiling: * 20 apps, 200 keywords each, 5 markets is 20,000 searches, about 22 hours. A sixth market does not fit. * 50 apps, 200 keywords each, 2 markets is 20,000 searches, the same wall from a different direction. * 10 apps, 400 keywords each, 5 markets is 20,000 searches again. The shape does not matter. The product does, and so does the store. Raising this ceiling is not a matter of a bigger machine. Every number above is a rate limit on one egress address, so more capacity means more addresses, which is what the egress proxy pool is for. See [Configuration reference](/configuration/reference). ## The per market multiplier Adding a storefront for a keyword set you already track **doubles** the searches for that set. The same phrase in two markets is two keyword rows, checked by two searches, because rankings differ per storefront and one search cannot answer for both. At the `target` scale, dropping from two markets to one halves the App Store projection from 4.5 hours to about 2.3. Adding a third takes it to about 6.7. This is the single most expensive decision you make, and it is invisible until the run stops finishing. Check it before you add a market, not after. ## Check your own numbers `GET /jobs/budget` estimates the fan-out from your real data, broken down per store, and the settings page renders the same figures as a budget card for the [platform operator](/security/admin-surfaces#who-the-platform-operator-is). It answers any personal API token, read below from `ASOBEAST_API_TOKEN`. The estimate is verified against the actual fan-out in CI on every pull request, so the number the card shows is the number of jobs the pipeline will enqueue. ```bash theme={null} curl --silent --header "Authorization: Bearer $ASOBEAST_API_TOKEN" http://127.0.0.1:3001/api/backend/jobs/budget | jq '{total, utilization, stores}' ``` `marketListings` is how many of the `apps` jobs refresh the listing of a market, one per app and competitor for each market with a tracked keyword. `localizations` is how many more listing reads those jobs make for the native localizations of storefronts that default to English, one per localization of each App Store listing. `utilization` is the fraction of a day the run will occupy at your configured limits. Above 1.0 the run cannot finish before the next one starts. `completion` answers the question a customer actually asks: `startsAt` is the next scheduled run, `hours` is how long the work takes at the current capacity, and `completesAt` is when the run should finish. When an egress proxy pool is configured the capacity behind those numbers is the healthy endpoint count, capped at `PROXY_WORKER_MAX_CONCURRENCY`, times `PROXY_ENDPOINT_RPM` rather than the single host budget, because a store job holds one endpoint for its whole run and the workers run at most that many jobs at once. Endpoints beyond the ceiling add no capacity until it is raised, and the projection degrades honestly as endpoints cool down. `quota` is present only when `BILLING_ENABLED` is true. It reports the plan, the apps and keyword markets used against their limits, and `overLimitSince` once a workspace has been over its keyword limit long enough for daily checks to cover only the first N keyword markets in a stable order. Operators get the cross-tenant view at `GET /api/backend/admin/capacity`, which sums demand across every workspace the daily run visits and names the ones consuming most. It is reserved for the [platform operator](/security/admin-surfaces#who-the-platform-operator-is) and answers `404` to anyone else, including an anonymous caller and a customer who owns their own workspace. The admin area shows the same report at **Admin, Capacity**, with a line per store and the top consumers by name. See [The admin area](/security/admin-surfaces#the-admin-area). ## Under pressure When the previous run's backlog plus today's demand passes the daily capacity, the run sheds work in a stated order rather than overlapping itself. Category ranks and review sync go first, followed chart categories with them, app refreshes next, and rank checks are never dropped because they are the product. The decision is logged at error level with the pressure figure and what was shed. On demand actions are limited per workspace so one frustrated customer cannot spend the pool. Manual refresh, run daily, keyword scoring, deep search, app imports, competitor adds and top chart captures are capped per day; suggestion lookups and store lookups (name search, similar apps) are capped per hour. Over the limit the API answers `429` with `retryAfterSeconds` in the error envelope. Nothing is limited while `BILLING_ENABLED` is `false`, so a self hosted instance behaves as before. ## What each container is allowed on the host Everything above bounds how much work the day can do. This bounds what one container can take from the machine while it does it, because on a single host PostgreSQL, Redis, the API and the web app are neighbours rather than isolated tenants. Compose sets a memory ceiling per service and rotates every container log. | Service | Memory ceiling | Measured at rest | What happens at the ceiling | | - | - | - | - | | `api` | 1024 MB | \~230 MB | The kernel stops the container and `restart: unless-stopped` brings it back. In flight jobs are retried on the next run | | `web` | 384 MB | \~58 MB | The kernel stops the container and Compose restarts it. The API keeps running, so nothing is lost | | `postgres` | 384 MB | \~48 MB | The kernel stops the container. This is the one to watch, because recovery on the next start replays the write ahead log | | `redis` | 320 MB | \~14 MB | Redis refuses writes before the kernel gets involved. See below | The ceilings sum to roughly 2.1 GB, which is why [System requirements](/install/requirements) asks for about 2.5 GB. The at rest figures come from an idle stack with a small portfolio. The API ceiling includes about 190 MB for the screenshot reader while it works. It is returned after five idle minutes. The \~230 MB at rest was measured before the reader existed. See [Read screenshot captions](/guides/screenshot-captions). Under load the picture is tighter, and it was measured rather than assumed. | Workload | Database size | Peak PostgreSQL | Peak Redis against its 192 MB ceiling | Killed | | - | - | - | - | - | | 75 keyword markets | 10 MB | 25 MB, 6% of 384 MB | 12 MB, 6% | No | | 8,000 keyword markets, 90 days of history | 227 MB | 306 MB, 80% of 384 MB | 60 MB, 31% | No | | 75,000 keyword markets, 90 days of history | 2,060 MB | 362 MB, 94% of 384 MB | 190 MB, 99% | No | Nothing was OOM killed at any scale, and the largest workload leaves almost no headroom in either service. `storage.headroom.low` and `queue.memory.high` alert at 80 percent and page at 90, so both would have fired on the way to that last row rather than after it. ### Why Redis refuses instead of evicting Redis runs with `maxmemory 192mb` and `maxmemory-policy noeviction`, well under its container ceiling. BullMQ job state is not a cache. Under an eviction policy Redis would silently discard queued work to make room, and the run would simply not happen with nothing to show for it. Under `noeviction` a write past the ceiling fails with `OOM command not allowed when used memory > 'maxmemory'`, the API answers `500` on the affected request, and previously queued state stays readable. Loud and recoverable beats quiet and lost. Do not change this to `allkeys-lru` for convenience. ### Every container log is rotated Each service writes to the `json-file` driver capped at 10 MB across 3 files, so no container can grow past 30 MB of logs and the four together cannot pass 120 MB. The Compose stack also lowers `LOG_LEVEL` to `log`. At `debug` the API records every request, which on a busy pipeline day fills the rotation window fast enough that yesterday's evidence is gone before you go looking for it. ### The ceilings are watched Every ceiling above used to be detected only by the symptom it produced, which on a single host means the service is already stopping. The API now measures what it can reach and alerts before that. | Reading | Metric | Alert | | - | - | - | | Database size on disk | `asobeast_database_bytes` | `storage.headroom.low` at 80 percent of `DISK_BUDGET_BYTES`, paging at 90 | | Storage budget | `asobeast_disk_budget_bytes` | Its own value is the denominator | | Redis memory in use | `asobeast_queue_memory_bytes` | `queue.memory.high` at 80 percent of the ceiling, paging at 90 | | Redis memory ceiling | `asobeast_queue_memory_max_bytes` | Its own value is the denominator | The API container cannot see the host filesystem, so it does not pretend to measure free space. It measures the database, which is the thing that grows, and compares it against `DISK_BUDGET_BYTES`, which you set to what the partition can spare. Set it and you get a true headroom alert; leave it at `0` and you get no alert rather than a false one. Redis reports its own ceiling, so `queue.memory.high` needs no configuration. An unlimited Redis reports `maxmemory` as `0` and is left alone rather than compared against nothing. A reading that cannot be taken is left out of the scrape instead of being reported as zero, and one failing collector never takes the rest of `/metrics` down with it. See [Hosted observability](/operations/hosted-observability). ### Which symptom points at which limit | Symptom | Cause | What to do | | - | - | - | | PostgreSQL refuses to write and the disk is full | Container logs are not the cause once rotation is on. Look at the `pgdata` volume | Tighten the `RETENTION_*` windows and re run the retention job | | A container restarts on its own with no error in its log | The kernel stopped it at its memory ceiling | `docker inspect ` and read `State.OOMKilled` | | Jobs fail with `OOM command not allowed` | Redis reached `maxmemory` | Check for a retention gap leaving completed jobs behind, then raise the ceiling if the working set is genuinely larger | | The API answers but the queue never drains | Not a memory limit. See [Troubleshooting](/operations/troubleshooting) | | ## Should you raise the limits? Usually not. `SCRAPE_ITUNES_RPM` defaults to 15 against an informal ceiling of roughly 20 requests per minute per IP address. There is headroom, and there is not much of it. Going past what the store tolerates does not produce an error you can plan around: requests start failing, jobs land in the failed set, and in the worst case the store stops answering that IP address for a while. That cost falls on you, not on the project, and the recovery is waiting. Prefer, in this order: 1. Remove keywords you do not act on. Most tracked sets have a long tail nobody reads. 2. Remove markets you do not sell in. This is the multiplier. 3. Only then consider raising `SCRAPE_ITUNES_RPM`, in small steps, watching the failed job count on the health endpoint after each change. Both workers run at concurrency 1 behind their limiter deliberately. That is not a performance oversight, it is the thing keeping your address in good standing. See [The daily pipeline and rate limits](/concepts/pipeline). ## The read path holds up Query plans were captured at the `target` scale, with 720,000 ranking rows loaded, after `ANALYZE`: | Query | Rows examined | Time | | - | - | - | | 90 days of rankings for one app | 36,000 | 4.1 ms | | Latest position per keyword | 36,000 | 46.5 ms | | Visibility aggregate over 90 days | 36,000 | 7.5 ms | All three use the existing `("appId", date)` index rather than scanning the table, so no index was added. The dashboard stays responsive at the benchmark scale; the collection window is the thing that runs out first. ## Related > **[The daily pipeline and rate limits](/concepts/pipeline)** > > Why concurrency is 1 and what the limiter does. > **[Countries and markets](/concepts/countries)** > > Why a market multiplies rather than adds. # Email deliverability Source: https://docs.asobeast.com/operations/email-deliverability Make account and alert emails reach the inbox: authenticate the sending domain with SPF, DKIM and DMARC, and keep one click unsubscribe working. Mailbox providers decide where a message lands from two things: whether the sending domain proves the message is its own, and whether the message itself looks like careful automated mail. AsoBeast takes care of the message. The domain is yours to set up. ## What AsoBeast does for you Every email AsoBeast sends carries the following. | Property | Why it matters | | - | - | | A plain text part next to the HTML | Filters penalise HTML only mail, and screen readers and text clients need it | | `Auto-Submitted: auto-generated` and `X-Auto-Response-Suppress: All` | Vacation responders and out of office replies stay silent, so no reply loop reaches the sender mailbox | | A `Message-ID` on the `SMTP_FROM` domain | The identifier aligns with the sender | | No tracking pixels, click redirects or link shorteners | Every link shows the address it opens, which is what anti phishing checks compare | | One small logo image and live text everywhere else | The message reads correctly with images turned off | | HTML under 90 KB, even for the largest daily update | Gmail clips a message above about 102 KB and hides its footer | Alert emails are subscription mail. When `WEB_PUBLIC_URL` is `https`, each one also carries `List-Unsubscribe` and `List-Unsubscribe-Post: List-Unsubscribe=One-Click` (RFC 8058), so Gmail, Yahoo, Apple Mail and Outlook.com show their own unsubscribe button. Pressing it pauses that one email alert. The footer of every alert email links to the same choice on a confirmation page. Account emails (confirmation, password reset, invitation), the confirmation email an alert recipient receives and billing notices are transactional, so they carry no unsubscribe. ## What your domain needs Publish three DNS records for the domain in `SMTP_FROM`. The examples use `mail.example.com`. SPF lists the servers allowed to send for the domain: ```text theme={null} mail.example.com. TXT "v=spf1 include: -all" ``` DKIM publishes the public key that verifies each message signature: ```text theme={null} asobeast._domainkey.mail.example.com. TXT "v=DKIM1; k=rsa; p=" ``` DMARC tells receivers what to do when neither check aligns with the visible sender, and where to send reports: ```text theme={null} _dmarc.mail.example.com. TXT "v=DMARC1; p=none; rua=mailto:dmarc@example.com" ``` Gmail, Yahoo and Outlook.com require SPF, DKIM and an aligned DMARC record from anyone sending about 5,000 or more messages a day to their users, together with a working one click unsubscribe on subscription mail. Smaller senders should publish them anyway, because unauthenticated mail is the first to be filtered. Start DMARC at `p=none`, read the reports, and move to `p=quarantine` once they show only your own sources. ## Choose a relay A managed relay such as Amazon SES, Postmark, Resend, Mailgun or SendGrid signs every message with DKIM for you, keeps reverse DNS correct and handles TLS. Point `SMTP_HOST` at it and publish the records it gives you. Running your own Postfix means you sign the mail, keep the reverse DNS of the sending address matching its hostname, and watch the reputation of that address yourself. ## Sign in AsoBeast instead When the relay does not sign, AsoBeast can. Generate a key in the folder both Compose stacks mount: ```bash theme={null} openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out apps/api/keys/dkim-private.pem ``` Print the public half in the form the DNS record expects: ```bash theme={null} openssl pkey -in apps/api/keys/dkim-private.pem -pubout -outform DER | openssl base64 -A ``` Publish that value as `p=` in the DKIM record above, then set `SMTP_DKIM_DOMAIN`, `SMTP_DKIM_SELECTOR` and `SMTP_DKIM_PRIVATE_KEY_PATH` and restart the API. The rules for the three variables are on the [configuration reference](/configuration/reference#email-delivery). The signature covers both unsubscribe headers, which RFC 8058 requires. ## Set the sender name Use a display name with an address on the authenticated domain, for example `SMTP_FROM="AsoBeast "`. Providers judge the display name too, and an address on another domain breaks alignment. ## Check a message Send a test from **Settings**, **Email alerts**, then open the original message in the receiving client ("Show original" in Gmail). Look for `spf=pass`, `dkim=pass` with `d=` set to your domain, and `dmarc=pass`. An alert email should also show the `List-Unsubscribe` pair. Watch the spam complaint rate in Google Postmaster Tools. Keep it under 0.1% and never let it reach 0.3%. ## Related > **[Send alerts](/guides/alerts)** > > Email and webhook delivery, and how unsubscribe works. > **[Configuration reference](/configuration/reference)** > > Every SMTP variable and its rules. > **[Rotate secrets](/operations/rotate-secrets)** > > What a new `AUTH_SECRET` does to unsubscribe links. # Health checks and monitoring Source: https://docs.asobeast.com/operations/health-checks The API health endpoint reports the database, Redis, the last daily pipeline run, the failed job count and the open action count in one response. Two endpoints answer two different questions. One says the web container is alive. The other says the whole pipeline is working. ## Endpoints | URL | Answers | Authentication | | - | - | - | | `/api/health` | Is the web container serving | None | | `/api/backend/health` | Is the API alive, and is collection healthy | None | Both are reachable on the web origin, so an uptime check needs no credentials and no access to the API port. The API status badge in the web header reads `/api/backend/health` and is shown only to the [platform operator](/security/admin-surfaces#views-only-the-operator-sees). The endpoint itself stays public for probes. ## What does a healthy instance look like? ```json theme={null} { "status": "ok", "db": "up", "redis": "up", "pipeline": { "lastDailyRunAt": "2026-07-30T03:00:12.000Z", "stale": false, "failedJobs": 0, "actions": { "generatedAt": "2026-07-30T04:11:02.000Z", "open": 7 } } } ``` | Field | Meaning | | - | - | | `status` | `ok` when the database answers. The endpoint returns `503` when it does not | | `db` | `up` or `down`, from a real query rather than a connection count | | `redis` | `up` or `down`. A Redis ping that exceeds one second reports `down` | | `pipeline` | The object below, or `null` while Redis is unreachable and while the pipeline signals cannot be read, because they come from both Redis and the database | | `pipeline.lastDailyRunAt` | When the daily pipeline last started, or `null` | | `pipeline.stale` | `true` when the last run is more than 26 hours old and at least one app is tracked | | `pipeline.failedJobs` | Failed jobs across the App Store, Google Play and alert queues. A count that keeps climbing across daily runs points at [a broken store parser](/operations/scraper-breakage) | | `pipeline.actions` | When actions were last generated, and how many are open | The 26 hour window is deliberate. A daily schedule plus a slow night should not page you, so `stale` only fires once a run has genuinely been missed. A fresh instance with no apps never reports stale, because there is nothing to collect. ## What does a database outage look like? When the database does not answer, the endpoint returns `503` with `Retry-After: 5`. The body is the [error envelope](/api-reference/errors#the-envelope) with the same fields as above added to it, so one parser reads both. ```json theme={null} { "statusCode": 503, "error": "Service Unavailable", "message": "The database cannot be reached. Try again in 5 seconds.", "retryAfterSeconds": 5, "path": "/health", "timestamp": "2026-10-19T09:12:44.118Z", "status": "error", "db": "down", "redis": "up", "pipeline": null } ``` `db` is `down` and `status` is `error`. `redis` still reports Redis on its own, and `pipeline` is `null` because the pipeline signals include a database read. The same applies to Redis: while it is unreachable `redis` is `down` and `pipeline` is `null`, and the endpoint still answers `200` as long as the database answers. ## Monitor it One command is enough for an uptime check. ```bash theme={null} curl --fail --silent --show-error https://your-host/api/backend/health ``` `--fail` turns the `503` into a non zero exit status, which is what most monitoring agents key on. Use `--fail-with-body` instead to see the body of the `503` and read `db`. To alert on a stalled pipeline rather than on a dead API, check the `stale` and `failedJobs` fields rather than the status code. A stale pipeline still answers `200`, because the API itself is healthy. The same is true of Redis. The API keeps answering reads from PostgreSQL while Redis is down, so `status` stays `ok` and the outage shows only in `redis`. Alert on `redis` being `down`. See [When Redis is unreachable](/api-reference/rate-limits#when-redis-is-unreachable) for what the API does meanwhile. ## Egress proxy pool Only relevant when `PROXY_PROVIDER` is set. `GET /api/backend/admin/proxy-pool` answers with the pool's shape and is reserved for the [platform operator](/security/admin-surfaces#who-the-platform-operator-is): anyone else gets `404`, an anonymous caller included, the same answer the queue dashboard gives, so the surface never confirms it exists. The response carries pool size, per store healthy and cooling down counts, per endpoint success rate over a rolling hour, the distribution of the last failure classification, residential fallback rate and month to date spend, and requests per hour against capacity. The `alerts` array is the part to watch. It is empty on a healthy pool. | Alert | Meaning | First response | | - | - | - | | `pool.healthy.low` | Fewer than half the serving endpoints are usable for a store | Check the failure distribution, then the provider dashboard | | `pool.blocked.rising` | A tenth of the pool was last seen blocked | The store is refusing that address range. Rotate the pool | | `pool.silent.rising` | An endpoint returned a plausible looking empty result | Treat as urgent. This is the only signal that data quality is degrading | | `residential.spend.near-cap` | Fallback spend passed 80 percent of the monthly cap | Fix the datacenter block. At the cap the fallback stops rather than spends | The same alerts are written to the API log on every pool reconciliation, so an operator with no dashboard still sees them. ## Container health checks Every Compose service defines its own health check, and the dependency order uses them: the API waits for PostgreSQL and Redis to report healthy, and the web app waits for the API. ```bash theme={null} docker compose ps ``` `docker compose up --wait` blocks until every check passes, so a successful exit is a real signal rather than a process start. ## Related > **[The daily pipeline and rate limits](/concepts/pipeline)** > > What the pipeline does and when. > **[Troubleshooting](/operations/troubleshooting)** > > What to do when a field goes the wrong way. # Observability for a hosted instance Source: https://docs.asobeast.com/operations/hosted-observability Structured logs, per workspace metrics, the alert catalogue and the support surface. What a hosted operator watches, and where each signal comes from. A self hosted instance has one tenant and one blast radius. A hosted instance has many, and the questions that need answering at 03:00 are per tenant: is the daily run completing for everyone, who is consuming the pool, is anyone losing data quality, is anyone costing more than they pay, and did anything cross a workspace boundary. Everything below is built into the API. There is no separate metrics pipeline, log stack or dashboard to run. ## Structured logs Logs are JSON on stdout in production and human readable in development, controlled by `LOG_LEVEL`. Any hosting platform or log service can consume them without extra software. Every line emitted inside a request or a job carries: | Field | Meaning | | - | - | | `workspaceId` | The workspace in scope, taken from the async context that also scopes the database | | `correlationId` | One id per request, propagated into every job that request enqueues | | `context` | The class that logged the line | Send `X-Correlation-Id` on a request to choose the id yourself, which lets a customer report carry the exact key you need to search on. An id that is missing, too long or carries characters that could forge a log line is replaced with a generated one. > [!WARNING] > > Secrets are redacted before a line is written, not by remembering to leave them out. Personal API tokens, session cookies, Stripe keys, proxy credentials, `AUTH_SECRET`, `OPENAI_API_KEY` and the SMTP password are replaced with `[redacted]` wherever they appear, including inside an error message or a stack. > > The value of a query parameter named like a credential, for example `token`, `access_token`, `refresh_token`, `id_token`, `auth_token`, `password`, `passwd`, `pwd`, `secret`, `client_secret`, `api_key`, `private_key` or `credentials`, is replaced the same way in the logged request address and in the logged `query` object, whatever the letter case or percent encoding of the name. The rest of the address is logged as it arrived. The API ignores a credential sent this way, so send it in the `Authorization` header. ## The metrics endpoint `GET /metrics` serves the Prometheus text format. It is gated exactly like the queue dashboard: anything other than the entitled [platform operator](/security/admin-surfaces#who-the-platform-operator-is) receives **404**, never 401 or 403, so the endpoint never confirms it exists. A customer who owns their own workspace is not an operator and receives **404**, and so does an operator whose own workspace has no plan in force, as [the admin surfaces page](/security/admin-surfaces#when-the-operators-workspace-has-no-plan) explains. Reach it through the web origin at `/metrics`, the same way you reach `/admin/queues`. Per workspace, one series per metric: | Metric | Answers | | - | - | | `asobeast_workspace_plan_info` | Which plan is in force | | `asobeast_workspace_apps`, `asobeast_workspace_competitors` | How much is tracked | | `asobeast_workspace_keyword_markets` | Keyword market pairs, the unit quota counts | | `asobeast_workspace_quota_apps_limit`, `asobeast_workspace_quota_keyword_markets_limit` | The plan limit, `-1` when unlimited | | `asobeast_workspace_daily_requests_estimated` | Store requests one daily run demands, split by store | | `asobeast_workspace_on_demand_used` | On demand operations spent in the current window | | `asobeast_workspace_ai_calls_month` | AI calls reserved or spent this UTC month | | `asobeast_workspace_quota_ai_calls_limit` | The monthly AI call allowance, `-1` when unlimited | | `asobeast_workspace_ai_tokens_month` | OpenAI tokens of counted calls this UTC month, by `kind` `input`, `cached_input` or `output`, the figure to reconcile against the OpenAI invoice | | `asobeast_workspace_rankings_captured` | Captures since the last daily trigger | | `asobeast_workspace_rankings_captured_yesterday` | The same figure for the previous UTC date, for a day over day comparison | | `asobeast_workspace_rankings_unresolved` | Captures with no position inside the searched depth | | `asobeast_workspace_daily_run_completed_seconds` | When the last ranking landed | | `asobeast_workspace_daily_run_incomplete` | Whether fewer keywords were captured than are tracked | | `asobeast_workspace_stored_rankings`, `asobeast_workspace_stored_reviews` | Stored rows, the storage side of cost | Instance wide, the pool and the money: `asobeast_pool_endpoints`, `asobeast_pool_endpoints_healthy`, `asobeast_pool_success_rate`, `asobeast_pool_endpoints_blocked`, `asobeast_pool_endpoints_silent`, `asobeast_proxy_requests_month`, `asobeast_residential_spend_usd`, `asobeast_residential_cap_usd`, `asobeast_billing_workspaces`, `asobeast_billing_subscriptions`, `asobeast_billing_trials_active`, `asobeast_billing_events_unprocessed`, `asobeast_billing_events_failed`, `asobeast_billing_events_orphaned`, `asobeast_billing_orphan_subscriptions`, `asobeast_backup_last_completed_seconds`, `asobeast_backup_max_age_hours`, `asobeast_database_bytes`, `asobeast_disk_budget_bytes`, `asobeast_queue_memory_bytes`, `asobeast_queue_memory_max_bytes`, `asobeast_account_mail_attempts`, `asobeast_store_canary` and `asobeast_isolation_anomalies`. `asobeast_store_canary{store,outcome}` carries the parser canary verdict as a label rather than a number, so an alert expression reads `outcome="broken"` instead of decoding an enum. A store the canary has not answered for yet has no series at all, which is what a fresh instance looks like until the first scheduled run. The four resource series and the canary are the only ones that can be absent while the instance is healthy. A reading the API could not take is omitted rather than reported as zero, because a zero here reads as an empty database or an idle queue, which is the opposite of what a failed measurement means. One collector failing degrades its own series and nothing else, so a scrape still carries everything the operator needs to see the failure. Backup freshness is exposed as the completion time rather than as an age, so `time() - asobeast_backup_last_completed_seconds` graphs it correctly whatever `METRICS_CACHE_SECONDS` is set to. A cached age would be stale by up to the cache window; a cached timestamp is not. Cardinality is bounded on purpose. There is one series per workspace per metric, and never one per keyword. ## The alert catalogue Every scrape evaluates the rules below and exposes each firing alert as `asobeast_operator_alert{alert,severity}` with the value `1`. The same alert is written to the log at a matching level, so an instance with no scraper still leaves a trail. Absence of the series means the alert is not firing. ### Page immediately | Alert | Fires when | Runbook | | - | - | - | | `isolation.anomaly` | A query scoped to one workspace returned a row owned by another | [Suspected isolation incident](/operations/runbooks/isolation-incident) | | `dependency.redis.unavailable` | Redis did not answer, so no queue work is running | [Daily run overrun](/operations/runbooks/daily-run-overrun) | | `daily.run.incomplete` | A workspace captured fewer keywords than it tracks, 20 hours past the trigger | [Daily run overrun](/operations/runbooks/daily-run-overrun) | | `billing.webhooks.failing` | A stored billing event recorded a failure and still waits for a fix | [Billing incident](/operations/runbooks/billing-incident) | | `pool.healthy.low` | Fewer than half the endpoints serving a store are healthy | [Proxy pool degradation](/operations/runbooks/proxy-pool-degradation) | | `backup.stale` | No backup has ever reported in, the last one reported a time in the future, or the last one is more than twice `BACKUP_MAX_AGE_HOURS` old | [Backups](/operations/backups) | | `storage.headroom.low` | The database passed 90 percent of `DISK_BUDGET_BYTES` | [Capacity and limits](/operations/capacity) | | `queue.memory.high` | Redis passed 90 percent of its memory ceiling, where it refuses writes | [Capacity and limits](/operations/capacity) | | `mail.failures.clustered` | Half or more of the account emails attempted in the last day were refused | [Troubleshooting](/operations/troubleshooting) | | `store.parser.broken` | A store canary run failed a shape assertion, on the first failing run rather than the confirmed second | [Scraper breakage](/operations/scraper-breakage) | The database being unreachable does not need an alert rule. The scrape itself fails, and so does `/health`, which is what an uptime check watches. That check is not part of asobeast and cannot be, because every rule below is evaluated by the API during a scrape of the API. See [Uptime probes and the status page](/operations/uptime) for what watches the machine from outside it. `backup.stale` is the one rule that watches something outside the API. The backup script writes its completion time to a Redis key when a run finishes, including the offsite upload, and the scrape reads it back. That makes a timer somebody disabled, a disk too full for `pg_dump`, and a remote that stopped accepting uploads all look the same from here: the age stops moving. It says nothing until `BACKUP_MAX_AGE_HOURS` declares a window, because an instance that backs up some other way should not be told it is broken, and it stays quiet while `dependency.redis.unavailable` is firing, because the key it reads lives in the Redis that is down. Set the window to comfortably more than one backup interval, for example `36` for a daily schedule, so a single skipped run is not a page. ### Investigate the same day | Alert | Fires when | | - | - | | `pool.silent.rising` | Endpoints are returning plausible but wrong results | | `pool.blocked.rising` | Endpoints are being refused by a store | | `residential.spend.near-cap` | Residential fallback spend passed 80 percent of its cap | | `billing.reconcile.discrepancy` | Ten or more billing events are still waiting to be processed | | `billing.orphan.events` | A billing event in the last seven days named a subscription this instance cannot place | | `billing.orphan.subscriptions` | The last nightly reconciliation found Stripe subscriptions that belong to no workspace. The summary lists up to five ids | | `workspace.suspended` | A workspace is suspended and every write is refused | | `workspace.cost.unpaid` | An unpaid workspace takes a tenth or more of daily pool demand | | `workspace.cost.exceeds-revenue` | A paying workspace takes three times more pool demand than its share of revenue | | `backup.stale` | The last backup is older than `BACKUP_MAX_AGE_HOURS` | | `storage.headroom.low` | The database passed 80 percent of `DISK_BUDGET_BYTES` | | `queue.memory.high` | Redis passed 80 percent of its memory ceiling | | `mail.failures.clustered` | A fifth or more of at least five account emails attempted in the last day were refused | The two `workspace.cost` rules compare a workspace's share of pool demand with what it pays, so they are evaluated only when `BILLING_ENABLED` is `true`. A self hosted instance has no prices to compare against, and without that gate its only workspace would read as unpaid on every scrape. ### Review weekly | Alert | Fires when | | - | - | | `capacity.headroom.low` | Total daily demand passed 80 percent of pool capacity | | `trial.conversion.low` | Fewer than a fifth of at least ten started trials converted | ## Error reporting Both apps report through the official Sentry SDK, each behind its own gate. | App | Variable | Reports when | | - | - | - | | API | `ERROR_TRACKING_DSN` | The dsn is set, `NODE_ENV` is `production` and `BILLING_ENABLED` is `true` | | Web | `SENTRY_DSN` | The dsn is set and `NODE_ENV` is `production` | When a gate is closed the SDK is never initialized, so nothing is instrumented and nothing is sent. **The API never reports from a self hosted deployment.** `BILLING_ENABLED` is `false` there, so `ERROR_TRACKING_DSN` is ignored even if somebody sets it, and the end to end suite asserts that a deployment with no cloud configuration opens no connection beyond its own database and queue. **The web app has no equivalent third condition, and one thing follows from that.** No Compose file sets `SENTRY_DSN`, so a self hosted deployment reports nothing by default. An operator who sets it anyway is opting their own browsers in, and the reports go to whichever project that dsn names. Nothing reaches the hosted service, because the dsn is never baked into the published image. The web dsn is read at runtime and handed to the browser by `/api/health`, the same way `STATUS_PAGE_URL` reaches it. It is never baked into the image, because this repository publishes one set of images that the hosted service and every self hoster run. ### What is reported | Surface | Covers | | - | - | | API requests | Only `5xx`. A refusal the API meant to make, such as a quota or a rate limit, is not an incident | | API queue work | A job whose retry chain is exhausted, once, tagged with the job, the queue and the store | | API process | An uncaught exception or an unhandled rejection outside any request or job | | Web server | Server Component, route handler and proxy failures, through `onRequestError` | | Web browser | A render failure that reaches a route error boundary and that asobeast cannot already explain. An expired session, a spent budget or a missing record is not reported, and neither is a server rendered failure, which the server already reported with a real stack | Tracing is deliberately off. `GET /metrics` already answers the performance questions this instance asks, and spans from a scraping pipeline are high volume for a question nobody is asking. ### What a report carries The error type, a secret scrubbed message, a structured stack with source lines, the workspace and correlation id, and the request method with identifiers replaced by `:id`. > [!WARNING] > > Headers, cookies, query strings, request and response bodies, database query values, stack frame variables and user identity are all turned off at the SDK level rather than filtered afterwards, and a second pass over every event drops the request down to its method and masked path before it leaves the process. Breadcrumbs are turned off in both apps. A breadcrumb records the url of every outgoing request, and a store request carries the term a customer tracks in its query string, so the trail that would help debugging is the one place customer data would reach a report. The same reasoning covers the two places a url reaches an event by another route: the path Next.js hands to `onRequestError` and the transaction name. Both are masked, so a keyword typed into the spider or the serp filter never leaves the browser's own machine. ## Support tooling `/admin/support` answers operational questions about one workspace without browsing what that workspace tracks. It is gated exactly like the queue dashboard and answers 404 to anyone else. | Route | Does | | - | - | | `GET /admin/support/workspaces` | Lists every workspace with plan, subscription state and counts | | `GET /admin/support/workspaces/{id}` | Adds plan limits, seven days of run history, that workspace's failed jobs and the recent audit trail | | `POST /admin/support/workspaces/{id}/reconcile` | Reconciles one workspace against Stripe | | `POST /admin/support/workspaces/{id}/billing-events/{eventId}/replay` | Clears how a stored billing event settled and queues it again, once its cause is fixed. Answers 404 for an event that names neither the workspace nor its Stripe customer | | `POST /admin/support/workspaces/{id}/suspend` | Suspends a workspace. Answers `409` for the operator workspace, which cannot be suspended | | `POST /admin/support/workspaces/{id}/restore` | Lifts a suspension | | `POST /admin/support/workspaces/{id}/run-daily` | Queues the daily run for one workspace | Three read only routes describe the instance as a whole: `GET /admin/support/overview` for the totals, and `GET /admin/support/users` and `GET /admin/support/apps` for every account and every tracked app. The web app reads all three in [the admin area](/security/admin-surfaces#the-admin-area). Three rules hold the surface honest: * **Every mutation needs `confirm: true` and a written reason** of at least eight characters. Both are stored. The reason for a suspension is also shown to every member of the suspended workspace, on every page and in every refused write, so write it for the customer rather than as an internal note. * **Every access is recorded, reads included**, in `SupportAccess` with the operator, the workspace, the action and the reason. The instance overview is the one exception, because it returns counts only. That table carries no foreign key, so deleting a workspace or an account leaves the audit trail intact. * **The operator workspace cannot be suspended.** A suspension refuses the workspace's writes and takes the support surface and the queue dashboard away from every operator credential, so suspending the bootstrap workspace would remove the way back. The request answers `409` and the refusal is recorded in the audit trail as a failed `suspend`. The directory names the apps a workspace tracks, with counts, and nothing more: there is deliberately no way to read a customer's keywords, rankings or reviews from this surface. A question that needs their data needs their consent first. ### Suspending and restoring from the command line The API image ships a command that suspends or restores a workspace without the support surface. Use it when the API cannot answer, and to restore the operator workspace on an instance that an earlier release left suspended, because a suspended operator workspace answers `404` on every support route. ```bash theme={null} docker compose exec api node dist/auth/abuse/workspace-suspension.cli.js restore ws_default docker compose exec api node dist/auth/abuse/workspace-suspension.cli.js suspend ws_example "sustained rate limit abuse" ``` Run it inside the `api` container, which already holds the database credentials. On a stack started from the published images, put `-f docker-compose.pull.yml` after `docker compose`. The command opens a database connection and nothing else. It starts no queue workers, schedules no jobs and needs no type checker, so it fits in the container's 1024 MB memory limit beside the running API. It prints `restored` or `suspended` with the workspace id and exits `0`. A refusal or an unknown workspace id prints the reason and exits `1`. The command writes to the workspace row directly, so it is not recorded in the support audit trail. Its reason reaches every member of the workspace the same way, so write it for the customer. The operator workspace is still refused for `suspend`. From a source checkout, build once with `pnpm --filter api build`, then run `pnpm --filter api workspace:restore ws_default`. # Recover a wedged release Source: https://docs.asobeast.com/operations/release-recovery Two ways a release stops shipping: Release Please advances the version manifest without creating the tag, or no workflow starts at all. Recognize each, then finish the release by hand. Release automation can advance the version manifest without ever creating the tag that goes with it. The workflow still reports success, so the repository looks healthy while it publishes nothing at all. Separately, the runner itself can be unavailable, in which case no workflow starts and no release happens for a reason that has nothing to do with the code. This page names both states, explains what causes them and gives the sequence that finishes a release by hand. ## Signature Three signals appear together. One alone is not conclusive, but all three at once mean the pipeline is wedged. | Where to look | What a wedged pipeline shows | | - | - | | The merged `chore: release main` pull request | Still labelled `autorelease: pending` long after the merge | | The Release workflow log | `Expected 1 releases, only found 0`, with the job still green | | `git ls-remote --tags origin` | No tag matching the version in `.release-please-manifest.json` | Confirm it in one pass: ```bash theme={null} jq -r '."."' .release-please-manifest.json git ls-remote --tags origin gh release list gh pr list --state merged --search "chore: release main" --json number,labels ``` A version in the manifest with no matching tag on `origin` is the whole diagnosis. ## Cause Release Please decides what work remains from two places: the version in `.release-please-manifest.json` and the label on the release pull request. Merging the release pull request updates the manifest in the same commit, but creating the tag and the GitHub release is a separate step that runs afterwards. When that second step does not complete, the two disagree permanently. The manifest claims a version that was never tagged, and the pull request keeps its `autorelease: pending` label. Every later run finds the same merged pull request, decides the release is still outstanding, warns and stops. It never opens the next release pull request, so the repository silently stops releasing. Because `docker-publish.yml` and `mcp-registry-publish.yml` run only when `release_created` is `true`, no images are built and the MCP Registry is not updated either. Documentation that tells operators to pull a published tag then points at something that does not exist. ## When no workflow starts at all A wedged pipeline is a pipeline that ran. A different failure looks similar from the release page and is not the same thing: every job in every workflow fails within a few seconds with the annotation `The job was not started because recent account payments have failed or your spending limit needs to be increased`. Nothing ran, so nothing tagged, nothing published and no test result means anything. ```bash theme={null} gh run list --limit 10 gh run view --json jobs --jq '.jobs[]|"\(.name) \(.conclusion) \(.startedAt)"' ``` Jobs that conclude in two or three seconds, across unrelated workflows, are the signature. Fix the billing state first. Release Please then resumes on the next push and needs none of the recovery below. ### Releasing by hand while the runner is unavailable Do this only when the release cannot wait for the billing fix, and run the full suite locally first, because no CI evidence exists for the commit. ```bash theme={null} pnpm lint && pnpm test && pnpm build pnpm --filter api test:e2e && pnpm --filter api test:isolation && pnpm --filter web test:e2e pnpm check:rate-classes && pnpm check:compose-limits && pnpm check:env-documented pnpm check:image-defaults && pnpm check:release-versions && pnpm docs:mcp-tools:check ``` Then bump the version everywhere Release Please would have. Nine files move together, and `.release-please-manifest.json` must agree with them or `verify-release-state.sh` fails the next Release run. `pnpm check:release-versions` reports any that were missed: | File | Why | | - | - | | `package.json` | The root version | | `apps/api/package.json` | Reported by the API and by `/health` | | `apps/web/package.json` | The web application version | | `apps/mcp/package.json` | Reported to MCP clients in the server manifest | | `packages/shared/package.json` | Contract package version | | `packages/mcp-tools/package.json` | Tool catalog package version | | `docs/api-reference/openapi.json` | `info.version` in the captured document, which the drift check compares against the running API | | `docker-compose.pull.yml` | The `ASOBEAST_IMAGE_TAG` default an installation runs when it sets no tag | | `server.json` | The version the official MCP Registry lists for the hosted endpoint | | `.release-please-manifest.json` | What Release Please believes is released | Write the `CHANGELOG.md` section by hand in the same shape Release Please produces, then tag the release commit directly. There is no release pull request in this path, so take the commit that carries the version bump rather than looking one up: ```bash theme={null} git tag -a v -m "v" git push origin v awk '/^## \[\]/{f=1} /^## \[\]/{f=0} f' CHANGELOG.md > notes.md gh release create v --title "v" --notes-file notes.md --verify-tag ``` Nothing needs relabelling, because no release pull request exists. Publishing the images cannot run until the runner is available, so they arrive late; say so in the release notes rather than leaving operators to discover that a documented tag does not resolve. Dispatch `docker-publish.yml` and `mcp-registry-publish.yml` as described in steps 4 and 5 of [Recovery](#recovery) as soon as the runner works again. ## Recovery Run these in order, from an up to date `main`. Steps 1 and 2 are public and effectively irreversible, so verify the commit before pushing. **1. Tag the commit the release pull request merged** Take the merge commit of the release pull request, not the current tip of `main`. Everything after it belongs to the next release. ```bash theme={null} release_commit="$(gh pr view --json mergeCommit --jq .mergeCommit.oid)" git merge-base --is-ancestor "$release_commit" origin/main && echo ancestor git tag -a v "$release_commit" -m "v" git push origin v ``` The ancestor check must print `ancestor` before you tag. **2. Create the release from the changelog section** Release Please already wrote the notes. Extract them rather than retyping them, so the release and the changelog cannot disagree. ```bash theme={null} awk '/^## \[\]/{f=1} /^## \[\]/{f=0} f' CHANGELOG.md > notes.md gh release create v --title "v" --notes-file notes.md --verify-tag ``` `--verify-tag` fails rather than inventing a tag if the push in step 1 did not land. **3. Relabel the release pull request** This is what tells Release Please the release is finished. Without it the tool keeps reprocessing the same pull request and never proposes the next version. ```bash theme={null} gh label create "autorelease: tagged" --color ededed --description "" || true gh pr edit --remove-label "autorelease: pending" --add-label "autorelease: tagged" ``` **4. Publish the images** `docker-publish.yml` accepts a manual dispatch with a tag input. ```bash theme={null} gh workflow run docker-publish.yml -f tag=v gh run watch "$(gh run list --workflow=docker-publish.yml --limit 1 --json databaseId --jq '.[0].databaseId')" docker buildx imagetools inspect ghcr.io/asobeast/asobeast-api: docker buildx imagetools inspect ghcr.io/asobeast/asobeast-web: ``` Both manifests must list `linux/amd64` and `linux/arm64`. **5. Publish to the MCP Registry** `mcp-registry-publish.yml` accepts the same manual dispatch. It signs in with the `asobeast.com` domain key held in the `MCP_REGISTRY_PRIVATE_KEY` secret and refuses a tag whose `server.json` states another version, which includes every tag up to `v1.7.0` because none of them carries the file. ```bash theme={null} gh workflow run mcp-registry-publish.yml -f tag=v gh run watch "$(gh run list --workflow=mcp-registry-publish.yml --limit 1 --json databaseId --jq '.[0].databaseId')" curl -s "https://registry.modelcontextprotocol.io/v0/servers/com.asobeast%2Fasobeast/versions/" | jq -r .server.version ``` The registry refuses a version it already lists, so a dispatch for a tag that published on its own fails without changing anything. **6. Confirm the tool recovers** Merge any pull request into `main`, then check that a fresh release pull request opens for the next version and that the warning is gone. ```bash theme={null} gh run list --workflow=release.yml --limit 1 gh pr list --state open --search "chore: release main" ``` A fresh release pull request proves the tool moved on. It does not prove the next release will tag itself: while the component mismatch above stands, that release wedges too and the guardrail fails the run again. > [!WARNING] > > A recovery dispatch builds the tag you pass rather than the tip of `main`, and it publishes the same tag shapes a normal release does, including `latest`. Dispatching a version older than the newest published release therefore moves `latest`, `0` and the minor tag **backwards** onto it. > > Recover the newest release first, or accept that you have to re-dispatch the newest version afterwards to put the moving tags back. Verify the version tag resolves before you tell anyone to pull it. > > The MCP Registry does not move backwards. It marks the highest version as the latest one, whatever order the versions were published in. ## The release pull request needs its workflows approved A release pull request is opened by `github-actions[bot]`, and its workflow runs land in the `action_required` state rather than starting on their own. `main` requires the `checks`, `e2e`, `web-e2e` and `compose-smoke` contexts, so until those runs are approved the release pull request reports no checks at all and cannot be merged. This looks like a wedged pipeline but is not one. Approve the waiting runs, let them finish, then merge: ```bash theme={null} gh run list --branch release-please--branches--main --limit 4 \ --json databaseId,workflowName,conclusion \ --jq '.[]|select(.conclusion=="action_required")|"\(.databaseId) \(.workflowName)"' gh api -X POST "repos/AsoBeast/asobeast/actions/runs//approve" ``` Approving is preferable to merging with administrator privileges, because it runs the checks the branch rules ask for rather than skipping them. ## Prevention `release.yml` runs a `verify-release-state` job on every push that does not create a release. It asserts that the version in `.release-please-manifest.json` exists as a tag on `origin` and fails the workflow with an annotation when it does not. A wedged state that would otherwise report success stops the pipeline the first time it appears. `ci.yml` reports the same invariant as a warning on every pull request, so an author sees an unhealthy release pipeline before merging rather than after. A second failure shape has nothing to do with tags. Any file that states the version and is not listed in `release-please-config.json` stops moving with the release, so the release pull request carries a file that disagrees with the version it ships. The openapi document is the case that bites, because `openapi-drift` compares the captured `info.version` against the running API and reports a generic drift. `pnpm check:release-versions` runs in `checks` and names the file and the fix instead, and it fails the pull request that introduces an unregistered version rather than the release pull request months later. ## Next steps > **[Run a published release](/install/published-images)** > > Which tags are published for each release, and how to pin one. > **[Troubleshooting](/operations/troubleshooting)** > > Symptoms a running instance produces, with causes and fixes. # Restore PostgreSQL Source: https://docs.asobeast.com/operations/restore Restoring replaces the current database. Stop the applications, drop and recreate it, restore the verified archive, then check both health endpoints. A restore replaces the current database entirely. Schedule a maintenance window, confirm which Compose project you are pointing at, and take a fresh pre restore backup before you start. > [!WARNING] > > Migrations are forward only. Starting a newer application image against an older restored database applies the pending migrations, which is fine, but starting an older image against a newer database is not supported. Restore the code revision that matches the archive. See [Upgrade and roll back](/install/upgrade). ## Restore into a running stack ```bash theme={null} set -eu restore_file="$HOME/asobeast-backups/asobeast-YYYYMMDDTHHMMSSZ.dump" test -s "$restore_file" docker compose exec -T postgres pg_restore --list < "$restore_file" > /dev/null docker compose stop web api docker compose exec -T postgres dropdb --username asobeast --if-exists asobeast docker compose exec -T postgres createdb --username asobeast asobeast docker compose exec -T postgres pg_restore --username asobeast --dbname asobeast --clean --if-exists --no-owner --exit-on-error < "$restore_file" docker compose up -d --wait curl --fail --silent --show-error http://127.0.0.1:3001/api/health curl --fail --silent --show-error http://127.0.0.1:3001/api/backend/health docker compose exec -T postgres psql --username asobeast --dbname asobeast --command 'select count(*) from "App";' docker compose exec -T postgres psql --username asobeast --dbname asobeast --command 'select count(*) from "Keyword";' ``` The `pg_restore --list` at the top is the important line. It reads the archive before anything is dropped, so a corrupt file fails while the current database is still intact. A truncated archive fails there with `pg_restore: error: could not read from input file: end of file`, and the running database is untouched. `docker compose stop web api` is not optional. With the API running, `dropdb` refuses with `database "asobeast" is being accessed by other users`, because the API holds a connection pool open. Like the backup, both commands run inside the `postgres` container, so the client matches the server. See [Back up PostgreSQL](/operations/backups). Starting the stack applies any migrations the checked out application version requires. ## Restore into a fresh host Same sequence, with two steps in front. **1. Bring up an empty stack** Install asobeast normally and let it boot once, so the volumes and the schema exist. See [Self host with Docker Compose](/install/docker-compose). **2. Check out the matching code revision** Use the revision the archive was taken from, or a newer one. Never an older one. **3. Run the restore block above** Then verify as below. Use a fresh `AUTH_SECRET` on the new host only if you accept that every session is invalidated. Account records come from the archive either way. ## Restore from the offsite copy This is the path that matters after a disk failure, a theft or a fire, and it is the one nobody rehearses. Rehearse it: fetch the archive onto a **different machine**, with the original powered off, and time the whole thing. **1. Fetch the encrypted object** ```bash theme={null} rclone copyto offsite:asobeast-backups/daily/asobeast-YYYYMMDDTHHMMSSZ.dump.age ./asobeast.dump.age ``` `rclone lsf offsite:asobeast-backups/daily` lists what is there. The newest name sorts last, because the name carries a UTC timestamp. **2. Decrypt it with the identity you kept off the host** ```bash theme={null} age --decrypt --identity asobeast-backup-identity.txt --output asobeast.dump asobeast.dump.age ``` If this fails, the identity file is not the one the recipient key belongs to, and no other copy of it exists. See [Back up PostgreSQL](/operations/backups). **3. Verify before you drop anything** ```bash theme={null} docker compose exec -T postgres pg_restore --list < ./asobeast.dump > /dev/null ``` **4. Restore it** Run the fresh host sequence above with `restore_file` pointing at the decrypted archive. ### Recovery objectives State both as numbers, because at 3am nobody derives them from a cron expression. | Objective | Value | Where it comes from | | - | - | - | | Recovery point, RPO | Up to 24 hours | One archive per day. Losing the host loses everything since the last run | | Recovery time, RTO from the local archive | Minutes | Measured at 0.20 seconds of `pg_restore` on an 11 MB database, plus the stack restart | | Recovery time, RTO from the offsite copy | Transfer time plus minutes | Decryption is negligible, measured at 0.014 seconds for a 101 KB object. The transfer dominates | The offsite number is bandwidth, not compute. Divide the archive size by the download speed of the machine doing the restoring, add the local restore time, and add the time it takes you to find the identity file. Write the total down here after your own drill, on your own hardware, and repeat it whenever the database grows by an order of magnitude. An offsite copy nobody has restored is not a backup. Do the drill with the source host genuinely powered off, because that is the only way to prove the drill is not quietly reading the local copy. ## Restore an older archive onto the current image This is the ordinary disaster recovery path: last month's archive, today's image. It works, and it is exercised in CI on every pull request. Restore the archive as above, then let the stack start. `prisma migrate deploy` runs on boot and applies every migration added since the archive was taken. Row counts and row contents are preserved; migrations are additive and forward only. The reverse is not supported. An older image against a newer database has no way to undo a migration, which is what the warning at the top of this page means. ## After a restore Check three things, in this order. | Check | Where | Expected | | - | - | - | | Apps and keywords | The app list, or the two `psql` counts above | Matches what the instance had | | Health | `/api/backend/health` | `status` is `ok`, `db` and `redis` are `up` | | Collection | The queue dashboard, or `pipeline.lastDailyRunAt` | The next daily run enqueues normally | `pipeline.lastDailyRunAt` lives in Redis rather than in PostgreSQL, so after a restore onto a fresh Redis it starts empty and fills on the next run. That is expected and is not data loss. See [Health checks and monitoring](/operations/health-checks). ### Verify the schema came back whole Row counts prove the data arrived. These four queries prove the structure did, which is what decides whether the next boot behaves. ```bash theme={null} docker compose exec -T postgres psql --username asobeast --dbname asobeast --command \ 'select count(*) from "_prisma_migrations" where finished_at is not null;' docker compose exec -T postgres psql --username asobeast --dbname asobeast --command \ 'select count(*) from pg_constraint where connamespace = '"'"'public'"'"'::regnamespace and not convalidated;' docker compose exec -T postgres psql --username asobeast --dbname asobeast --command \ 'select indexname from pg_indexes where schemaname = '"'"'public'"'"' and indexname like '"'"'%_key'"'"' order by 1;' docker compose exec -T postgres psql --username asobeast --dbname asobeast --command \ 'select count(*) from pg_sequences where schemaname = '"'"'public'"'"';' ``` The first must match the migration count of the image you are running, or the next boot tries to replay migrations over an existing schema. The second must be zero. The third must list the unique indexes, including `AlertEvent_workspaceId_dedupeKey_key` and `ActionItem_workspaceId_fingerprint_key`. The fourth is zero, because every identifier is a cuid rather than a serial, so there is no sequence to fall behind. Then write something. Add a keyword through the app and confirm it saves. A restore can report success and still leave the database unable to accept an insert, and only a write finds that. ## If the restore fails Keep the applications stopped. Read `docker compose logs postgres`, then restore the pre restore archive you took at the start before reopening service. ## Related > **[Back up PostgreSQL](/operations/backups)** > > Where the archive comes from. > **[Troubleshooting](/operations/troubleshooting)** > > Symptoms after a restore. # Rotate secrets Source: https://docs.asobeast.com/operations/rotate-secrets Rotating AUTH_SECRET signs out every session but leaves personal API tokens working. The database password needs an alter role statement. Three secrets are worth rotating, and each one behaves differently. Take a backup first in every case. ## Rotate AUTH\_SECRET `AUTH_SECRET` signs session cookies. Rotating it invalidates every signature, so every browser session is signed out at once. **1. Generate a new value** ```bash theme={null} openssl rand -hex 32 ``` **2. Replace it in the root .env** Or in `apps/api/.env` for a local development instance. **3. Recreate the API** ```bash theme={null} docker compose up -d --force-recreate api --wait ``` **4. Sign in again** Every user has to. There is no way to rotate the signing secret without that. Unsubscribe links in alert emails already sent stop working after a rotation, because they are signed with `AUTH_SECRET`. The next email carries a new link, and recipients can still pause an alert from **Settings**. Confirmation links for alert recipients stop working the same way, and **Resend confirmation** sends a new one. On a hosted instance with billing enabled a rotation also forgets which mailboxes already had a trial, because those records are keyed with `AUTH_SECRET`. A returning mailbox then starts a trial again, and the first start afterwards claims the owners of workspaces that already had one. Rotate on a schedule that you are content to pay that for, or only when the secret leaked. Personal API tokens are unaffected. They are stored as SHA-256 hashes rather than signed with `AUTH_SECRET`, so scripts and the MCP server keep working across a rotation. Revoke an individual token instead when that token is what leaked. See [Personal API tokens](/security/api-tokens). ## Rotate the database password The role password and the root `.env` must change together, so generate the value once and apply it to both before recreating the services. ```bash theme={null} set -eu umask 077 new_password="$(openssl rand -hex 32)" docker compose stop web api printf "alter role asobeast with password '%s'\n" "$new_password" | docker compose exec -T postgres psql --username asobeast --dbname postgres --set=ON_ERROR_STOP=1 printf 'POSTGRES_PASSWORD=%s\n' "$new_password" > .env chmod 600 .env docker compose up -d --force-recreate postgres api web --wait curl --fail --silent --show-error http://127.0.0.1:3001/api/backend/health ``` Two details make this work. Recreating the PostgreSQL container does not change an existing role password, which is why the `alter role` statement is required. The new value reaches the database over standard input rather than as a command line argument, so it stays out of the process list. > [!WARNING] > > Writing `.env` with `>` replaces the file. If your `.env` carries more than `POSTGRES_PASSWORD`, including `AUTH_SECRET`, edit it in place or rewrite every line you need. Losing `AUTH_SECRET` here signs everyone out. If the API fails to start afterwards, the role and `.env` disagree. Re run the block. ## Rotate an OpenAI key The simplest of the three. Replace `OPENAI_API_KEY` in `apps/api/.env`, recreate the API, then revoke the old key at OpenAI. ```bash theme={null} docker compose up -d --force-recreate api --wait ``` Nothing is stored that depends on the old key. Cached AI audit results stay valid because they are stored output rather than credentials. See [Enable the optional AI features](/guides/ai-features). ## Related > **[Back up PostgreSQL](/operations/backups)** > > Take one before every rotation. > **[Hosting behind TLS and a reverse proxy](/security/hosting)** > > The rest of the pre exposure checklist. # Billing incident Source: https://docs.asobeast.com/operations/runbooks/billing-incident Webhooks are failing, or a customer is wrongly locked out or wrongly served. Reconcile against the provider rather than editing plans by hand. Local plan state is a cache of what the billing provider says. Every billing incident is therefore the same shape: the cache and the provider disagree, and a customer is on the wrong side of it. Two failures matter and they are not equally urgent. A customer wrongly locked out is losing something they paid for. A customer wrongly served is costing you capacity. Fix the first one first. ## Symptoms * `billing.webhooks.failing` pages, from a stored billing event that recorded a failure and still waits for a fix. * `billing.reconcile.discrepancy` arrives when events pile up unprocessed. * `billing.orphan.events` arrives at investigate when an event named a subscription this instance cannot place, and settled as `orphaned` without retrying. It is almost never a customer. It usually means a drill left a subscription behind, a workspace was erased while Stripe still sent its last events, or another stack shares this sandbox and its checkouts arrive here. Find the subscription named in the `settled as orphaned` log line. If it belongs to a test customer, delete that customer in its sandbox, which cancels the subscription. If another stack shares the sandbox, move that stack to a sandbox of its own, as described in [Billing](/operations/billing#one-sandbox-per-environment). * `billing.orphan.subscriptions` arrives at investigate when the last nightly reconciliation found Stripe subscriptions that belong to no workspace. The summary lists up to five ids. Treat each like an orphaned event: a leftover test customer is deleted in its sandbox, and a real customer with no workspace is almost always one whose workspace was erased before erasure cancelled billing, so cancel the subscription in the dashboard and refund. * `asobeast_billing_events_unprocessed` climbs and does not drain. * A customer reports a `402` after paying, or keeps full access after cancelling. ## Triage The commands run on the host and read a personal API token of the [platform operator](/security/admin-surfaces#who-the-platform-operator-is) from `ASOBEAST_API_TOKEN`. Behind the tunnel overlay, which publishes no host port, replace `http://127.0.0.1:3001` with your public origin. The `POST` requests need a write scoped one. See [API tokens](/security/api-tokens). **1. Check the webhook secret before anything else** An unverified endpoint refuses every delivery, so a paid subscription never provisions. If `STRIPE_WEBHOOK_SECRET` is empty or stale, nothing else you do will hold. **2. Read the workspace** ```bash theme={null} curl --silent --header "Authorization: Bearer $ASOBEAST_API_TOKEN" http://127.0.0.1:3001/api/backend/admin/support/workspaces/ \ | jq '{storedPlan, plan, subscriptionStatus, hasSubscription, planExpiresAt, trialEndsAt}' ``` `storedPlan` is what the database holds. `plan` is what entitlement resolves right now, which is what the customer actually experiences. **3. Compare with the provider** Open the same customer in the provider dashboard. The provider is the source of truth for the subscription; the database is not. **4. Read why an event stopped** An event that stopped retrying is counted by `asobeast_billing_events_failed` and keeps its `failure` text on the stored row. The API log names it with `will not be retried until an operator replays it`. It is almost always a subscription on a price that carries neither a catalog lookup key nor `asobeast_plan` metadata. Give the price that metadata in the dashboard, or run `pnpm --filter api stripe:catalog` so the current prices carry their lookup keys. ## Containment > [!WARNING] > > Never edit a workspace plan directly in the database. It drifts back at the next reconciliation and you lose the record of why it changed. 1. Reconcile the one workspace, which corrects local state from the provider and records who did it and why: ```bash theme={null} curl --silent --request POST \ --header "Authorization: Bearer $ASOBEAST_API_TOKEN" \ --header 'Content-Type: application/json' \ --data '{"confirm":true,"reason":"customer locked out after a successful payment"}' \ http://127.0.0.1:3001/api/backend/admin/support/workspaces//reconcile ``` 2. Reconciliation looks for a subscription on the workspace customer as well as by the stored id, so a customer who paid and whose webhook never landed is repaired by this step rather than needing the id pasted in by hand. 3. If many workspaces are wrong, fix the webhook path first. Reconciling everyone against a broken cause just delays the same incident. 4. Once the cause of a stopped event is fixed, replay it. The replay clears how the event settled, queues it again and records who asked and why: ```bash theme={null} curl --silent --request POST \ --header "Authorization: Bearer $ASOBEAST_API_TOKEN" \ --header 'Content-Type: application/json' \ --data '{"confirm":true,"reason":"price added to the catalog"}' \ http://127.0.0.1:3001/api/backend/admin/support/workspaces//billing-events//replay ``` Replayed events are safe. Webhook handling is idempotent, and an event older than the state the workspace already holds is ignored. An event that names neither the workspace nor its Stripe customer answers 404. 5. A customer who deleted their account and is still charged had their workspace erased before erasure cancelled billing. Cancel the subscription in the dashboard and refund the charges since the deletion. ## Recovery 1. Confirm the daily reconciliation from `CRON_BILLING_RECONCILE` runs clean, with `asobeast_billing_events_unprocessed` back at zero. 2. Confirm `asobeast_billing_events_failed` is zero. It counts only events still waiting for a fix, and it pages precisely so that a single failure does not sit unnoticed. 3. Re-register the webhook endpoint with the provider if the secret changed, and verify with one test delivery. ## Who to tell * The affected customer, directly, the same day. Say what happened, that access is restored, and whether they were charged. * Every customer, only when checkout itself was down. A lockout affecting a handful of accounts is handled account by account. * Nobody, for a payment that failed for the customer's own reason. Dunning already emails them a portal link. # Capacity exhaustion Source: https://docs.asobeast.com/operations/runbooks/capacity-exhaustion Demand is approaching the pool ceiling. Gate registration rather than degrade the customers who already pay. Capacity exhaustion is the failure that arrives disguised as success. A front page post drives hundreds of signups into a pool sized for a fraction of that, and every paying customer's collection degrades at once. First impressions do not get a retry. The decision is settled in advance: **gate registration rather than degrade paying customers.** ## Symptoms * `capacity.headroom.low` arrives in the weekly review, with total demand past 80 percent of pool capacity. * `daily.run.incomplete` starts appearing for workspaces that were fine last week. * `asobeast_workspace_daily_requests_estimated` grows faster than `asobeast_pool_endpoints`. * Registrations answer `503` because the signup gate is already holding the line. ## Triage The commands run on the host and read a personal API token of the [platform operator](/security/admin-surfaces#who-the-platform-operator-is) from `ASOBEAST_API_TOKEN`. Behind the tunnel overlay, which publishes no host port, replace `http://127.0.0.1:3001` with your public origin. See [API tokens](/security/api-tokens). **1. Measure the ceiling, do not estimate it** ```bash theme={null} curl --silent --header "Authorization: Bearer $ASOBEAST_API_TOKEN" http://127.0.0.1:3001/api/backend/admin/capacity | jq ``` This reports demand against capacity for both stores. `GET /jobs/budget` gives the same view for one workspace. **2. Separate growth from a store mix change** A Google Play keyword check costs roughly eight store requests against one for the App Store, so a handful of customers moving to Play can multiply demand without any change in customer count. Compare the two `store` labels on `asobeast_workspace_daily_requests_estimated` week over week. **3. Name the heaviest tenants** Sort the same metric descending, then check `workspace.cost.unpaid` and `workspace.cost.exceeds-revenue`. A trial workspace taking a tenth of the pool is a different problem from healthy paid growth. ## Containment 1. **Close the door before the room fills.** Set `SIGNUP_CAPACITY_MAX_UTILIZATION` so that registration is refused once tracked collection passes that share of daily capacity. It only applies when billing is enabled, so self hosted installs are never gated. 2. Leave existing customers untouched. Shedding work from paying tenants to admit new ones is the wrong trade in every direction. 3. If one unpaid workspace dominates the pool, suspend it with a reason written for the customer, who sees it on every page (see [Support tooling](/operations/hosted-observability#support-tooling)), rather than degrading everyone. Suspension stops collection for that workspace even on keywords another tenant also tracks, because a shared search is judged per workspace before any row is written. 4. Do not lower the depth of Google Play searches as a first move. It changes the data customers already have and is hard to explain. ## Recovery 1. Buy datacenter endpoints ahead of demand. They are the cheapest input in the business by a wide margin, and the sync job adds them on its next run. 2. Confirm capacity on **Admin, Capacity** or with `/admin/capacity` before raising the registration gate. 3. Raise the gate in steps and watch one full daily cycle at each step. 4. Revisit the plan limits if a single plan repeatedly reaches the pool ceiling on its own. A limit that never bites is not a limit. ## Who to tell * People who could not register, in the refusal itself. A waiting list is a better outcome than a broken product. * Existing customers, only if their collection was actually affected. * Nobody, for a gate that held. A gate doing its job is not an incident, it is the plan working. # Daily run overrun Source: https://docs.asobeast.com/operations/runbooks/daily-run-overrun The daily run did not finish before the next trigger. Decide whether to skip, resume or truncate, and keep one heavy tenant from starving everyone else. The daily pipeline has one job: capture every tracked keyword market once per UTC day. An overrun means the previous run is still working when the next one is due, and left alone that backlog compounds until nobody's data is current. ## Symptoms * `daily.run.incomplete` pages, naming the workspaces that fell short. * `asobeast_workspace_daily_run_completed_seconds` sits well after the trigger, or does not move at all. * The health badge reports a stale pipeline, and `/admin/queues` shows waiting counts that do not drain. * `dependency.redis.unavailable` pages, which is a different fault with the same symptom. ## Triage The commands run on the host and read a personal API token of the [platform operator](/security/admin-surfaces#who-the-platform-operator-is) from `ASOBEAST_API_TOKEN`. Behind the tunnel overlay, which publishes no host port, replace `http://127.0.0.1:3001` with your public origin. The `POST` requests need a write scoped one. See [API tokens](/security/api-tokens). **1. Rule out the dependencies first** ```bash theme={null} curl --silent http://127.0.0.1:3001/api/backend/health | jq '{db, redis, pipeline}' ``` A `redis` of `down` means nothing is running at all. Restore Redis before reading anything else. **2. Ask whether it is demand or throughput** `GET /jobs/budget` estimates the fan out against capacity. Compare the total demand with `asobeast_pool_endpoints_healthy` multiplied by the configured requests per minute. | Reading | Cause | | - | - | | Demand grew and capacity held | A tenant added markets or keywords | | Capacity fell and demand held | Pool degradation. See [Proxy pool degradation](/operations/runbooks/proxy-pool-degradation) | | Both steady, jobs failing | A parser break. See [Hosted parser breakage](/operations/runbooks/hosted-parser-breakage) | **3. Find who grew** Sort `asobeast_workspace_daily_requests_estimated` descending. Google Play keyword checks cost roughly eight requests each against one for the App Store, so a store mix change moves this number far more than a keyword count change. ## Containment The pipeline already sheds load under pressure, dropping the least valuable stages first and keeping keyword checks longest. It plans each store separately, because the App Store and Google Play budgets are separate: a buried Google Play queue never sheds healthy App Store work, and spare Google Play capacity never hides an App Store queue that is already over. Your decisions sit on top of that. 1. **Skip rather than stack.** Let the running flow finish and allow the next trigger to be missed. Two overlapping runs halve the throughput of both. 2. **Truncate the tail.** Category ranks, reviews and app refreshes can wait a day. Keyword rankings are the product. 3. **Do not raise the requests per minute** to catch up. That is how a slow day becomes a blocked pool. 4. If one workspace is over its plan limit, the keyword limiter already covers a rotating subset. Confirm with `asobeast_workspace_keyword_markets` against `asobeast_workspace_quota_keyword_markets_limit`. ## Recovery 1. Once the cause is fixed, requeue the affected workspaces one at a time from the support surface: ```bash theme={null} curl --silent --request POST \ --header "Authorization: Bearer $ASOBEAST_API_TOKEN" \ --header 'Content-Type: application/json' \ --data '{"confirm":true,"reason":"catching up after an overrun"}' \ http://127.0.0.1:3001/api/backend/admin/support/workspaces//run-daily ``` 2. Watch `asobeast_workspace_rankings_captured` reach `asobeast_workspace_keyword_markets` for each one before queuing the next. 3. If the overrun was demand rather than a fault, the fix is capacity, not scheduling. See [Capacity exhaustion](/operations/runbooks/capacity-exhaustion). ## Who to tell * Affected customers, once, in app, naming the store and the delay. The delay notice already appears for a workspace whose own run is behind. * Everyone, on a status page, only when the run missed a full day for most tenants. * Nobody, for a run that finished late but complete. A late complete run is not an incident. # Store parser breakage on the hosted service Source: https://docs.asobeast.com/operations/runbooks/hosted-parser-breakage A store changed its response shape and every customer is affected at once. Communication comes first, the one module fix comes second. The self hosted version of this incident is covered in [Recover from a broken store parser](/operations/scraper-breakage). The triage and the fix are identical. What changes on the hosted service is the blast radius: every customer stops collecting at the same moment, and they find out from their own dashboards before they hear from you. **On the hosted service, communication is the first step, not the last.** ## Symptoms Everything in the self hosted runbook, plus: * `asobeast_workspace_rankings_captured` falls to near zero across every workspace that tracks the affected store, while the other store keeps working. * `daily.run.incomplete` pages for many workspaces at once rather than one. * Error reports arrive tagged with many different workspaces and one repeated error type. One workspace failing is not this incident. Every workspace on one store failing is. ## Triage **1. Confirm the blast radius before anything else** Count the workspaces reporting `daily.run.incomplete`. Many workspaces on one store is a parser break. Many workspaces on both stores is the pool, not the parser. **2. Read one failed job** Open `/admin/queues`, pick the failed set for that store, and read `failedReason`. It names the store, the provider method and the original message, which is where the fix starts. **3. Reproduce outside the queue** Run the provider smoke check against the affected store. A parse failure reproduces immediately and consistently. A rate limit does not. ## Containment 1. **Announce it to every opted in installation first.** Edit `docs/status.json` and push. The docs deployment publishes it at `https://docs.asobeast.com/status.json`, and every installation that set `STORE_STATUS_URL` picks it up within the hour, on `CRON_STORE_STATUS`. Set the affected store to `broken`, give it a `since` and a `summary` a person can read, and leave the healthy store on `ok`: ```json theme={null} { "schemaVersion": 1, "updatedAt": "2026-08-28T09:00:00Z", "stores": { "APP_STORE": { "state": "ok" }, "GOOGLE_PLAY": { "state": "broken", "since": "2026-08-28T02:10:00Z", "summary": "Google Play changed the shape of its search response. Collection is paused for Google Play apps. Stored data is untouched." } } } ``` Keep `summary` under 280 characters, because installations truncate it there. Do not add a `url` or a version field: the schema carries neither on purpose, and an installation that receives one ignores it. Setting the store back to `ok` when the fix ships clears the announcement within one poll interval, so remember to make that second edit. Confirm the document is actually served before relying on it, and again after any change to the docs deployment: `curl https://docs.asobeast.com/status.json` must return the file rather than a 404, because an installation that receives one changes nothing and says nothing. 2. **Tell customers within the hour.** Post the incident, and say which store is affected and that stored data is untouched. 3. Pause that store's queue. Failed jobs retry with backoff and add load to a pool that cannot use it. 4. Leave the other store running. Half a product is much better than none, and the stores fail independently by design. 5. Do not roll back the database. Collection pauses; it never rewrites history. ## Recovery 1. Fix the parser inside `apps/api/src/store-providers/`. No other module parses store responses, so the change is contained there by design. 2. Add a regression test built from the response shape that broke, taken from the stored raw payload. 3. Deploy, unpause the queue, and confirm one workspace captures cleanly before requeueing the rest. 4. Backfill nothing automatically. Ranking history is a daily series and a synthetic fill would be a lie. Leave the gap and say it is a gap. ## Who to tell * **Customers, first and in public.** Name the store, state that stored data is safe, and give the next update time rather than an estimate you cannot keep. * Customers again when collection resumes, naming the days that will stay empty. * Self hosted users, through the release notes, since the same fix reaches them as an upgrade. # Suspected isolation incident Source: https://docs.asobeast.com/operations/runbooks/isolation-incident One workspace may have seen another's data. Contain, assess the scope, then notify. Written before it is needed, because disclosure has deadlines. This is the most serious incident the hosted service can have, and the least likely. Tenancy is enforced by the database: every tenant owned table carries a policy, every query runs as a role that cannot bypass it, and a query with no workspace in scope returns nothing rather than everything. That is exactly why it is instrumented. **An impossible event that happens anyway is the one you most need to hear about immediately.** > [!WARNING] > > Read this page before you need it. Writing an incident process under pressure produces bad decisions, and disclosure obligations have deadlines measured in hours. ## Symptoms * `isolation.anomaly` pages. A query scoped to one workspace returned a row owned by another, and the log line names the operation, the scope and the foreign owners. * `asobeast_isolation_anomalies` is above zero. It should never leave zero. * A customer reports seeing an app, keyword or ranking that is not theirs. A customer report with no anomaly counter is still this incident until proven otherwise. Treat the report as the signal. > [!WARNING] > > The counter is a second line of defence, not the boundary itself. Row level security in the database is what actually isolates tenants. The tripwire only inspects rows a query returned, so it recognises a foreign owner when the result carries a `workspaceId`, directly or on an included relation within two levels. A projection that selects neither, such as a query returning only ids or an aggregate, gives it nothing to compare, and it samples the first 500 rows of a large result. A quiet counter therefore means nothing crossed that the tripwire could see, never that nothing crossed. ## Triage **1. Preserve the evidence first** Copy the anomaly log lines somewhere durable before restarting anything. They carry the operation, the workspace in scope, the foreign owners and the correlation id. The counter is in process and a restart clears it. **2. Find the request behind it** Search the logs for that `correlationId`. It gives you the route, the workspace, the time and every job the request spawned. **3. Decide whether data actually left** A row that crossed a scope inside the process is a defect. A row that reached an HTTP response is a disclosure. The route and the status in the same correlation trail tell you which. ## Containment 1. **Suspend the receiving workspace** if data reached a response, with a reason written for the customer, because every member of that workspace sees it on every page; see [Support tooling](/operations/hosted-observability#support-tooling). It refuses every write and every on demand action immediately. 2. Revoke that workspace's API tokens if the path was the API or the remote MCP endpoint. 3. Stop any deploy in progress. Do not ship a fix that has not been reasoned about. 4. Confirm the policies are still in place, because a missing policy is the only way most of these can happen: ```bash theme={null} pnpm --filter api test:isolation ``` 5. Check that migrations did not leave a table without a policy. The suite asserts that every table is classified as tenant owned, shared or operator only, and a new table with no classification fails it. ## Recovery 1. Fix the defect and add a case to the isolation suite that fails for the exact reported symptom before the fix and passes after. 2. Verify with a two workspace fixture that the path is closed, not merely unreachable. 3. Deploy, then confirm `asobeast_isolation_anomalies` stays at zero across a full daily cycle. 4. Lift the suspension and record why in the support audit trail. 5. Write the timeline down the same day, while it is exact. ## Who to tell * **The affected customers, both sides, within hours.** Say what was exposed, to whom, for how long, and what has changed. Name the data, not a category. * Your data protection contact immediately. Regulatory notification clocks start at awareness, not at resolution. * Everyone else, in a public post, once the affected customers have been told first. * Nobody at all is not an option here, even when the exposure was one row and one customer. # Proxy pool degradation Source: https://docs.asobeast.com/operations/runbooks/proxy-pool-degradation Healthy endpoints fall below the threshold. Tell blocking apart from a transport fault, decide between waiting, buying more and falling back, and recover without burning residential budget. Every hosted store request leaves through the egress pool. When the pool degrades, collection slows for every customer at once, and the worst version of the failure is the quiet one: endpoints that answer with plausible but wrong results. ## Symptoms * `pool.healthy.low` pages, or `pool.blocked.rising` and `pool.silent.rising` arrive for the same day. * `asobeast_pool_endpoints_healthy` falls while `asobeast_pool_endpoints` holds steady. * `asobeast_workspace_rankings_unresolved` climbs across many workspaces at once, which is what a silent failure looks like from the customer side. * Worker concurrency drops on its own, because it tracks the healthy pool size. * Google Play jobs for one storefront wait or time out while endpoints in other countries sit idle. That storefront's own endpoints are saturated, because a Google Play request never leaves through another country while one of its own is healthy. See [Egress proxy pool](/configuration/reference#egress-proxy-pool). ## Triage **1. Read the pool** `GET /admin/proxy-pool` reports per store health, per endpoint outcomes and residential spend. The `alerts` array repeats what fired. **2. Classify the outcome that dominates** | Last outcome | Cause | First move | | - | - | - | | `TRANSPORT` | The provider or the network, not the store | Wait one cycle, then open a provider ticket | | `RATE_LIMITED` | The pool is being paced too hard | Lower `SCRAPE_ITUNES_RPM` or `SCRAPE_GPLAY_RPM` | | `BLOCKED` | A store is refusing the address range | Continue below | | `SILENT` | Requests succeed and return results that are not real | Treat as the most serious. Continue below | **3. Separate one store from both** Blocking on one store with the other healthy is a store decision about the range. Both stores failing at once is almost always the provider. ## Containment > [!WARNING] > > A silent failure writes wrong data. Contain it before you fix it, because every hour it runs adds ranking rows a customer will act on. 1. **Silent failures**: pause the affected store's queue from `/admin/queues`. No capture is better than a wrong capture. 2. **Blocking**: leave the pool to cool down. Blocked endpoints are already parked, and forcing them back accelerates the block. 3. **Rate limiting**: reduce the store's requests per minute and let the current run finish late rather than fail. 4. Do not raise the residential cap to ride out a datacenter block. Residential egress is metered and priced per gigabyte, and a mixed customer costs several times their subscription there. ## Recovery 1. Buy more datacenter endpoints from the provider and let `CRON_PROXY_SYNC` add them, or run the sync early. 2. Confirm `asobeast_pool_endpoints_healthy` recovers above half of `asobeast_pool_endpoints` for both stores. 3. If a store blocks the whole range, the options in order of preference are: a different datacenter range, reduced Google Play depth, and residential egress for a small share of traffic with `PROXY_RESIDENTIAL_MONTHLY_CAP_USD` set. 4. Delete ranking rows captured during a confirmed silent window. They are worse than a gap. 5. Unpause the queue and watch one full daily cycle. ## Who to tell * Customers, if collection paused for longer than one daily cycle. Say which store is delayed. A delay that is explained reads as a delay; silence reads as broken data. * The proxy provider, with endpoint addresses and outcome counts, for anything that looks like a transport fault. * Nobody outside, for a block. It is a normal cost of the business and does not affect customer data. # Recover from a broken store parser Source: https://docs.asobeast.com/operations/scraper-breakage Apple and Google can change a response shape without notice. Recognize it, tell it apart from rate limiting, contain it, and ship the one module change that fixes it. asobeast reads two stores that never promised it a stable response. Apple and Google can change a shape whenever they like, and when they do, every installation is affected at once. The blast radius is contained on purpose. Only `apps/api/src/store-providers/` parses store responses, so a break is one module, not the application. **Historical data is never lost.** Collection pauses, it does not roll back. This page is what to do between the break and the fix. ## Symptoms In the order they appear: 1. **The store canary reports `broken`.** A scheduled check asks each store whether it still parses on `CRON_STORE_CANARY`, four times a day by default, an hour before the daily run. It names the store before any user notices a stale number, and `store.parser.broken` pages on the first failing run. 2. Rankings stop advancing. The most recent date on the keyword monitor stays fixed while the calendar moves. 3. For the platform operator, the health badge in the header turns amber and names a failed job count. 4. Failed jobs accumulate in the queue dashboard at `/admin/queues`. 5. **The data derived alerts stay silent.** This is the most misleading symptom, and it is worth stating plainly: those alerts are generated from captured data, so when nothing is captured, nothing alerts. The canary is the exception precisely because it asks the store a question of its own instead of reading what was collected. Silence from the rest is not health. ## Triage The commands run on the host and read a personal API token of the [platform operator](/security/admin-surfaces#who-the-platform-operator-is) from `ASOBEAST_API_TOKEN`. Behind the tunnel overlay, which publishes no host port, replace `http://127.0.0.1:3001` with your public origin. See [API tokens](/security/api-tokens). Three causes look identical from the outside. This tells them apart. **1. Read the canary verdict first** The canary has already classified the failure, so read it before reading the failed set: ```bash theme={null} curl --silent --header "Authorization: Bearer $ASOBEAST_API_TOKEN" http://127.0.0.1:3001/metrics | grep asobeast_store_canary ``` | Verdict | What it means | Where to go | | - | - | - | | `ok` | The store parsed on the last run, so a failure elsewhere is not a parser break | Look at the failed set for a single app | | `broken` | A shape assertion failed, or search returned nothing | Continue below | | `unreachable` | Transport, so the pool or the network, not the parser | [Proxy pool degradation](/operations/runbooks/proxy-pool-degradation) | | `target-missing` | The canary's own fixture app was delisted. Not an incident | Change the canary target in `CANARY_TARGETS` | A verdict of `unreachable` is decided by matching the underlying message against a named list, `TRANSPORT_MESSAGES` in `apps/api/src/store-providers/canary/canary-outcome.ts`: `ECONNRESET`, `ECONNREFUSED`, `ETIMEDOUT`, `EHOSTUNREACH`, `ENETUNREACH`, `EPIPE`, `EAI_AGAIN`, `ENOTFOUND`, `UND_ERR`, `socket hang up`, `tunneling socket`, `timeout`, `fetch failed`, `429` and `503`. Anything the list does not name is called `broken` on purpose, because a failure the code cannot name is likelier a shape it has never seen than a network error, and a false `broken` is corrected by a human within hours while a false `unreachable` hides a real outage for days. No series at all means the canary has not run yet, which is what a fresh instance looks like before its first scheduled check. **2. Read the failure count** ```bash theme={null} curl --silent http://127.0.0.1:3001/api/backend/health | jq '.pipeline' ``` `failedJobs` counts the App Store, Google Play and alert queues together. A non zero value that keeps climbing across daily runs is the signal. A single failure that clears on retry is not an incident. **3. Open a failed job and read its message** Go to `/admin/queues`, choose the failed set, and read `failedReason`. Every store failure is wrapped the same way, which makes the message the primary evidence: ```text theme={null} APP_STORE getApp failed: ``` The store, the provider method and the original message are all there. Note the method: it names the parser to start from. **4. Classify it** | Pattern in the failed set | Cause | What to do | | - | - | - | | Failures across all apps, on both stores, with transport messages such as HTTP 429 or a connection reset | Rate limiting | Check `GET /jobs/budget` against recent market or keyword additions. See [The daily pipeline and rate limits](/concepts/pipeline) | | Failures across all apps of **one** store, with type or parse messages, while the other store keeps working | Parser breakage | Continue below | | Failures for one app only | Not an incident. Usually a region restriction or a changed store id. A delisted app fails no listing refresh: the job finishes, the API log says the app is no longer listed and the app pages show a notice. See [What happens when the store stops listing an app?](/concepts/pipeline#what-happens-when-the-store-stops-listing-an-app) | Check the app in the store, then remove or re-import it | The canary answers the same question earlier and without a human reading messages: `unreachable` is the first row, `broken` is the second, and a single failing app leaves the canary on `ok` because the canary probes its own fixture rather than yours. The distinguishing signal for a parser break is that it is confined to one store and the messages are structural rather than transport. Both stores failing at once is almost always rate limiting or a network problem, because Apple and Google do not change their markup on the same night. > [!WARNING] > > The provider layer classifies one error. An app the store does not list arrives as `StoreAppNotFoundError`, which a queued listing refresh logs and finishes on instead of retrying. A transport failure and a parse failure both arrive as `StoreRequestError`, so the message text is what you read, not the error type. The canary does this classification for you, once, in `outcomeOfError`. The failed set still needs reading by eye. ## What the user sees A confirmed break raises one banner above every page, for every signed in user, from `GET /jobs/store-health`. It names the store, states that the cause is on asobeast rather than on the reader's configuration, says that stored data is untouched and that collection resumes on its own, and links this page. Three things about its timing are worth knowing before somebody asks why it did not appear. * **It waits for two consecutive failing canary runs.** The user facing threshold is `CANARY_CONFIRMATIONS`, so at the default schedule a break is announced within twelve hours rather than instantly. One failing run raises `store.parser.broken` for the operator and nothing for the user, which is the whole reason both signals exist. * **It clears on its own.** The banner polls once a minute, so it disappears within a minute of the canary returning `ok`. Nobody has to reload. * **It replaces the delayed run notice rather than stacking with it.** A broken parser causes a delayed run, and reading the symptom and the cause as two problems is worse than reading one. Only the highest severity notice renders. An `unreachable` verdict deliberately renders nothing. It means the pool or the network, the delayed run notice already describes what the reader can see, and a second message about a cause nobody can act on is noise. ## Containment What to do immediately, without waiting for a fix. * **Failed jobs do not block the queue.** The natural assumption is the opposite, so it is worth being explicit: each app, keyword and category is its own job. A job that fails is retried with backoff and then set aside, and the rest of the run continues. If Google Play is broken, the App Store still collects that night. * **Nothing is lost.** Every position, review and snapshot already captured stays exactly as it was. A break pauses collection. * **Decide whether to pause the daily run.** Leaving it on against a broken parser spends rate limit budget on requests that cannot succeed and fills the failed set. If the break is confirmed and a fix is days away, set `CRON_DAILY` to a schedule that will not fire, restart the API, and put it back afterwards. If a fix is hours away, leave it alone. * **Clear the failed set once resolved.** Retry the failed jobs from `/admin/queues` if the day still matters, or discard them if the next daily run will cover it. Retrying a rank check for a date that has passed captures today's SERP under today's date, not the missed one. ## Recovery For whoever ships the fix. **1. Reproduce outside the application** Confirm whether the fault is the scraper library or the integration: ```bash theme={null} pnpm --filter api run smoke:providers ``` It is opt in and never runs in CI. See [Check the parsers directly](#check-the-parsers-directly). **2. Check upstream** Look for an open issue or a release on the library the failing store uses. An upstream fix is usually a dependency bump rather than a code change here. ```text theme={null} Apple @perttu/app-store-scraper Google Play @mradex77/google-play-scraper ``` **3. Change one module** Only the provider directory needs to change. Start from the file for the failing store, and the method the failed job named. ```text theme={null} apps/api/src/store-providers/app-store.provider.ts apps/api/src/store-providers/google-play.provider.ts ``` **4. Ship it with a regression test** Build the test from the captured raw payload, so it fails for the reported reason before the fix and passes after. Commit as `fix(providers)`. ### Reprocess history from stored payloads Raw scraper responses are stored in `raw` JSON columns precisely so a corrected parser can be re-run over them. Nobody would guess this is available, so it is worth saying: `AppSnapshot.raw` holds the full store response for every snapshot ever captured. That means a parser bug which mapped a field wrongly, rather than one which failed outright, can be corrected retroactively. Re-read the stored payload and rewrite the derived columns. A parser that threw captured nothing, so there is nothing to reprocess for those days. ## Backfill Whether the missed days come back, honestly: | Data | Recoverable | | - | - | | Keyword positions | **No.** A store SERP is a point in time observation. Yesterday's ranking is gone | | Category ranks | **No.** Same reason | | Metadata snapshots | Current state, yes, with a refresh. The intermediate changes between the break and the fix are gone | | Reviews | Mostly. Reviews stay on the store, so a review sync collects what was missed | | Scores | Yes. Popularity and difficulty are recomputed from the data that is present | The honest summary is that a gap in ranking history stays a gap. Charts will show it. This is inherent to observing a search result rather than being handed a dataset. ## Check the parsers directly One command that answers "is the parser working right now" without running the pipeline or waiting for cron: ```bash theme={null} SMOKE_PROVIDERS=1 pnpm --filter api run smoke:providers ``` It performs a single live request per store and asserts the parsed shape. The assertions are the canary's own, imported from `apps/api/src/store-providers/canary/canary-checks.ts`, so a green run here and a green canary mean the same thing. It is the only thing in the repository that talks to a real store, so it is gated twice: it is not part of any test run, and it exits without doing anything unless `SMOKE_PROVIDERS=1` is set. CI never runs it, because a build must not depend on a third party. ## Related > **[The daily pipeline and rate limits](/concepts/pipeline)** > > Why both workers run at concurrency 1. > **[Health checks and monitoring](/operations/health-checks)** > > Where the failed job count comes from. # Troubleshooting Source: https://docs.asobeast.com/operations/troubleshooting Twenty symptoms a self hosted asobeast instance produces, each with its cause and its fix, from a stack that refuses to start to alerts that never arrive. Each entry below names the symptom the way you would describe it, then gives the cause and the fix. ## Startup and access > **The stack refuses to start** > > Compose exits with a message about `POSTGRES_PASSWORD` or `AUTH_SECRET`. Both are required in the root `.env` and Compose refuses to start when either is missing or empty. Generate them, then start again. > > ```bash theme={null} > printf 'POSTGRES_PASSWORD=%s\nAUTH_SECRET=%s\n' "$(openssl rand -hex 32)" "$(openssl rand -hex 32)" > .env > ``` > **The API exits immediately on boot** > > Two settings stop the API at boot. > > * `AUTH_SECRET` is shorter than 32 characters. The API refuses to run with a weak signing secret. Generate one with `openssl rand -hex 32` and recreate the API container. > * `AUTH_COOKIE_SECURE` is `false` while `NODE_ENV` is `production`, which the Docker image always sets. The log reads `AUTH_COOKIE_SECURE must be true when NODE_ENV is production.` Set it back to `true` and see [Sessions do not persist behind a proxy](#sessions-do-not-persist-behind-a-proxy) for ways to reach the stack without TLS. See [What the API refuses to start with](/security/hosting#what-the-api-refuses-to-start-with). > > A failed migration also stops the API at boot. The log then reports `P3018` or `P3009` and names the migration. See [If a migration fails](/operations/upgrades#if-a-migration-fails). > **I cannot register a second account** > > Registration closed automatically once the owner account existed, which is the default. Set `AUTH_ALLOW_REGISTRATION=true` and restart the API. See [Authentication and accounts](/security/authentication). > **I am signed out after a restart** > > `AUTH_SECRET` changed, which invalidates every session signature. Personal API tokens are unaffected. See [Rotate secrets](/operations/rotate-secrets). > **Sessions do not persist behind a proxy** > > `AUTH_COOKIE_SECURE=true` marks the cookie `Secure`, so a browser reaching the site over plain HTTP from another machine discards it. Setting it to `false` is not an option, because the Docker image then refuses to boot. Pick one of these instead. > > * Terminate TLS in front of port 3001 with your own reverse proxy and certificate. > * Compose the Cloudflare Tunnel overlay, which terminates TLS at Cloudflare. > * Forward the port over SSH and open `http://localhost:3001` in Chrome or Firefox, which keep a `Secure` cookie on localhost without TLS. Safari does not. See [Signing in returns to the sign in form](#signing-in-returns-to-the-sign-in-form). > > ```bash theme={null} > ssh -N -L 3001:localhost:3001 user@nas.local > ``` > > See [Hosting behind TLS and a reverse proxy](/security/hosting). > **Signing in returns to the sign in form** > > The API accepted the registration or the password, but the browser did not keep the session cookie, so the next request arrived signed out. The form then shows the message "Your browser did not keep the session cookie". On an older release, registering lands on the sign in form and signing in simply loads it again. The cookie is `Secure`, and a browser stores it only over HTTPS or on an origin it treats as secure. Three causes cover almost every case. > > * **Safari on plain HTTP, even on localhost.** Chrome and Firefox keep a `Secure` cookie on `http://localhost` and `http://127.0.0.1`. Safari drops it there, so `http://localhost:3001` accepts the sign in and bounces straight back. WebKit tracks the gap as [bug 281149](https://bugs.webkit.org/show_bug.cgi?id=281149), and the MDN compatibility data records it for `Set-Cookie`. Safari 27.0 announces support for `Secure` cookies on loopback hosts, but the change depends on the system cookie storage, and on macOS 26.6 that storage still drops the cookie, so Safari 27.0 there behaves as before. > * **Plain HTTP from another machine.** A LAN address or any hostname that is not loopback is not a secure origin in any browser. > * **Cookies blocked for the site.** A browser setting, extension or policy that blocks all cookies discards the cookie even over HTTPS. > > To confirm, open the developer tools and register or sign in. A `POST /api/backend/auth/register` that answers `201`, or a `POST /api/backend/auth/login` that answers `200`, with a `Set-Cookie: asobeast_session` header, followed by no stored `asobeast_session` cookie, is this problem. > > Pick one fix. > > * Use Chrome or Firefox for a local evaluation on `http://localhost:3001`. > * Put TLS in front of the stack. For a local evaluation that works in Safari, run Caddy with the Caddyfile below and open `https://localhost`. Caddy signs the certificate with its own local authority, which the system has to trust before Safari accepts it. > * Compose the Cloudflare Tunnel overlay for a host you reach from elsewhere. > > ```caddyfile theme={null} > localhost { > reverse_proxy 127.0.0.1:3001 > } > ``` > > Set `TRUST_PROXY=1` as [Bring your own proxy](/security/hosting#bring-your-own-proxy) describes. Do not set `AUTH_COOKIE_SECURE=false` to get past this, because the Docker image then refuses to boot. ## Admin surfaces > **/admin/queues returns 404** > > You are not signed in as the owner in that browser. The surface answers `404` rather than `403` so it does not confirm it exists. Sign in and reload, or check that `BULL_BOARD_ENABLED` is not `false`. See [The queue dashboard and the OpenAPI surface](/security/admin-surfaces). > **/admin/support returns 404 although I am signed in as the operator** > > An earlier release let the operator suspend the bootstrap workspace, which takes the support surface and the queue dashboard away from the operator's own credentials. Restore the workspace from the command line, see [Suspending and restoring from the command line](/operations/hosted-observability#suspending-and-restoring-from-the-command-line). Current releases refuse that suspension. > **/docs returns 404** > > `API_DOCS` defaults to `owner`, which gates the documentation routes the same way. Sign in as the [platform operator](/security/admin-surfaces#who-the-platform-operator-is), or set `API_DOCS=public` if the surface should be readable by anyone. See [The queue dashboard and the OpenAPI surface](/security/admin-surfaces). > **The admin area says my workspace needs a plan** > > With billing on, the admin area, `/admin/queues`, `/docs` and `/metrics` open only while the operator's own workspace has a plan in force, so a lapsed trial or a trial still waiting for the email confirmation closes all of them. Confirm the email if the notice asks for it, choose a plan on the upgrade page, or set `plan` on the bootstrap workspace in the database. See [When the operator's workspace has no plan](/security/admin-surfaces#when-the-operators-workspace-has-no-plan). > **Every admin surface answers 404 and no account is the operator** > > The workspace `ws_default` was erased while other accounts survived, which older releases allowed from **Settings**. Check for it. > > ```bash theme={null} > docker compose exec -T postgres psql --username asobeast --dbname asobeast \ > --command "select id, name from \"Workspace\" where id = 'ws_default';" > ``` > > No row means the operator is gone. Register a fresh account, which gets a workspace of its own, then give that account the bootstrap workspace. Registration is closed by default once accounts exist, so set `AUTH_ALLOW_REGISTRATION=true` and restart the API for the sign up, then set it back to `false` and restart again. An existing account whose workspace holds no apps works as well and needs no sign up. Replace the email with the account you chose. > > ```bash theme={null} > docker compose exec -T postgres psql --username asobeast --dbname asobeast --set=ON_ERROR_STOP=1 <<'SQL' > begin; > insert into "Workspace" ("id", "name") values ('ws_default', 'Default'); > update "User" set "workspaceId" = 'ws_default', "role" = 'owner' where "email" = 'you@example.com'; > commit; > SQL > ``` > > The account is the platform operator from its next request, with no restart and no new sign in. The workspace it registered with stays behind empty and can be left alone. Use a fresh account rather than a customer's, because a user takes no data with it: the apps and keywords of the workspace it leaves stay there. On an instance with `BILLING_ENABLED=true` the admin surfaces also need an entitled operator, so add `"plan"` to the insert with `'ultimate'` as its value. See [Deleting the operator workspace](/security/admin-surfaces#deleting-the-operator-workspace). ## Collection > **No rankings appeared overnight** > > The daily pipeline starts at `CRON_DAILY`, which defaults to `0 3 * * *` and is evaluated in UTC rather than in your local timezone. Check `pipeline.lastDailyRunAt` on `/api/backend/health`, then look at the queue dashboard for stuck or failed jobs. See [The daily pipeline and rate limits](/concepts/pipeline). > **My rankings stopped moving and a banner says the store is broken** > > The parser canary confirmed that a store stopped parsing, so collection is paused for that store. The banner appears after two consecutive failing canary runs rather than on the first, so a single blip is never announced, and it clears on its own within a minute of the canary returning `ok`. Your stored history is untouched: a break pauses collection, it does not remove anything. Nothing on your side needs changing. See [Recover from a broken store parser](/operations/scraper-breakage). > **Rankings show >200 instead of a number** > > Not a failure. The keyword was checked and the app was not found within the captured depth, which defaults to 200. The bound is read from that row's own depth. See [Positions and rank depth](/concepts/positions). > **Collection is slow or jobs are backing up** > > Both workers run at concurrency 1 behind a rate limiter, so a large keyword and market count simply takes longer. As the [platform operator](/security/admin-surfaces#who-the-platform-operator-is), check the request budget card on the settings page or `GET /jobs/budget`. Remove keywords or markets rather than raising the limits, which risks the store throttling your IP address. See [The daily pipeline and rate limits](/concepts/pipeline). > **The health badge says degraded after an upgrade** > > Failed jobs from before the upgrade are still counted. The badge reads the failed job count of the store and alert queues, and a failed job stays in that count until it is removed, so failures from a keyword the upgrade paused keep the badge degraded. Sign in as the [platform operator](/security/admin-surfaces#who-the-platform-operator-is), open `/admin/queues`, select the store queue, open its failed jobs and clean them. The badge recovers on the next health check. See [The queue dashboard and the OpenAPI surface](/security/admin-surfaces). > **Scores changed after upgrading** > > Expected after an upgrade that changes the scoring formula. Each score row records the `formulaVersion` that produced it, and the API queues one rescore for every tracked keyword whose latest score used an older version, through the same rate limited queues as weekly scoring. Until its job runs, a keyword keeps its old numbers and the table says "Older formula". A large list takes a while: about 100 minutes per 150 App Store keywords at the default rate limit. See [Popularity, difficulty and opportunity](/concepts/scoring#what-happens-to-old-scores-after-an-upgrade). > **A store import fails to parse** > > A store changed a response shape. The failure is contained to the provider layer, the job fails, and BullMQ retries it with backoff while request handling continues. See [Recover from a broken store parser](/operations/scraper-breakage) for triage, containment and recovery. ## Alerts and AI > **Webhook alerts never arrive** > > The endpoint returned a non 2xx status, which the delivery log records per channel on the settings page. Fix the endpoint, then use flush now to claim the facts currently pending. See [Send alerts](/guides/alerts). > **Email alerts never arrive** > > Email stays disabled until both `SMTP_HOST` and `SMTP_FROM` are set. One without the other leaves it off. Check the delivery log for a relay rejection, which usually means the envelope sender is not on a domain the relay accepts. Mail that arrives but lands in spam needs the sending domain authenticated. See [Email deliverability](/operations/email-deliverability). > **A confirmation, recovery or invitation email never arrives** > > Account email is recorded like alert email. Every attempt writes a row to the delivery log on the `account` channel, with the event naming what it was for, `verification`, `recovery` or `invitation`, and one of three outcomes. > > | Outcome | What it means | > | - | - | > | `delivered` | The relay accepted the envelope. Anything after that is the recipient's mail provider | > | `failed` | The relay refused it, and `detail` carries its response with credentials and link tokens scrubbed out | > | `skipped` | The instance has no SMTP transport, so nothing was attempted. See [Hosting behind TLS and a reverse proxy](/security/hosting) | > > Read the rows with the database the deployment already has. > > ```bash theme={null} > docker compose exec -T postgres psql --username asobeast --dbname asobeast \ > --command "select \"createdAt\", event, status, detail from \"AlertDelivery\" where channel = 'account' order by \"createdAt\" desc limit 20;" > ``` > > The same outcomes are counted in `/metrics` as `asobeast_account_mail_attempts` over the last day, and `mail.failures.clustered` fires once a fifth of the attempts are being refused. See [Hosted observability](/operations/hosted-observability). > > The relay is verified once before the first message of a process, so a wrong host, port or credential fails with the relay's own response rather than being discovered by a customer who never got their link. > **Alert links point at localhost or are missing** > > `WEB_PUBLIC_URL` is unset, so the `link` field is `null` by design rather than a guess that would not open. Set it to your public origin. See [Hosting behind TLS and a reverse proxy](/security/hosting). > **The AI endpoints return 409** > > `OPENAI_API_KEY` is unset, which disables the AI audit, the metadata drafts card and action explanations. Every other feature works without it. See [Enable the optional AI features](/guides/ai-features). ## Still stuck? Collect the bounded diagnostics below and open an issue with them. ```bash theme={null} docker compose ps docker compose logs --no-color --tail=200 api web ``` > [!WARNING] > > Never paste the output of `docker compose config` into an issue. It contains the resolved database password, and with the tunnel overlay composed it also contains the Cloudflare Tunnel token, which is enough to route traffic through your tunnel. # Upgrade an installation Source: https://docs.asobeast.com/operations/upgrades Which versions upgrade directly to the current release, what the API container does to your database on boot, how the upgrade path is proven in CI before it reaches you, and how to recover when a migration fails. The API container runs `prisma migrate deploy` on boot. A migration that cannot apply to your data stops the container rather than warning, and every later boot stops at the same point, so the API never serves a schema that is partly migrated. Getting the stack back is a deliberate step, described in [If a migration fails](#if-a-migration-fails). This page states which versions upgrade directly, and how that claim is tested. ## Supported upgrade baselines | From | To | Supported | | - | - | - | | `1.x` | current | Yes, proven on every pull request | `1.0.0` is the first release, so it is also the oldest baseline the project verifies. Migrations are additive and forward only. ## What the upgrade does Migrations run in order against your existing rows. They never rewrite history: * Existing rows are preserved. The drill fails the build if any table's row count changes across an upgrade. * `_prisma_migrations` records what has already been applied, so a restart never replays a migration. * An applied migration is never edited. A correction ships as a new forward migration. A migration that adds an index builds it with a plain `CREATE INDEX`, which blocks writes to that table until it finishes. `add_listing_market` rebuilds the snapshot and change event indexes, and `AppSnapshot` is the largest table of an instance with a long history, so expect that step to take longer there. It runs inside `prisma migrate deploy`, before the API starts, so no request or job waits on it. ## Before you upgrade Take a backup. See [Back up your data](/operations/backups). A backup taken shortly before the daily pipeline window is the cheapest point, because the least work is in flight. Then pull the new image and restart. Watch the API container's first log lines: `prisma migrate deploy` reports each migration it applies. ## What a restart does to a job in flight `docker stop`, `docker compose restart` and a host reboot all send `SIGTERM`. The API answers it by taking no further jobs and letting the job that is already running finish, then disconnecting from PostgreSQL and Redis. A running job is drained, not abandoned. PostgreSQL is disconnected last, once every request and job that was still running has finished, so neither loses its database half way. One stage is cut short rather than waited out. A job that has not yet been given a proxy endpoint stops waiting for one the moment the signal arrives, instead of sitting out the `PROXY_ACQUIRE_TIMEOUT_MS` budget, which is longer on its own than the whole shutdown allowance below. See [Configuration reference](/configuration/reference). That job fails its attempt and the queue retries it with backoff, which is the same path a saturated pool already takes. What is left to wait for is the store work itself. Compose allows the API 90 seconds through `stop_grace_period`, and only sends `SIGKILL` afterwards. The number comes from the slowest single job: a Google Play rank check at depth 200 is roughly eight sequential store requests under `SCRAPE_GPLAY_RPM`, so about a minute in the worst case. Docker's ten second default is shorter than that, which is why the stack sets its own. While Redis cannot be reached the stop is shorter, because nothing that waits on Redis can finish. The API tries Redis three times, a third of a second apart, closes its queue and worker connections at once instead of draining them, and then gives requests that are still open two seconds before it cuts them. A short request that only needs PostgreSQL finishes in that time. Every request still open after those two seconds is cut, whatever it waits on: the queue dashboard, which waits on Redis, and also a long export or a refresh that is still reaching a store. A direct client sees a closed connection, and a browser using the web app sees its `502` answer. A database query that such a request left running is abandoned two seconds later and rolled back by PostgreSQL. The whole stop takes a few seconds, well inside Docker's ten second default. With Redis answering nothing is cut and a request in flight completes. A job that still has not finished when the budget runs out is killed with the process. Nothing is lost, but the recovery is slower than a drain: the job keeps its lock until the lock expires, and the stalled job check on the next start returns it to the queue. A job that stalls a second time is failed rather than retried forever, so it appears in the failed list on the queue dashboard instead of cycling. You do not need to drain the queues by hand before an ordinary restart. You do need to for the release named below. ## Drain the queues before upgrading to a tenancy-aware release Every queued job now names the workspace that owns it. A job queued by an older release carries no workspace, so the new workers reject it as unrecoverable: it fails once, without retries, and appears in the failed list on the queue dashboard. Nothing is silently retried and no work runs against the wrong workspace. Let the queues empty before you swap the image. The cheapest window is well after the daily pipeline has finished, when the waiting and delayed counts on the queue dashboard are zero. See [The queue dashboard and the OpenAPI surface](/security/admin-surfaces) for how to reach it. If you upgrade with jobs still queued, the affected jobs fail immediately and the next daily run schedules their replacements, so the only cost is one lost day for the drained jobs. Alert jobs are the exception worth watching: their source events may already be marked flushed, so a batch that fails this way is not sent again. Check the failed list after the first boot and clear it once you have read it. ## Move WEB\_PUBLIC\_URL to the root .env The Compose stacks pass `WEB_PUBLIC_URL` to the API from the root `.env` and default it to `http://localhost:3001`, so a value in `apps/api/.env` no longer reaches them. If you set it there, move the line to the root `.env` before you restart. Otherwise every email links to `localhost`, and the API logs a warning at startup that names the address. See [Hosting behind TLS and a reverse proxy](/security/hosting). ## How the upgrade path is proven Every pull request runs an upgrade drill in CI. It builds a database at the schema of the oldest supported baseline, loads a fixture carrying multi store and multi market data, applies every migration added since, and then asserts: * `prisma migrate status` reports the database up to date. * `prisma migrate diff` reports no drift between the migrations and the schema. * Per table row counts are identical before and after. A migration that resolved a collision by deleting rows fails here. * The API boots against the upgraded database and serves authenticated reads across apps, keywords, rankings, reviews, changes, actions and alerts. * Data retention prunes only what its windows allow, and never prunes open or snoozed actions by age. The baseline is a variable, so the same drill covers a new baseline once one is declared. While the baseline tag is the current commit there is nothing to upgrade from, and the drill says so and skips: ```bash theme={null} UPGRADE_FROM_TAG=v1.0.0 \ DATABASE_URL=postgresql://asobeast:asobeast@localhost:5432/asobeast_upgrade \ apps/api/test/upgrade/run-drill.sh ``` The script refuses to run unless the database name ends in `_upgrade`, because it drops the public schema before it starts. ## If a migration fails Prisma does not run a migration inside a transaction. When a statement fails, every statement before it in the same migration stays applied, and the migration is recorded as failed in `_prisma_migrations`. `prisma migrate deploy` then refuses to apply anything while a failed migration is recorded, whichever image runs it. The API container exits on every boot, and starting the previous image tag fails at the same check, so changing the tag alone does not bring the stack back. The web app waits for a healthy API, so the site stays down until the API starts. When you run the published images, add `-f docker-compose.pull.yml` to every command below and keep `ASOBEAST_IMAGE_TAG` in the root `.env`, so each command runs the image you mean. ### Read the failure The first boot after the upgrade names the migration and the database error. ```bash theme={null} docker compose logs --no-color api | grep --after-context=12 P3018 ``` ```text theme={null} Error: P3018 A migration failed to apply. New migrations cannot be applied before the error is recovered from. Read more about how to resolve migration issues in a production database: https://pris.ly/d/migrate-resolve Migration name: 20261014120000_example Database error code: 23502 Database error: ERROR: column "n" of relation "Example" contains null values ``` Every later boot reports `P3009`, `migrate found failed migrations in the target database`, and names the same migration. Stop the API so it stops restarting, then read the full error from the database, which keeps it after the container log is gone. ```bash theme={null} docker compose stop api docker compose exec -T postgres psql --username asobeast --dbname asobeast --expanded --command 'select migration_name, logs from "_prisma_migrations" where finished_at is null and rolled_back_at is null;' ``` ### Return to the previous release This is the recovery to choose when the error comes from the migration meeting your data, such as a constraint or a type that existing rows violate, and the safe choice whenever you are unsure. Follow [Upgrade and roll back](/install/upgrade#roll-back): switch back to the previous version, then restore the backup you took before the upgrade. The archive predates both the partial changes and the failed record, so nothing needs resolving afterwards. [Report the failure](https://github.com/AsoBeast/asobeast/issues) with the migration name, the database error code and the error message. ### Undo the migration by hand Use this when the cause lies outside the migration, such as a full disk or a lost connection, and you want to retry the same release once it is fixed, or when you have no backup from before the upgrade. A data error fails again on the same release, so after undoing it start the previous version. **1. Back up the current state** Take a backup of the database as it is now, so a mistake in the next step can be undone. See [Back up your data](/operations/backups). **2. Reverse what the migration applied** Read the migration from the image of the release that failed. ```bash theme={null} docker compose run --rm api cat prisma/migrations//migration.sql ``` The database error names a table, a column or a constraint. Find the statement that touches it: every statement before it is applied, and the failing one and everything after it never ran. Reverse the applied statements in the opposite order with `psql`. A statement that changed or removed rows, such as an `UPDATE`, a `DELETE` or a `DROP`, cannot be reversed this way, so restore the backup from before the upgrade instead. Without one, report the failure before you change anything else. **3. Mark the migration rolled back** ```bash theme={null} docker compose run --rm api npx prisma migrate resolve --rolled-back ``` This changes only the record in `_prisma_migrations`, never the schema. Without the previous step, the next attempt fails again on the first statement that already ran, for example with `column already exists`. **4. Start the API** Start the same release once the cause is fixed, and it applies the migration again from its first statement. ```bash theme={null} docker compose up -d --wait ``` To start the previous version instead, set the previous `ASOBEAST_IMAGE_TAG`, or check out the previous revision and add `--build` to the command above. It boots against the schema it expects. # Uptime probes and the status page Source: https://docs.asobeast.com/operations/uptime Every alert asobeast evaluates runs inside asobeast. Something outside the machine has to watch it, and customers need somewhere to look when the answer is no. Every rule in the [alert catalogue](/operations/hosted-observability) is evaluated by the API, during a scrape of the API. That is the right place for a rule about keyword capture or queue memory, and the wrong place for a rule about the machine being on. When the mini PC is off, the connection is down, the tunnel is not running, or Docker did not come back after a kernel upgrade, there is no scrape, no evaluation and no alert. The monitoring is entirely inside the thing being monitored. For a single machine, those are the outages that actually happen. ## What to watch Two endpoints, both already public and both cheap. | Endpoint | Answers | What a failure means | | - | - | - | | `https:///api/health` | The web app | Next.js is not serving. The tunnel, the container or the machine | | `https:///api/backend/health` | The API through the web proxy | The API, PostgreSQL, Redis or the pipeline. The body names which | Watch the customer facing origin, not the container. A probe against `localhost` on the machine tells you the container is up while every customer sees nothing. The second endpoint reports each dependency separately rather than as one boolean, so a probe that records the body distinguishes a database outage from a stalled pipeline without anyone opening a terminal. ```json theme={null} {"status":"ok","db":"up","redis":"up","pipeline":{"lastDailyRunAt":"2026-08-20T03:12:00.000Z","stale":false,"failedJobs":0}} ``` ## The interval **One minute.** Both endpoints touch PostgreSQL and Redis on every request and neither is authenticated, so a five second interval from several regions is a self inflicted load test against the smallest part of the deployment. One minute from two or three regions is enough to tell an outage from a blip, and cheap enough that the probe never shows up in the capacity numbers. ## Where the alert has to land **A device that is not the mini PC.** This is the part that is easy to get wrong. An email alert is worthless if the SMTP relay is configured in the instance that just went down, or if the notification is delivered to a mailbox you read on a laptop that is off at 3am. Route the probe's alerts to a phone: push, SMS or a chat app that pushes. The probe is a hosted service, so its alerts leave from somewhere unaffected by your outage. That is the entire point of it being outside. ## Certificates With the shipped [Cloudflare Tunnel](/security/hosting), the certificate is Cloudflare's and renews without you. There is nothing on the machine to expire. If you brought your own reverse proxy, add a certificate expiry check to the same probe. An expired certificate is an outage that every uptime check reports as a connection failure, and every hour spent diagnosing it is an hour not spent on the one line that fixes it. ## The status page Host it **off the machine**. A status page that shares a power supply with the service is decoration: the outage that takes down the service takes down the page that would have explained it. Every hosted status page provider will drive the page from the same probe. Point the probe at the two endpoints above, publish the page, and put the URL in three places: 1. `STATUS_PAGE_URL` in the web app environment, which links the app's own error states to it. See [Web app configuration](/configuration/web). 2. [Support](/support), so somebody looking for help finds the page before they find the contact address. 3. The terms, so the availability you promise and the availability you publish are the same document trail. ## What to write during an incident Decide this before the incident, because during one you will not. | Question | Answer to fix now | | - | - | | Who writes the update? | The operator. On a single host deployment there is nobody else | | How fast? | Within fifteen minutes of the probe alerting, even if the update is only that you are looking | | What goes in it? | What is broken, what still works, and when the next update comes. Never a cause you have not confirmed | | When does it close? | When the service is verified working, not when the fix is deployed | The first update costs nothing and is the only one that matters to somebody deciding whether to open a support ticket. ## Verify it Unplug the network on the machine. The probe must alert within its stated interval, to a device that is not on that network. That is the whole test and it takes two minutes. A probe nobody has watched fire is a belief, not a signal. ## Related > **[Hosted observability](/operations/hosted-observability)** > > The alerts asobeast evaluates about itself. > **[Health checks](/operations/health-checks)** > > What each endpoint reports and why. # Quickstart Source: https://docs.asobeast.com/quickstart Run asobeast with Docker Compose, create the owner account and import your first App Store or Google Play app in about five minutes. By the end of this page you have asobeast running on your own machine, an owner account, and one app importing its metadata and tracking its first keywords. > [!NOTE] > > The only prerequisite is Docker with the Compose plugin. See [System requirements](/install/requirements) for memory, disk and network detail. ## Install and start asobeast **1. Clone the repository** ```bash theme={null} git clone https://github.com/AsoBeast/asobeast.git cd asobeast ``` **2. Generate the two required secrets** ```bash theme={null} printf 'POSTGRES_PASSWORD=%s\nAUTH_SECRET=%s\n' "$(openssl rand -hex 32)" "$(openssl rand -hex 32)" > .env chmod 600 .env ``` Compose refuses to start when either value is missing or empty. **3. Start the stack** ```bash theme={null} docker compose up --build -d --wait ``` The first build takes a few minutes. `--wait` returns only after every health check passes. **4. Open the dashboard** Go to `http://localhost:3001`. The dashboard leads with four tiles for keywords in the top 10, keyword movement, open actions and changes this week, then lists your apps. See [Read the dashboard](/guides/portfolio#read-the-dashboard). ## Create the owner account A fresh installation redirects to registration. The first account you create becomes the owner, and registration closes automatically once it exists. Set `AUTH_ALLOW_REGISTRATION=true` only when further sign ups should stay available. The Compose stack already sets `AUTH_COOKIE_SECURE=true`, which Chrome and Firefox keep over `http://localhost` and every browser keeps behind TLS. Safari needs TLS, so use another browser for a local evaluation or see [Signing in returns to the sign in form](/operations/troubleshooting#signing-in-returns-to-the-sign-in-form). Leave it at `true`; only a development API run outside Docker sets it to `false`. See [Authentication and accounts](/security/authentication). ## Import your first app Type your app's name into the import dialog and pick it from the results, or paste its App Store or Google Play URL. asobeast then does the following, in order. 1. Parses the store and the home storefront from the URL. A bare iTunes id, or a URL with no country segment, falls back to `DEFAULT_COUNTRY`, which defaults to `us`. 2. Fetches the listing and writes the first metadata snapshot, keeping the raw payload so a later parser fix can reprocess it. 3. Extracts keywords from the indexed fields and starts tracking them in the home storefront. 4. Schedules its own first pass: a rank check for every tracked keyword, a score for every tracked keyword and one action generation run for your workspace. On a fresh instance with an empty queue the first positions and scores arrive within minutes on the App Store and within tens of minutes on Google Play, because the whole first pass is queued at import rather than waiting for the next scheduled run. See [Import an app](/guides/import-an-app). ### The panel that says what is still running Until every stage has landed, the app overview page shows a panel naming what is still being collected and, where the schedule can be read, when the rest arrives. It stops rendering and stops polling on its own once collection is done, so there is nothing to dismiss. It is a status report rather than a notification. Two horizons are worth knowing before you read it. * **Positions and scores arrive as the queue drains**, in minutes rather than days, and the daily and weekly runs catch anything that failed on the first attempt. * **The trend rules need a week.** Rules that compare a before against an after cannot fire until several days of daily captures exist, so the action queue grows over the first week rather than arriving full. The panel counts those days so a thin queue reads as young rather than broken. > [!TIP] > > The iOS keyword field holds 100 characters, never appears on the public store page and cannot be collected. Type it by hand on the metadata page, once for each localization you filled, so coverage and audit checks can see it. See [Edit the iOS keyword field](/guides/metadata-workbench#edit-the-ios-keyword-field). ## Complete the guided setup The first import opens a guided setup with three steps, in this order, each one done inside its own card. 1. **Pick competitors.** Track at least one. The apps ranking in your keyword results and the apps the store calls similar are suggested with a checkbox each and added together, and any other app can be added by name or from its store URL. See [Suggested competitors](/guides/competitors#suggested-competitors). 2. **Track keywords.** Track at least ten in your home storefront. Tick phrases from your listing or from the store autocomplete and track them in one click. 3. **Add countries.** The storefronts that read a language your listing ships are suggested with their daily request cost, and any other country can be chosen from the list. See [How do you add a market?](/concepts/countries#how-do-you-add-a-market) A step can be skipped with one click and undone later. Completing the setup opens the app overview, where the first run panel lists the first positions as they arrive. Progress is kept on your account, so you can continue on another device and a teammate sees the same steps. Each imported app has a setup of its own, so two apps can be set up at once, and the dashboard names the one you touched last. ## Check that collection is running | Check | URL | | - | - | | Web liveness | `http://localhost:3001/api/health` | | API health through the proxy | `http://localhost:3001/api/backend/health` | | Queue dashboard, platform operator only | `http://localhost:3001/admin/queues` | A healthy queue has no jobs in the failed list and shows the daily pipeline as scheduled rather than running around the clock. `/admin/queues` answers 404 to anyone who is not the [platform operator](/security/admin-surfaces#who-the-platform-operator-is), which is deliberate. See [Health checks and monitoring](/operations/health-checks). ## What happens on day two? The daily pipeline starts at `CRON_DAILY`, which defaults to `0 3 * * *`, meaning 03:00 UTC. It checks every tracked keyword in every tracked market, captures SERP snapshots, category ranks and the top charts your apps sit in or you follow, syncs reviews, generates action items and then flushes batched alerts. The import already collected the first positions, scores and action items, so the daily run is what keeps them current rather than what produces them. What it adds on day two is the second capture, which is the first point a chart can draw a line between. See [The daily pipeline and rate limits](/concepts/pipeline). > [!TIP] > > Daily request volume scales with tracked keywords multiplied by tracked markets, against a fixed budget set by `SCRAPE_ITUNES_RPM` and `SCRAPE_GPLAY_RPM`. The settings page shows the [platform operator](/security/admin-surfaces#who-the-platform-operator-is) a daily request budget card, and `GET /jobs/budget` returns the same estimate. ## Next steps > **[Track keywords](/guides/track-keywords)** > > Add keywords, import a list and add markets. > **[Work the Action Center](/guides/action-center)** > > Turn the collected history into a prioritized queue of work. > **[Configuration reference](/configuration/reference)** > > Every environment variable with its default and its effect. > **[Send alerts](/guides/alerts)** > > Signed webhooks or SMTP email for rank, review and action events. # Account recovery Source: https://docs.asobeast.com/security/account-recovery A customer who forgot their password recovers by email, without the operator touching the database. Somebody who cannot remember their password recovers on their own. The operator is never asked to edit a password hash, which matters on a hosted instance where the customer has no access to PostgreSQL and no relationship with you beyond a support address. Recovery needs a mail transport and a public web address, because the message carries a link and the link needs a host. With either one missing the request is refused with `503` and says so, rather than accepting the request and discarding the message. See [Configuration reference](/configuration/reference) for the SMTP block and for `WEB_PUBLIC_URL`. ## The flow 1. The sign in card links to **Forgot password?**, which asks for an email address. 2. asobeast emails a single use link to that address when it belongs to an account. 3. Opening the link asks for a new password and sets it. 4. Every session that account had open is signed out, including the attacker's if the password was the reason for the recovery. Every personal API token the account held is revoked in the same write, so a token minted during that session stops working too. Mint the ones you still need again from **Integrations**. The link expires one hour after it is minted. Asking for another link retires the previous one. ## What the request does not reveal The response to a recovery request is identical for an address that has an account and one that does not: the same status code, the same body and the same confirmation on screen. A reset form that answers differently is an account enumeration oracle, so this one does not. Timing is part of that answer. The message is handed to the relay after the response is written, so a slow or unreachable relay delays no request and an address with an account is answered as quickly as one without. That is also why nothing on the page reports whether the message was sent. A customer who typed the wrong address sees the same reassurance as one who typed the right one, and finds out when no message arrives. ## Limits | Limit | Value | Why | | - | - | - | | Requests per client address | 10 per hour | Stops one client flooding many mailboxes | | Requests per account | 3 per hour | Stops one mailbox being flooded from many clients | | Link lifetime | 1 hour | A recovery link is a password equivalent | Requests past the per account allowance are dropped silently, because refusing them out loud would answer the question the identical response exists to hide. ## What is stored Only a SHA-256 hash of the token, alongside its expiry, on the user row. The plaintext exists in the email and nowhere else, so a stolen database backup carries no usable recovery links. Redeeming a link clears both columns, which is what makes it single use. The clearing and the new password are one conditional write, matched on the stored hash and an expiry still in the future. Two requests arriving on the same link at the same moment therefore settle rather than race: one is accepted, the other is answered `404`, and the password is the one the accepted request chose. Recovery never grants entitlement and never spends store capacity. It is an account action, so it works while a workspace is unentitled, which is exactly when a customer needs to sign in to export their data or to pay. ## Endpoints | Route | Purpose | | - | - | | `POST /auth/password/forgot` | Ask for a recovery link. `204` once recovery is configured, `503` while it is not | | `POST /auth/password/reset` | Redeem a link and set the new password. `204`, or `404` for a spent link and `410` for an expired one | Both are public. Neither accepts a session, and neither returns one: after recovery the customer signs in with the password they just chose. ## Troubleshooting * **No message arrives.** Confirm SMTP is configured and read the delivery outcome. See [Troubleshooting](/operations/troubleshooting). * **The link says it is no longer valid.** It was already redeemed, or a newer link retired it. Ask for another one. * **The link says it has expired.** More than an hour passed. Ask for another one. * **Recovery is refused with `503`.** The instance has no mail transport, or no `WEB_PUBLIC_URL` for the link to point at. See [Configuration reference](/configuration/reference). ## Related > **[Authentication and accounts](/security/authentication)** > > Sessions, registration and entitlement. > **[Rotate secrets](/operations/rotate-secrets)** > > What changing `AUTH_SECRET` signs out. # The queue dashboard and the OpenAPI surface Source: https://docs.asobeast.com/security/admin-surfaces The queue dashboard, the OpenAPI routes, the metrics scrape and the operator endpoints answer 404 to anyone who is not the platform operator, and the admin pages show the app's not found view. Five administrative surfaces ship with asobeast: the admin area of the web app at `/admin`, the queue dashboard at `/admin/queues`, the OpenAPI surface at `/docs`, the metrics scrape at `/metrics` and the support tooling at `/admin/support`. Two more operator endpoints sit beside the support tooling: `/admin/capacity` and `/admin/proxy-pool`. Anyone who is not the platform operator is refused all of them, and what the caller sees depends on the kind of address. | Address | What a caller who is not the operator gets | | - | - | | The admin pages of the web app: `/admin`, `/admin/capacity`, `/admin/workspaces`, `/admin/users` and `/admin/apps` | The not found view inside the app shell, with HTTP status `200` and a `noindex` robots tag. Nothing from the admin area loads | | `/admin/queues` and `/metrics` on the web origin | A real `404` with the not found envelope | | `/docs`, `/docs-json` and `/docs-yaml` on the web origin | A real `404` with the not found envelope under `API_DOCS=owner`, the default, and under `API_DOCS=off`. Under `API_DOCS=public` they are served to anyone | | The API endpoints below `/admin`: the support tooling, `/admin/capacity` and `/admin/proxy-pool`, reached through `/api/backend` on the web origin | A real `404` with the same envelope | | Any other address, an unknown one below `/admin` such as `/admin/whatever` included | The not found page of the framework, with HTTP status `404`, for every signed in visitor. An anonymous visitor is sent to the sign in page first | The second, third and fourth rows are literal: an anonymous caller, a caller with a refused credential, a customer and a read scoped token all get the identical `404` envelope, for every HTTP method but the `OPTIONS` preflight that every `/api/backend` path answers with `204`, from a gate that runs before any other check, so no `401`, `402`, `403`, `405` or `429` can tell a scanner that the route exists. An anonymous visitor to an admin page is sent to the sign in page, like every other page of the app. The admin pages answer `200` because the not found view is chosen by the admin layout, which runs inside the app shell after the response has started streaming, and a streamed response cannot change its status. The robots tag keeps search engines from indexing it. A signed in customer can therefore tell `/admin/users` from `/admin/whatever` by the status alone. That is accepted: the admin area is described on this page, the view carries no instance data and the page never requests any. The last two matter to a hosted operator rather than a self hoster, and [Observability for a hosted instance](/operations/hosted-observability) describes what they serve. ## Who the platform operator is The platform operator is the owner of the bootstrap workspace, the first account the instance ever created. It is a different principal from a workspace owner. That distinction matters because registration decides who becomes an `owner`. By default every account after the first gets a workspace of its own and owns it, on a self hosted instance as much as on a hosted one, so `owner` alone cannot authorize an instance wide surface. A one person instance has a single account, which is both the owner of the bootstrap workspace and the operator. A customer who owns their own workspace is not an operator and receives `404` from every surface on this page, and neither is a member who joined the bootstrap workspace because the instance opted into `AUTH_REGISTRATION_WORKSPACE=shared`. See [Where a self registration lands](/security/authentication#where-a-self-registration-lands). Owning your own workspace still authorizes everything inside it, including managing its team and scheduling its deletion. Only the cross tenant surfaces require the operator. ## Deleting the operator workspace The operator's own workspace is the one exception to scheduling a deletion. While any account exists outside it, `POST /account/deletion` answers `409 Conflict`, because erasing the bootstrap workspace would leave an instance with accounts and no operator. Registration only hands out the role to the first account the instance ever creates, so a later sign up would never take it over. The instance is allowed to reset itself. When the operator's account is the only one, deletion works as for any workspace, and the next registration becomes the operator, because an instance with no accounts bootstraps again. The web app explains the restriction in **Settings**, under **Workspace**. The retention run checks again when the grace period ends. A deletion scheduled while the operator was alone is cancelled, not erased, if an account has registered since, and the log records `its scheduled deletion was cancelled instead of erased`. asobeast has no way to hand the operator role to another account, so to clear your own data delete its apps and keywords. If an instance has already lost its operator, see [Every admin surface answers 404 and no account is the operator](/operations/troubleshooting#every-admin-surface-answers-404-and-no-account-is-the-operator). ## Views only the operator sees The web app keeps the views that describe the whole instance for the operator too. Capacity, the request budget and the API status are facts about the machine collecting for every workspace, not about one customer's account, so only the operator sees the **Capacity** section of the settings page, the request budget share on the dashboard status line and its 85 percent warning, the capacity step of app setup and the API status badge in the header. Every other account sets up an app in four steps and reads its own usage on the plan card. A one person self hosted instance looks the same as before, because its only account is the operator. The public `/health` endpoint is unchanged, so probes keep working without credentials. See [Health checks](/operations/health-checks). These views hide the figures rather than restrict them. `GET /jobs/budget` still answers any signed in account and any personal token with the same estimate, so a script can read it. The MCP surface does not offer it as a tool. ## The admin area The web app has an **Admin** area at `/admin` for the platform operator. It appears in the sidebar, the command palette and the account menu only for the operator, and any other account that opens the address sees the not found view of the app, with the status described above. | Tab | What it shows | Endpoint | | - | - | - | | Overview | workspaces by plan, accounts and sign ups over 30 days, tracked apps by store, keyword markets, searched keywords, AI calls this month, activation | `GET /admin/support/overview` | | Capacity | daily demand against capacity, per store, the top consumers by name, and the proxy pool when one is configured | `GET /admin/capacity`, `GET /admin/proxy-pool`, `GET /admin/support/workspaces` | | Workspaces | every workspace with its plan, members, apps and keyword markets | `GET /admin/support/workspaces` | | Users | every account, newest first, with its workspace and whether its email is confirmed | `GET /admin/support/users` | | Apps | every tracked app with its workspace, store, home market, competitors and keyword markets | `GET /admin/support/apps` | Keyword markets count one phrase once per workspace, however many of its apps track it, which is the number the workspace's own plan card shows. The Overview and Workspaces tabs follow that rule, and the Overview total adds the workspaces together, so a phrase two workspaces track counts once in each. The Apps tab counts what each app tracks, so its rows can add up to more than the workspace figure. Users and Apps accept `?workspaceId=` and return the newest 1,000 rows with the total. Reading either list is written to the support audit trail, the same trail the workspace list writes, because both return personal data. The overview returns counts only and is not written to the trail. The capacity report names its top consumers by workspace id only, because a self registered workspace is named after its owner's email; the Capacity tab takes the names from the workspace list, so reading them is written to the trail too. Every response from these endpoints carries `Cache-Control: no-store`. The admin area is read only. It lists accounts and apps; it never opens a workspace, so an app name is not a link: the operator's own session cannot read another workspace's pages. ### Activation The Activation tile counts the apps imported in the last 30 days, across every workspace, that had a position for one of their keywords within 24 hours of the import, as **9 of 12**. That is the time to value the guided setup is built for. Its note adds how many of those apps completed the guided setup. Competitors are left out, and the count reads the positions and setup rows the instance already keeps, so it needs no configuration. ## When the operator's workspace has no plan With billing on, the support tooling, the queue dashboard, the metrics scrape and the OpenAPI surface under the default `owner` setting need an entitled operator. An operator whose own workspace has no plan in force receives `404` from every one of them, exactly as a customer does, although `GET /auth/me` still reports `platformOperator: true` next to `entitled: false`. A plan is in force while a paid plan has not expired or a trial is running. The state is a natural one on a hosted instance: the bootstrap trial waits for the email confirmation, and every trial ends after `TRIAL_DAYS`. The admin area reads `platformOperator`, `entitled` and `trialAwaitsConfirmation` from `GET /auth/me` and follows the same rule, so it says why instead of showing five tabs that cannot load. | Operator state | What the admin area shows | | - | - | | A plan is in force | The five tabs | | No plan, and the trial waits for the email to be confirmed | A notice to confirm the email, with no offer to pay | | No plan, any other reason: the trial ended, the plan expired or none was ever set | A notice that the workspace needs a plan, with a link to the plans and to this page | The **Admin** entry stays in the sidebar, the command palette and the account menu, so the explanation is always one click away. To open the area again, choose a plan on the upgrade page, or set `plan` on the bootstrap workspace to `indie` or `ultimate` in the database, as described in [Authentication and accounts](/security/authentication#entitlements-and-the-paid-plan-seam). The final gate on `/admin/capacity` and `/admin/proxy-pool` checks only that the caller is the operator, so they keep answering `200` to an operator session without a plan, and `402` to an operator token, exactly as before. Anyone who is not the operator gets `404`, plan or no plan. The web app closes the whole area in that state so it never half loads. An admin request can also be refused while the operator's workspace has a plan, for example on an instance an earlier release left with that workspace suspended. The tab then says that the admin endpoints answer not found unless the workspace has a plan in force and is not suspended, rather than saying a record was deleted. ## Why 404 and not 403? A `403` confirms that the thing you asked for exists. A `404` does not. Anonymous callers therefore get the same answer whether the surface is present, disabled or was never built. There is no login prompt to attack, and a scanner learns nothing from the response. This holds for the queue dashboard, the OpenAPI routes, the metrics scrape and the API endpoints. The admin pages are the exception described above, because they load nothing for a caller who is not the operator. If you expected a sign in page on `/admin/queues` and got a not found page, nothing is broken. Sign in as the operator in the same browser and reload. ## The queue dashboard `/admin/queues` serves Bull Board, which shows every queue, its waiting, active, completed and failed jobs, and lets the operator retry a failed job. | Setting | Default | Effect | | - | - | - | | `BULL_BOARD_ENABLED` | `true` | `false` removes the dashboard entirely | The operator gate is unconditional. There is no configuration that opens the queue dashboard to anonymous callers. ## The OpenAPI surface `API_DOCS` controls who may read `/docs`, `/docs-json` and `/docs-yaml`. | Value | Behaviour | | - | - | | `owner` | Default. Requires an operator session or an operator personal API token, and answers `404` otherwise | | `public` | Serves the documentation and the OpenAPI document to anyone | | `off` | Never registers the routes at all | `public` is a deliberate choice, not an accident, and it is reasonable when the API surface is meant to be read by people you have not given accounts to. ## Both are reached through the web origin You never reach either surface on the API port. The web app serves route handlers at the identical paths and proxies them through. Identical paths matter: both surfaces are single page applications that build their asset URLs from the path they are mounted on, so serving `/admin/queues` at any other path breaks its own assets. The gate itself runs before the application framework's guards, because both surfaces register their own routing outside them. It resolves the session cookie or the `asob_` token itself, and the queue dashboard re asserts the grant afterwards, so a deployment that forgot the wiring fails closed rather than open. The gate covers the whole `/admin` prefix on the API, not a list of routes. `/admin/queues` and `/admin/support` are checked first with their own rules; every other path below `/admin`, `/admin/capacity` and `/admin/proxy-pool` among them, is checked by a final gate that asks only whether the caller is the operator, so an operator endpoint added later is hidden from the moment it is mounted. The web app's `/docs`, `/docs-json`, `/docs-yaml` and `/metrics` handlers answer any method other than `GET` with the same `404` themselves, and so does `OPTIONS` on `/admin/queues`, so the web origin never answers `405` or an `Allow` header that confirms a route. A refused credential costs the same here as anywhere else in the API: it is counted against the per address budget described in [Rate limits](/api-reference/rate-limits), under the same rule about which credentials count, and an accepted credential that does not belong to the operator is never counted. Once an address has spent that budget these surfaces still answer `404` rather than `429`. Every other route answers that address with `429`, so a caller who spends the budget here can tell that a gate counted the refusals. That reveals nothing about the queue dashboard, the metrics scrape or the support tooling, which every instance mounts, and for the OpenAPI routes only whether `API_DOCS` is `owner` rather than `off`. The web app hides the links to both surfaces from anyone who is not the platform operator, using a capability the API computes and returns with the signed in user. That is presentation only. Authorization is the gate above, and a customer who follows a link anyway still gets `404`. ## A read scoped token cannot change queue state The queue dashboard is the one admin surface that writes. Pausing, resuming, retrying, promoting, cleaning and obliterating a queue all arrive as `PUT`, `POST` or `DELETE`, so the gate applies the token scope the rest of the API applies, and a read scoped token gets `404` on any of them. | Credential | Read the dashboard | Change queue state | | - | - | - | | Operator session | Served | Served | | Operator token, write scope | Served | Served | | Operator token, read scope | Served | `404` | A reporting or MCP integration therefore only needs a read scoped token, and a leaked one cannot stop the daily run. See [Personal API tokens](/security/api-tokens). ## A suspended workspace loses the queue dashboard The gate applies the same suspension rules the rest of the API applies, because running outside the guards would otherwise mean running outside the policy. | Credential | `/admin/queues` | `/docs` | | - | - | - | | Operator session, workspace suspended | `404`, because the dashboard is an operational surface rather than a read | Served, because reading stays open | | Operator token, workspace suspended | `404` | `404` | A suspended account keeps reading and exporting its data, and keeps billing, so the account can always pay and always leave. See [Rate limits and quotas](/api-reference/rate-limits). The operator workspace is the one workspace that cannot be suspended. The support surface answers `409` and the command line refuses too, because a suspended operator workspace takes the support surface and the queue dashboard away from the operator, and with them the way to lift the suspension. An instance that an earlier release left with its operator workspace suspended restores it from [the command line](/operations/hosted-observability#suspending-and-restoring-from-the-command-line). ## Retired variables `BULL_BOARD_USER` and `BULL_BOARD_PASSWORD` are ignored. The dashboard used to use basic authentication and now requires an operator session or a personal API token instead. The API logs a warning at boot if either is still set. Remove them from your environment. ## Related > **[Personal API tokens](/security/api-tokens)** > > An operator token reaches both surfaces. > **[Health checks and monitoring](/operations/health-checks)** > > Queue health without opening the dashboard. # Personal API tokens Source: https://docs.asobeast.com/security/api-tokens Mint a revocable personal API token in Integrations and send it as a bearer header. The plaintext value is shown once and stored only as a hash. A personal API token authenticates a script, a scheduled job or the MCP server exactly like a signed in session, without a browser. ## Create a token **1. Open the API tokens card in Integrations** Sign in first. A token belongs to your account. **2. Name it after what will use it** A name such as `backup-script` or `laptop-mcp` makes revoking the right one possible later. **3. Choose a scope and an expiry** Read only is the default and is all a reporting script or the MCP read tools need. Choose read and write only for something that has to change data, such as a script, or an agent you allow to track keywords, manage competitors and update actions. An expiry of 30, 90 or 365 days beats a permanent credential sitting in a continuous integration configuration. **4. Copy the value immediately** The plaintext value, which starts with `asob_`, is shown once. asobeast stores only a SHA-256 hash of it and can never show it again. ## Scope and expiry | Setting | Values | Default | Behaviour | | - | - | - | - | | Scope | `read` or `write` | `read` | A read only token is refused with `403` on anything that changes data | | Expiry | 30, 90 or 365 days, or never | Never | An expired token fails authentication with `401`, exactly like a revoked one | The tokens card shows each token's scope, expiry, last use and request count, so the one consuming your budget is easy to find. ## Use a token Send it as a bearer header on every request. ```bash theme={null} curl -H "Authorization: Bearer asob_…" https://your-host/api/backend/health ``` The path matters. `/api/backend/*` on the web origin is the same proxy the browser uses, and it forwards the bearer header to the API. When your script runs on the same host as the API, you can call the API directly instead. ## Revoke a token Delete it from the same card. Revocation is immediate: the next request carrying that token fails authentication. Changing your password, or recovering a forgotten one, revokes every token the account holds at once. Mint the tokens you still need again afterwards. Rate limits count per workspace rather than per token, so a second token adds no capacity and revoking one does not free any. See [Rate limits and quotas](/api-reference/rate-limits). Revoke rather than rotate `AUTH_SECRET` when a single token leaks. Rotating the secret signs out every browser session and does not affect tokens at all, because tokens are hashes rather than signed values. ## What a token cannot do A token, whatever its scope, cannot manage credentials or the workspace itself. These routes need a signed in browser session and answer a token with `403` and the message `This needs a signed in session. A personal API token cannot do it.` | Cannot be done with a token | Route | | - | - | | Create or revoke a token | `POST /auth/tokens`, `DELETE /auth/tokens/{id}` | | Change the password | `POST /auth/password` | | Schedule or cancel the deletion of the workspace | `POST /account/deletion`, `DELETE /account/deletion` | | Invite a member, revoke an invitation or remove a member | `POST /workspace/invites`, `DELETE /workspace/invites/{id}`, `DELETE /workspace/members/{id}` | This is what stops a leaked token from minting its own successor. After revoking a leaked write token, also review the webhooks and email alerts it could have added. A token that could mint another token would survive its own revocation and its own expiry. To rotate a token, create the new one in Integrations and then revoke the old one. Listing tokens and reading the team stay open to a token. ## Where tokens are used | Use | Notes | | - | - | | Scripts and scheduled jobs | Anything that reads the API without a browser | | The MCP server | `ASOBEAST_API_TOKEN` is mandatory, and a boot preflight fails without a valid one. A read scoped token lists the read tools. A write scoped token also lists seven tools that change keywords, competitors, actions and annotations, so give an agent a token of its own | | Continuous integration | Store it as a secret, never in the repository | > [!WARNING] > > A write scoped personal API token carries the access of the account that created it, including owner access to the admin surfaces when the owner created it, except for the routes that need a signed in session. Never commit one, never paste one into an issue, and give each consumer its own token so a single revocation is enough. Do not reuse a script's write token for an agent: mint a new one named after the agent, so revoking it ends the agent's access and nothing else. ## Related > **[Authentication and accounts](/security/authentication)** > > How a request is authenticated and authorized. > **[The queue dashboard and the OpenAPI surface](/security/admin-surfaces)** > > What an owner token unlocks. # Authentication and accounts Source: https://docs.asobeast.com/security/authentication Every request is authenticated. The first account on a fresh instance becomes the owner, and registration closes automatically afterwards. Authentication is always on and cannot be disabled. The same image runs on `localhost` and on a public host, so a disable switch would be a setting somebody forgets to change on the day the instance becomes reachable. ## First run A fresh installation redirects to registration. The account you create there becomes the owner. The first account always bootstraps as owner regardless of `AUTH_ALLOW_REGISTRATION`, so a locked down deployment can still create its first user. Set `AUTH_SECRET` before the first boot. The API refuses to start with fewer than 32 characters. ```bash theme={null} openssl rand -hex 32 ``` ## Registration `AUTH_ALLOW_REGISTRATION` defaults to `false`, which closes registration the moment the owner account exists. That is the right default for a single operator deployment. Set it to `true` when more people need to sign themselves up. ### Where a self registration lands Opening registration decides who may create an account. `AUTH_REGISTRATION_WORKSPACE` decides what that account can see, and it defaults to `own`. | Value | Effect | | - | - | | `own` (default) | Every sign up after the owner gets a workspace of its own and owns it. Someone who registers starts empty and never sees the apps, keywords and rankings already tracked on the instance | | `shared` | Every sign up joins the bootstrap workspace as a member, with read and write access to everything in it | Pick `shared` only when everyone who can reach the registration form is already trusted with the data on the instance. Anyone who finds an internet reachable instance with open registration and `shared` can read and edit the whole workspace, so the safe combination for a public host is open registration on `own`, or registration closed. A hosted instance refuses to start on `shared`, because `BILLING_ENABLED=true` opens registration to strangers by design. ### Add somebody to your own workspace Self registration is not how a teammate joins you. Invite them from **Settings**, which sends a link that creates their account inside your workspace as a member. That works on `own` and needs no change to registration at all. ## Password rules A password is between 10 and 128 characters long and must contain at least 10 characters that are not whitespace. Spaces inside a passphrase are fine, so `correct horse battery` is accepted, while a password made only of spaces, tabs or other whitespace is refused with `400`. The same rule applies everywhere a password is chosen: registration, accepting an invitation, redeeming a recovery link and changing the password from the account menu. The password is stored exactly as it was sent. Nothing is trimmed, so the characters you type at sign in are the characters that were chosen. Sign in does not apply the rule. An account created before the rule existed can still sign in with its old password and change it to one that meets the rule, and a wrong password within the length limit is answered with the same `401` whatever it contains. ## Sessions A session is a JWT in an `httpOnly`, `SameSite=Lax` cookie named `asobeast_session`, signed with `AUTH_SECRET`. Every session also has a record on the server, and the token carries a random id that names it. The API accepts the cookie only while that record exists and has not expired, so a signature alone is not enough. Registering, signing in, confirming an email address and accepting an invitation each open a session. **Signing out ends the session you are using on the server**, not only in the browser. A copy of that cookie stops working at once, whoever holds it, while your sessions on other devices stay signed in. Records of expired sessions are removed by the daily retention job, so there is nothing to configure. | Setting | Default | Effect | | - | - | - | | `AUTH_SESSION_DAYS` | `7` | Cookie lifetime in days | | `AUTH_COOKIE_SECURE` | `false` | Must be `true` behind TLS so the cookie is sent only over HTTPS | Because the browser talks only to the web origin and the proxy forwards the cookie, there is nothing cross site to configure. A JSON only API plus `SameSite=Lax` is the CSRF story. Rotating `AUTH_SECRET` invalidates every existing session, so everyone is signed out. See [Rotate secrets](/operations/rotate-secrets). ## Rotate a password Changing your password from the account menu ends every other session, by bumping a session version and deleting their records, while the one you are using keeps working. That is what you want after a laptop is lost. The same write deletes every personal API token the account holds, because a token minted during a session somebody else briefly held would otherwise keep working. Mint the tokens you still need again from **Integrations** afterwards. ## How are sign in attempts throttled? The authentication endpoints are rate limited per [client address](/api-reference/rate-limits#limits-are-per-workspace). | Endpoints | Attempts allowed | | - | - | | Register, sign in, confirm an email address, change a password, create a token, invite and accept an invite | 10 per minute | | Request a recovery link and redeem it | 10 per hour | | Resend a confirmation link | 3 per hour | A refused attempt answers `429` with a `Retry-After` header and a `retryAfterSeconds` field in the error body, both in whole seconds, and the message names the same wait in words. A wait under a minute is counted in seconds, for example `Too many attempts from this address. Try again in 42 seconds.` A longer wait is rounded up to whole minutes and counted in hours and minutes, so the hourly limits read `Try again in 49 minutes.` or `Try again in 1 hour.` rather than a count of thousands of seconds. The sign in card and the recovery pages show that message as it is. While Redis cannot be reached an attempt cannot be counted, so these endpoints answer `503` with a `Retry-After` header instead of admitting it. See [When Redis is unreachable](/api-reference/rate-limits#when-redis-is-unreachable). Behind a reverse proxy, `TRUST_PROXY` decides what the client address is. Left at `0` behind a proxy, every browser shares the proxy's address and therefore one allowance. See [Hosting behind TLS and a reverse proxy](/security/hosting). ## Recover a forgotten password Somebody who cannot remember their password asks for a recovery link from the sign in card and sets a new one without the operator touching the database. Redeeming the link signs out every session that account had open and revokes every personal API token it held. See [Account recovery](/security/account-recovery). ## How is a request authorized? Two guards run in order on every request. | Guard | Accepts | Rejects with | | - | - | - | | `AuthGuard` | A session cookie or an `asob_` bearer token | `401` | | `EntitlementGuard` | An entitled account | `402` | A small set of routes is marked public, and account plus paywall routes stay reachable even for an unentitled account so an expired user can sign in and pay. Where a registration lands is the same on a self hosted instance and a hosted one: by default every account after the first gets a workspace of its own, so accounts never see each other's apps. `AUTH_REGISTRATION_WORKSPACE=shared` is the opt in that puts them in the bootstrap workspace instead. See [Where a self registration lands](#where-a-self-registration-lands). ## Entitlements and the paid plan seam `BILLING_ENABLED` defaults to `false`, which means every account is entitled and nothing is gated. Setting it to `true` turns on an entitlement check without wiring a payment provider. Registration stays open, each new mailbox is stamped with a `TRIAL_DAYS` trial defaulting to 7 (see [One trial per mailbox](#one-trial-per-mailbox)), and an unentitled request answers `402` through the standard error envelope, which the web app catches and turns into the upgrade page. Entitlement lives on the workspace, not the user, because a workspace has one plan whatever the size of the team. The seam writes only `plan`, `trialEndsAt`, `planExpiresAt`, `billingCustomerId` and `subscriptionId` on `Workspace`. The plans are `free`, `trial`, `indie` and `ultimate`; `indie` and `ultimate` are the paid tiers and carry the quota limits, so setting `plan` to either one in the database restores access with no code change. `premium` is accepted as a legacy spelling of `indie` so accounts written before the tiers were named keep working. The plan in force is derived, not just stored. A paid plan past its `planExpiresAt` falls back to the trial if one is still running, and to `free` otherwise, so an expired subscription never keeps the limits it paid for. ## Email confirmation and the trial With billing enabled and SMTP configured, registration creates the account but withholds the trial until the address is confirmed. asobeast emails a single use link; opening it stamps the address as confirmed and starts the trial on the workspace. Only a hash of the link token is stored. A workspace gets one trial, ever. `trialStartedAt` records it and is never cleared, so a cancelled customer who returns resumes on a paid plan rather than on a second free week. A mailbox gets one too, as the next section explains. A workspace that holds a paid plan, or that ever held a subscription, gets none: confirming an address there only marks the address confirmed, including when the customer paid before confirming. Where SMTP is not configured the trial starts at registration, because there is no way to deliver a confirmation. Until the address is confirmed the web app asks the account to confirm instead of asking it to pay. `GET /auth/me` reports `trialAwaitsConfirmation: true` for an account that has not confirmed its address, in a workspace that has never had a trial or a subscription and has no plan in force, on an instance that sends confirmation email. The banner, the upgrade page and the plan card then say that confirming starts the free trial, the banner and the upgrade page offer a new link, and the plans stay available to anyone who would rather pay first. The field is `false` where SMTP is not configured, because the trial starts at registration there, and for a member of a workspace whose trial has already run or that ever held a subscription, because confirming would start nothing. Registration survives a confirmation email that could not be sent, so a mail outage never costs the account. The signed in account asks for a fresh link with `POST /auth/verify/resend`, which retires the previous one; the confirmation page offers it whenever a link is refused. Opening a link while signed in as someone else is refused before the link is spent, so it stays usable for the account it belongs to. A deployment with billing and SMTP but no `WEB_PUBLIC_URL` refuses to boot, because every link it sent would carry no host. ## One trial per mailbox A trial is not a reason to register again. With billing enabled, an address is reduced to its mailbox before the trial is decided, and a mailbox gets one trial, ever, however many workspaces or addresses it has been registered under. | Rule | Example | | - | - | | Case and surrounding spaces are ignored | `Person@Example.com` is `person@example.com` | | A plus tag is dropped, on every domain | `person+trial2@example.com` is `person@example.com` | | Dots are ignored on Gmail only | `p.erson@gmail.com` is `person@gmail.com` | | `googlemail.com` is Gmail | `person@googlemail.com` is `person@gmail.com` | | Quotes around the local part are ignored | `"person"@example.com` is `person@example.com` | | A unicode domain is its ASCII form | `person@bücher.de` is `person@xn--bcher-kva.de` | Dots stay significant everywhere else, because most providers count them. The stored address and the sign in address are never changed: this is only how the trial is decided and how the daily limit on [confirmation emails to one recipient](/guides/alerts#confirm-a-recipient) is counted. An account whose mailbox already had a trial is created as usual and signs in, but its workspace gets no trial and sits on the paywall until it pays. The same holds for an address on a throwaway domain, taken from the [`mailchecker`](https://www.npmjs.com/package/mailchecker) list of disposable email providers. That list includes privacy relay alias domains such as `duck.com`, `mozmail.com`, `passmail.net` and the SimpleLogin domains, so an alias from one of them starts no trial either, because one relay account mints as many aliases as it likes. Each refusal is logged as a warning that names the address. Registration answers `201` in every case and `GET /auth/me` reports `entitled: false`, `trialEndsAt: null` and `trialAwaitsConfirmation: false`, so the web app offers the plans instead of asking for a confirmation that would start nothing. Where an instance sends confirmation email the mailbox is claimed when the address is confirmed, never at registration, so nobody can use up a mailbox they cannot read. Where it sends none the mailbox is claimed at registration, because nothing can prove it there. A claim is kept only for a trial that started: when a subscription reaches the workspace while its trial is being granted, the claim is given back. Each claim is stored as a keyed hash of the mailbox in `TrialClaim`, an operator table that belongs to no workspace and has no foreign key, so it survives the erasure of the workspace that took the trial. The key is an HMAC of the mailbox under `AUTH_SECRET`, which means the table alone cannot be tested against a list of known addresses and holds no email. [Rotating `AUTH_SECRET`](/operations/rotate-secrets) therefore forgets every claim, and the next registration of a returning mailbox starts a trial again. Every start of the API claims, in the background, the mailbox of every owner whose workspace already had a trial, so an upgrade covers the existing accounts without delaying the start, and a failed attempt is retried every thirty seconds. Until the first pass after an upgrade finishes, a variant of such an owner's mailbox can still register with a trial. Self hosted instances without billing store no claims. This slows trial farming down; it does not stop someone with a domain of their own, or with many real mailboxes. A card on file or a phone number would be the next step, and neither is required today. ## What an unentitled workspace keeps Data belongs to the customer; capacity is what the plan pays for. An unentitled workspace therefore keeps every read: | Still available | Refused with `402` | | - | - | | Reading apps, keywords, rankings, audits and reviews in the web app | Importing an app, tracking a keyword, adding a competitor | | Exporting anything already collected as CSV or JSON | On demand refresh, daily run, scoring, keyword suggestions, deep search, name search and top chart captures | | Reaching the account, plan and upgrade routes | Every other request authenticated with a personal API token, including the MCP server | Reads stay open only to a browser session. A personal API token keeps the account, plan and upgrade routes, the account export among them, so a script can still take the data out, and is refused everywhere else, because the token surface is part of what the plan sells. Nothing is deleted when entitlement lapses. ## Team members A workspace has one owner and any number of members. The owner manages billing and the team; a member does everything else. Nobody belongs to two workspaces. A member sees the plan and its usage read only. The plan card in settings and the upgrade page say that the workspace owner manages billing, and neither offers a checkout or the billing portal, which the API refuses to anyone but the owner. * The owner invites by email from **Settings, Team**. The invitation expires after seven days. * asobeast emails the invitation when SMTP and `WEB_PUBLIC_URL` are both configured. The link then reaches the invited mailbox and nowhere else: the owner's response carries only the bare `/invite` page, so nobody but the person who can read that mailbox can accept it. * Where mail is not configured, the dialog shows the link for the owner to pass on. After a failed send it shows the link only while registration is closed, because no stranger can then sign up to send an invitation. With registration open and mail configured a failed send shows no link; fix the mail transport and invite again, which retires the earlier link. * The invitee opens the link, chooses a password, and joins the existing workspace as a member. The token is single use. * The owner can revoke a pending invitation or remove a member. A workspace always keeps its owner. Invitations are the way to add a teammate when registration is closed, which is the normal self hosted setting. ## Troubleshooting * **You are signed out after a restart.** `AUTH_SECRET` changed. Sessions are signed with it. * **Everyone was signed out once after an upgrade.** The first release with stored sessions refuses the session cookies issued before it, because those carry no session id. Sign in again. Personal API tokens and the MCP server are not affected. * **You cannot register a second account.** Registration closed after the owner account. Set `AUTH_ALLOW_REGISTRATION=true`. * **Sessions do not persist behind a proxy.** The session cookie is `Secure`, so a browser discards it over plain HTTP from another machine. Terminate TLS in front of port 3001, compose the Cloudflare Tunnel overlay, or forward the port over SSH and browse `http://localhost:3001` in Chrome or Firefox. Keep `AUTH_COOKIE_SECURE=true`, because the Docker image refuses to boot without it. See [Sessions do not persist behind a proxy](/operations/troubleshooting#sessions-do-not-persist-behind-a-proxy) and [Hosting behind TLS and a reverse proxy](/security/hosting). * **Signing in returns to the sign in form.** The browser did not keep the session cookie. Safari drops a `Secure` cookie on plain HTTP even on localhost, so it needs TLS. See [Signing in returns to the sign in form](/operations/troubleshooting#signing-in-returns-to-the-sign-in-form). ## Related > **[Personal API tokens](/security/api-tokens)** > > Authenticate a script or an agent. > **[Hosting behind TLS and a reverse proxy](/security/hosting)** > > The checklist before exposing an instance. > **[Account recovery](/security/account-recovery)** > > What a customer does when they forget their password. # Hosting behind TLS and a reverse proxy Source: https://docs.asobeast.com/security/hosting Reach the stack through a Cloudflare Tunnel or your own reverse proxy, set AUTH_COOKIE_SECURE to true, and verify TRUST_PROXY against the real chain. One setting decides whether a public deployment is safe: `AUTH_COOKIE_SECURE` must be `true` on any instance reachable over TLS, so the session cookie is never sent over plain HTTP. ## Put asobeast behind TLS asobeast does not terminate TLS itself. The shipped path is a Cloudflare Tunnel, which terminates TLS at Cloudflare's edge and reaches the stack over an outbound connection. That choice is about the host, not about certificates. A tunnel needs **no inbound port at all**, so a machine behind CGNAT or an ISP that blocks port 80 works unchanged, there is no certificate to obtain or renew on the host, and nothing on the machine is reachable from the internet except through the tunnel. Create the tunnel in the Cloudflare dashboard, point its public hostname at `http://web:3000`, and put its token in the root `.env` beside the domain. ```bash theme={null} TUNNEL_TOKEN=eyJhIjoi... ASOBEAST_DOMAIN=aso.example.com ``` Then compose the overlay on top of the stack. ```bash theme={null} docker compose -f docker-compose.yml -f docker-compose.tunnel.yml up -d --wait ``` That one command adds `cloudflared` to the stack, stops publishing host port 3001 so nothing is left listening beside the tunnel, and sets the three settings that depend on the hop: `TRUST_PROXY=1` for the web app, `AUTH_COOKIE_SECURE=true`, and `WEB_PUBLIC_URL=https://$ASOBEAST_DOMAIN`. The overlay pins all three literally, so a value you set in the root `.env` cannot leave the web app counting no hop while the tunnel is in front of it. `cloudflared` is pinned to a tested version, so two hosts built on different days run the same ingress binary and a rollback goes back to something known. Cloudflare does deprecate old builds, and the container runs with `--no-autoupdate`, so the pin is bumped by hand in `docker-compose.tunnel.yml` the same way `MINT_VERSION` and `VALE_VERSION` are. Treat a new cloudflared release as maintenance rather than something the deployment picks up on its own. CI composes the same overlay with the root `.env` value for `TRUST_PROXY` set to `0`, and asserts that the resolved configuration publishes no port at all, that the web app still counts the hop, that the other two settings arrive with it, that `cloudflared` carries the same memory and log ceilings as every other service, that no service image floats on a tag like `latest`, and that the origin still answers where `cloudflared` reaches it. What CI cannot do is stand up a real tunnel, so verify the hop count on your own deployment once. See [Verify the hop count](#verify-the-hop-count). ### Run cloudflared on the host The overlay is a convenience, not a requirement. When `cloudflared` already runs as a service on the machine, as a Windows service or a systemd unit, keep the base stack and let the host binary reach it over the published port. Point the tunnel's public hostname at `http://localhost:3001` rather than at `http://web:3000`, because the host binary is outside the Compose network and reaches the web app the same way any other local client does. The overlay is what normally pins the three settings that depend on the hop, so set them yourself. All three live in the root `.env`, from which Compose passes `AUTH_COOKIE_SECURE` and `WEB_PUBLIC_URL` to the API service and `TRUST_PROXY` to the web service: ```bash theme={null} AUTH_COOKIE_SECURE=true TRUST_PROXY=1 WEB_PUBLIC_URL=https://aso.example.com ``` `TRUST_PROXY` is still `1` here. The chain is the same as with the overlay: Cloudflare appends the address it observed and `cloudflared` adds no entry, so exactly one hop you control sits in front of the web app. The web app warns at startup when it runs in production counting no hop, which is the check the overlay used to make unnecessary. Host port 3001 is published on this path, so bind it to the loopback or firewall it. Nothing else about the stack changes. ### Bring your own proxy A tunnel is not the only way. Put any reverse proxy in front of host port 3001 instead, and set the same three variables in the same files as the tunnel path above. The proxy must forward the `Host` header and the request path unchanged, because the web app serves the admin surfaces at identical paths. ```caddyfile theme={null} aso.example.com { reverse_proxy 127.0.0.1:3001 } ``` nginx and Traefik work the same way. Whatever you choose, host port 3001 is then reachable on the machine, so bind it to the loopback or firewall it. The tunnel overlay exists partly to remove that step. ### Refuse TRACE at the proxy The web app does not process an HTTP `TRACE` request. The framework builds a standard `Request` object for each request it renders or forwards, and Node.js refuses to build one for `TRACE`, so a dynamic page, the API proxy under `/api/backend` and the other route handlers answer `500 Internal Server Error` with the body `Internal Server Error` before any asobeast code runs. A page prerendered at build time, such as the sign in page, is served from the build as for `GET` and answers `200` with the page, and a few other static pages answer `405` or a redirect. In every case nothing from the request is echoed back, which is what makes `TRACE` dangerous elsewhere, and the server answers the next request normally. The only effect of the `500` is one error entry in the web log per request. A deployment without a proxy keeps these answers, which are harmless. If your scanner or policy expects a `405 Method Not Allowed` on every path, refuse the method at the proxy, where you already terminate TLS. ```caddyfile theme={null} aso.example.com { @trace method TRACE TRACK respond @trace 405 reverse_proxy 127.0.0.1:3001 } ``` ```nginx theme={null} if ($request_method ~ ^(TRACE|TRACK)$) { return 405; } ``` Put the nginx rule inside the `server` block that proxies to host port 3001. ## Set TRUST\_PROXY correctly `TRUST_PROXY` is a hop count, and both apps read their own. The value is the number of proxies **you control** between the internet and that app. `true` still means 1 and `false` still means 0, so an existing setting keeps working. The API reads that many entries back from the right of `X-Forwarded-For`. Every proxy you control appends after the entries a client wrote, so the entry that many hops from the right is the first one the client could not have written. | App | Value in the documented deployment | Why | | - | - | - | | Web | `1`, which the tunnel overlay pins and you set yourself on any other path | Cloudflare appends the address it observed, so the rightmost entry is the real client, and `cloudflared` adds no entry of its own | | API | `1`, which Compose pins | The web app is always exactly one hop in front of the API on this stack | Set the web value in the root `.env`, which Compose passes to the web service. You do not set the API value on this stack, because the topology decides it. ```bash theme={null} TRUST_PROXY=1 ``` > [!WARNING] > > Count only hops you control. A count higher than the real chain reaches past your own proxies into the part of `X-Forwarded-For` the client wrote, which lets any caller spread login attempts across fabricated addresses and defeats the auth rate limiter. Leaving it `0` is safe but blunt: the API sees the web container as the client, so every browser client shares one bucket. That is fine for a single operator instance and wrong for anything with real users. Both apps say so at startup, warning once when they run in production counting no hop. ### Verify the hop count The hop count is the one setting that is wrong silently. Too high and any caller spoofs `X-Forwarded-For` to spread login attempts across fabricated addresses; too low and every browser shares one bucket. Nothing in the stack can tell the difference on its own, because both look like a working site. Verify it once, on the real deployment, with two devices on different networks. A phone on mobile data and a laptop on the office connection are enough. **1. Spend the login allowance from one device** Present a wrong password to the sign in form until it is refused with a rate limit message. **2. Try the second device immediately** From the other network, present a wrong password once. If the second device is refused straight away, the count is **too low**: both devices share one bucket, so the app is not seeing the real client address. If it gets its own allowance, the count is right. Then check the other direction, which is the dangerous one. From a single device, spend the allowance while sending a made up `X-Forwarded-For` on every request. ```bash theme={null} for attempt in $(seq 1 12); do curl --silent --output /dev/null --write-out '%{http_code}\n' \ --header "x-forwarded-for: 203.0.113.${attempt}" \ --header 'content-type: application/json' \ --data '{"email":"you@example.com","password":"wrong"}' \ "https://${ASOBEAST_DOMAIN}/api/backend/auth/login" done ``` The last responses must be `429`. If every one is `401`, the count is **too high** and the forged header is buying an unlimited supply of addresses. Lower it until they are not. ### What the web app forwards The web proxy never passes a caller supplied `X-Forwarded-For` or `X-Real-IP` through to the API. A caller can write either header, and Next.js keeps an incoming `X-Forwarded-For` rather than replacing it, so the proxy cannot tell a forged value from an observed one. Instead it sends exactly one address, the one its own `TRUST_PROXY` says it can trust, on the requests it relays for the browser and on the requests it makes while rendering a page. At `0` it sends none, and the API falls back to the connection. See [Web app configuration](/configuration/web). ## Set WEB\_PUBLIC\_URL `WEB_PUBLIC_URL` builds absolute deep links inside alert payloads, and it is also the single origin the API allows through CORS. Set it to the origin a person would type. Only `http` and `https` are accepted, and the API refuses to boot on anything else, because a scheme the browser gives no origin to would otherwise become the allowed origin. The Compose stacks set it from the root `.env` and default it to `http://localhost:3001`, the port they publish, so a confirmation email opens the stack that sent it. A value in `apps/api/.env` does not reach them, because that file also configures `pnpm dev`, which serves on port 3000. Unset, the `link` field is `null` rather than a `localhost` guess that would not open for anyone, and the API sends no CORS headers at all. See [Send alerts](/guides/alerts). Sending nothing is the right default here. The browser only ever talks to the web origin, and a same origin request needs no CORS headers, so the shipped topology is unaffected either way. What it does break is a custom frontend on another origin calling the API directly. That breaks loudly, in the browser console, with a message naming the cause, which is a better outcome than the wildcard the API used to send to every origin on earth. ## What does Compose expose? The base stack publishes only host port 3001, mapped to port 3000 in the web container. The API, PostgreSQL and Redis stay on Docker networks, and the backend network is marked `internal`. Composing `docker-compose.tunnel.yml` removes even that, so the machine publishes nothing at all and `cloudflared` reaches the web container over the `frontend` network. A reverse proxy on the host needs port 3001; a tunnel does not. See [Self host with Docker Compose](/install/docker-compose). ## Checklist before going public **1. Compose the tunnel overlay, or terminate TLS yourself** Confirm the site loads over HTTPS with a valid certificate, and that nothing answers on host port 3001. **2. Set AUTH\_COOKIE\_SECURE=true** The tunnel overlay sets it. Set it yourself if you brought your own proxy, then restart the API and sign in again. **3. Verify TRUST\_PROXY against the real chain** Reading it is not verifying it. Run the two device check in [Verify the hop count](#verify-the-hop-count). **4. Generate AUTH\_SECRET properly** `openssl rand -hex 32`. Never a memorable string, never reused from development. **5. Leave registration closed** `AUTH_ALLOW_REGISTRATION=false` once the owner account exists. If you do open it, leave `AUTH_REGISTRATION_WORKSPACE=own` so a stranger who signs up lands in an empty workspace of their own. See [Registration](/security/authentication#registration). **6. Schedule and test backups** A backup that has never been restored is not a backup. See [Back up PostgreSQL](/operations/backups). ## What the API refuses to start with Some settings are individually valid and jointly unsafe. The API checks those combinations at startup and stops rather than logging a warning that nobody reads, because both failures below are silent and expensive. | Combination | What would happen | | - | - | | `NODE_ENV=production` with `AUTH_COOKIE_SECURE=false` | Session cookies ship without the `Secure` attribute, so anything on the network path can read them | | `BILLING_ENABLED=true` with `TRIAL_DAYS=0` | Every new account is locked out the moment it registers, with no route to the paywall | | `BILLING_ENABLED=true` with `AUTH_REGISTRATION_WORKSPACE=shared` | Hosted registration is open to strangers, so every one of them would land in the bootstrap workspace with read and write access to the data already there | The Docker image sets `NODE_ENV=production`, so the first check is live on every self hosted deployment. The Compose stack therefore defaults `AUTH_COOKIE_SECURE` to `true`. Chrome and Firefox keep the `Secure` cookie on `http://localhost`, so evaluating locally over plain HTTP works there. Safari does not, so a Safari user needs TLS. See [Signing in returns to the sign in form](/operations/troubleshooting#signing-in-returns-to-the-sign-in-form). The error names the variable, the required value and the reason. Fix the variable and start again. ## What the API warns about These cannot be decided from configuration alone, so they log a warning at startup and let you proceed. The mail warning is the one to read twice. Every individual mail guard is satisfied when SMTP is absent, because each of them is conditioned on SMTP being present, so a metered instance with no relay starts perfectly clean and cannot email a paying customer. It warns rather than refuses because bringing a hosted instance up before the relay credentials exist, with registration closed, is a legitimate step. Half a configuration warns as loudly as none, because `SMTP_HOST` without `SMTP_FROM` is the likelier mistake. | Setting | Why it is worth reading | | - | - | | `TRUST_PROXY=0` in production | Authentication throttling keys on the connecting address, so every client behind the web app shares one bucket. Counting a hop you do not control is worse: any client can then spoof `X-Forwarded-For` | | `AUTH_ALLOW_REGISTRATION=true` with `AUTH_REGISTRATION_WORKSPACE=shared` in production | Anyone who registers joins the bootstrap workspace as a member, so they see and can edit every tracked app, keyword and ranking in it | | `API_DOCS=public` in production | The whole OpenAPI surface is served to anonymous callers | | `ALERT_DELIVERY=instant` with no SMTP | Email alerts are generated and discarded. Webhook subscriptions live in the database, so this cannot see them | | `BILLING_ENABLED=true` with no SMTP | The instance can send no account email at all. Nobody confirms an address, so no registration reaches its trial; nobody is warned before a trial ends; and nobody who forgets a password can recover without you editing the database | | `SMTP_SECURE=true` on a port other than 465 | Implicit TLS is port 465. Ports 587 and 25 expect STARTTLS, so the connection usually hangs | ## Related > **[Authentication and accounts](/security/authentication)** > > Sessions, registration and the guards. > **[The queue dashboard and the OpenAPI surface](/security/admin-surfaces)** > > Why those routes answer 404. # Store console credentials Source: https://docs.asobeast.com/security/store-credentials What asobeast stores when an owner connects an App Store Connect key or a Google Play service account, how the key is sealed at rest, who can use it and how to revoke it or rotate the encryption key. Connecting a store console is optional. Without `STORE_CONNECT_ENCRYPTION_KEY` the feature is off, nothing contacts Apple or Google, and asobeast reads public store data only. ## What is stored | Value | How | | - | - | | Issuer ID and key ID | In clear, so the store connections card can name the key | | Key fingerprint | The first sixteen hex characters of the SHA-256 of the public key, so you can tell two keys apart without either being shown | | Private key | Sealed with AES-256-GCM under `STORE_CONNECT_ENCRYPTION_KEY`, together with the issuer ID and the key ID | A Google Play connection stores the same way: | Value | How | | - | - | | Service account email, private key id and bucket id | In clear, so the store connections card can name the account and the bucket | | Key fingerprint | The first sixteen hex characters of the SHA-256 of the service account's public key | | Service account private key | Sealed with AES-256-GCM under `STORE_CONNECT_ENCRYPTION_KEY`, together with the email, the private key id and the bucket id. The rest of the JSON key file is not kept | To read reports, asobeast asks Google for a token limited to reading Cloud Storage, and reads only the store performance files in the bucket. To post a review reply, it asks for a separate token for the Google Play Android Developer API and calls only its review reply endpoint. The sealed value is bound to its workspace and store. A sealed value copied into another workspace's row cannot be opened there. The private key is never returned by the API, never written to an export, never sent to OpenAI and never logged. A PEM private key block or a signed token that reaches a log line is replaced with `[redacted]` before it is written. ## Who can use it Only the workspace owner connects, verifies, syncs or disconnects, and only through a signed in session. A personal API token, even one with the write scope, is refused, so a leaked agent token cannot replace or remove the key. Members see the connection and the numbers it brings. The key can also write one thing: a public reply to a review of the workspace's own apps. A reply is posted, updated or withdrawn only when a member or the owner confirms it on the reviews page through a signed in session; a personal API token and the MCP tools cannot post one, and nothing posts on a schedule. asobeast sends the reply text it keeps for that review and nothing else. See [Reply to reviews](/guides/reviews#reply-to-reviews). ## How to revoke it Press **Disconnect** in **Integrations**, then revoke the key in App Store Connect under **Users and Access**, **Integrations**. For Google Play, delete the key of the service account in the Google Cloud console, or remove the account from **Users and permissions** in Play Console. Disconnecting deletes the sealed key and the report requests and keeps the numbers already stored. A key revoked at the store first is noticed on the next sync: the connection is marked rejected and nothing more is requested with it. ## How to rotate the encryption key **1. Keep the old key as the previous one** Set `STORE_CONNECT_ENCRYPTION_KEY_PREVIOUS` to the current value and `STORE_CONNECT_ENCRYPTION_KEY` to a new one from `openssl rand -base64 32`, then restart. **2. Let every connection reseal** A stored key sealed with the previous value is opened with it and sealed again with the new one the first time it is used. Press **Verify**, or wait for the daily sync. **3. Remove the previous key** Unset `STORE_CONNECT_ENCRYPTION_KEY_PREVIOUS` and restart. Losing `STORE_CONNECT_ENCRYPTION_KEY` loses every stored console key. The numbers already stored stay, and every owner connects again. Keep the value with your other secrets and out of the repository. # Get help Source: https://docs.asobeast.com/support Open a GitHub issue for bugs, start a discussion for questions, and read the security policy before reporting a vulnerability privately. Start with [Troubleshooting](/operations/troubleshooting). Most reports are answered by one of the twenty symptoms listed there, and checking it first is the fastest route to an answer. ## Is the hosted service down? Check the status page before anything else. It is hosted off the machine asobeast runs on, so it stays up when the service does not. > **[asobeast status](https://status.asobeast.com)** > > Current state, open incidents and the history behind them. An open incident already carries what is broken, what still works and when the next update lands, so there is nothing to report and nothing to wait for. A first update is published within fifteen minutes of the outage being detected, even when it says only that we are looking, and an incident closes when the service is verified working rather than when a fix is deployed. If the status page is green and asobeast is not working for you, that is a bug report rather than an incident, and it belongs below. Self hosted installations have no status page and no incident feed, because they have no operator but you. The same endpoints are yours to watch. See [Uptime probes and the status page](/operations/uptime). ## Where do you report what? > **[Bugs](https://github.com/AsoBeast/asobeast/issues)** > > Something behaves differently than this documentation says. Open a GitHub issue. > **[Questions and ideas](https://github.com/AsoBeast/asobeast/issues/new)** > > How do I, should I, would you consider. Open an issue and label it a question. > **[Security vulnerabilities](https://github.com/AsoBeast/asobeast/blob/main/SECURITY.md)** > > Follow the security policy. Never open a public issue for a vulnerability. > **[Feature requests](https://github.com/AsoBeast/asobeast/issues)** > > Open an issue to agree on a feature before sending a pull request. ## Before you open an issue Four details turn a report into something reproducible. | Include | Why | | - | - | | The version or image tag you run | Behaviour differs between releases | | How you deployed | Docker Compose, published images, or a development checkout | | The relevant log lines | `docker compose logs --no-color --tail=200 api web` | | Whether it reproduces on a fresh instance | Separates a defect from a data or configuration issue | > [!WARNING] > > Never paste the output of `docker compose config` into an issue. It contains your resolved database password. Redact secrets from logs before posting them. ## Reporting a documentation problem A page that is wrong is a bug like any other. Open an issue naming the page and the incorrect statement, or open a pull request against `/docs` directly. Both are welcome. If an AI assistant answered a question about asobeast incorrectly, that usually means a page is ambiguous rather than that the assistant is broken. Say which question you asked, and it becomes a fixable documentation issue.