App Reviews
Fetch reviews, search apps, and look up metadata from the Apple App Store and Google Play Store.
Why App Reviews?
Apple and Google use different APIs, formats and auth. This package puts both behind one Python API, with no API keys required.
from app_reviews import AppStoreSearch, GooglePlayReviews, Country
# Search for apps
results = AppStoreSearch().search("whatsapp", country=Country.US, limit=5)
print(results[0].name, results[0].icon_url)
# Fetch reviews
reviews = GooglePlayReviews().fetch("com.whatsapp")
for review in reviews:
print(f"{review.rating}* {review.body[:80]}")
Highlights
| Both stores | Apple App Store + Google Play in one package |
| Search & lookup | Find apps by keyword, look up metadata by ID |
| No API keys | The default sources are public endpoints |
| 155 countries | Fetch across regions in a single call |
| Official APIs | Optionally use App Store Connect or Google Play Developer API |
| Async | Every entry point has a real async twin, not a thread-pool wrapper |
| Own the loop | fetch_page / iter_pages / iter_reviews: resumable cursors, streaming |
| Typed errors | ErrorKind on every failure, and per-country outcomes |
| Pooled | One connection per client, not one per request |
| Minimal deps | Just cryptography for JWT and httpx for transport |
Install
pip install app-reviews
Or with uv:
uv add app-reviews
Quick Start
Apple App Store
from app_reviews import AppStoreReviews, Country
client = AppStoreReviews()
result = client.fetch("324684580", countries=[Country.US, Country.GB])
for review in result:
print(f"[{review.country}] {review.rating}* {review.title}")
Google Play Store
from app_reviews import GooglePlayReviews
client = GooglePlayReviews()
result = client.fetch("com.instagram.android")
for review in result:
print(f"{review.rating}* {review.body[:80]}")
No countries here on purpose: Google Play has a single global review corpus, so
review.country is None and passing a country list collapses to one request
either way. The App Store RSS feed above is the one source where storefront
genuinely partitions the results.
Review
fetch() returns a FetchResult containing Review objects:
| Field | Type | Description |
|---|---|---|
id |
str |
Raw identifier assigned by the source (see below) |
store |
Store |
"appstore" or "googleplay" |
app_id |
str |
App Store ID or package name |
country |
str | None |
Storefront queried, not the reviewer's location. None when the source does not report one |
rating |
int |
Star rating (1-5) |
title |
str | None |
Review title. None for sources with no title concept (Google Play) |
body |
str |
Review text |
author_name |
str |
Reviewer display name |
app_version |
str | None |
App version at time of review |
created_at |
datetime | None |
When written. None on sources that only report a modification time |
updated_at |
datetime | None |
Last modified. None on sources that only report a creation time |
source |
Source |
Provider (e.g. "appstore_scraper", "googleplay_official") |
language |
str | None |
Review language code |
fetched_at |
datetime | None |
When the review was fetched |
raw |
dict | list | None |
The provider's own payload, passed through unchanged. A list from Play, whose endpoints send positional arrays rather than objects |
Exactly one of created_at / updated_at is set by every source here, and it is the timestamp
that source orders by; no store reports both. review.dated_at returns whichever
one is there, and sort, since and until all use it. App Store Connect
reports creation; the RSS feed and the Play Developer API report last-modified;
the Play web feed reports creation to millisecond precision.
Not every other source fills every field either, and a blank is permanent rather
than occasional: app_version is always None on appstore_official, title and
country are always None on both Google Play sources, and language is never
populated by anything. See
what each source fills
before writing code against a field.
None means "this source does not report it", never "this review has no value",
so a None country and a None title are honest rather than an empty-string
stand-in. Every source populates raw, which is what makes a fetch reprocessable
later. See
how the sources differ.
to_dicts() leaves it out unless you pass include_raw=True.
Review IDs
id is the raw identifier assigned by the source, passed through unchanged. Treat it as unique within a (store, source) pair rather than globally, use source to tell provenance apart, and key any deduplication on (store, source, id).
Ids are not necessarily comparable across sources. On the App Store they definitely differ: RSS ids are numeric while Connect ids are opaque, with no mapping between them, so the same review fetched via appstore_scraper and appstore_official carries two different ids. Google Play appears to use a single identifier space for both providers.
For App Store Connect specifically, customerReviewResponses requires a Connect customerReviews.id, so an appstore_scraper id cannot be used to reply.
Search & Lookup
Find apps by keyword and look up app metadata. No authentication required.
Search
from app_reviews import AppStoreSearch, GooglePlaySearch, Country, AppMetadata
# App Store: returns list[AppMetadata]
results: list[AppMetadata] = AppStoreSearch().search("fitness tracker", country=Country.US, limit=10)
for app in results:
print(f"{app.name} by {app.developer} ({app.rating}*)")
# Google Play: returns list[AppMetadata]
results: list[AppMetadata] = GooglePlaySearch().search("fitness tracker", country=Country.US, limit=10)
for app in results:
print(f"{app.name} by {app.developer}")
Lookup
# Look up by bundle ID (App Store) or package name (Google Play)
# Returns AppMetadata | None
app = AppStoreSearch().lookup("com.burbn.instagram")
if app:
print(f"{app.name} - {app.icon_url}")
app = GooglePlaySearch().lookup("com.whatsapp")
if app:
print(f"{app.name} - {app.rating}*")
AppMetadata
Both search() and lookup() return AppMetadata objects:
| Field | Type | Description |
|---|---|---|
app_id |
str |
Bundle ID (App Store) or package name (Google Play) |
store |
"appstore" | "googleplay" |
Which store |
name |
str |
App display name |
developer |
str |
Developer or publisher name |
category |
str |
Primary category (e.g. "Social Networking") |
price |
str |
Formatted price (e.g. "Free", "$4.99") |
version |
str |
Current version. "Varies with device" on Google Play for an app shipping per-device variants, and for any search hit; lookup() returns the real one when published |
rating |
float |
Average star rating (0.0-5.0) |
rating_count |
int |
Total number of ratings. 0 for a regular Google Play search hit; use lookup() |
url |
str |
Store page URL |
icon_url |
str | None |
App icon image URL |
current_version_release_date |
datetime | None |
When the current version shipped. Real timestamp on the App Store; midnight UTC on Google Play, which publishes only the day |
first_release_date |
datetime | None |
When the app first appeared on the store. Same precision caveat |
Both dates are None when the store does not publish them, and a Google Play
search hit never carries either; use lookup().
Authentication (Optional)
For higher limits and more data, use the official APIs with your developer credentials.
Requires an Apple Developer Program membership ($99/year).
from app_reviews import AppStoreReviews, AppStoreAuth, Country
auth = AppStoreAuth(
key_id="ABC123DEF4",
issuer_id="12345678-1234-1234-1234-123456789012",
key_path="/path/to/AuthKey.p8",
)
client = AppStoreReviews(auth=auth)
result = client.fetch("324684580", countries=[Country.US, Country.GB])
Requires a Google Play Developer account ($25 one-time).
from app_reviews import GooglePlayReviews, GooglePlayAuth, Country
auth = GooglePlayAuth(service_account_path="/path/to/service-account.json")
client = GooglePlayReviews(auth=auth)
result = client.fetch("com.instagram.android") # Play is global
Advanced Usage
fetch() walks every page and buffers the result so it can filter and sort
across countries. Drop a rung when you want the cursor, or when the corpus is too
big to hold.
from app_reviews import AppStoreReviews, Sort
client = AppStoreReviews()
# rung 1: one request. Persist next_cursor and resume later, even in
# another process.
page = client.fetch_page("324684580", country="us")
save(page.next_cursor)
page = client.fetch_page("324684580", country="us", cursor=load())
# rung 2: one country, paginated. The last page carries stopped_because.
for page in client.iter_pages("324684580", country="us", since=since):
store(page.reviews)
checkpoint(page.next_cursor)
# rung 3: reviews, streamed across countries, one page held at a time
for review in client.iter_reviews("324684580", countries=["us", "gb"]):
handle(review)
# rung 4: everything, filtered and sorted
result = client.fetch("324684580", countries=["us", "gb"], sort=Sort.RATING)
since reduces requests rather than just filtering: the walk stops once a page's
oldest review predates it, on sources that guarantee newest-first ordering.
Every rung has an a-prefixed async twin.
result = client.fetch("324684580", countries=["us", "gb"])
for outcome in result.outcomes:
# "exhausted" means no more data. "limit"/"since"/"cycle"/"stalled"/
# "max_pages"/"error" all mean there may be more
print(outcome.country, outcome.pages, outcome.reviews_fetched,
outcome.stopped_because)
for err in result.errors: # derived from outcomes, always in sync
if err.kind == "rate_limited": # auth | not_found | server | transport | parse
back_off()
fetch() never raises on a partial failure: a country that fails still reports
its reviews, its stopped_because == "error", and its FetchError. Search and
lookup are single requests with a single outcome, so they raise HttpError
instead. The class is the classification, so catch what you want to react to:
from app_reviews import AppStoreSearch, AuthError, RateLimitError, HttpError
try:
apps = AppStoreSearch().search("fitness tracker")
except RateLimitError as err:
back_off(err.status)
except AuthError:
alert_a_human() # never worth retrying
except HttpError as err: # the catch-all for a failed request
log(type(err).__name__, err.status)
RateLimitError, NotFoundError, ServerError, TransportError and
ParseError all subclass HttpError; AuthError sits beside it, because a bad
key file is an auth failure with no HTTP in it. AppReviewsError is the base for
everything.
client = GooglePlayReviews()
client.source # "googleplay_scraper"
client.resolve_countries(["us","gb"]) # [""]: global, so one request, not two
The four sources differ in ways that change how you call them: only the App Store RSS feed is per-storefront, only the Play Developer API is unordered and capped at seven days of history, and the RSS feed tops out near 500 reviews per storefront. All of it is in how the sources differ.
Each client owns one HTTP connection pool, so a multi-page walk costs one TLS handshake rather than one per page. The sockets outlive a request, so close the client when you are done:
with AppStoreReviews() as client: # or `async with`, plus close()/aclose()
result = client.fetch("324684580")
concurrency bounds the cross-country fan-out; pass 1 to make it sequential
when you are rate-limiting a source yourself:
result = client.fetch("324684580", countries=[...], concurrency=1)
To share one pool between clients, set a proxy in one place, or inject a transport in tests, build it yourself:
from app_reviews import AppStoreReviews, AppStoreSearch, HttpClient
pool = HttpClient(proxy="http://proxy.example.com:8080")
reviews, search = AppStoreReviews(http=pool), AppStoreSearch(http=pool)
from app_reviews import AppStoreReviews, RetryConfig
retry = RetryConfig(
max_retries=5, # default: 3
backoff_factor=1.0, # default: 0.5
timeout=60.0, # default: 30.0
retry_on=[429, 503], # default: [500, 502, 503, 504, 429]
max_backoff=30.0, # default: 60.0, ceiling on any one wait
)
# A server's `Retry-After` overrides the backoff schedule, capped at max_backoff.
client = AppStoreReviews(retry=retry, proxy="http://proxy.example.com:8080")
result = client.fetch("324684580", countries=["us"])
to_dicts() returns JSON-safe plain dicts: ISO 8601 timestamps, and the
provider payload (raw) omitted unless you ask. The standard library handles the
rest; this package ships no exporters.
import csv, json
from app_reviews import GooglePlayReviews
result = GooglePlayReviews().fetch("com.instagram.android")
json.dumps(result.to_dicts(), indent=2) # JSON
"\n".join(json.dumps(d) for d in result.to_dicts()) # JSONL
rows = result.to_dicts()
with open("reviews.csv", "w", newline="", encoding="utf-8") as f:
w = csv.DictWriter(f, fieldnames=list(rows[0]))
w.writeheader()
w.writerows(rows)
Pass include_raw=True to keep the provider payload, useful if you want the
fetch to stay reprocessable later.
Limitations
Full reference: how the sources differ.
-
How far back you can reach:
appstore_scraper: ~500 most recent per country (the RSS feed serves 10 pages).googleplay_official: only the last 7 days. Google documents this; full history needs a Play Console CSV export, which this package does not read.appstore_official,googleplay_scraper: unbounded.
-
Country is not always a real axis:
- Only
appstore_scraperpartitions by storefront. Google Play has one global review corpus, and both official APIs are global: passing more countries there costs nothing extra, because it collapses to a single request.
- Only
-
Replies need official ids (not implemented here):
- Reply APIs only accept identifiers their own list endpoint minted, so a scraper-sourced review is permanently non-repliable.
-
Ordering is not always guaranteed:
googleplay_officialdocuments none, so thesince/limitearly stop never applies to it and every fetch walks to exhaustion.
-
Scrapers are unofficial: the Google Play web endpoint is undocumented and rate-limited, and can change without notice.
-
Official APIs require developer accounts: Apple ($99/year), Google ($25 one-time).
Documentation
Read the full docs: the Python API, paging and cursors, async, authentication, the models, and how the four sources differ.
Contributing
git clone https://github.com/firattamurcw/app-reviews.git
cd app-reviews
uv sync --group dev
make test
See CONTRIBUTING.md · Security Policy
Acknowledgements
The Google Play scraping logic (parsing AF_initDataCallback datasets, field index paths) is based on the work done in google-play-scraper by JoMingyu. We re-implemented it on top of our own HTTP layer to support retries and proxies, but the data-structure knowledge originates from that project.
License
Metadata
Release files for app-reviews 0.6.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| app_reviews-0.6.0.tar.gz | 252.4 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| app_reviews-0.6.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 334.7 kB
Release files / app_reviews-0.6.0.tar.gz
| Download URL | app_reviews-0.6.0.tar.gz |
|---|---|
| Size | 252.4 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
f4383df99e46b0bcabc3b090065ec4ef379e4c042c70b2d348daca469c310a61
|
|
BLAKE2b-256 checksum How to use checksums |
e75d0569ae1d42513bfb845ea852b10bca06706685f338128c8d757fbf542923
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Aug 2, 2026.
Transparency logRelease files / app_reviews-0.6.0-py3-none-any.whl
| Download URL | app_reviews-0.6.0-py3-none-any.whl |
|---|---|
| Size | 82.3 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
6ec4cd9244964bfc12a18b4fa508b4a332baedbc1956580eafba7f4bd66dc3e8
|
|
BLAKE2b-256 checksum How to use checksums |
ab9bf902219889dae746c75de1b58a5c4efb6258d5dd459fcf4459dc39ecd8b3
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Aug 2, 2026.
Transparency log