# App Review Delta Monitor — Google Play + App Store New Reviews (`kaz_kakyo/app-review-delta`) Actor

Get only NEW Google Play + Apple App Store reviews since your last run — stateful delta monitor with app-version regression flags. Both stores in one scheduled actor. No reviewer PII. Keyless.

- **URL**: https://apify.com/kaz\_kakyo/app-review-delta.md
- **Developed by:** [Heim AI](https://apify.com/kaz_kakyo) (community)
- **Categories:** Automation, Agents, Developer tools
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $2.00 / 1,000 new review delivereds

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/platform/actors/running/actors-in-store#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

## App Review Delta Monitor — Google Play + App Store

**Scheduled delta monitor for app reviews.** One actor covers Google Play and the Apple App Store. Each run emits **only reviews that are new since the last run** for a given `monitorId`, plus an optional uncharged per-app version-regression summary. No reviewer names, handles, or profile URLs — ever.

| | |
|---|---|
| **Actor id** | `kaz_kakyo/app-review-delta` |
| **Minimal input** | `{ "apps": ["com.whatsapp", "310633997"], "countries": ["us"] }` |
| **Cost** | **$0.005** start + **$0.002**/app×country delta poll + **$0.002**/new review · baseline free |
| **Output** | Dataset rows: `review` (charged), `baseline` / `version_summary` / `source_degraded` / `error` (uncharged) |

### Why delta beats re-pulling

Full-pull review scrapers re-download the same recent pages every hour and bill you for volume you already have. This actor keeps a per-`monitorId` snapshot of seen review ids and only delivers **new** ones.

**Cost math (list prices above):** 4 app×country pairs polled hourly, ~5 new reviews per pair per day:

- Delta: 24 × ($0.005 start + 4 × $0.002 polls) = $0.31/day, plus 4 × 5 × $0.002 = $0.04/day in new reviews → **~$0.35/day ≈ $10.60/month**
- Re-pulling 200 recent reviews per pair every hour at typical per-review prices re-bills you for the same reviews hundreds of times for the same signal

First run is a free **baseline** (seeds the snapshot, 0 charged reviews). Prefill input succeeds keyless as-is.

### Quickstart (Console)

1. Open the actor → Input. Prefill already has WhatsApp on both stores (`com.whatsapp` on Play, `310633997` on Apple) for `us`.
2. Run once → expect two `type:"baseline"` rows, 0 charged reviews, status **SUCCEEDED**.
3. Schedule it (see below) with a stable `monitorId`. Next runs emit only new `type:"review"` rows.

### Input contract

| Field | Type | Default | Notes |
|---|---|---|---|
| `apps` | stringList | (prefill only) | Play package ids, Apple numeric/`id…` ids, or store URLs. Auto-detect. Schema max 100; runtime hard-cap 200. |
| `countries` | stringList | `["us"]` | Allowlisted 2-letter codes (Apple storefront table — enforced for **both** stores). Max 20. |
| `monitorId` | string | `""` → `default` | Snapshot + lease scope. **One schedule / one run per monitorId.** Truncated to 120 chars. |
| `maxNewReviewsPerAppCountry` | integer | `200` (1–500) | Newest-first; overflow dropped permanently. |
| `includeVersionSummary` | boolean | `true` | Uncharged `version_summary` when ≥1 new review. |

Missing/empty `apps` → one uncharged `error` row, run **SUCCEEDS**. Dedupe pairs; hard cap **30** app×country pairs/run (excess → uncharged skip errors). Unsupported country codes → uncharged error per pair (Play included).

### Output row types

#### `review` (charged as `new-review`)

```json
{
  "type": "review",
  "store": "play",
  "appId": "com.whatsapp",
  "country": "us",
  "monitorId": "default",
  "reviewId": "…",
  "rating": 1,
  "title": null,
  "text": "…",
  "appVersion": "2.26.27.85",
  "reviewedAt": "2026-08-01T12:00:00.000Z",
  "detectedAt": "2026-08-01T12:05:00.000Z",
  "isEdited": null
}
```

Play: `title`/`isEdited` are `null`. Apple: `appVersion` is `null` (endpoint has no version field). **PII guard:** no author name, username, profile URL, or avatar in any row type.

#### `baseline` (uncharged)

Emitted once per app×country when no **v2** snapshot entry exists. Seeds `seen: [{i,d},…]` + `floorDate`; no `poll` / `new-review` charges. Legacy v1 snapshots re-baseline cleanly.

#### `version_summary` (uncharged)

Per pair with ≥1 delivered new review (when enabled):

```json
{
  "type": "version_summary",
  "store": "play",
  "appId": "com.whatsapp",
  "country": "us",
  "monitorId": "default",
  "newReviewCount": 12,
  "overallAvgRating": 3.4,
  "groups": [{"appVersion": "2.26.27.85", "count": 8, "avgRating": 2.1}],
  "regressionSuspect": true,
  "regressionVersions": ["2.26.27.85"],
  "versionDataAvailable": true,
  "deliveryConfirmed": true,
  "detectedAt": "…"
}
```

**Regression heuristic:** any version group with `count ≥ 5` AND `avgRating ≤ overallAvgRating − 1.0`. Apple pairs set `versionDataAvailable: false` and group under `"unknown"`.

`deliveryConfirmed: false` means the review push for that batch errored, so the summary covers the reviews the poll *attempted* — some may be missing from this dataset and arrive on a later run. The summary is emitted either way rather than lost.

#### `source_degraded` / `error` (uncharged)

Fetch/parse failures and bad input. Snapshot for that pair is **not** advanced. Run still **SUCCEEDS**.

### Billing

| Event | Price | When |
|---|---|---|
| `apify-actor-start` | $0.005 | Once per run |
| `poll` | $0.002 | Each completed **delta** app×country check (baseline free; empty/stale feeds are free) |
| `new-review` | $0.002 | Each delivered new review |

**Worked example:** 3 delta pairs, 7 new reviews → $0.005 + 3×$0.002 + 7×$0.002 = **$0.025**.

Charge order per delta pair (fail-closed): pending-marker recovery from any interrupted previous run → fetch → empty/stale/floor filters → lease re-check → charge `poll` → **pre-clamp** to remaining `new-review` budget (budget 0 → uncharged skip, snapshot untouched; excess clamped newest-first and re-found next run) → **persist-before-push** (all clamped ids durably recorded as seen together with a `pending {items, datasetId, runId, preCount}` marker; if this persist fails, the reviews are withheld uncharged and retried next run) → `pushData(…, 'new-review')` → confirm (marker stripped, only delivered ids kept). If `pushData` throws, the outcome is ambiguous and the actor does **no same-run shrink** (same-run item counts can be stale): the marker stays and the **next run** settles it. The `apify-actor-start` charge is fail-closed too: if it cannot be confirmed on a PPE run, the run ends uncharged before any polling. Never save-then-charge in the loss direction: no review is ever charged before its id is durable. Off-PPE (local / events not registered) delivers without charging.

**Interrupted-run recovery (what a marker costs you: nothing).** A marker is settled by the next run before anything else, and only two outcomes exist:

| Situation | What happens | Billing |
|---|---|---|
| The previous run is finished and its whole dataset can be read | Every marked id is checked against that dataset **in full** — no recorded offset or window is trusted. Ids found there were written, so they were charged and stay delivered. | Nothing re-charged |
| Ids are absent from that complete scan | Proven never written, therefore never charged → re-delivered on this run | Charged once, on the re-delivery |
| The outcome cannot be proven (dataset unreadable/expired, previous run still alive, scan too large) | Ids are re-delivered anyway and flagged in an uncharged `source_degraded` (`recovery_unverifiable`) row. They may duplicate a row from the interrupted run's dataset. | **Free** — never charged again |

Because a review is only ever charged when it is written (charge-on-write), anything you were billed for is already in a dataset — a lost obligation can only ever be a review you were never charged for. Obligations are re-delivered from the store's live feed, so a review that scrolls out of reach before the next successful poll is not recoverable. Polling stops at the first page that contains nothing new, so in practice an owed review stays reachable while fewer than ~100 newer reviews have arrived since — poll often enough for that to hold.

Ambiguity always costs us the revenue, never you the data: no review is ever silently dropped and no review is ever billed twice.

### Schedule recipe

Create an Apify Schedule that runs this actor hourly with a dedicated `monitorId` (`POST https://api.apify.com/v2/schedules`):

```json
{
  "name": "app-reviews-hourly",
  "cronExpression": "0 * * * *",
  "isEnabled": true,
  "actions": [{
    "type": "RUN_ACTOR",
    "actorId": "<actorId of kaz_kakyo/app-review-delta>",
    "runInput": {
      "contentType": "application/json",
      "body": "{\"apps\":[\"com.whatsapp\",\"310633997\"],\"countries\":[\"us\"],\"monitorId\":\"app-reviews-hourly\"}"
    }
  }]
}
```

### API / MCP

```bash
curl -X POST "https://api.apify.com/v2/acts/kaz_kakyo~app-review-delta/runs?token=$APIFY_TOKEN" \
  -H "content-type: application/json" \
  -d '{"apps":["com.whatsapp","310633997"],"countries":["us"],"monitorId":"api-test"}'
```

```json
{
  "actor": "kaz_kakyo/app-review-delta",
  "input": {
    "apps": ["com.whatsapp", "310633997"],
    "countries": ["us"],
    "monitorId": "mcp-watch"
  }
}
```

### State semantics

- Named KV store `app-review-delta-state`, key `snapshot-{monitorId}` (schema **v2**). One record holds every app×country pair that `monitorId` has polled, so the actor keeps the **40 most recently polled** pairs (never pruning one that still owes a delivery) to stay inside the platform's record-size limit. If you rotate apps under one `monitorId`, a returning pair simply re-baselines — free, and it delivers nothing that run.
- Per-pair entry: `seen: [{i: reviewId, d: reviewedAt}, …]` newest-first (cap 1500), `newestDate`, `floorDate` (= `d` of the oldest retained element). The cap trim is **tie-safe**: an entry is never evicted while another entry with the same timestamp is retained (whole tied groups are evicted together), so an evicted id can never tie with `floorDate` and slip past the floor filter. In the degenerate case where >1500 entries share one timestamp, the floor becomes exclusive (`floorExclusive`) — ties at the floor are suppressed rather than risk re-billing.
- **Baseline** (no v2 entry): fetch newest up to cap, seed `seen` + `floorDate`, emit `baseline`, **0 charges**. Legacy v1 `seenIds` is ignored → clean re-baseline. A failed baseline persist just re-baselines (free) next run.
- **Delta**: paginate newest-first for ids not in `seen`; deliver only if also `reviewedAt >= floorDate` (unseen-but-older-than-floor are skipped — they predate id coverage and would risk re-billing evicted ids). Stop on all-seen page, no continuation, cap, or hard page limit (Play: 5×100; Apple: ≤10 pages × 100).
- Persist **per pair**, ids-before-charge: persist-before-push with a `pending` marker, then a confirm persist once the delivered set is known (re-get → merge one key → set, 3 retries). An interrupted run leaves the marker; the next run settles it before doing anything else (table above). If that repair cannot be persisted, the pair is **skipped uncharged** for the whole run rather than continuing on unsettled state.
- Every id a recovery releases becomes a persisted **obligation** (`owed`, cap 500 per app-country) that outlives the run that released it: it bypasses the floor filter until it is actually delivered, and ids released by an *unverifiable* recovery are additionally marked free so their re-delivery is never charged. If the backlog ever exceeds the cap, the oldest entries — long gone from the store's feed — are dropped with an uncharged `owed_backlog_dropped` row that is written before the drop is saved, so the loss can never go unreported.
- **Lease:** `lock-{monitorId}` (15 min TTL) blocks a second concurrent run sharing the same monitor — soft-skip with an uncharged error, **fail-closed** (an unverifiable lease also skips uncharged) and re-checked before each pair's charges. Apify KV has no compare-and-swap, so a sub-second concurrent-acquire window remains — still run **one schedule per monitorId**.
- `maxNewReviewsPerAppCountry` is **lossy**; edited reviews (same id) are **not** re-emitted.
- **Empty-feed:** zero raw reviews on a delta poll → uncharged `source_degraded` (`empty_feed`), no poll charge.
- **Stale-feed guard:** if the feed's newest review is >30 min older than what this monitor has already seen, the poll is skipped **uncharged** (`stale_feed`) — Play replicas can lag >24h.
- **Deadline:** when `ACTOR_TIMEOUT_AT` is set, pairs that would start within 60s of timeout are skipped with one aggregated uncharged error; the deadline is also enforced *inside* HTTP retries and pagination (in-flight pairs degrade or deliver partially instead of overrunning). The run still SUCCEEDS.
- A review is delivered when it first becomes **visible** to the monitor; `reviewedAt` can be older than the poll interval (store indexing/moderation lag).

### Honest limits

- **Google Play reviews are language-scoped, not country-scoped**: the feed is filtered by language (English), and the `country` field on Play rows is the *requested* storefront country. The same Play review can appear under multiple countries you monitor (each delivery is billed). For Play, one country per app is usually enough; Apple storefronts are genuinely country-separated.
- Apple MZStore `userReviewsRow` has **no per-review app version** → regression grouping is Play-only (`versionDataAvailable: false` on Apple).
- Play batchexecute is an **internal/undocumented** RPC — shape drift soft-degrades (`source_degraded`), never fails the run.
- Review depth per poll is bounded (page caps above); older unseen reviews beyond the cap / below `floorDate` are dropped.
- Customer Reviews RSS is **dead** — this actor does not use it.

# Actor input Schema

## `apps` (type: `array`):

One entry per app. Accepts Google Play package ids (`com.whatsapp`), Apple numeric ids (`310633997` or `id310633997`), or full store URLs (`play.google.com/store/apps/details?id=…`, `apps.apple.com/…/id…`). Auto-detected: pure digits (after stripping an `id` prefix) → Apple; contains a dot → Play; URLs are parsed. Anything else (including entries over 512 chars) becomes an uncharged error row. Each app is checked in every `countries` code (app×country pairs). Cap: 30 pairs per run; overflow is reported in one aggregated error row.

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

ISO 2-letter lowercase country codes from the Apple storefront allowlist (also enforced for Play). Each app is polled in each country. Unknown codes are reported once in a single aggregated uncharged error row (never crash the run).

## `monitorId` (type: `string`):

Names the persistent snapshot this schedule diffs against. Run **one schedule per monitorId** — a fail-closed KV lease blocks concurrent runs sharing the same id (double-billing guard); if the lease cannot be verified, the run skips uncharged. Empty → `default`. Truncated to 120 chars, then sanitized to `[a-zA-Z0-9._-]`.

## `maxNewReviewsPerAppCountry` (type: `integer`):

Newest-first cap of new reviews delivered per app×country pair on a delta run (and of ids seeded on baseline). **Lossy:** overflow beyond this cap is dropped permanently and never re-emitted. Baseline seeds up to this many ids into the snapshot.

## `includeVersionSummary` (type: `boolean`):

When true, emit one uncharged `version_summary` row per app×country pair that delivered ≥1 new review. Groups by `appVersion` (Play) or `unknown` (Apple — no per-review version on the endpoint). Flags `regressionSuspect` when any version group has count ≥ 5 and avgRating ≤ overallAvg − 1.0.

## Actor input object example

```json
{
  "apps": [
    "com.whatsapp",
    "310633997"
  ],
  "countries": [
    "us"
  ],
  "monitorId": "",
  "maxNewReviewsPerAppCountry": 200,
  "includeVersionSummary": true
}
```

# Actor output Schema

## `reviews` (type: `string`):

No description

# 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 = {
    "apps": [
        "com.whatsapp",
        "310633997"
    ],
    "countries": [
        "us"
    ]
};

// Run the Actor and wait for it to finish
const run = await client.actor("kaz_kakyo/app-review-delta").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 = {
    "apps": [
        "com.whatsapp",
        "310633997",
    ],
    "countries": ["us"],
}

# Run the Actor and wait for it to finish
run = client.actor("kaz_kakyo/app-review-delta").call(run_input=run_input)

# Fetch and print Actor results from the run's dataset (if there are any)
print("💾 Check your data here: https://console.apify.com/storage/datasets/" + run["defaultDatasetId"])
for item in client.dataset(run["defaultDatasetId"]).iterate_items():
    print(item)

# 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/python/docs/quick-start

```

## CLI example

```bash
echo '{
  "apps": [
    "com.whatsapp",
    "310633997"
  ],
  "countries": [
    "us"
  ]
}' |
apify call kaz_kakyo/app-review-delta --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "command": "npx",
            "args": [
                "mcp-remote",
                "https://mcp.apify.com/?tools=kaz_kakyo/app-review-delta",
                "--header",
                "Authorization: Bearer <YOUR_API_TOKEN>"
            ]
        }
    }
}

```

## OpenAPI specification

Download the OpenAPI definition: https://api.apify.com/v2/actors/G0UAnCSKq9SuG7qrk/builds/vvuAVSrhz1VL1wpX8/openapi.json
