# App Store Rank Tracker – iOS Keyword Rankings & App Changes (`skuang/app-store-rank-tracker`) Actor

Track where iOS apps rank for your keywords in any App Store country, plus version, price and rating changes between runs. Uses Apple's official public iTunes Search and Lookup APIs.

- **URL**: https://apify.com/skuang/app-store-rank-tracker.md
- **Developed by:** [skuang](https://apify.com/skuang) (community)
- **Stats:** 2 total users, 1 monthly users, 46.2% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $4.00 / 1,000 keyword checkeds

This Actor is paid per event. You are not charged for the Apify platform usage, but only a fixed price for specific events.

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

## App Store Rank Tracker – iOS Keyword Rankings & App Changes

Track where iOS apps rank for your keywords in any App Store country, and get told what changed since the last run:
rank moves, apps entering or leaving the top N, new versions, price changes and rating changes. Data comes from
Apple's official public **iTunes Search API** and **iTunes Lookup API**. No login, no scraping of the App Store app,
no AI.

**About the ranks.** The ranks are the order in which Apple's public iTunes Search API returns results for a keyword.
That order approximates, but is not identical to, the ranking a user sees when searching inside the App Store app on
an iPhone (which can be personalized and is not published by Apple). Apple's ranking can differ from one request to
the next even when nothing about the apps changed; a rank move is reported only when two searches a few seconds apart
agree (see [Rank changes are confirmed](#rank-changes-are-confirmed)).
Trends over several runs are still more reliable than a single move.

### Who it is for

- **App developers and indie studios** checking whether their app ranks for the keywords they target, per country.
- **ASO consultants and agencies** reporting keyword positions and competitor releases to clients on a schedule.
- **Competitor watchers** who want to know when a rival ships a new version, changes its price or gains ratings.

### What it does

1. **Keyword rankings.** For every keyword × country it asks Apple's Search API for N + 10 results (at most 200)
   (`https://itunes.apple.com/search?term=...&country=..&entity=software&limit=N+10`), because Apple often returns a few
   fewer than asked, and keeps the top N. It writes one `ranking` row with the ranked list and, for every app you track,
   its rank or `null` with "not in top N".
2. **App snapshots.** For every tracked app × country it reads Apple's Lookup API (up to 50 apps in one call per
   country) and writes one `app` row: version, version release date, release notes, price, average rating, number of
   ratings, genres, seller and more.
3. **Changes.** It compares with the snapshot saved by the previous run and writes one `change` row per change. The
   first run only saves the baseline; changes appear from the second run on.
4. A run summary (per target: ok / partial / failed / skipped, ranks, changes, errors) goes to the `OUTPUT` record.

### Input

| Field          | Default             | What it does                                                                                                                                 |
| -------------- | ------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- |
| `keywords`     | `["habit tracker"]` | Up to 50 search terms. Keywords × countries can be at most 100 searches per run. Case and extra spaces are ignored for duplicates.            |
| `countries`    | `["us"]`            | Up to 10 two-letter App Store country codes. `uk` is accepted as `gb`.                                                                       |
| `trackApps`    | `["1438388363"]`    | Up to 50 App Store URLs or numeric app IDs (yours and competitors'). The default is "Habit Tracker" by Inner Grow Limited. Can be empty.     |
| `topN`         | `10`                | 1–200 results kept per keyword and country, and how deep tracked apps are looked for. Apple often returns a few fewer than requested.        |
| `trackChanges` | `true`              | Compare with the previous run and write change rows.                                                                                         |
| `outputMode`   | `all`               | `all` (rankings, app snapshots, changes), `changes_only` (cheapest for daily monitoring) or `rankings_only` (no app lookups and no changes). |

Example:

```json
{
    "keywords": ["habit tracker", "meditation"],
    "countries": ["us", "gb"],
    "trackApps": ["https://apps.apple.com/us/app/habit-tracker/id1438388363", "1528595748"],
    "topN": 10,
    "trackChanges": true,
    "outputMode": "all"
}
```

A keyword that is empty, a country code that is not two letters or that Apple does not recognize, or an entry in
`trackApps` that is not an App Store URL or ID is reported as `failed` in `OUTPUT`; the rest of the run continues.

### Output

All rows go to the run's dataset; `type` tells them apart. The dataset has three views: **Keyword rankings**, **App
snapshots** and **Changes**.

#### Ranking row (`type: "ranking"`, one per keyword × country)

Trimmed from a live run on 2026-10-12:

```json
{
    "type": "ranking",
    "keyword": "habit tracker",
    "country": "us",
    "topN": 10,
    "resultsReturned": 9,
    "trackedApps": [
        { "trackId": 1438388363, "name": "Habit Tracker", "rank": 1, "note": null },
        { "trackId": 1528595748, "name": "Finch: Self-Care Pet", "rank": 4, "note": null }
    ],
    "topResults": [
        {
            "rank": 1,
            "trackId": 1438388363,
            "name": "Habit Tracker",
            "seller": "Inner Grow Limited",
            "price": 0,
            "currency": "USD",
            "averageUserRating": 4.79168,
            "userRatingCount": 147818
        },
        "... up to topN entries"
    ],
    "checkedAt": "2026-10-12T11:35:20.112Z"
}
```

An app that is not among the results has `"rank": null, "note": "not in top 10"`.

#### App row (`type: "app"`, one per tracked app × country)

```json
{
    "type": "app",
    "trackId": 1438388363,
    "name": "Habit Tracker",
    "seller": "Inner Grow Limited",
    "bundleId": "com.davetech.habit",
    "url": "https://apps.apple.com/us/app/habit-tracker/id1438388363",
    "version": "2.25.1",
    "currentVersionReleaseDate": "2026-10-09T23:42:16Z",
    "releaseNotes": "- support iOS 27\n- fix bugs\n...",
    "price": 0,
    "formattedPrice": "Free",
    "currency": "USD",
    "averageUserRating": 4.79168,
    "userRatingCount": 147818,
    "averageUserRatingForCurrentVersion": 4.79168,
    "userRatingCountForCurrentVersion": 147818,
    "primaryGenreName": "Productivity",
    "genres": ["Productivity", "Health & Fitness"],
    "releaseDate": "2019-01-31T09:10:14Z",
    "minimumOsVersion": "15.0",
    "fileSizeBytes": 412291072,
    "contentAdvisoryRating": "4+",
    "iconUrl": "https://is1-ssl.mzstatic.com/image/thumb/.../100x100bb.jpg",
    "country": "us",
    "checkedAt": "2026-10-12T11:35:16.420Z"
}
```

#### Change row (`type: "change"`)

Illustrative example (the values show the shape, not a recorded change):

```json
{
    "type": "change",
    "changeType": "rank_changed",
    "country": "us",
    "trackId": 1528595748,
    "appName": "Finch: Self-Care Pet",
    "keyword": "habit tracker",
    "topN": 10,
    "before": 4,
    "after": 3,
    "releaseNotes": null,
    "detectedAt": "2026-10-13T11:35:20.112Z"
}
```

| `changeType`           | Target            | `before` → `after`                                                                       |
| ---------------------- | ----------------- | ---------------------------------------------------------------------------------------- |
| `rank_changed`         | keyword × country | old rank → new rank (both within the top N)                                              |
| `entered_top_n`        | keyword × country | `null` → rank                                                                            |
| `left_top_n`           | keyword × country | rank → `null`                                                                            |
| `new_version`          | app × country     | `{ version, releaseDate }` → `{ version, releaseDate }`, only when the release date moved forward; `releaseNotes` holds the notes |
| `price_changed`        | app × country     | old price → new price (in the country's currency)                                        |
| `rating_changed`       | app × country     | average rating, when it moved by **0.05 or more** from the value last reported           |
| `rating_count_changed` | app × country     | number of ratings, when it moved by **at least max(10, 1%)** from the value last reported |

Rank changes are reported for the apps in `trackApps`, not for every app in the top N (the full list is in each
ranking row). An app you add to `trackApps` later starts its own baseline; it gets no change row on its first run.

##### Rank changes are confirmed

Apple's ranking can differ from one request to the next even when nothing about the apps changed. When a run finds that a
tracked app's rank differs from the saved one, it repeats the same search once (same result count, within the rate
limit) and reports only the moves both searches agree on: the same new rank, or out of the top N in both. A move the
second search does not repeat is not reported and not charged; the saved rank stays as it was, so the app is checked
again on the next run. The number of such unconfirmed moves is in `OUTPUT` (`changesUnconfirmed`). If the confirming
search itself fails, or the run is stopping (charge limit reached, aborted, Apple refusing requests) before it can be
made, no rank change is reported for that keyword in that run and the saved ranks stay as they were.

`new_version` is reported only when the version differs and its release date is later than the saved one. Apple's
Lookup API can briefly serve an older record; such a step back is ignored and the saved version is kept. A field Apple
does not report in a run keeps its saved value.

Both rating changes compare with the value last reported (the baseline, or the "after" of the last row of that type),
so a slow drift adds up and is reported once it crosses the threshold. `rating_changed` needs a move of 0.05 or more.
`rating_count_changed` needs a move of at least 10 ratings or 1% of the last reported count, whichever is larger
(for example 10 for an app with 300 ratings, 1,479 for one with 147,818), so a popular app gaining ratings every day
does not produce a row on every run. The thresholds are fixed.

#### Run summary (`OUTPUT` record)

`OUTPUT` in the default key-value store has `totals`, `apps` (one entry per app × country), `keywords` (one per
keyword × country, with the tracked ranks) and `invalidInputs`. Each entry has:

- `status`: `ok`, `partial` (some rows were delivered, then the charge limit or an error stopped it), `failed`
  (Apple's API could not be read after retries, the country is unknown, the app is not available in that country, or
  the input entry is invalid) or `skipped` (not processed because the charge limit was reached or the run was aborted).
- `baseline`: `created` (first run, nothing compared), `compared`, `reset-top-n-changed`, `reset-request-changed` (the saved ranks were fetched
  with a different request size, for example after an update of this actor), `kept` (nothing delivered, the
  previous snapshot was left untouched) or `not-tracked`.
- `errorType` and `error` when something went wrong.

The run fails only if every target failed.

### How change tracking works

- **First run = baseline.** It saves a snapshot per keyword × country and per app × country and reports no changes.
  Schedule the actor (daily is typical); each later run compares with the previous one.
- **Snapshots live in your account**, in a named key-value store called `app-store-rank-tracker-state-wwc4swiilbljk0oas` (the name ends with this actor's ID in lowercase, so it does not clash with the state store of another actor that has the same name), one record per
  target. All runs of this actor in your account share them, so two schedules on the same keyword see only the changes since the other one ran, as long as they do not run at the same time; overlapping runs can both report (and charge for) the same change. Delete a record to start that target over.
- **Keep `topN` the same between runs.** Apple's order can depend on how many results are requested, so a different
  `topN` (or a different request size, which the snapshot records) starts a new rank baseline for that keyword instead
  of reporting rank changes.
- **Undelivered changes come back, delivered ones do not.** If the charge limit stops a run while change rows are being
  written, the snapshot keeps the old value for each change that was not delivered, so the next run reports it again.
- `rankings_only` neither reads nor updates the snapshots.

### Pricing

Pay per event. You pay only for rows actually written to your dataset:

| Event             | When                                                 | Price                              |
| ----------------- | ---------------------------------------------------- | ---------------------------------- |
| `keyword-checked` | One ranking row written (one keyword × one country)  | $0.004 ($4.00 per 1,000 searches)  |
| `app-snapshot`    | One app snapshot row written (one app × one country) | $0.002 ($2.00 per 1,000 snapshots) |
| `change-detected` | One change row written                               | $0.003 ($3.00 per 1,000 changes)   |

Changes are only written from the second run on (the first run sets the baseline). Searches, lookups and failed
targets are not charged.

Example: 10 keywords × 2 countries with 3 tracked apps, `outputMode: "all"`, daily: 20 rankings ($0.08) + 6 app
snapshots ($0.012) + the day's changes (say 8 × $0.003 = $0.024) ≈ $0.12 per run. With `changes_only` you pay only for
the changes.

If you set a maximum cost per run, the actor stops cleanly when it is reached, says so in the status message and in
`OUTPUT`, and marks targets that were not started `skipped` and targets cut short `partial`.

### Limitations

- **Ranks come from Apple's public iTunes Search API**, which approximates but is not identical to the ranking inside the
  App Store app, and can vary between runs. There is no search volume, keyword difficulty or download data.
- Apple returns at most 200 results per search, often a few fewer than requested (for example 9 for `limit=10`). For a
  term that matches nothing, Apple still returns a handful of loosely related apps.
- iPhone and iPad apps only (`entity=software`); no Mac apps, no Apple Arcade filter.
- **Rate limit.** Apple documents roughly 20 calls per minute per IP. The actor starts at most one call every 3.5 s
  (about 17 per minute) and retries HTTP 403, 429, 5xx, timeouts and network errors up to 3 times (waiting 10, 20, then
  40 s, or what Apple's Retry-After says, up to 60 s). After that the target is reported `failed`. When 3 Apple calls
  in a row fail that way, the actor stops calling Apple and marks the remaining targets `failed` with "Apple is
  refusing requests from this IP".
- **Run time.** A full run (100 searches, up to 100 confirming searches when ranks moved, 10 lookups) makes at most
  210 calls, about 12 minutes when Apple answers normally (a default run with one keyword takes under 15 s). Each
  failing call can take up to about 5 minutes (4 attempts with a 30 s timeout each, plus up to 3 waits of 60 s). Three
  failures in a row stop the run, but failures mixed with successes do not, so in the worst case a run can take hours;
  set a run timeout (for example 1 hour) in the run options if that matters to you.
- Prices, ratings and versions are per country storefront, as Apple reports them. Ratings are Apple's all-time averages
  (with a separate current-version average in the app row); there are no review texts.
- These are public Apple APIs that Apple can change or limit at any time.

### FAQ

**Why does the rank differ from what I see on my iPhone?** The App Store app's search is personalized and uses signals
Apple does not publish. This actor reports what Apple's public Search API returns, which is a consistent, reproducible
approximation.

**Why did the first run report no changes?** There was nothing to compare with yet. Changes appear from the second run.

**Do I need proxies?** No. The actor calls Apple's public API directly, with a clear user agent, below Apple's
documented rate limit.

**What happens if the run is moved to another server or restarted?** `PROGRESS` is saved after every push of rows to
the dataset, right after they are saved and charged. When Apify restarts the run elsewhere (a migration, or a
resurrected run after a crash), it skips what was already delivered, so nothing is charged twice. Targets that had
finished before the move are not redone.

# Actor input Schema

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

Search terms to check, one per line, as a user would type them in the App Store. Up to 50 keywords; keywords × countries can be at most 100 searches per run. Letter case and extra spaces are ignored when finding duplicates.

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

Two-letter App Store country codes (us, gb, de, jp, ...). Every keyword is searched in every country. Up to 10. "uk" is accepted as an alias for "gb".

## `trackApps` (type: `array`):

Your apps and competitors: App Store URLs (https://apps.apple.com/us/app/habit-tracker/id1438388363) or numeric app IDs. For each one you get its rank in every keyword search (or "not in top N") and, per country, a snapshot of version, price and ratings. Up to 50. Leave empty to only record the keyword rankings.

## `topN` (type: `integer`):

How many search results to keep per keyword and country, and how deep to look for your tracked apps. The actor asks Apple for 10 more (at most 200), because Apple often returns a few fewer than asked. Keep it the same between runs: a different value starts a new rank baseline, because Apple's order can depend on how many results are requested.

## `trackChanges` (type: `boolean`):

Compare with the snapshot saved by this actor's previous run and output one change row per change: a tracked app's rank moved, entered or left the top N, a new version was released, the price changed, the average rating moved by 0.05 or more, or the number of ratings moved by at least 10 or 1% (whichever is larger). The first run only saves the baseline. Schedule the actor (for example daily) to get changes.

## `outputMode` (type: `string`):

"Everything": keyword rankings, app snapshots and changes. "Changes only": only change rows (cheapest for daily monitoring; needs change tracking on). "Rankings only": only keyword rankings; no app lookups, no changes, and the saved snapshots are not touched.

## Actor input object example

```json
{
  "keywords": [
    "habit tracker",
    "daily planner"
  ],
  "countries": [
    "us",
    "gb"
  ],
  "trackApps": [
    "https://apps.apple.com/us/app/habit-tracker/id1438388363"
  ],
  "topN": 10,
  "trackChanges": true,
  "outputMode": "all"
}
```

# Actor output Schema

## `rankings` (type: `string`):

No description

## `apps` (type: `string`):

No description

## `changes` (type: `string`):

No description

## `allItems` (type: `string`):

No description

## `summary` (type: `string`):

No description

# 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",
        "daily planner"
    ],
    "countries": [
        "us",
        "gb"
    ],
    "trackApps": [
        "https://apps.apple.com/us/app/habit-tracker/id1438388363"
    ]
};

// Run the Actor and wait for it to finish
const run = await client.actor("skuang/app-store-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",
        "daily planner",
    ],
    "countries": [
        "us",
        "gb",
    ],
    "trackApps": ["https://apps.apple.com/us/app/habit-tracker/id1438388363"],
}

# Run the Actor and wait for it to finish
run = client.actor("skuang/app-store-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",
    "daily planner"
  ],
  "countries": [
    "us",
    "gb"
  ],
  "trackApps": [
    "https://apps.apple.com/us/app/habit-tracker/id1438388363"
  ]
}' |
apify call skuang/app-store-rank-tracker --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,skuang/app-store-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/wwc4swIILBLJK0oAS/builds/AaomvI04ntwEw4CuC/openapi.json
