# App Store App Intel (`zinin/appstore-app-intel`) Actor

Get a public intel card for any iOS App Store app by numeric id, slug+id, or Store URL: rating, review count, price, publisher, seller, size, age rating, in-app purchases, compatibility and the latest release notes. No login, no Apple API key.

- **URL**: https://apify.com/zinin/appstore-app-intel.md
- **Developed by:** [Tim Zinin](https://apify.com/zinin) (community)
- **Categories:** E-commerce, Developer tools
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $1.70 / 1,000 app record delivereds

This Actor is paid per event. You are not charged for the Apify platform usage, but only a fixed price for specific events.
Since this Actor supports Apify Store discounts, the price gets lower the higher subscription plan you have.

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 Store App Intel: The Public App-Detail Card, With the Right JSON Block, Every Time

![How this Actor turns your input into a decision](https://raw.githubusercontent.com/TimmyZinin/apify-actor-assets/e1ce54c6c199c6d374dde18443e244118994a66b/hub10/appstore-app-intel/readme-hero.webp)
Every iOS App Store app-detail page carries **two** `<script type="application/ld+json">` blocks. The
first is typed `Organization` and has no rating at all. The second is typed `SoftwareApplication` and
carries the rating, the review count, the price and the publisher. A scraper that reads "the first
`ld+json` block on the page" — a completely reasonable-looking implementation choice — returns an empty
rating at **HTTP 200**. Not an error. Not a timeout. A confident, success-shaped response with the wrong
data in it, which is worse than an honest failure because nothing about the response tells you it's
wrong. This Actor selects the block it needs by its declared `@type`, scanning every `ld+json` block on
the page and matching on type, never on position — verified live, and re-verified while writing this
page, against the exact page shape that makes a naive implementation fail.

Give this Actor a numeric App Store id, a slug+id, or a full Store URL, and get back one JSON row per
app: rating, review count, price, publisher, seller, size, age rating, in-app purchases, iOS
compatibility, and the latest release notes with their version number and date. No login, no Apple API
key, no headless browser, no anti-bot bypass.

The billable outcome is a **delivered app**: an id that resolved to a live app-detail page and was
parsed with confidence. An id Apple's own App Store confirms does not exist — a genuine HTTP 404 — comes
back as a free row. A blocked, timed-out, or unparseable request also comes back free, with a named
reason. You pay for apps actually delivered, never for a lookup that failed or an app that was never
there.

### What you get

- **The rating and review count from the RIGHT structured-data block, proven, not assumed.** See the
  CRITICAL entry under Evidence and boundaries for the exact live page (WhatsApp, id `310633997`) that
  demonstrates the trap, and this Actor's own `evidence` field, which states outright which of the two
  blocks supplied the data (`"block 2 of 2"`) — so the fact this Actor read the right block isn't a claim
  you have to trust blind, it's printed on every single row.
- **Eight fields Apple keeps OUTSIDE that structured data entirely.** Seller, size, age rating, in-app
  purchases and iOS/iPadOS/macOS/watchOS compatibility live in the page's plain HTML "Information" panel
  — not in any `ld+json` block. Release notes, version number, and release date live in a separate,
  genuinely server-rendered "Version History" panel. A parser that only reads JSON-LD keys reports every
  one of these eight fields as "missing" on a page where all eight are sitting in plain HTML at HTTP 200.
- **The apostrophe landmine, closed.** The page spells "What's New" with a Unicode right single quote
  (`’`, U+2019) — not the ASCII apostrophe (`'`, U+0027) a hand-typed label constant would naturally use.
  A label match built against the ASCII character silently fails to find a real, present field. This
  Actor's HTML-field engine normalizes both the page text and every label constant before comparing, so
  this specific landmine — and any future one shaped the same way — is closed for every field, not
  patched around for one string.
- **Two bonus fields Apple's own page includes for free**: `version` and `releaseDate`, read directly
  from the same "Version History" panel that supplies `whatsNew`. Neither was in this Actor's original
  field list — found live while this Actor was built, on two separate apps, and kept because the source
  is reliable and the fields are genuinely useful for a change-tracking watch.
- **Flexible input, one authoritative identifier.** A numeric id (`310633997`), a slug+id together
  (`whatsapp-messenger/id310633997`), or a full Store URL all work — the numeric id is always what
  actually gets used, because Apple's own server redirects any slug paired with a correct numeric id to
  the canonical one (a real, live-observed 301, not an assumption), so this Actor never has to trust or
  validate slug text at all.
- **Honest not-found, never a guess.** A nonexistent numeric id gets Apple's own HTTP 404 — verified
  live, re-verified while writing this page — returned as `status: "not_found"`, at no charge.
- **Built for watchlists.** Up to 100 ids per run, concurrency configurable 1–20, so a dozen competitor
  apps for rating and release-note drift is one scheduled run.
- Runs on Apify: trigger from the Console, a scheduled Task, the Apify API, the Apify MCP server, or any
  HTTP client — export as JSON, CSV, Excel, or push results onward via webhook or integration.

### Who uses it

- **Competitive and product intelligence teams** watching a set of competitor iOS apps for rating
  movement, a price change, or a new release — `version` and `releaseDate` turning over is a direct,
  unambiguous signal that a competitor just shipped, days before that shows up anywhere else.
- **BD, ASO and app-marketing teams** who need the exact fields an App Store shopper actually sees —
  seller, size, age rating, in-app-purchase pricing, iOS-version compatibility — in one structured row
  instead of read manually off the page for each app in a portfolio.
- **Investor and analyst research** building a lightweight app-portfolio table where release cadence
  (`releaseDate` recency) and the latest `whatsNew` text are cheap, honest signals of whether a product
  is actively maintained versus effectively abandoned.
- **Developers and agent/workflow builders** who want a flat, three-state contract —
  `found` / `not_found` / `error` — an agent or n8n/Make node can branch on directly, without parsing
  Apple's HTML or picking the right `ld+json` block themselves.
- **Buyers of the paired `googleplay-app-intel` Actor** who want the iOS half of the same competitive set
  on the same schema and the same price, so both stores show up in one dashboard without reconciling two
  vendors' field names.

![Where this Actor sits in your workflow](https://raw.githubusercontent.com/TimmyZinin/apify-actor-assets/e1ce54c6c199c6d374dde18443e244118994a66b/hub10/appstore-app-intel/readme-workflow.webp)

### How to run

1. Click **Try for free** — the free plan needs no card.
2. Paste numeric ids, slug+id pairs, or full Store URLs into **App IDs or Store URLs**, one per line —
   `310633997`, `whatsapp-messenger/id310633997`, and
   `https://apps.apple.com/us/app/whatsapp-messenger/id310633997` are all accepted, and can be mixed
   freely in the same list.
3. Leave **Max concurrent requests** at its default of 5 unless you have a specific reason to raise it —
   see Operating guide.
4. Press **Start**. Console, a scheduled Task, the Apify API, or an MCP client all behave identically.
5. Read the Dataset for one row per app.
6. Check this run's key-value store record `OUTPUT` for a one-time run summary — requested/delivered/
   paid/free/failed/incomplete counts, replay safety and any fatal error — written once at the end of
   the run.

There is no separate review step and no captcha. A run either finishes one row per requested app, or
stops early only if the run's own charge budget runs out.

### Pricing

Pay-per-event: **$0.005 per run start, plus $0.002 per delivered app.** A delivered app is an id that
resolved and was parsed as `status: "found"`. An id that comes back `not_found` (a confirmed 404) or
`error` (a blocked or malformed request) is still written to the Dataset as a full row, with the reason
— and is never charged.

**Delivered app** is the one pricing noun used throughout this page, in the Actor's pricing record, and
in the Dataset's own terminology — ten ids where eight resolve and two come back `not_found` is billed
for eight delivered apps plus one run start, not ten.

This price is identical to the paired `googleplay-app-intel` Actor, on purpose — a buyer tracking the
same app portfolio across both stores reasons about one price, not two.

On Apify's own billing record these are metered as two separate platform events — `apify-actor-start`
for the run fee, and `result-found` for each delivered app — the same two lines you will see on your
invoice.

### Input contract

- **`appIds`** (required) — an array of strings, each a numeric App Store id (`"310633997"`), a slug+id
  pair (`"whatsapp-messenger/id310633997"`), or a full Store URL
  (`"https://apps.apple.com/us/app/whatsapp-messenger/id310633997"`). Up to 100 entries per run, all
  three forms mixable in the same list. A URL-shaped entry is checked against `apps.apple.com` as an
  exact parsed hostname (never a substring match) before anything is requested; the numeric id is
  extracted from an `id<digits>` token wherever it appears, and any slug text is discarded outright — it
  is never substituted into the fetch URL, so it needs no validation of its own, because it never reaches
  the network. Duplicate entries are de-duplicated before billing.
- **`maxConcurrency`** (optional, default `5`, range `1`–`20`) — how many apps are fetched in parallel.
  Conservative by default; see Operating guide.

Region is **not** a buyer-controlled input in this build. Every URL this Actor constructs uses the `/us/`
path — the only region verified live during this Actor's build. See Limits for exactly what that means
for a non-US listing.

```json
{
  "appIds": ["310633997"]
}
```

```json
{
  "appIds": [
    "389801252",
    "https://apps.apple.com/us/app/spotify-music-and-podcasts/id324684580",
    "553834731",
    "310633997"
  ],
  "maxConcurrency": 4
}
```

*(Both are contract illustrations built from `.actor/input_schema.json`, not run output — an input has no
result to reproduce. The second one is the exact input used for the four-app batch run whose real output,
including a real `partial: true` row, appears throughout the next section.)*

### Real happy, partial and failure output

Every row shares one flat schema regardless of outcome: eight decision-envelope fields
(`appId`, `sourceUrl`, `status`, `evidence`, `confidence`, `partial`, `checkedAt`, `error`) plus, on a
`found` row, thirteen data fields. `error` is `null` on every honestly-completed row, including
`not_found`.

Every JSON block below is copied verbatim from this Actor's own local runs (`apify run --purge`),
executed while writing this page — nothing invented, nothing backfilled from documentation.

#### Happy path — WhatsApp, id `310633997`, the trap defeated and printed in `evidence`

```json
{
    "appId": "310633997",
    "sourceUrl": "https://apps.apple.com/us/app/app/id310633997",
    "status": "found",
    "evidence": "ld+json SoftwareApplication (block 2 of 2) + html fallback (8/8)",
    "confidence": "high",
    "partial": false,
    "checkedAt": "2026-08-17T20:23:36.713Z",
    "error": null,
    "title": "WhatsApp Messenger",
    "ratingValue": 4.7,
    "reviewCount": 18424969,
    "priceText": "0 USD",
    "publisher": "WhatsApp Inc.",
    "whatsNew": "We update the app regularly to fix bugs, optimize performance and improve the experience. Thanks for using WhatsApp!",
    "seller": "WhatsApp Inc.",
    "sizeText": "392.2 MB",
    "ageRating": "13+ 13+ Learn More Infrequent Profanity or Crude Humor Mature or Suggestive Themes Horror/Fear Themes Medical Treatment information Contains Advertising Messaging and Chat User-Generated Content",
    "inAppPurchases": "Yes No ads (1 account) $4.00 No ads (4 accounts) $21.99 No ads (4 accounts) $21.99 No ads (4 accounts) $21.99 No ads (4 accounts) $21.99 No ads (4 accounts) $21.99 No ads (5 accounts) $26.99 No ads (5 accounts) $26.99 No ads (5 accounts) $26.99 No ads (5 accounts) $26.99 Learn More",
    "compatibility": "Requires iOS 15.1 or later. iPhone Requires iOS 15.1 or later. iPad Requires iPadOS 15.1 or later. iPod touch Requires iOS 15.1 or later. Mac Requires macOS 12.1 or later. Apple Watch Requires watchOS 10.0 or later.",
    "version": "26.32.73",
    "releaseDate": "2026-08-17"
}
```

Notice `evidence` says `"block 2 of 2"` explicitly, on every single row this Actor delivers — not a
README claim you have to trust, a fact printed on the row itself, every time. `ageRating`,
`inAppPurchases` and `compatibility` are Apple's own raw display text, exactly as rendered — verbose
because Apple's own page is verbose; this Actor does not summarize or reformat it.

#### Partial path, real — Spotify, id `324684580`, one HTML field genuinely absent

Run in the same batch as WhatsApp, Instagram and Candy Crush Saga. Three of the four came back
`confidence: "high"`, `partial: false`; Spotify's own live page did not carry an in-app-purchases dt/dd
pair at the moment of this check, and the row reports that honestly instead of inventing a value:

```json
{
    "appId": "324684580",
    "sourceUrl": "https://apps.apple.com/us/app/app/id324684580",
    "status": "found",
    "evidence": "ld+json SoftwareApplication (block 2 of 2) + html fallback (7/8)",
    "confidence": "medium",
    "partial": true,
    "checkedAt": "2026-08-17T20:23:36.918Z",
    "error": null,
    "title": "Spotify: Music and Podcasts",
    "ratingValue": 4.8,
    "reviewCount": 41579690,
    "priceText": "0 USD",
    "publisher": "Spotify",
    "whatsNew": "We’re always making changes and improvements to Spotify. To make sure you don’t miss a thing, just keep your Updates turned on.",
    "seller": "Spotify",
    "sizeText": "292.7 MB",
    "ageRating": "13+ 13+ This app is rated 13+ with content restrictions. Some content may be rated higher, and may therefore be restricted and managed by the developer through in-app controls. Learn More In-App Controls Age Assurance Parental Controls Infrequent Cartoon or Fantasy Violence Profanity or Crude Humor Mature or Suggestive Themes Horror/Fear Themes Alcohol, Tobacco, Drug Use or References Sexual Cont…",
    "inAppPurchases": null,
    "compatibility": "Requires iOS 16.1 or later. iPhone Requires iOS 16.1 or later. iPad Requires iPadOS 16.1 or later. Apple TV Requires tvOS 17.0 or later. Apple Watch Requires watchOS 10.0 or later.",
    "version": "9.1.72",
    "releaseDate": "2026-08-10"
}
```

Two things worth reading closely here, both real, neither edited for tidiness: `inAppPurchases` is
`null`, not an empty string and not omitted — this Actor never substitutes a placeholder for a field it
genuinely could not find. And `ageRating` ends in `…` — the field's own 400-character cap was hit on
Apple's verbose age-rating text and the value was truncated with an ellipsis rather than silently cut
with no indication; see Limits for the exact cap on every field.

#### Not-found path — a real, confirmed 404

```json
{
    "appId": "999999999999999",
    "sourceUrl": "https://apps.apple.com/us/app/app/id999999999999999",
    "status": "not_found",
    "evidence": "http 404 (App Store's own \"Not Found\" page)",
    "confidence": "high",
    "partial": false,
    "checkedAt": "2026-08-17T20:23:08.327Z",
    "error": null
}
```

A genuine HTTP 404 with Apple's own "Not Found" page as the body — not a redirect to a genre page, not
an HTTP 200 with an empty `ld+json`, either of which SPEC work for this Actor flagged as plausible before
this exact case was checked live. Confirmed, re-confirmed while writing this page.

#### Failure path — the SSRF guard, a real blocked request

```json
{
    "appId": null,
    "sourceUrl": null,
    "status": "error",
    "evidence": "input validation failed before any request was made",
    "confidence": "low",
    "partial": false,
    "checkedAt": "2026-08-17T20:24:25.022Z",
    "error": "blocked host \"evil.example\" — only apps.apple.com URLs are accepted"
}
```

Input for this run was `["https://evil.example/us/app/x/id310633997", "310633997"]`. The first entry is
rejected before any request leaves this Actor — no DNS lookup, no connection attempt — while the second,
legitimate id in the same run is processed and billed normally (`1 found (billed), 0 not found,
1 errored, of 2 total` in this run's own log). A `status: "error"` row is always free.

### Field dictionary

| Field | Meaning | Important boundary |
|---|---|---|
| `appId` | The numeric App Store id you passed, or extracted from a slug+id/URL | `null` on a row where input validation failed before an id could be extracted |
| `sourceUrl` | The exact URL requested, built from the numeric id with a placeholder slug | Apple's own server 301-redirects it to the real slug — this Actor never has to know or trust the real one |
| `status` | `found`, `not_found` (confirmed 404) or `error` | The billing boundary — only `found` is charged |
| `evidence` | What confirmed the result, including which of the two `ld+json` blocks was used | States `"block 2 of 2"` explicitly on every `found` row — this is the printed proof of the critical trap being handled, not a claim to trust blind |
| `confidence` | `high`, `medium` or `low` | `high` when the correct `ld+json` block AND all 8 HTML-fallback fields parsed; `medium` when one or more HTML fields did not; `low` on any `error` row |
| `partial` | `true` if any of the 8 HTML-fallback fields was not found | See the real Spotify example above — happens live, not hypothetical |
| `checkedAt` | ISO timestamp of this row's own check | Independent per row within a run |
| `error` | Human-readable reason | `null` on every honestly-completed row, including `not_found` |
| `title` | The app's display name | From the `SoftwareApplication` `ld+json` block |
| `ratingValue` | 0–5 star rating | From `ld+json`, the correct block, selected by `@type` |
| `reviewCount` | Number of ratings behind the score | From `ld+json`, same block |
| `priceText` | Price and currency as a single display string (e.g. `"0 USD"`) | Built from `ld+json`'s `offers.price`/`offers.priceCurrency`; `null` if either is missing |
| `publisher` | The developer/publisher name | From `ld+json`'s `author.name` |
| `whatsNew` | Latest release notes | From the page's "Version History" panel, first (most recent) entry — plain server-rendered HTML, no JavaScript execution needed |
| `seller` | The legal selling entity | From the page's plain-HTML `dt`/`dd` "Information" grid — can differ from `publisher` (see the Candy Crush example: publisher `"King"`, seller `"King.com Limited"`) |
| `sizeText` | App download size, display text | From the same HTML grid — a display string (`"392.2 MB"`), not a byte-exact number |
| `ageRating` | Full age-rating text, including content-descriptor list | From the same HTML grid — verbose by nature, capped at 400 characters with `…` truncation if longer |
| `inAppPurchases` | Full in-app-purchase price list, or `null` if the page carries none | From the same HTML grid, capped at 400 characters — genuinely `null` on some apps, not every app has one (see the real Spotify example) |
| `compatibility` | Full OS-version-requirement text across every supported Apple device | From the same HTML grid, capped at 400 characters |
| `version` | The version number behind the latest release notes | From the same "Version History" panel as `whatsNew` — bonus field, not in the original spec, kept because the source is reliable |
| `releaseDate` | The date of that version's release, as Apple's own `<time datetime>` attribute | Bonus field, same source as `version` |

### Evidence and boundaries

**The critical trap, in full, with the exact live page that proves it.** `apps.apple.com/us/app/whatsapp-
messenger/id310633997` — checked live, and re-checked while writing this page — carries exactly two
`<script type="application/ld+json">` blocks in this order: first `@type: "Organization"` (no rating
property at all), then `@type: "SoftwareApplication"` (carries `ratingValue`, `reviewCount`,
`offers`, `author`). A parser written as "find the first `ld+json` script tag and parse it" — arguably the
*more* natural first implementation, not a contrived edge case — returns `ratingValue: null` at HTTP 200.
This is the single most important design decision in this Actor: it scans **every** `ld+json` block on
the page and selects strictly by `@type === "SoftwareApplication"`, never by position. `evidence` prints
`"block 2 of 2"` on every delivered row specifically so this isn't a claim buried in a README — it's on
the data itself. Verified against two separate apps during this Actor's build (WhatsApp and Instagram)
and reproduced live again for this page.

**The eight fields Apple keeps entirely outside `ld+json`.** Seller, size, age rating, in-app purchases
and compatibility sit in a plain-HTML `<dt>`/`<dd>` "Information" grid, not in any structured-data block
— a parser reading only JSON-LD keys reports all five as "not present" on a page where all five are
sitting in plain markup at HTTP 200. Release notes, version and release date live in a separate
server-rendered "Version History" `<dialog>` — genuinely rendered into the HTML response, not injected by
client-side JavaScript, so no browser or JS execution is needed to read it.

**The apostrophe landmine, exactly.** The "What's New" label on the live page uses a Unicode right single
quote, `’` (U+2019) — not the ASCII apostrophe, `'` (U+0027), that a hand-typed constant naturally
contains. A naive label match against the ASCII string silently fails to find a real, present field —
the same class of failure as the `ld+json` position trap: confident absence at HTTP 200. This Actor's
field-extraction engine normalizes apostrophe variants on both the page text and every label constant
before comparing, closing this for every label, current and future — not patched around one string.
(As it happens, the specific `whatsNew` field is extracted via the separate "Version History" anchor, not
a label match at all — so this landmine doesn't bite that one field directly. The normalization still
runs defensively for every other label in the HTML-fallback field map, and any future one.)

**The "Version History" anchor, and why it isn't a naive string search either.** The literal text
`"Version History"` appears **four times** on the live page — once as the panel's own `<h1>`, three more
times as button-action labels inside a separate, later hydration data blob. Anchoring on the bare string
would work today, by luck, because the `<h1>` happens to come first in document order — and would silently
break the moment Apple ever reorders the page. This Actor anchors on the full closing tag,
`Version History</h1>`, which appears exactly once and does not depend on document order at all. The
release-notes `<ul>` that follows is read as newest-first, and only the very first `<li>` entry is parsed
— so an older release's notes can never be mistaken for the current one.

**Seller versus publisher, a real, observed difference — not a display quirk.** `publisher` (from
`ld+json`) and `seller` (from plain HTML) are not always the same string. WhatsApp: `"WhatsApp Inc."` for
both. Candy Crush Saga: `publisher: "King"`, `seller: "King.com Limited"` — both correct, both Apple's own
text, from two genuinely different fields on the same page. Neither field is dropped or reconciled to
match the other; both are delivered as Apple published them.

**The SSRF/DNS-rebinding guard.** URL input is checked against `apps.apple.com` as an exact parsed
hostname, never a substring. Every outbound request additionally resolves DNS itself, judges every
returned address against private/reserved ranges (loopback, link-local, RFC 1918, CGNAT, cloud-metadata,
and IPv6 equivalents), and pins the connection to exactly the addresses just checked — closing the window
where a later, separate DNS lookup could answer differently. The live example under Real happy, partial
and failure output shows this rejecting a request to `evil.example` mid-run, before any network call.

**The numeric-id redirect, verified live, not assumed.** This Actor always builds its request URL from a
fixed template with a placeholder slug — `/us/app/app/id<numeric id>` — and lets Apple's own server
redirect it to the real slug. This was checked live, not assumed: `.../app/app/id310633997` returns a
genuine `301` to `.../app/whatsapp-messenger/id310633997`, and this Actor's fetch layer follows that
redirect, re-verifying the new host on every hop, up to 5 hops. This is why a slug is never trusted or
validated as authoritative input — Apple's own numeric id always wins, proven by Apple's own redirect
behavior, not by industry convention alone.

**The byte cap and retry policy.** Every response is capped at 3,000,000 bytes streamed, comfortably
above the roughly 816KB this Actor's own pages measured live, and flagged `truncated` (never silently
parsed as complete) if the cap is hit. Retries (up to two, exponential backoff) apply only to
network-level failures, HTTP 429 and HTTP 5xx — never to a 404, which is the source's own confirmed
answer, and never to an anomalous 200 whose expected `ld+json` block is missing, which is reported
honestly rather than retried blind.

### Decision routing

| Row shape | Billable? | `status` | `confidence` | What it means |
|---|---|---|---|---|
| Full parse | **Yes** | `found` | `high` | Correct `ld+json` block AND all 8 HTML-fallback fields parsed |
| Partial parse | **Yes** | `found` | `medium` | Correct `ld+json` block parsed; one or more HTML-fallback fields did not — `partial: true`, real live example above |
| Confirmed missing | No | `not_found` | `high` | Apple's own HTTP 404 — a genuine confirmed negative |
| Input rejected | No | `error` | `low` | SSRF/format validation failed before any request left this Actor |
| Fetch failed | No | `error` | `low` | Timeout, DNS failure, non-404 HTTP error, or byte-cap truncation |
| Anomalous 200 | No | `error` | `low` | HTTP 200 but no `SoftwareApplication` block found — the exact class of anomaly the `@type` selection rule exists to make loud instead of silent |
| Budget exhausted mid-run | No | — | — | The run's charge cap was reached; remaining ids are neither processed nor charged, reported as a free explanatory row |

A workflow needs two rules: act on `status === "found"`, and treat `partial === true` as "usable, but
check which specific field is `null` in `evidence`/the row itself before trusting it complete" — as the
real Spotify row above shows, `inAppPurchases: null` on a `partial: true` row is a genuine "not present,"
not a bug.

### Commercial playbooks

#### Competitor release-cadence tracking

A product team lists direct competitors and schedules a weekly run, watching `version` and `releaseDate`
turn over — a shipped release is an unambiguous signal, days ahead of a changelog blog post or a
press-cycle mention, and `whatsNew` gives the actual release-notes text for free in the same row.

#### Pricing and monetization-model watch

A BD or pricing team tracks `priceText` and `inAppPurchases` across a competitive set — a competitor
adding a new IAP tier, or a price change on a previously-free app, shows up as a diffable field, not a
manual App Store visit.

#### Portfolio maintenance-recency signal for diligence

An analyst uses `releaseDate` recency the same way `googleplay-app-intel`'s `updatedOn` is used on
Android — a cheap, honest, non-inferential signal of whether a product is actively maintained.

#### Cross-store competitive dashboard, paired with `googleplay-app-intel`

A team tracking the same portfolio on both platforms runs this Actor alongside `googleplay-app-intel` on
the same schedule. Both share the identical decision-envelope shape
(`status`/`confidence`/`evidence`/`partial`) and the same price, so one dashboard query unions both
Actors' output without reconciling two vendors' schemas.

### Integration recipes

#### Scheduled release watch

An Apify Task runs this Actor weekly against a fixed competitor list, reading the Dataset for
`status: "found"` rows and alerting when `version` or `releaseDate` differs from the previous run's
value — this Actor is stateless per run, so change-detection lives in the calling workflow.

#### Agent/MCP pattern

An agent supplies a numeric id, slug+id, or Store URL and reads `status` before trusting any other field
— `found` means the row is real data (check `partial` for completeness), `not_found` means the app
genuinely does not exist right now, `error` means retry later or surface the `error` string.

#### Data-warehouse append pattern

Rows are appended keyed by `appId` plus `checkedAt`; `null` is preserved as `null` — an app genuinely
without in-app purchases (like the real Spotify row above) is distinguishable from a field this Actor
simply failed to extract, because both cases produce the identical `null`, but `partial`/`confidence`
tell you which one you're looking at.

### Operating guide

**Why `maxConcurrency` defaults to 5, not 20.** The Gate B check that proved this source reachable was
one request per app, on two independent network legs — not a sustained burst. Sustained high-concurrency
load has not been load-tested by this Actor's own build process; raise concurrency gradually on a large
watchlist rather than assuming the schema's ceiling of 20 is safe from the first run.

**Reading a `medium`-confidence row.** It means the correct `ld+json` block parsed cleanly (so `title`,
`ratingValue`, `reviewCount`, `priceText`, `publisher` are all trustworthy) but at least one of the eight
HTML-fallback fields did not. This happens live — not a hypothetical edge case, see the real Spotify
example. Check `evidence`'s fraction (`"(7/8)"` vs `"(8/8)"`) and treat the specific `null` field as
genuinely absent on the source, not a bug to retry away.

**Why long fields get truncated with `…`, and what that means for you.** `ageRating`, `inAppPurchases`
and `compatibility` are capped at 400 characters; `seller` at 200; `sizeText` at 60; `whatsNew` and
`version` are not separately capped beyond the general 500-character HTML-cleaning default. Apple's own
`ageRating` and `inAppPurchases` text is genuinely long on some apps (see the real Spotify row, which
truncates mid-sentence) — the cap exists to keep one anomalously long field from destabilizing a row, and
the `…` marker tells you truncation happened rather than hiding it.

**Batch sizing.** Up to 100 ids per run, enforced both in the input schema and independently in the
Actor's own code. A larger watchlist needs more than one run.

**Why the pair shares one price and one envelope, on purpose.** `googleplay-app-intel` and this Actor
were built together, from the same underlying library, specifically so a buyer tracking a cross-platform
app portfolio reasons about one schema and one price, not two.

#### Related tools

Related tools for adjacent workflows in e-commerce.

| Actor | What it does |
|---|---|
| [Google Play App Intel](https://apify.com/zinin/googleplay-app-intel) | Pair it in the e-commerce workflow: Get a public intel card for any Google Play app by package id or Store URL: rating, category, description,... |
| [Shopify Catalog & Merchandising Intelligence](https://apify.com/zinin/shopify-store-intelligence) | Pair it in the e-commerce workflow: Qualify Shopify merchant leads with evidence-backed public catalog coverage, observed price positioning,... |
| [Zid Product Export Intelligence](https://apify.com/zinin/zid-store-products) | Pair it in the e-commerce workflow: Turn merchant-owned, merchant-authorized, or otherwise licensed Zid product exports into stable evidence,... |
| [Shopify Store Price & Catalog Change Monitor](https://apify.com/zinin/shopify-price-change-monitor) | Pair it in the e-commerce workflow: Monitor store-level price range, catalog size, currency, Shopify detection, and heuristic revenue-band... |
| [Chotot Vietnam Listings Scraper](https://apify.com/zinin/chotot-listings) | Pair it in the e-commerce workflow: Normalize authorized or licensed classified listings into provenance-linked review rows with stable... |

### FAQ

#### Does this need an Apple ID or an API key?

No. It reads the same public detail page anyone sees in a browser, unauthenticated.

#### Can I get reviews or review text?

No, permanently. Both `itunes.apple.com/robots.txt` (`Disallow: /*/rss/*`, `Disallow: /*/lookup?`) and
`apps.apple.com/robots.txt` (`Disallow: */search?*`, covering the search-driven discovery path) forbid
the relevant paths — this Actor never touches either host's disallowed paths, on this account or any
other.

#### Can I search by keyword?

No, same reason. `apps.apple.com/robots.txt` explicitly disallows `*/search?*`. This Actor only ever
reads a specific app's detail page by id.

#### Why do I sometimes see `publisher` and `seller` as different strings?

Because they genuinely are different fields on Apple's own page, sourced from two different places —
`publisher` from `ld+json`, `seller` from the plain-HTML legal-entity field. Candy Crush Saga is a real
example: `publisher: "King"`, `seller: "King.com Limited"`. Neither is wrong; they answer different
questions.

#### Why is `inAppPurchases` sometimes `null`?

Because some apps genuinely don't carry an in-app-purchases entry on their page at the moment checked —
see the real Spotify row above, where `partial: true` and `confidence: "medium"` mark this honestly
rather than silently.

#### What region does this cover?

Only `/us/` — the only region verified live during this Actor's build. See Limits for what that means if
you need a different storefront's pricing or availability.

#### What happens if I pass an id that doesn't exist?

`status: "not_found"`, confirmed by Apple's own HTTP 404 — verified live, not a guess — and you are not
charged for it.

#### Can I trust the rating even though the page has two `ld+json` blocks?

Yes — that's the entire reason this Actor exists in this exact form. `evidence` states which block was
used on every row (`"block 2 of 2"`), so this isn't something you have to take on faith.

#### Can I call this from an AI agent?

Yes — a standard Apify Actor, callable from the Apify API, the Apify SDK, or the Apify MCP server, with a
flat `status` field designed to be branched on directly.

#### How reviewed is this build before release?

The `ld+json` block-selection logic, the SSRF/DNS guard, and the billing guard are shared, line for line,
with the paired `googleplay-app-intel` Actor. `acceptance.json` carries 5 goldens for this Actor
specifically, including one that fails loudly (`ratingValue: null`) if the block-selection logic ever
regresses to picking by document position instead of `@type` — the single most important regression this
build is guarded against.

### Sources and rights

Everything below is read from the public app store listing page of the app you name — the same HTML any visitor receives, fetched anonymously with no login and no API key.

**The only sources.** The public App Store app-detail page,
`https://apps.apple.com/us/app/<slug>/id<numeric id>`. No `itunes.apple.com` path is ever requested by
this Actor — that host's disallowed review/lookup paths are a completely separate host from the one this
Actor reads, and this Actor does not touch it at all.

**What was checked, stated precisely.** `apps.apple.com/robots.txt`, read live while writing this page,
confirms the app-detail path this Actor requests carries no `Disallow` rule; `/WebObjects/*`, `/api/*`,
`/includes/*`, `/v1/*` and `*/search?*` are all explicitly disallowed, and this Actor requests none of
them. `itunes.apple.com/robots.txt` separately confirms `/*/rss/*` and `/*/lookup?` are disallowed on
that host — this Actor never requests that host at all, so this is stated as context for why reviews stay
out of scope permanently, not as a rule this Actor needs to obey on its own traffic.

**What was NOT checked, stated equally precisely.** This Actor's build verified `robots.txt` crawler-
access permission for the specific path it reads. It did not conduct a legal review of Apple's Media
Services Terms and Conditions or App Store usage terms regarding automated collection or resale of
listing data, and makes no claim about what those documents do or do not permit beyond the crawler-access
question `robots.txt` answers. Title, description, seller/publisher name, and release notes are the
publisher's own text, already made public on this same page — not gated or confidential — but a buyer is
responsible for their own review of how they intend to use this Actor's output, the same as building on
any other public web page.

**No warranty of continued availability.** Apple can change this page's markup or structured-data shape
at any time without notice — the two-block `ld+json` trap and the "Version History" quirks this page
describes are true of the page as it exists today, checked live and re-checked while writing this page,
not a permanent guarantee about the future. This Actor's acceptance tests are pinned to a live snapshot
(17.08.2026) and are expected to need updating if the source's shape drifts.

### Limits

- No reviews, no review text, no review-count trend, and no keyword search — permanently, by two
  separate hosts' `robots.txt`, not a roadmap item.
- Covers only the `/us/` App Store region — other regions' pricing, availability, or listing text are
  neither measured nor promised.
- `seller`, `sizeText`, `ageRating`, `inAppPurchases`, `compatibility` are capped at 200/60/400/400/400
  characters respectively and truncated with `…` if longer — observed live on the Spotify `ageRating`
  example above, so this is a demonstrated limit, not a theoretical one.
- `inAppPurchases` can be genuinely `null` — not every app carries this field on its page (see the real
  Spotify row).
- Sustained high-concurrency load has not been load-tested against this source — `maxConcurrency`
  defaults conservatively for this reason.
- `version`/`releaseDate` are bonus fields found live during this build, not part of the original spec
  and not a documented stable Apple API — best-effort, not contractually guaranteed present forever.
- `sizeText` is Apple's own display text (`"392.2 MB"`), not a byte-exact number.
- Up to 100 ids per run; a larger watchlist needs more than one run.
- This Actor does not fetch or verify the review-count trend over time — `reviewCount` is a single live
  snapshot per run, and detecting change is a job for the calling workflow, not this Actor.

### Support boundary

Actor support covers this page's described input validation, the source-access and parsing behavior
(including the `ld+json` block-selection rule), the Dataset schema, and the billing/replay guarantees for
an accepted run. It cannot guarantee Apple's own uptime, page shape, or continued publication of any
specific field — Apple can change any of this without notice, and this Actor's acceptance tests will need
updating if it does. It cannot provide legal advice on Apple's Media Services Terms, cannot restore access
if Apple blocks this Actor's traffic pattern going forward, and cannot add reviews or keyword search —
both permanently out of scope by `robots.txt` on two separate hosts, not a missing feature. When reporting
a problem, include the Actor run ID, the exact `appIds` input used, the affected row's
`status`/`evidence`/`error` fields, and the approximate `checkedAt` time.

***

Built by [zinin](https://apify.com/zinin). Questions? Telegram [@timzinin](https://t.me/timzinin).

# Actor input Schema

## `appIds` (type: `array`):

Numeric App Store id (e.g. "310633997"), a full Store URL (e.g. "https://apps.apple.com/us/app/whatsapp-messenger/id310633997"), or slug+id together. The numeric id is the authoritative identifier — Apple itself redirects any slug to the correct one, so this Actor always builds the URL from the numeric id.

## `maxConcurrency` (type: `integer`):

How many apps to fetch in parallel. Kept conservative by default — sustained load on the App Store has not been load-tested yet.

## Actor input object example

```json
{
  "appIds": [
    "310633997"
  ],
  "maxConcurrency": 5
}
```

# Actor output Schema

## `results` (type: `string`):

API URL for the default dataset items produced by this run.

## `runSummary` (type: `string`):

One JSON object written once at the end of this run: requestedCount, deliveredRows, paidRows, freeRows, failedCount, incompleteRows, replaySafe + replaySafeReason, fatalError + fatalErrorReason, generatedAt.

# 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 = {
    "appIds": [
        "310633997"
    ]
};

// Run the Actor and wait for it to finish
const run = await client.actor("zinin/appstore-app-intel").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 = { "appIds": ["310633997"] }

# Run the Actor and wait for it to finish
run = client.actor("zinin/appstore-app-intel").call(run_input=run_input)

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

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

```

## CLI example

```bash
echo '{
  "appIds": [
    "310633997"
  ]
}' |
apify call zinin/appstore-app-intel --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,zinin/appstore-app-intel"
        }
    }
}

```

The hosted server signs you in with OAuth on first connect, so no API token belongs in this config. Clients without OAuth support can send an `Authorization: Bearer <APIFY_API_TOKEN>` header instead, using a token from API & Integrations in Apify Console (https://console.apify.com/settings/integrations).

## OpenAPI specification

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