# ASO Keyword Rank Tracker (`renzomacar/aso-keyword-rank-tracker`) Actor

Give it a keyword and an app: it returns the exact App Store search position, who outranks it, and the gap to the top. Track ASO rankings per country over time.

- **URL**: https://apify.com/renzomacar/aso-keyword-rank-tracker.md
- **Developed by:** [Renzo Madueno](https://apify.com/renzomacar) (community)
- **Categories:** Marketing, SEO tools
- **Stats:** 2 total users, 1 monthly users, 63.6% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $2.00 / 1,000 rank checks

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?

Actors are web data automations that power AI and operations. They run on the Apify platform to scrape websites, process data, connect APIs, and automate workflows.
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.
Actors are written with capital "A".

## 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.
The best way to integrate Actors is as follows.

- **AI agents and MCP clients** — the [Apify MCP server](https://docs.apify.com/integrations/mcp.md) at `https://mcp.apify.com` (remote, streamable HTTP, OAuth on first use).
- **Agentic workflows and local Actor development** — [Agent Skills](https://apify.com/.well-known/agent-skills/index.json) with the [Apify CLI](https://docs.apify.com/cli/docs.md): `npm install -g apify-cli`, then `apify login`.
- **JavaScript/TypeScript projects** — the official [JS/TS client](https://docs.apify.com/api/client/js/docs.md): `npm install apify-client`.
- **Python projects** — the official [Python client](https://docs.apify.com/api/client/python/docs.md): `pip install apify-client`.
- **Any other language** — the [REST API](https://docs.apify.com/api/v2.md).

For usage examples, see the [API](#api) section below.

For more details, see Apify documentation as [Markdown index](https://docs.apify.com/llms.txt) and [Markdown full-text](https://docs.apify.com/llms-full.txt).

# README

## ASO Keyword Rank Tracker - App Store Positions

Give it a keyword and an app. It tells you exactly what position that app holds in App Store search for that keyword, in that country, right now - plus who is ranking above it, and how the position moved since your last run.

This is the question every ASO tool charges a monthly subscription to answer. There is no subscription here, no API key, no App Store Connect access, and no proxy. It reads Apple's public search endpoint, which answers `HTTP 200` anonymously and returns the same ordering a user sees.

### What you get per check

One row per **keyword x app x country**.

| Field | What it holds |
|---|---|
| `keyword` | The search term checked |
| `country` | Storefront checked |
| `appId` / `appName` / `developer` | The app being tracked |
| `found` | Whether the app appeared at all within the depth checked |
| `rank` | The position, 1 upward. `null` when not found |
| `previousRank` | The position on the previous run |
| `rankChange` | `rank - previousRank`. **Negative means the app moved up** |
| `depthChecked` | How many results were really examined |
| `resultsReturned` | How many results Apple actually returned |
| `competitorsAboveCount` | How many apps sit between this app and position 1 |
| `competitorsAbove` | The ordered list of those apps, with their ratings |
| `topRankedAppId` / `topRankedAppName` | Who owns position 1 for this keyword |
| `appRating` / `appRatingCount` | The tracked app's rating, as Apple returned it in the result |
| `appUrl` | Store link |
| `checkedAt` | ISO 8601 timestamp |

### A real result

From a live run tracking Calm against two keywords in two storefronts:

| keyword | country | app | rank | apps above | depth checked |
|---|---|---|---|---|---|
| meditation app | us | Calm | **2** | 1 | 189 |
| meditation app | gb | Calm: Sleep & Meditation | **4** | 3 | 196 |
| sleep sounds | us | Calm | **5** | 4 | 192 |
| sleep sounds | gb | Calm: Sleep & Meditation | **5** | 4 | 190 |

And the `competitorsAbove` payload for the first row:

```json
[{ "rank": 1, "appId": "337472899", "appName": "Insight Timer: Meditate, Sleep",
   "developer": "Insight Network Inc", "rating": 4.9, "ratingCount": 445033 }]
```

Two things that table shows and a bare rank number would not. The app's **store name differs per country**, which is itself an ASO decision you can now audit. And the depth genuinely checked was 189 to 196, not the 200 requested - which brings us to the honest part.

### Honesty about depth

Apple accepts `limit=200` on its search endpoint and then returns **slightly fewer than asked**: 189 to 196 in the runs above. Every row therefore reports `depthChecked` and `resultsReturned` as the number that was actually examined, not the number requested.

That matters because `found: false` only means "not in the top N", and you need to know what N really was. A tool that prints "not ranked" without telling you how deep it looked is giving you an unfalsifiable answer.

### Input

```json
{
  "keywords": ["meditation app", "sleep sounds", "guided meditation"],
  "appIds": ["571800810"],
  "countries": ["us", "gb", "de", "au"],
  "depth": 200,
  "includeCompetitorsAbove": true,
  "competitorsAboveLimit": 10,
  "reportNotFound": true,
  "namedStateStore": "aso-rank-history"
}
```

App identifiers are flexible: numeric App Store ID, full `apps.apple.com` URL, or iOS bundle ID. Mix them freely in one run.

**Input aliases.** `keywords` also accepts `keyword`, `terms`, `term`, `queries`, `query` and `search`. `appIds` also accepts `ids`, `appId`, `apps` and `targetApps`. `countries` also accepts `country` and `countryCodes`. A comma separated string works anywhere a list does.

#### Every input field

- **`keywords`** - the search terms to check. Required.
- **`appIds`** - the apps to find a position for. Required.
- **`countries`** - two letter iTunes storefronts. Ranking is per storefront; an app at #2 in the US can be unranked in Germany.
- **`depth`** - 10 to 200. How far down to look.
- **`includeCompetitorsAbove`** - attach the ordered list of apps outranking yours.
- **`competitorsAboveLimit`** - how many of them to list, 1 to 50.
- **`reportNotFound`** - keep rows where the app did not rank. On by default, because a confirmed absence is a real ASO answer.
- **`stateKey`** / **`namedStateStore`** - see rank history below.
- **`maxItems`** - hard ceiling on checks delivered and therefore on the bill.
- **`proxyConfiguration`** - optional, off by default.

### Rank history and `rankChange`

Set **`namedStateStore`** to a name of your choosing, for example `aso-rank-history`, and schedule the actor. Each run stores every position it found in that named key-value store and compares against the previous run. `previousRank` and `rankChange` then populate automatically.

`rankChange` is `rank - previousRank`, so **negative is good**: `-6` means the app climbed six positions. Sort your dataset by that column and you have a movers report.

Without `namedStateStore` the state lives in the run's own storage, so every run looks like a first run and `rankChange` stays `null`. This is the single setting people forget, so it is called out in the input description too.

### Why `competitorsAbove` is the part that makes it actionable

Knowing you rank #14 is a number. Knowing the thirteen apps above you - with their names, developers, ratings and rating counts - is a plan.

The list tells you what kind of gap you are looking at. Thirteen apps above you with 400,000 ratings each is an authority gap and no metadata edit will close it. Thirteen apps above you with 300 ratings each is a relevance gap, and that is a title, subtitle and keyword field problem you can fix this week.

### Tracking a keyword set across countries

Ranking is per storefront and the differences are large. Calm was #2 in the US and #4 in the UK for the identical keyword. Run your keyword set across every market you have localised for, and the output is a per-market ASO scorecard: where you are strong, where a competitor owns the term, and which markets have a weak position 1 worth attacking.

### Competitor keyword tracking

Nothing restricts `appIds` to apps you own. Put a competitor's app ID in and track their positions on your keyword set. Combined over weeks, the dataset shows when a competitor starts pushing a term - a rank climb across a cluster of related keywords is what an ASO campaign looks like from outside.

### Keyword research workflow

1. Draft a keyword list from your category and your competitors' store listings.
2. Run this actor with your app and two or three competitors across the list.
3. Sort by `competitorsAboveCount`. Terms where you sit just outside the top 10 are the cheapest wins.
4. Terms where `found: false` at depth 200 are either irrelevant to your app or genuinely uncontested - check `topRankedAppName` to tell which.
5. Re-run weekly with `namedStateStore` set and watch `rankChange`.

### How this actor behaves when something goes wrong

1. **Errors never enter the dataset.** A keyword that returns zero results, a storefront that fails, an app ID Apple does not recognise - each goes into a `FAILURES` key-value record with the exact reason. None becomes a dataset row, because a row that says "error" would bill you for receiving an error.
2. **`found: false` is not an error.** It is a real answer and it is delivered as a normal row, with `depthChecked` proving how hard the actor looked. Turn `reportNotFound` off if you only want to pay for hits.
3. **An app ID that does not exist is rejected as bad input**, not silently reported as unranked. Verified: two invalid IDs in a test run were sent to `FAILURES` while the valid one was tracked normally.
4. **A run that delivered nothing is marked FAILED**, not SUCCEEDED.
5. **The free tier returns data.** Proxy is optional and off by default.

### Pricing

Pay per event. **$0.002 per rank check delivered** - one keyword, one app, one country. No subscription.

Twenty keywords against one app in three countries is 60 checks, or **$0.12 per run**. Run that daily for a month and it is about $3.60, against an ASO SaaS subscription that starts around $50 a month for the same question.

You are billed only for checks that reach the dataset. Failed keywords and rejected app IDs are not billed. `maxItems` is a hard ceiling on both rows and spend.

### Output sample

```json
{
  "keyword": "meditation app",
  "country": "us",
  "appId": "571800810",
  "appName": "Calm",
  "developer": "Calm.com, Inc.",
  "found": true,
  "rank": 2,
  "previousRank": 4,
  "rankChange": -2,
  "depthChecked": 189,
  "resultsReturned": 189,
  "competitorsAboveCount": 1,
  "competitorsAbove": [
    { "rank": 1, "appId": "337472899", "appName": "Insight Timer: Meditate, Sleep",
      "developer": "Insight Network Inc", "rating": 4.9, "ratingCount": 445033 }
  ],
  "topRankedAppId": "337472899",
  "topRankedAppName": "Insight Timer: Meditate, Sleep",
  "appRating": 4.8036,
  "appRatingCount": 1994523,
  "appUrl": "https://apps.apple.com/us/app/calm/id571800810?uo=4",
  "checkedAt": "2026-08-22T04:15:55.290Z"
}
```

### Dataset views

- **Rankings** - keyword, country, app, rank, change, depth. The tracking table.
- **Competitive gap** - keyword, rank, how many apps are above, who holds position 1, and the tracked app's rating. The decision table.

### Frequently asked questions

**Is this the same ordering a user sees in the App Store app?** It is Apple's public search ordering for that storefront. It is the closest public signal available without a device farm, and it is what every ASO tool in this price range uses.

**What is the maximum depth?** 200 requested, 189 to 196 typically returned. Each row reports the true figure.

**Can I track apps I do not own?** Yes. No ownership check exists, because none is possible - this is public search.

**Why is `rankChange` null?** You did not set `namedStateStore`, so there is no previous run to compare with. See the rank history section.

**Does `found: false` mean my app is not in the App Store?** No. It means it did not appear within `depthChecked` results for that keyword and storefront. Use the App Store App Details Scraper to confirm the app exists and is available in that country.

**Can I schedule it?** Yes, and you should - the value is in the trend. Daily or weekly with `namedStateStore` set.

### Related actors

- **App Store Search Scraper** - the full ranked result list for a keyword, not just your app's position.
- **App Store Top Charts Scraper** - category and country chart rankings.
- **App Store App Details Scraper** - ratings, in-app purchase prices, versions.
- **App Store Reviews Scraper** - customer reviews across every storefront.

# Actor input Schema

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

Search terms to check, exactly as a user would type them into App Store search. Aliases: keyword, terms, term, queries.

## `appIds` (type: `array`):

Numeric App Store IDs, apps.apple.com URLs, or iOS bundle IDs. Aliases: ids, appId, apps, targetApps.

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

Two letter iTunes storefronts. Ranking is per storefront: an app at #4 in the US can be unranked in Germany. Aliases: country, countryCodes.

## `depth` (type: `integer`):

How deep into the result list to look for your app. 200 is Apple's ceiling; the API often returns slightly fewer, and the real depth checked is reported in every row.

## `includeCompetitorsAbove` (type: `boolean`):

Attaches the ordered list of app names sitting above your app for that keyword. This is the part that makes the number actionable.

## `competitorsAboveLimit` (type: `integer`):

How many higher-ranked competitors to list for each keyword. Only applies when "Include competitors above" is on.

## `reportNotFound` (type: `boolean`):

A confirmed "not in the top N" is a real ASO answer, so it is delivered as a row with found=false and rank=null. Turn off to only pay for hits.

## `stateKey` (type: `string`):

Key holding the previous run's positions, which is what makes rankChange possible.

## `namedStateStore` (type: `string`):

Set this to a named store (for example aso-rank-history) so scheduled runs compare against each other. Without it every run reports rankChange as null.

## `maxItems` (type: `integer`):

Hard ceiling on rank checks delivered and therefore on what you are charged.

## `proxyConfiguration` (type: `object`):

Not required.

## Actor input object example

```json
{
  "keywords": [
    "meditation app",
    "sleep sounds"
  ],
  "appIds": [
    "571800810"
  ],
  "countries": [
    "us"
  ],
  "depth": 100,
  "includeCompetitorsAbove": true,
  "competitorsAboveLimit": 10,
  "reportNotFound": true,
  "stateKey": "ASO_RANK_STATE",
  "maxItems": 500,
  "proxyConfiguration": {
    "useApifyProxy": false
  }
}
```

# 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": [
        "meditation app",
        "sleep sounds"
    ],
    "appIds": [
        "571800810"
    ],
    "countries": [
        "us"
    ]
};

// Run the Actor and wait for it to finish
const run = await client.actor("renzomacar/aso-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": [
        "meditation app",
        "sleep sounds",
    ],
    "appIds": ["571800810"],
    "countries": ["us"],
}

# Run the Actor and wait for it to finish
run = client.actor("renzomacar/aso-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": [
    "meditation app",
    "sleep sounds"
  ],
  "appIds": [
    "571800810"
  ],
  "countries": [
    "us"
  ]
}' |
apify call renzomacar/aso-keyword-rank-tracker --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,renzomacar/aso-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/CmVEE8EYvRXb69LeL/builds/MJcLLWbFmjHts0beH/openapi.json
