The four parts of a review monitor: public sources, normalize, memory keyed on review IDs, webhook delivery, with a guard for empty feeds

Why the stores' own tools are not enough

The stores tell you about reviews late, partially, or only for apps you own. Google Play's Developer API returns reviews for your own apps only, and only the ones with text from roughly the last week. Apple's App Store Connect API needs your own keys and never covers a competitor. If you want "every new review on both stores, mine and the two apps I compete with, the morning it lands", nothing in the consoles does it.

I ship three Android apps, and a review is usually the first signal that something broke or that a feature is wanted. A five-star review on one of them asked for a date-format setting, and it shipped in the next release. A three-star "It doesn't work sometimes" on another was the first report of a bug. The gap between "a review landed" and "a human read it" is the whole problem.

Part 1: where the reviews actually come from

Both stores expose public review data, just not through an API you'd enjoy. Google Play has no public reviews API at all. The store page loads its reviews through an internal batchexecute endpoint, which is what every Play scraper on npm calls under the hood (google-play-scraper is the well-known one). Apple is friendlier. There is a public RSS feed per app and country, newest first:

https://itunes.apple.com/us/rss/customerreviews/page=1/id=324684580/sortby=mostrecent/json
      

It serves at most 10 pages of 50 reviews, so 500 per app per country, and it carries no developer replies. Google Play does carry replies, and it dropped review titles years ago, so title is always null there. Your normalized row has to accept both shapes:

{
        "store": "google-play",
        "appId": "dev.astraedus.nudge",
        "country": "us",
        "reviewId": "6ca5d4fd-5ef6-49bc-877e-159f062a5a91",
        "rating": 3,
        "title": null,
        "text": "It doesn't work sometimes",
        "date": "2026-09-06T09:09:52.941Z",
        "appVersion": "1.15.2",
        "developerReply": null,
        "isNew": true
      }
      

Part 2: the de-duplication key is the whole product

The review ID is the only stable thing. Both stores give every review an ID that survives edits, rating changes and re-sorting, so "new" means "an ID I haven't emitted before", never "dated after my last run". Dates fail in two ways: users edit old reviews (new date, same review), and stores serve pages out of order under load. Keep a set of emitted IDs per app and country, and diff against it:

// state key: `${store}:${appId}:${country}` -> string[] of emitted review IDs
      async function newReviews(key: string, fetched: Review[], store: KVStore) {
        const seen = new Set<string>((await store.get(key)) ?? []);
        const fresh = fetched.filter((r) => !seen.has(r.reviewId));
        await store.set(key, [...seen, ...fresh.map((r) => r.reviewId)]);
        return fresh; // first run: the backlog; every run after: only the delta
      }
      

The first run per app is the baseline, so cap it (200 is plenty). Every later run returns only the delta, which is what makes a daily digest readable.

Part 3: don't believe an empty answer

"No new reviews" and "the store wouldn't serve the feed" look identical unless you check. Apple's public feed periodically answers 200 OK with zero reviews for every app, which is rate limiting wearing a valid response. Recording that as "nothing new" costs you nothing in data, since the IDs were never emitted. It costs you the truth: the monitor now believes it looked when it didn't. The cheap guard is to cross-check the app's public rating count: an app with 4,000 ratings and an empty feed is a store problem, not a quiet week. Log three different empties, and never collapse them into one:

  • this app has no reviews yet
  • this app ID doesn't exist in this storefront
  • the store declined right now, retry later

Never tell yourself "no new reviews" when the truth is "we could not look".

Part 4: deliver to a webhook, not a dashboard

A monitor you have to open is a dashboard, and dashboards don't get opened. Post one JSON summary per run to a webhook and let Slack, Telegram, Zapier or n8n do the rest:

{
        "runAt": "2026-09-24T08:00:00.000Z",
        "totals": { "appsChecked": 5, "newReviews": 3, "errors": 0 },
        "apps": [
          {
            "store": "google-play",
            "appId": "com.example.app",
            "newCount": 2,
            "avgRating": 3.4,
            "lowestReviews": [{ "rating": 1, "text": "Crashes on launch", "url": "https://play.google.com/store/apps/details?id=com.example.app" }]
          }
        ]
      }
      

Filter before you post. Alerting only on ratings of 2 and below means every message is worth reading, and a webhook that's down should be logged and ignored, never allowed to fail a run.

Running it daily with no server

The scheduler is the only part that has to live somewhere other than your laptop, and a GitHub Actions cron is free. A workflow on a daily schedule, google-play-scraper plus the Apple feed, and a committed seen.json as the ID set is about a hundred lines. Commit the state file back at the end of the run and your memory survives the ephemeral runner, which is the only trick in it:

on:
        schedule:
          - cron: "0 8 * * *"
        workflow_dispatch:
      jobs:
        check:
          runs-on: ubuntu-latest
          steps:
            - uses: actions/checkout@v4
            - run: node monitor.js
            - run: |
                git config user.name github-actions
                git commit -am "reviews: update seen set" || exit 0
                git push
      

I also packaged the four parts as an Apify actor, App Store and Google Play Review Monitor, if you'd rather not own the cron. Same four parts, same empty-feed guard, pay-per-event, a couple of dollars a month at five apps.

The takeaway

The hard part of a review monitor is not fetching reviews. It's remembering what you already saw and refusing to trust an empty page. Build the memory around review IDs, treat "no reviews" as a claim to verify, and push the result somewhere you already look. Then the monitor keeps working on the day you stop thinking about it, which is the only day that matters.


I write these from real work at astraedus.dev, where I build apps and tools. Building something, or stuck on something like this? Reach me at astraedus.dev or [email protected].

Get the next one in your inbox → subscribe at astraedus.dev.