DEV Community

Andy Zeng
Andy Zeng

Posted on

How to track App Store keyword rankings and competitor reviews across countries (free Python + Apple's official endpoints)

Disclosure: I build and sell two paid Apify Actors that automate this workflow. They're mentioned in step 6 as one option. Everything else in this post works with free tools and Apple's public endpoints. This article was drafted with AI assistance; the code and examples were tested.

App Store Optimization tools are expensive, and most indie developers only need three numbers: where the app ranks for its main keywords, where it sits in its category chart, and what users (yours and your competitors') complain about. You also want them per country, because an app can be #15 for "habit tracker" in the US and not appear in the UK at all.

Apple exposes enough free, unauthenticated endpoints to get all three:

What Endpoint Limits
Keyword search results https://itunes.apple.com/search up to 200 results, roughly 20 calls/minute
App details https://itunes.apple.com/lookup IDs or bundle IDs, batched
Top charts https://itunes.apple.com/{cc}/rss/{feed}/limit=100/json top ~100 per chart
Customer reviews https://itunes.apple.com/{cc}/rss/customerreviews/page={n}/id={id}/sortby=mostRecent/json 10 pages × 50 = 500 per country and sort

Everything below is plain Python 3 with the standard library, no keys, no browser. I tested it on Oct 26, 2026 against Streaks, a paid habit tracker, in the US, UK and Germany.

Step 1: A polite JSON helper

The iTunes API rate-limits aggressively (around 20 requests per minute per IP) and occasionally returns 403 or empty bodies. Retry with a backoff and send a clear User-Agent:

import json, time, urllib.parse, urllib.request

UA = {"User-Agent": "appstore-tracker/1.0 (personal research script)"}

def get_json(url, params=None, retries=3):
    if params:
        url += "?" + urllib.parse.urlencode(params)
    for attempt in range(retries):
        try:
            with urllib.request.urlopen(urllib.request.Request(url, headers=UA), timeout=30) as r:
                return json.loads(r.read().decode("utf-8") or "{}")
        except Exception:
            if attempt == retries - 1:
                raise
            time.sleep(2 * (attempt + 1))
Enter fullscreen mode Exit fullscreen mode

You need the app's numeric ID. It's the id963034692 part of the App Store URL, or you can look it up by bundle ID: https://itunes.apple.com/lookup?bundleId=com.burbn.instagram.

Step 2: Keyword rank per country

Search the keyword in each storefront and find your app's position in the results:

def keyword_rank(app_id, term, country, limit=200):
    data = get_json("https://itunes.apple.com/search",
                    {"term": term, "country": country, "entity": "software", "limit": limit})
    ids = [str(r["trackId"]) for r in data.get("results", [])]
    rank = ids.index(str(app_id)) + 1 if str(app_id) in ids else None
    top3 = [r["trackName"] for r in data.get("results", [])[:3]]
    return rank, len(ids), top3
Enter fullscreen mode Exit fullscreen mode

Real output from my test run:

[us] 'habit tracker': rank 15 of 193  (top 3: Habit Tracker, Habit Tracker - HabitKit, Finch: Self-Care Pet)
[gb] 'habit tracker': rank None of 195  (top 3: Habit Tracker, Onrise: Habit Tracker & Focus, Habit Tracker - HabitKit)
[de] 'habit tracker': rank 30 of 181  (top 3: Habit Tracker - Tagesplaner, Habit Tracker - HabitKit, Onrise: Habit Tracker & Focus)
Enter fullscreen mode Exit fullscreen mode

That's already a useful finding: the same app is #15 in the US, #30 in Germany and outside the top ~195 in the UK for the same English keyword.

An important caveat. The iTunes Search API ranking is close to but not identical to what a person sees in the App Store app. The app's search is personalized, mixes in Search Ads, and changes more often. Treat these numbers as a consistent, free proxy: good for trends ("we moved from 30 to 18 after the title change") and for comparing countries, not as the exact position a given user sees. Paid ASO tools have the same problem and estimate it in different ways.

Step 3: Top-chart position

Apple still serves the classic RSS charts as JSON. Feeds include topfreeapplications, toppaidapplications, topgrossingapplications (plus topfreeipadapplications and similar), and you can filter by category with /genre={id}. Get the app's primaryGenreId from the Lookup API; Streaks is 6013 (Health & Fitness).

def chart_position(app_id, country, feed="topfreeapplications", genre=None, limit=100):
    g = f"/genre={genre}" if genre else ""
    data = get_json(f"https://itunes.apple.com/{country}/rss/{feed}/limit={limit}{g}/json")
    entries = data.get("feed", {}).get("entry", [])
    for i, e in enumerate(entries, 1):
        if e["id"]["attributes"]["im:id"] == str(app_id):
            return i, len(entries)
    return None, len(entries)
Enter fullscreen mode Exit fullscreen mode

Output:

[us] Top Paid / Health & Fitness: 1 of 97
[gb] Top Paid / Health & Fitness: 1 of 94
[de] Top Paid / Health & Fitness: 2 of 94
Enter fullscreen mode Exit fullscreen mode

The feed returns about 100 entries even if you ask for 200, so "None" means "not in the top ~100", not "unranked".

Step 4: Competitor reviews per country

The customer-reviews RSS feed gives up to 10 pages of 50 reviews per country and sort order (mostRecent or mostHelpful). Because each storefront has its own reviews, scanning several countries gets you far more data than the US store alone.

def reviews(app_id, country, pages=10, sort="mostRecent"):
    out = []
    for page in range(1, pages + 1):
        url = (f"https://itunes.apple.com/{country}/rss/customerreviews/"
               f"page={page}/id={app_id}/sortby={sort}/json")
        try:
            entries = get_json(url).get("feed", {}).get("entry", [])
        except Exception:
            break
        if isinstance(entries, dict):
            entries = [entries]
        rows = [e for e in entries if "im:rating" in e]  # skip the app-info entry
        if not rows:
            break
        for e in rows:
            out.append({
                "country": country,
                "id": e["id"]["label"],
                "rating": int(e["im:rating"]["label"]),
                "version": e.get("im:version", {}).get("label"),
                "date": e.get("updated", {}).get("label"),
                "title": e["title"]["label"],
                "text": e["content"]["label"],
            })
        time.sleep(0.5)
    return out
Enter fullscreen mode Exit fullscreen mode

Two gotchas: when a page has a single entry the feed returns a dict instead of a list, and some pages include an entry describing the app itself (no im:rating). The code handles both. In my test, 10 pages for the US returned the full 500 reviews.

Then summarize per country, and count words in 1–2★ reviews to see what people complain about:

from collections import Counter

STOP = set("the a an and or to of i it is in for on my this that with app was but not "
           "you have be are so just can its it's they when all at me do as get from no "
           "your if what would".split())

def complaint_keywords(rows, n=10):
    words = Counter()
    for r in rows:
        if r["rating"] <= 2:
            for w in (r["title"] + " " + r["text"]).lower().split():
                w = w.strip(".,!?\"'()[]:;")
                if len(w) > 2 and w not in STOP and w.isalpha():
                    words[w] += 1
    return words.most_common(n)
Enter fullscreen mode Exit fullscreen mode

Output for the 150 most recent reviews per country:

[us] avg 3.57, 1-2 star share 29%
[gb] avg 3.87, 1-2 star share 21%
[de] avg 3.53, 1-2 star share 26%
[us] top complaint words: [('watch', 13), ('more', 11), ('update', 10), ('way', 10), ('use', 9), ('apple', 9)]
[gb] top complaint words: [('streaks', 16), ('watch', 16), ('task', 13), ('sync', 11), ('only', 10), ('time', 9)]
Enter fullscreen mode Exit fullscreen mode

Even this crude count points at something concrete: recent negative reviews in both markets mention the Apple Watch and sync. Note that the recent average (3.5–3.9) is much lower than the app's all-time 4.8 rating from the Lookup API. That gap is exactly the signal you want from a competitor: a weakness you could build on.

Run the word count per country, as above. Mixing languages fills the list with stopwords; in German the top "complaint words" were die, nicht and und. Add a stopword list per language (or send the texts to an LLM to cluster) for non-English stores.

Step 5: Run it daily and keep history

A rank is only useful as a trend. Append one row per keyword, country and chart to a CSV:

import csv, datetime

today = datetime.date.today().isoformat()
with open("ranks.csv", "a", newline="") as f:
    w = csv.writer(f)
    for c in ["us", "gb", "de"]:
        for t in ["habit tracker", "to do list"]:
            rank, n, _ = keyword_rank(APP_ID, t, c)
            w.writerow([today, c, t, rank])
            time.sleep(3)  # stay under the rate limit
        pos, _ = chart_position(APP_ID, c, feed="toppaidapplications", genre=6013)
        w.writerow([today, c, "chart:toppaid:6013", pos])
Enter fullscreen mode Exit fullscreen mode

Schedule it with cron (0 6 * * * cd ~/aso && python3 tracker.py) or a free GitHub Actions schedule that commits the CSV. After a few weeks you can see exactly what a title, subtitle or screenshot change did in each country.

Practical tips:

  • Keep the keyword list small (10–30 terms) and sleep between calls. 3 countries × 20 keywords is 60 requests, about 3–4 minutes at a safe pace.
  • Track 2–3 competitors with the same function. Their rank changes after a release often tell you which keywords they targeted.
  • Pull reviews for competitors weekly, not daily. 500 reviews per country per sort is the ceiling, so mostRecent weekly catches almost everything for most apps.
  • The full tested script (all of the above in one file) is about 100 lines.

Step 6: Automating it (optional)

If you'd rather not maintain scripts, rate-limit handling and storage, I packaged both halves as Apify Actors. They're paid (pay-per-event) and use the same official Apple endpoints, so here's exactly what they do:

  • App Store Search & Charts Scraper: keyword search (up to 200 apps per keyword with rank), keyword rank tracking for your apps across countries, top charts (free, paid, grossing, new, overall or per category), developer portfolios and app details. Pricing: $0.005 per run plus $0.001 per app record, so 3 charts × 100 apps is about $0.31.
  • App Store Reviews Scraper: reviews from up to 76 storefronts in one run, both sort orders merged and de-duplicated, star and date filters, plus analytics (rating by version, by country and by month, top complaint and praise keywords). Pricing: $0.005 per run plus $0.15 per 1,000 reviews, plus $0.003 per app for details and analytics.

Both can be scheduled on Apify and let you set a max cost per run. They're subject to the same limits as the free scripts: the Search API ranking is a proxy for in-app search, and Apple caps reviews at about 500 per country and sort order.

Checklist

  • [ ] App ID found (URL or Lookup by bundle ID)
  • [ ] 10–30 keywords × your key countries tracked daily, with backoff
  • [ ] Category chart position from the RSS feed (top ~100)
  • [ ] 2–3 competitors tracked with the same code
  • [ ] Reviews pulled per country; complaints counted per language
  • [ ] History in a CSV so you can tie rank changes to releases and metadata changes

Top comments (0)