Skip to content

Models

All models are frozen dataclasses with __slots__.

Frozen means fields cannot be reassigned, not that contents cannot change

frozen=True rejects result.reviews = [...], but the list itself is a plain list: result.reviews.append(...) and review.raw["k"] = v both work and mutate the model in place. Nothing in this package does that; filter, sort and limit all return new objects. Treat the containers as read-only, and copy before mutating if you need to.


Review

A single app review, normalized across all stores and providers.

from app_reviews import Review
Field Type Default Description
store "appstore" or "googleplay" Required Which store.
app_id str Required App ID or package name.
country str \| None Required Storefront queried, not the reviewer's location. None if the source does not report one (googleplay_official, googleplay_scraper: Play has one global review corpus, so there is no storefront to report).
rating int Required Star rating 1-5. Validated on creation.
title str \| None Required Review title. Google Play web has none; the official API may expose a legacy title.
body str Required Review body text.
author_name str Required Author display name.
source Source Required Data source: appstore_scraper, appstore_official, googleplay_scraper, or googleplay_official.
id str Required Non-empty raw identifier assigned by the source. See below.
created_at datetime or None None When the review was posted. None where the source reports no creation date.
updated_at datetime or None None Last edit time. None where the source reports no modification date.
app_version str or None None App version reviewed.
language str or None None Review language.
fetched_at datetime or None None When the review was fetched.
raw dict, list or None None Raw API payload, exactly as the source sent it. Apple and official Play send objects; Play web sends positional arrays. An App Store RSS review read from the XML fallback carries its entry converted to the JSON feed's shape.

Rows are in field order, which is also the positional-constructor order, though Review is far easier to get right with keywords.

Review IDs

id is the identifier the source assigned, passed through unchanged: the App Store RSS id or Connect customerReviews.id, the Google Play batchexecute review id or androidpublisher reviewId.

IDs are not comparable across sources

An id is unique within a (store, source) pair, but not across sources. The two providers for a given store need not share an identifier space: the same real-world App Store review fetched via appstore_scraper and via appstore_official carries two different ids.

Key deduplication on (store, source, id), and use source to tell provenance apart.

For App Store Connect, customerReviewResponses requires a Connect customerReviews.id. RSS ids are numeric (14357217033) and Connect ids are opaque, and Apple exposes no mapping between them: the customerReviews endpoint has no id filter and no legacy-id attribute. So an appstore_scraper id cannot be used to reply.

Google Play appears to use one identifier space for both providers, so a googleplay_scraper id may be usable with GooglePlayReplies, which calls androidpublisher reviews.reply. That has not been tested, so treat it as unverified and reply with ids from GooglePlayReviews(auth=...).


FetchResult

The return value of client.fetch() / client.afetch(). Contains reviews, any per-country errors, and a per-country breakdown. Iterable: loop directly to get Review objects.

from app_reviews import FetchResult

Fields

Field Type Description
reviews list[Review] The fetched reviews, merged across countries, filtered and sorted.
errors list[FetchError] Per-country fetch failures. Derived from outcomes, so it can never disagree with them or be lost by a transform.
outcomes list[CountryOutcome] One entry per country actually walked. See CountryOutcome.
skipped_reviews int Total malformed or unusable review rows skipped across all outcomes. Derived from outcomes.

Methods

Method Returns Description
__iter__() Iterator[Review] Iterate over reviews.
__len__() int Number of reviews.
__bool__() bool True if there is at least one review.
filter(ratings, since, until) FetchResult Return a new filtered FetchResult.
sort(order) FetchResult Return a new sorted FetchResult.
limit(n) FetchResult Return a new FetchResult truncated to n reviews.
to_dicts(include_raw=False) list[dict] JSON-serialisable plain dicts: ISO 8601 timestamps, raw omitted unless asked.
to_dict(include_raw=False) dict Complete JSON-safe envelope containing reviews, outcomes, errors, and skipped_reviews.

Use FetchResult.to_dict(include_raw=False) for automation and persistence when the difference between an empty success and a failed fetch matters. Use to_dicts() only when review rows are sufficient. Passing include_raw=True includes provider payloads inside serialized reviews.

A fetch can partially succeed. Check result.errors to see which countries failed, and result.outcomes for the full per-country picture, including countries that succeeded but stopped early on since or limit.

Errors are more visible than they used to be

Older versions discarded a country's page error once that country had already yielded some reviews, so a non-empty FetchResult could still hide a failure. Errors now always reach result.errors and the matching CountryOutcome.error.


FetchError

A per-country fetch failure.

from app_reviews import FetchError
Field Type Description
country str \| None Storefront that failed, or None for a global source.
message str Error description.
kind ErrorKind What kind of failure this was. Branch retry policy on this, not message. See ErrorKind.
status int \| None HTTP status code, if the exchange produced one.
retryable bool Read-only, derived from kind. Prefer deciding policy per kind yourself over trusting this.

What each source actually fills

Measured against the live APIs, not inferred from the schema. A blank cell means the source never reports that field, so it is always None, not "sometimes missing".

field appstore_scraper appstore_official googleplay_scraper googleplay_official
id yes yes yes yes
store / app_id / source yes yes yes yes
rating / body / author_name yes yes yes yes
fetched_at yes yes yes yes
created_at - yes yes -
updated_at yes - - yes
raw yes yes yes yes
country yes yes - -
title yes yes - legacy text only
app_version yes - mostly yes
language - - - yes

Three things worth planning around:

  • The paid API reports less than the free one in places. App Store Connect sends only body, createdDate, rating, reviewerNickname, territory and title, so app_version is always None on appstore_official while the RSS feed does provide it.
  • Only the Google Play Developer API reports language. Its reviewerLanguage value is a language code, not the reviewer's country.
  • No source reports both timestamps. Exactly one of created_at/updated_at is set for every source this package has, and it is the field that source orders by; read review.dated_at for whichever is present. The model itself only accepts exactly one timestamp. is_edited was removed because no source provides enough information to derive it consistently.

country follows one alphabet everywhere: Connect reports ISO alpha-3 ("USA"), which is normalised to the alpha-2 form Country uses ("us"). Apple's original value stays in raw["attributes"]["territory"].

CountryOutcome

What one country's fetch actually did. Part of FetchResult.outcomes.

from app_reviews import CountryOutcome
Field Type Description
country str \| None The country walked, or None for a global source.
pages int Number of pages requested.
reviews_fetched int Reviews this country's walk pulled off the wire, before the cross-country filter/sort/limit. Compare with len(result.reviews), which is what survived: fetch(ratings=[5]) makes them differ, and the gap tells you the filter is working.
stopped_because StopReason Why the walk ended. See StopReason.
error FetchError \| None Set if the walk ended on an error.
elapsed float Wall-clock seconds spent on this country.
skipped_reviews int Malformed or unusable review rows skipped during this walk.
feed_format FeedFormat \| None App Store RSS only: "xml" if any page of this walk came from the XML fallback, "json" if every answered page came from the JSON feed. None for other sources, or when no feed answered. See FeedFormat.

CountryOutcome.to_dict() serializes every field and nests the complete FetchError dictionary when an error is present.

stopped_because distinguishes "there is no more data" ("exhausted") from "we stopped asking" ("limit", "since"), facts that look identical if you only see the review count.


PageResult

The result of one provider page request, returned by fetch_page() / afetch_page(), and yielded by iter_pages() / aiter_pages(). See Paging and cursors.

from app_reviews import PageResult
Field Type Description
reviews list[Review] Reviews on this page.
next_cursor str \| None Opaque, provider-specific cursor. Persist it verbatim to resume later. None means no more pages.
error FetchError \| None Set if this page failed.
stopped_because StopReason \| None Set only on the final page of an iter_pages()/aiter_pages() walk. Always None on a bare fetch_page() call, which has nothing to stop.
skipped_reviews int Malformed or unusable review rows skipped while parsing this page.
feed_format FeedFormat \| None Which App Store RSS feed this page came from: "json", or "xml" for the fallback. None for other sources and for a failed page.

PageResult.to_dict(include_raw=False) returns a JSON-safe page envelope with reviews, skipped_reviews, next_cursor, error, stopped_because, and feed_format.


FeedFormat

A Literal naming the App Store RSS feed that answered a page: "json" or "xml". Appears on PageResult.feed_format and CountryOutcome.feed_format.

from app_reviews import FeedFormat

The package asks the JSON feed first. Apple's JSON feed sometimes answers 200 with no entries, or with a body that cannot be parsed, while the XML (Atom) feed for the same page has the reviews. Such a page is asked again as XML, through the same client, so retry=, proxy=, and rate_limiter= apply, and the XML entries are used if there are any: feed_format is then "xml". Reviews from either feed have the same fields; only raw differs, since an XML entry is converted into the JSON feed's shape.

  • The XML request is made on page 1, and on a later page unless the page before it was short of Apple's 50 entries (then an empty page is the feed's real end). A walk resumed from a persisted cursor may cost one XML request at its end.
  • Both feeds empty is a normal "exhausted", with feed_format="json".
  • An unreadable XML body leaves the JSON feed's answer standing. A failed XML request (a 403, 5xx, or transport failure) is reported as the page's FetchError, since the empty JSON answer is the one in doubt.

ErrorKind

A Literal classifying why a fetch failed. Branch retry policy on this rather than on message text or a caught exception type.

from app_reviews import ErrorKind
Value Meaning
"rate_limited" HTTP 429, or HTTP 403 from the App Store RSS feed, which answers 403 while it throttles an address. Retryable.
"auth" HTTP 401 or 403 from a credentialed official API, or credentials that cannot be used.
"not_found" HTTP 404.
"server" HTTP 5xx.
"request" A completed, permanent HTTP 4xx rejection that is not rate limiting, not-found, or credentialed authentication. This includes 401/403 from credential-free public endpoints.
"transport" Connection failure or timeout.
"parse" The response body was malformed, not json.JSONDecodeError raised out of the call but a classified error you can inspect.

On search and lookup operations, the "request" kind is raised as RequestError. It is nonretryable. A public web, search, or lookup endpoint has no caller credentials to repair, so its 401/403 is also a request rejection; 401/403 is "auth"/AuthError only for an official credentialed endpoint. The credential-free App Store RSS feed reports its 403 as "rate_limited" instead.


StopReason

A Literal reporting why a page walk ended. Appears on PageResult.stopped_because (final page only) and CountryOutcome.stopped_because.

from app_reviews import StopReason
Value Meaning
"exhausted" The provider ran out of pages. There is no more data.
"limit" The caller's limit was reached. More data may exist.
"since" A page predated since, so paging stopped early. More data may exist.
"cycle" The source repeated a cursor, so following it again would not advance. More data may exist, but this walk cannot reach it.
"stalled" The source kept issuing fresh cursors but returned no reviews for several consecutive pages, so it is not advancing.
"max_pages" The walk hit its page ceiling. More data may exist; resume from the final page's cursor.
"error" The walk failed. See the accompanying FetchError.

"exhausted" outranks both "limit" and "since" when they apply together, so "there is no more data" is never mislabelled "we stopped asking". "stalled" and "max_pages" rank last for the same reason: they mean the walk gave up on a source that would not end, so any reason the source or the caller supplied is the truer answer.

"cycle", "stalled" and "max_pages" are the walk's three floors against a source that never finishes. Nothing else bounds one: the App Store RSS feed has its own page ceiling, but Connect and both Play sources rely on the endpoint to stop issuing cursors.

  • "cycle" catches a repeated page token.
  • "stalled" catches the harder case, a fresh token every page with no reviews on it. limit and since are both driven by reviews actually seen, so an empty-page source escapes them both: limit=5 would otherwise walk forever.
  • "max_pages" is the backstop for a source that returns data forever, and it also bounds the cursor set the walk retains to detect cycles. Raise the public max_pages= argument on fetch(), afetch(), iter_pages(), aiter_pages(), iter_reviews(), or aiter_reviews() when a bounded job intentionally needs more pages.

Country

StrEnum with two-letter country codes.

from app_reviews import Country

Region Groups

Group Description
Country.ALL All 155 supported countries.
Country.EUROPE European countries.
Country.AMERICAS North and South America.
Country.ASIA_PACIFIC Asia-Pacific region.
Country.MIDDLE_EAST Middle East and North Africa.
Country.ENGLISH_SPEAKING English-speaking countries.

Plain strings also work for Apple review storefronts: countries=["us", "gb"]. An explicit empty or all-blank countries collection makes no requests; Google Play review clients reject nonblank country selections because their review corpus is global.


Sort

Controls review order.

from app_reviews import Sort
Value Description
Sort.NEWEST Most recent first (default).
Sort.OLDEST Oldest first.
Sort.RATING Highest rated first.

RetryConfig

HTTP retry and timeout settings.

from app_reviews import RetryConfig
Field Type Default Description
max_retries int 3 Maximum number of retries per request.
backoff_factor float 0.5 Multiplier for wait time between retries.
timeout float 30.0 Per-request timeout in seconds.
retry_on tuple[int, ...] (500, 502, 503, 504, 429) Immutable HTTP status codes that trigger a retry. Any collection passed by the caller is copied to a tuple.
max_backoff float 60.0 Ceiling on any single wait, in seconds.

Waits follow backoff_factor * 2**attempt, capped at max_backoff. A server's Retry-After header overrides that schedule when present, because it is the only party that knows when it will serve again, and returning sooner than asked is what turns throttling into a longer block. Both seconds (Retry-After: 30) and the HTTP-date form are read, and both are capped at max_backoff too, so an outsized header cannot park a request.

from app_reviews import AppStoreReviews, RetryConfig

client = AppStoreReviews(
    retry=RetryConfig(max_retries=5, backoff_factor=1.0, retry_on=(429, 503))
)

AppMetadata

Returned by search() and lookup() on the search clients.

from app_reviews import AppStoreSearch

with AppStoreSearch() as client:
    metadata = client.lookup("123456789")   # AppMetadata | None
Field Type Description
app_id str App ID or package name.
store "appstore" or "googleplay" Which store.
name str App display name.
developer str Developer or publisher.
category str Primary category.
price str Store-formatted price, "Free", or "Unknown".
version str Current version, where the store publishes one.
rating float Average star rating.
rating_count int Total number of ratings.
url str Store page URL.
icon_url str \| None App icon URL, or None when the store reports none.
current_version_release_date datetime \| None When the current version shipped.
first_release_date datetime \| None When the app first appeared on the store.
release_notes str \| None "What's New" text for the current version.

Text and number fields are non-optional, so a store that does not report one gets a stated placeholder rather than None. The two dates are the exception: a date has no honest placeholder, and a sentinel would sort, filter and diff as though it were real. Precision differs by store: the App Store sends a real timestamp, Google Play only the day it renders, so a Play date is midnight UTC on that day. Measured against the live stores:

field AppStoreSearch GooglePlaySearch.lookup GooglePlaySearch.search
name / developer / category / rating yes yes yes
rating_count yes yes always 0
version yes when the app publishes one always "Varies with device"
icon_url yes yes yes
current_version_release_date / first_release_date yes, to the second yes, to the day always None
release_notes yes yes, <br> as newlines always None
  • Google Play publishes a version for some apps, not all. lookup() returns the real one when the detail page carries it, and "Varies with device" when it does not, which is what the store itself shows for an app shipping per-device variants. Verified against the us storefront: Firefox publishes 153.0.1, while Spotify and Duolingo publish nothing. A regular search hit has no version field at all, so it always reports the placeholder; use lookup() for a real one. Not to be confused with the version string attached to a review, which names the build that reviewer was running rather than the app's current release.
  • Play's search layout carries no rating count. A regular search hit has a rating but no count anywhere in it, so rating_count is 0. Two exceptions get a real count: lookup(), and the one featured hit a search returns, because Play embeds a full detail block for it. Use lookup() when you need counts.
  • price prefers the store's localized formatted price. For Google Play, the following fallbacks are deterministic. Absent price data and an explicit numeric zero are "Free". When no localized string is present, a positive numeric amount and a non-empty ISO currency code produce a conservative ISO fallback such as "TRY 109.00"; the client does not guess a currency symbol. Malformed or non-finite amounts are "Unknown", as are positive amounts with a missing currency. Apple preserves its formattedPrice value and uses "Unknown" when that value is absent or unusable.

AppVersionEntry

One entry of an app's App Store "Version History", returned newest first by AppStoreSearch.version_history() / aversion_history(). The history is scraped from the public App Store product page: best-effort, App Store only, and liable to break when Apple changes the page. AppStoreVersion comes from the official App Store Connect API, which has no release date, so this stays the source for dates.

from app_reviews import AppStoreSearch

with AppStoreSearch() as client:
    history = client.version_history("324684580")   # list[AppVersionEntry]
Field Type Description
version str Version string, such as "9.1.84".
released_at datetime When that version shipped: timezone-aware UTC, to the second.
release_notes str \| None That version's "What's New" text, or None when the store shows none. Same name as AppMetadata.release_notes.

AppStoreVersion

One version of an app as App Store Connect records it, returned newest created_at first by AppStoreVersions(auth).versions() / aversions().

The official API has no release date. For when versions reached the store, use AppVersionEntry from version_history().

Field Type Description
version_id str The appStoreVersions resource id.
version str versionString, such as "4.0.1".
platform str "IOS", "MAC_OS", "TV_OS", or "VISION_OS".
state str \| None appVersionState; "READY_FOR_DISTRIBUTION" means live.
release_type str \| None "MANUAL", "AFTER_APPROVAL", or "SCHEDULED".
created_at datetime \| None createdDate: when the version was created in App Store Connect, not when it shipped.
earliest_release_date datetime \| None earliestReleaseDate: the earliest moment a SCHEDULED release may go out.
release_notes dict[str, str] whatsNew per locale, such as {"en-US": "..."}; locales without text are omitted.

ReviewReply

The developer reply to one review, returned by AppStoreReplies and GooglePlayReplies.

Field Type Description
review_id str The review replied to.
reply_id str \| None Apple's customerReviewResponses id; None on Google Play, which has none.
text str The reply text as the store holds it.
state ReplyState "pending" until Apple shows it (up to 24 hours), then "published". Always "published" on Play.
updated_at datetime \| None When the reply was last written, timezone-aware.

Reply errors: ReplyRejectedError(reason) subclasses RequestError and means nothing was published. ReplyOutcomeUnknownError subclasses HttpError and means a write may or may not have taken effect. RateLimitError.retry_after is the wait the store asked for, in seconds, or None.


Type Aliases

from app_reviews.models.types import Store, Source
Type Values
Store "appstore", "googleplay"
Source "appstore_scraper", "appstore_official", "googleplay_scraper", "googleplay_official"
ReplyState "published", "pending"