# ASO Keyword Rank Tracker & Monitor - Google Play & App Store (`neverempty/app-store-keyword-rank-tracker`) Actor

See where apps rank in App Store and Google Play search, in the order each store's own search page shows them. Track your app's keyword rank across countries, get a row when it is not in the results, and use monitoring mode to be told only when a rank moves.

- **URL**: https://apify.com/neverempty/app-store-keyword-rank-tracker.md
- **Developed by:** [NeverEmpty](https://apify.com/neverempty) (community)
- **Categories:** SEO tools, Developer tools, Automation
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $7.30 / 1,000 rank row returneds

This Actor is paid per event. You are not charged for the Apify platform usage, but only a fixed price for specific events.
Since this Actor supports Apify Store discounts, the price gets lower the higher subscription plan you have.

Learn more: https://docs.apify.com/actors/running/actors-in-store.md#pay-per-event

## What's an Apify Actor?

An Actor is a serverless cloud program that runs on the Apify platform. It has two run modes.
In Batch mode, an Actor accepts a well-defined JSON input, performs an action which can take anything from a few seconds to a few hours,
and optionally produces a well-defined JSON output, datasets with results, or files in key-value store.
In Standby mode, an Actor provides a web server which can be used as a website, API, or an MCP server.

Apify vocabulary and the platform model are defined once, in the agent quickstart at https://apify.com/agents.md.

## How to integrate an Actor?

If asked about integration, you help developers integrate Actors into their projects.
You adapt to their stack and deliver integrations that are safe, well-documented, and production-ready.

Do not guess an integration path. Every one of them is in the agent quickstart at https://apify.com/agents.md: the Apify MCP server, Agent Skills with the Apify CLI, the JavaScript and Python clients, the REST API, and the account-free path for an agent with no human to sign in. It also carries the rule on stating cost before the first paid run.

For examples already wired to this Actor's own input schema, see the [API](#api) section below.

Each client library has reference documentation the quickstart does not restate: [JavaScript/TypeScript](https://docs.apify.com/api/client/js/docs.md) (`npm install apify-client`) and [Python](https://docs.apify.com/api/client/python/docs.md) (`pip install apify-client`).

# README

## ASO Keyword Rank Tracker & Monitor - Google Play & App Store

Check **where your app ranks for a keyword** in Google Play and the App Store, in **the same order each store's own search page shows**, across as many countries as you like in one run. List your app and your competitors and you get one row per app, keyword, store and country, including a row that says `not-in-shown-results` when an app is not on the search page. Leave the app list empty to get **every app the search page shows**, with its position, developer, rating, installs and price.

Turn on **monitoring mode**, schedule the Actor, and a run returns a row **only when a tracked app's position changed**, with the previous position and the positions gained. A change is only reported, and a first position only remembered, if a second read of the page a few seconds later shows the same position.

### What you can use it for

- **ASO rank tracking**: follow your app's position for the keywords you target, per country, and see which keywords move after a title, subtitle or screenshot change.
- **Competitor monitoring**: list rival apps next to yours and see who ranks above you for each keyword.
- **Keyword research**: run a list of candidate keywords with an empty app list and see who holds each search page, how many apps it shows, their ratings and installs.
- **Localization checks**: run the same keywords in `us`, `gb`, `de`, `jp`, `br` and more in one run and compare positions country by country.
- **Agencies and dashboards**: export flat rows to a spreadsheet or BI tool, one row per app per search.

### How it works

1. For each keyword, country and store the Actor opens the store's public search page:
   - **Google Play**: `https://play.google.com/store/search?q=<keyword>&c=apps&hl=<language>&gl=<COUNTRY>`
   - **App Store**: Apple's own App Store search page, `https://apps.apple.com/<country>/iphone/search?term=<keyword>`
2. It reads the apps in the order the page lists them. On Google Play a large card for one app (for example the Spotify app when you search "spotify") is shown above the list; that app is position 1. Shelves that are not search results (Google Play's "Similar apps", Apple's editorial story cards) are not counted.
3. Your tracked apps are looked up on that page. An app that is not on it gets `rank: null` and `rankStatus: "not-in-shown-results"`, together with `appsShown`, the number of apps the page showed.

**How we checked the order (2026-09-16, UTC).** For 10 Google Play searches (in six countries, with the page language set to English, German, Japanese or Portuguese) and 6 App Store searches (four countries), the order this Actor reads was identical to the order a browser showed after scrolling to the end of the page. Reading the same four Google Play searches three times from Apify and three times from another network gave the same order in 23 of 24 reads; the one difference was two neighbouring apps (positions 10 and 11) swapping places, which is why monitoring mode reads a page again before reporting a change. Three App Store searches read six times each gave the same order every time.

**Why not the iTunes Search API?** Apple's public iTunes Search API (`itunes.apple.com/search`) also lists apps for a term, but not in App Store search order: for "habit tracker" in the US, 5 of its first 11 apps were in a different position than on Apple's App Store search page read at the same time. This Actor does not use it for positions.

#### How deep it looks

Positions are what the store's search page shows, so the depth depends on the store and the keyword:

| Store | Apps per search page | Measured on 2026-09-16 (UTC) |
| --- | --- | --- |
| Google Play | about 10 to 50 | "habit tracker" (US) 22, "spotify" (US) 20, a Japanese keyword for "household budget" (Japan) 16, "vpn" (Germany) 16, "photo editor" (UK) 30 |
| App Store | about 11 or 12 | "habit tracker" (US) 11, the same Japanese keyword (Japan) 11, "vpn" (Germany) 12 |

If your app is further down, you get `not-in-shown-results` for that search, not a guessed position. Loading more results on these pages did not add apps in a browser either.

#### Things the stores do that show up in the rows

- **Google Play corrects spelling.** Searching "instagarm" shows the results for "instagram". The row then has `searchWasAutoCorrected: true` and `searchCorrectedTo: "instagram"`.
- **Google Play falls back to English** for a language code it does not know. `languageShown` is the language the page was actually shown in.
- **Neither store says "no results"** for a nonsense keyword: both still show a page of apps, and the Actor returns those positions as shown.
- **Apple sends an unknown country to the US store.** The Actor compares the storefront on the page with the country you asked for and returns a free `country-not-available` row instead of US positions. Google Play answers some country codes it does not use (for example `xx`) with HTTP 400, which gives the same row.
- **Google Play still shows a search page for some countries where it is not offered.** For `cn` and `ir` it returned a full page (prices in USD) on 2026-09-16 (UTC); those positions are returned as shown, so check that Google Play is available in a country before relying on its rows.
- **A dotted id can be both.** Many developers use the same id for the Google Play package and the iOS bundle id (HabitKit uses `com.roehl.habitkit` in both stores), so a dotted id is looked up in both stores.

### Input

| Field | Default | What it does |
| --- | --- | --- |
| `keywords` | example keyword | Search terms, one per line (commas also separate them). A repeated keyword is searched once and gets a free `duplicate` row. Up to 200 keywords per run. |
| `apps` | empty | Apps to track: a Google Play package name or URL, an App Store id or app page URL (`/app/...`), or a dotted id (looked up in both stores). A dotted id listed after the Google Play URL of the same app is used as its App Store bundle id. Empty = one row for every app each search page shows. If the list has entries but none of them can be used, the run is rejected with a free `invalid-input` row and nothing is searched (so a mistyped list never turns into every app on the page). A store none of your apps can be looked up in is not searched and a free `no-tracked-app-for-store` row says so. Up to 50 apps. |
| `stores` | both | `googlePlay`, `appStore` or both. |
| `countries` | `["us"]` | Two-letter country codes. Every keyword is searched in every country. Up to 50 countries; at most 2,000 searches (keywords x countries x stores) per run. |
| `language` | `en` | The language Google Play is asked to show the search in. It changes the Google Play order. The App Store search uses the storefront's own language. |
| `maxResults` | `1000` | With monitoring off, stop after this many rows and say what was left out. In monitoring mode it does not cut changes. |
| `monitoringMode` | `false` | Remember each tracked app's position per keyword, store and country and return a row only when it changes. Needs at least one app. |
| `resetMonitoringState` | `false` | Forget every remembered position and start a fresh baseline. Turn it off again after one run. |
| `useProxy` | `true` | Retry a page through a proxy only if a store answers it with a bot check or no answer. The country of the results comes from `countries`, not from where the request comes from. |

```json
{
    "keywords": ["habit tracker", "habit app", "daily planner"],
    "apps": [
        "https://play.google.com/store/apps/details?id=org.isoron.uhabits",
        "https://apps.apple.com/us/app/habit-tracker/id1438388363",
        "com.roehl.habitkit"
    ],
    "countries": ["us", "gb", "de"],
    "monitoringMode": true
}
```

If you leave `keywords` out with monitoring off, the example keyword "habit tracker" is searched (the run log says so). In monitoring mode `keywords` and `apps` are required, so a schedule never pays to watch the example. An empty list is rejected with a free `invalid-input` row.

### Output

One row per app per search. This one was built from a Google Play search page read through Apify on 2026-09-16 (UTC); the timestamps and the monitoring fields show what a later monitoring run returns after the app moved from position 5 to 3:

```json
{
    "source": "app-store-keyword-rank",
    "status": "ok",
    "scrapedAt": "2026-09-17T09:00:00.000Z",
    "keyword": "habit tracker",
    "store": "google-play",
    "country": "us",
    "languageRequested": "en",
    "languageShown": "en",
    "searchUrl": "https://play.google.com/store/search?q=habit%20tracker&c=apps&hl=en&gl=US",
    "searchCorrectedTo": null,
    "searchWasAutoCorrected": false,
    "isTrackedApp": true,
    "trackedAppInput": "com.roehl.habitkit",
    "rank": 3,
    "rankStatus": "ranked",
    "appsShown": 22,
    "appId": "com.roehl.habitkit",
    "appBundleId": "com.roehl.habitkit",
    "appName": "Habit Tracker - HabitKit",
    "appSubtitle": null,
    "developer": "Sebastian Röhl",
    "rating": 4.6,
    "ratingCountText": null,
    "installs": "500,000+",
    "genre": "Productivity",
    "price": 0,
    "currency": "USD",
    "priceText": null,
    "isFeaturedCard": false,
    "appleResultType": null,
    "iconUrl": "https://play-lh.googleusercontent.com/TdubEdRfoPscyyf_UncRx2VKLa08b07wHTNjdGyAP4W63r4Jht1kEry7UF-6UkqzSLSmEUu91hRmkvDhAvPgNg",
    "appUrl": "https://play.google.com/store/apps/details?id=com.roehl.habitkit",
    "rank1AppId": "org.isoron.uhabits",
    "rank1AppName": "Loop Habit Tracker",
    "change": "rank-changed",
    "isFirstCheck": false,
    "previousRank": 5,
    "positionsGained": 2,
    "previousCheckedAt": "2026-09-17T08:00:00.000Z",
    "previousRankReadAt": "2026-09-16T09:00:00.000Z",
    "confirmedByRereading": true
}
```

A tracked app that is not on the page comes back like this (App Store, same search):

```json
{
    "store": "app-store",
    "trackedAppInput": "https://apps.apple.com/us/app/example/id1234567890",
    "rank": null,
    "rankStatus": "not-in-shown-results",
    "appsShown": 11,
    "appId": "1234567890",
    "appName": null,
    "rank1AppId": "1438388363",
    "rank1AppName": "Habit Tracker"
}
```

What the columns mean:

- `rank` is the position on the search page, starting at 1. `appsShown` is how many apps the page showed.
- `isTrackedApp` is `true` for apps from your `apps` list (their input is in `trackedAppInput`) and `false` when the app list was empty.
- `appId` is the Google Play package name or the App Store id. `appBundleId` is the iOS bundle id on App Store rows (the package name again on Google Play rows).
- `installs` (for example `"500,000+"`) is Google Play's install range as the page writes it, in the page's number format. `ratingCountText` is the rating count as the page writes it: Apple rounds it (`"147K"` in the US store, a Japanese abbreviation in the Japanese store), and Google Play shows it only for the large card at the top (`"36,311,030"`).
- `price` is the price in `currency` (`0` = free); `priceText` is the price as shown, when the app is paid. The App Store search page shows neither, so both are `null` there.
- `appSubtitle` is the App Store subtitle. `appleResultType` is Apple's card type (`AppSearchResult`, or `AppEventSearchResult` when the app is shown with an in-app event).
- `isFeaturedCard` is `true` for Google Play's large card above the list.
- `languageRequested` is `language` (Google Play only); `languageShown` is the language of the page.
- `searchWasAutoCorrected` and `searchCorrectedTo` are Google Play only (`null` on App Store rows).
- `rank1AppId` / `rank1AppName` is the app at position 1 of that search.
- Every row also has `source` and `scrapedAt`.

#### Monitoring fields

`change` is `null` with monitoring off, and in monitoring mode one of:

| `change` | Meaning |
| --- | --- |
| `"first-check"` | The app had no remembered position for this keyword, store and country. Every pair is returned once to set the baseline. |
| `"rank-changed"` | The position is different from the one in the row last returned for the pair. |
| `"entered-results"` | The app was not on the search page last time and is now. |
| `"left-results"` | The app was on the search page last time and is not now (`rank: null`). |

`previousRank` is the position in the row last returned for the pair, `positionsGained` is `previousRank - rank` (positive = moved up), `previousCheckedAt` is when the pair was last checked and `previousRankReadAt` when that position was read. `confirmedByRereading` is `true` when the page was read a second time a few seconds later and showed the same position. If the second read shows a different position, or cannot be read, nothing is returned for the pair and the remembered position stays as it was (a pair with no remembered position yet stays unremembered and is a first check again next time). Because of this second read, a run that finds changes or first checks reads those search pages twice.

#### Free rows

These rows are never charged and always say why:

| `status` | When |
| --- | --- |
| `no-change` | Monitoring mode: none of the tracked apps changed position. Only the check fee applies. |
| `country-not-available` | The store rejected the country code (Apple answered with another storefront, or Google Play answered HTTP 400). |
| `blocked` | The store answered with a bot check, an empty page or no answer, even after retrying. Nothing is guessed. |
| `unreadable` | The search page could not be read (for example the page layout changed). Nothing is guessed. |
| `no-apps-shown` | With an empty app list, the search page showed no apps. |
| `no-tracked-app-for-store` | None of your apps can be looked up in one of the stores you picked, so that store was not searched. |
| `invalid-input` | A keyword, app or country could not be used, or the input as a whole was rejected. |
| `duplicate` | A keyword, app or country repeated an earlier one. |
| `not-checked` | Rows or searches left out because of `maxResults` or the per-run limits. |
| `budget-reached` | Rows or searches left out because the run's maximum total charge was reached. |

### Pricing

- **$10.00 per 1,000 rank rows** (lower on higher Apify plans), charged only for rows with `status: "ok"`. A tracked app that is not on the search page is a charged row too: `not-in-shown-results` is the answer to "where does my app rank". Free rows are never charged.
- **Monitoring mode: $0.30 per 1,000 app checks**, charged for every tracked app on every search page that was read, changed or not. Example: 20 keywords in both stores for one app, checked every hour, is 28,800 checks a month, which is **$8.64**, plus $10.00 per 1,000 rows for the changes returned.
- One search page is one request. With an empty app list a search returns every app the page shows (for example 22 rows for "habit tracker" on Google Play in the US), so a run of 10 keywords in both stores returns roughly 300 rows.
- The run's maximum total charge is respected: the Actor stops reading before a row or a check would go over it and says what was left out.

### Scheduling and monitoring tips

- Save your input as a task, turn on `monitoringMode` and schedule it (daily, or hourly for launches).
- Positions are remembered per app, keyword, store, country and Google Play language, for this Actor across all your runs. Do not put the same pairs in two schedules that can run at the same time (Apify's key-value store has no atomic update, so overlapping runs can overwrite each other's records).
- Use `resetMonitoringState` once to start a fresh baseline, then turn it off again.

### Limits

- Positions are those of the public web search pages of Google Play (play.google.com) and the App Store (apps.apple.com). The store apps on phones can personalize search, show ads and show a different number of results; this Actor does not see those.
- Depth is what the search page shows (see the table above); positions further down are not available.
- Search volume, keyword difficulty and download estimates are not provided.
- Up to 200 keywords, 50 apps, 50 countries and 2,000 searches per run.

# Actor input Schema

## `keywords` (type: `array`):

Search terms to look up, one per line (commas also separate them). Each keyword is searched in every store and country you pick. A repeated keyword (same words, any letter case) is searched once and gets a free 'duplicate' row. If you leave this field out with monitoring off, the example keyword 'habit tracker' is searched; with monitoring on it is required. An empty list is rejected. Up to 200 keywords per run.

## `apps` (type: `array`):

Your app and competitors: a Google Play package name (com.example.app) or URL (https://play.google.com/store/apps/details?id=com.example.app), an App Store id (389801252) or URL (https://apps.apple.com/us/app/instagram/id389801252). A dotted id is looked up both as a Google Play package and as an App Store bundle id. With apps listed you get one row per app per search, including a row that says 'not-in-shown-results' when the app is not on the search page. With the list empty you get one row for every app the search page shows. If the list has entries but none of them is an app id or store URL, the run stops without searching. A store none of your apps can be looked up in is not searched. Up to 50 apps.

## `stores` (type: `array`):

Which stores to search. Google Play: the order of play.google.com search (about 10 to 50 apps per search, depending on the keyword). App Store: the order of Apple's App Store search page on apps.apple.com (about 11 or 12 apps per search).

## `countries` (type: `array`):

Two-letter country codes (us, gb, de, jp, br, ...). Every keyword is searched in every country listed, in one run. Rankings differ by country. A country code the store rejects (Google Play answers some codes with an error, and Apple sends unknown codes to its US store) comes back as a free 'country-not-available' row. Google Play still shows a search page for some countries where it is not offered (for example cn), and those positions are returned as shown. Up to 50 countries.

## `language` (type: `string`):

The language Google Play is asked to show the search in (en, ja, de, pt-BR, ...). It changes the order of Google Play results, so use the language your users search in. Google Play shows English for a language it does not know; every row says which language the page was shown in (languageShown). The App Store search is shown in the storefront's own language and does not use this field.

## `maxResults` (type: `integer`):

With monitoring off, the run stops once this many charged rows have been returned, and a free row says how many rows and searches were left out. In monitoring mode it does not limit the changes returned: every search is checked, as far as the run's maximum total charge allows.

## `monitoringMode` (type: `boolean`):

Needs at least one app in 'Apps to track'. Off = every tracked app comes back with its current position, charged per row. On = the Actor remembers each app's position for each keyword, store and country and, on later runs, returns a row only when the position changed, the app entered the search page or left it, with the previous position and the positions gained. A change is only reported, and a first position only remembered, if a second read of the page a few seconds later shows the same position. The first run returns every pair once to set the baseline. **In monitoring mode every app checked on a search costs $0.30 per 1,000 checks, changed or not** (searches that could not be read are free), plus the row price for the rows returned. Example: 20 keywords in both stores for one app, checked every hour = 28,800 checks a month = $8.64. Positions are remembered per app, keyword, store and country; do not put the same pairs in two schedules that can run at the same time.

## `resetMonitoringState` (type: `boolean`):

Clears every remembered position for this Actor, so the next monitoring run returns each pair once again as a first check. This affects all your monitoring runs. Turn it off again after one run: left on in a schedule, every run returns every pair as a first check and charges the row price for it.

## `useProxy` (type: `boolean`):

Every search page is requested directly first. Only if a store answers with a bot check, an empty response or no response is that page retried through a proxy session; the next page is requested directly again. The country of the results comes from the country you pick, not from where the request comes from. With it off, that answer is retried directly and then returned as a free 'blocked' row, and no proxy is paid for.

## Actor input object example

```json
{
  "keywords": [
    "habit tracker"
  ],
  "apps": [
    "https://play.google.com/store/apps/details?id=org.isoron.uhabits",
    "https://apps.apple.com/us/app/habit-tracker/id1438388363"
  ],
  "stores": [
    "googlePlay",
    "appStore"
  ],
  "countries": [
    "us"
  ],
  "language": "en",
  "maxResults": 1000,
  "monitoringMode": false,
  "resetMonitoringState": false,
  "useProxy": true
}
```

# Actor output Schema

## `results` (type: `string`):

One row per app and search (keyword, store and country): the app's position in the order the store's own search page shows, or not-in-shown-results for a tracked app that is not on that page, with how many apps the page showed, the app's name, developer, rating, installs or rating count, genre, price, icon and store link, the app at position 1, and in monitoring mode the previous position and the positions gained. Searches that could not be read and invalid input come back as free rows that say why.

# API

You can run this Actor programmatically using our API. Below are code examples in JavaScript, Python, and CLI, as well as the OpenAPI specification and MCP server setup.

## JavaScript example

```javascript
import { ApifyClient } from 'apify-client';

// Initialize the ApifyClient with your Apify API token
// Replace the '<YOUR_API_TOKEN>' with your token
const client = new ApifyClient({
    token: '<YOUR_API_TOKEN>',
});

// Prepare Actor input
const input = {
    "keywords": [
        "habit tracker"
    ],
    "apps": [
        "https://play.google.com/store/apps/details?id=org.isoron.uhabits",
        "https://apps.apple.com/us/app/habit-tracker/id1438388363"
    ]
};

// Run the Actor and wait for it to finish
const run = await client.actor("neverempty/app-store-keyword-rank-tracker").call(input);

// Fetch and print Actor results from the run's dataset (if any)
console.log('Results from dataset');
console.log(`💾 Check your data here: https://console.apify.com/storage/datasets/${run.defaultDatasetId}`);
const { items } = await client.dataset(run.defaultDatasetId).listItems();
items.forEach((item) => {
    console.dir(item);
});

// 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/js/docs

```

## Python example

```python
from apify_client import ApifyClient

# Initialize the ApifyClient with your Apify API token
# Replace '<YOUR_API_TOKEN>' with your token.
client = ApifyClient("<YOUR_API_TOKEN>")

# Prepare the Actor input
run_input = {
    "keywords": ["habit tracker"],
    "apps": [
        "https://play.google.com/store/apps/details?id=org.isoron.uhabits",
        "https://apps.apple.com/us/app/habit-tracker/id1438388363",
    ],
}

# Run the Actor and wait for it to finish
run = client.actor("neverempty/app-store-keyword-rank-tracker").call(run_input=run_input)

# Fetch and print Actor results from the run's dataset (if there are any)
print(f"💾 Check your data here: https://console.apify.com/storage/datasets/{run.default_dataset_id}")
for item in client.dataset(run.default_dataset_id).iterate_items():
    print(item)

# 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/python/docs/quick-start

```

## CLI example

```bash
echo '{
  "keywords": [
    "habit tracker"
  ],
  "apps": [
    "https://play.google.com/store/apps/details?id=org.isoron.uhabits",
    "https://apps.apple.com/us/app/habit-tracker/id1438388363"
  ]
}' |
apify call neverempty/app-store-keyword-rank-tracker --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,neverempty/app-store-keyword-rank-tracker"
        }
    }
}
```

The hosted server signs you in with OAuth on first connect, so no API token belongs in this config. Clients without OAuth support can send an `Authorization: Bearer <APIFY_API_TOKEN>` header instead, using a token from API & Integrations in Apify Console (https://console.apify.com/settings/integrations).

## OpenAPI specification

Download the OpenAPI definition: https://api.apify.com/v2/actors/jKGNeR2FV0NDptJhR/builds/RVE9we7rdmCJGf4g3/openapi.json
