Most App Store listing advice is a checklist: use your 30 title characters, fill the screenshot slots, localize, write real release notes. The checklist is easy. Running it against your own listing, in every storefront, next to your rivals, is the part people skip.

This post builds a small PHP command-line tool that does it. You give it your app id and a few rival ids. It pulls the public listing from Apple's iTunes Lookup API, runs about a dozen checks, prints a side-by-side table per storefront, and can write JSON for later. One file, around 100 lines, no framework, no Composer packages.

I ran everything below on PHP 8.5 CLI against the live API on November 2, 2026. The output in this post is what I got. You need PHP 8.3 or newer (for mb_str_pad) with the curl and mbstring extensions.

The data source

The Lookup API takes one or more App Store ids and returns public listing data as JSON. No key, no account:

https://itunes.apple.com/lookup?id=1111447047,963034692&country=us&entity=software 

The id is the number at the end of an app's store URL (apps.apple.com/us/app/.../id963034692). country picks the storefront. Several ids, comma separated, come back in one response, so one request per storefront is enough.

The fields this audit uses are trackName, description, releaseNotes, version, currentVersionReleaseDate, screenshotUrls, ipadScreenshotUrls, features, languageCodesISO2A, averageUserRating, userRatingCount, contentAdvisoryRating and formattedPrice.

The script

Save this as audit.php. The first id is your app, the rest are rivals.

<?php // Audit public App Store listing metadata via Apple's iTunes Lookup API. // Usage: php audit.php [--country=us,gb] [--json=out.json] YOUR_ID [RIVAL_ID ...] declare(strict_types=1); const LOOKUP = 'https://itunes.apple.com/lookup'; const UA = 'listing-audit/1.0 (+you@example.com)'; function lookup(array $ids, string $country, int $tries = 3): array { $url = LOOKUP . '?' . http_build_query(['id' => implode(',', $ids), 'country' => $country, 'entity' => 'software']); for ($i = 1; $i <= $tries; $i++) { $ch = curl_init($url); curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 20, CURLOPT_CONNECTTIMEOUT => 5, CURLOPT_USERAGENT => UA]); $body = curl_exec($ch); $code = curl_getinfo($ch, CURLINFO_RESPONSE_CODE); $err = curl_error($ch); if ($code === 200 && is_array($data = json_decode((string) $body, true))) { return array_column($data['results'] ?? [], null, 'trackId'); } fwrite(STDERR, "[$country] try $i failed: HTTP $code $err\n"); if ($code >= 400 && $code < 500 && !in_array($code, [403, 429], true)) break; // bad request: retrying won't help sleep(5 * $i); // back off: 403/429 here usually means "slow down" } throw new RuntimeException("lookup failed for $country"); } function audit(array $a, DateTimeImmutable $now): array { $title = $a['trackName']; $desc = $a['description'] ?? ''; $firstLine = trim(strtok($desc, "\n") ?: ''); $notes = trim($a['releaseNotes'] ?? ''); $days = $now->diff(new DateTimeImmutable($a['currentVersionReleaseDate']))->days; $iphone = count($a['screenshotUrls'] ?? []); $ipad = count($a['ipadScreenshotUrls'] ?? []); // supportedDevices lists iPads even for iPhone-only apps; this flag is the real signal. $universal = in_array('iosUniversal', $a['features'] ?? [], true); $langs = count($a['languageCodesISO2A'] ?? []); // Each row: label => [value shown, status]. Status: ok | warn | info. return [ 'Title' => [$title, 'info'], 'Title chars' => [sprintf('%d/30 (%d bytes)', mb_strlen($title), strlen($title)), mb_strlen($title) > 30 ? 'warn' : (mb_strlen($title) < 20 ? 'info' : 'ok')], 'Subtitle' => ['not in API', 'info'], 'Description' => [mb_strlen($desc) . '/4000 chars', mb_strlen($desc) < 1000 ? 'warn' : 'ok'], 'First line' => [mb_strimwidth($firstLine, 0, 48, '…'), $firstLine === '' ? 'warn' : 'info'], 'iPhone shots' => ["$iphone/10", $iphone < 5 ? 'warn' : 'ok'], 'iPad shots' => [$universal ? "$ipad/10" : 'n/a (iPhone-only)', $universal && $ipad < 3 ? 'warn' : 'ok'], 'Languages' => [(string) $langs, $langs < 2 ? 'warn' : 'ok'], 'Version' => [$a['version'] . " ({$days}d ago)", $days > 90 ? 'warn' : 'ok'], 'Release notes' => [mb_strlen($notes) . ' chars', $notes === '' || preg_match('/^(bug fixes|minor|performance)[^\n]{0,60}$/i', $notes) ? 'warn' : 'ok'], 'Rating' => [sprintf('%.2f (%s)', $a['averageUserRating'] ?? 0, number_format($a['userRatingCount'] ?? 0)), ($a['userRatingCount'] ?? 0) === 0 ? 'warn' : 'ok'], 'Age rating' => [$a['contentAdvisoryRating'] ?? '?', 'info'], 'Price' => [$a['formattedPrice'] ?? '?', 'info'], ]; } function printTable(string $country, array $audits): void { $mark = ['ok' => ' ', 'warn' => '!', 'info' => ' ']; echo "\n== Storefront: " . strtoupper($country) . " ==\n"; $labels = array_keys(reset($audits)); foreach ($labels as $label) { $cells = array_map(fn($rows) => $mark[$rows[$label][1]] . ' ' . $rows[$label][0], $audits); $cells = array_map(fn($c) => mb_str_pad(mb_strimwidth($c, 0, 34, '…'), 35), $cells); echo mb_str_pad($label, 15), implode('', $cells), "\n"; } } $opts = getopt('', ['country:', 'json:'], $rest); $ids = array_map('intval', array_slice($argv, $rest)); if (!$ids) { fwrite(STDERR, "usage: php audit.php [--country=us,gb] [--json=out.json] ID [ID ...]\n"); exit(2); } $countries = explode(',', strtolower($opts['country'] ?? 'us')); $now = new DateTimeImmutable('now', new DateTimeZone('UTC')); $report = ['checked_at' => $now->format(DATE_ATOM), 'storefronts' => []]; $warnings = 0; foreach ($countries as $n => $country) { if ($n > 0) sleep(3); // polite pause between storefronts try { $found = lookup($ids, $country); } catch (RuntimeException $e) { fwrite(STDERR, $e->getMessage() . "\n"); exit(3); } $audits = []; foreach ($ids as $id) { if (!isset($found[$id])) { fwrite(STDERR, "[$country] $id not returned (wrong id, or not sold here)\n"); continue; } $audits[$id] = audit($found[$id], $now); $warnings += count(array_filter($audits[$id], fn($r) => $r[1] === 'warn')); } if ($audits) printTable($country, $audits); $report['storefronts'][$country] = $audits; } if (isset($opts['json'])) { file_put_contents($opts['json'], json_encode($report, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . "\n"); } echo "\n$warnings warning(s). '!' marks a check worth a look.\n"; exit($warnings > 0 ? 1 : 0); 

A few notes on the design.

Status, not score. Each row is ok, warn or info. I tried a 0-100 "listing score" first, and it was useless: a number hides which check failed. A ! next to one cell tells you exactly what to look at.

The thresholds are mine. The 30-character title limit and the 4,000-character description limit come from App Store Connect, and 10 is the screenshot maximum per device size. Everything else is a judgement call: under 5 iPhone screenshots, under 1,000 description characters, no update in 90 days, or release notes that say only "Bug fixes". Change them to match your category.

Exit codes. The script exits 0 when it found no warnings, 1 when it did, 2 on bad usage and 3 when the API call failed. That makes it easy to run in CI or cron and only shout when something changes.

Running it

I don't own a habit tracker, so for the example I pretend Habitify is mine and compare it with Streaks and Atoms, in the US and UK storefronts:

php audit.php --country=us,gb --json=audit.json 1111447047 963034692 6474421906 

Here is the US half of the output:

== Storefront: US == Title Habitify: Habit Tracker Streaks Atoms - from Atomic Habits Title chars 23/30 (23 bytes) 7/30 (7 bytes) 26/30 (26 bytes) Subtitle not in API not in API not in API Description 2997/4000 chars 3832/4000 chars 2293/4000 chars First line Build better habits. Build a be… STREAKS. The habit tracker that… Introducing Atoms – The Officia… iPhone shots 7/10 10/10 7/10 iPad shots 3/10 9/10 n/a (iPhone-only) Languages 40 28 ! 1 Version 51.1.0 (5d ago) 11.5.3 (1d ago) ! 3.5.0 (259d ago) Release notes 1133 chars 407 chars ! 26 chars Rating 4.59 (7,160) 4.81 (27,354) 4.83 (10,767) Age rating 4+ 4+ 4+ Price Free $5.99 Free 

And the end of the run:

6 warning(s). '!' marks a check worth a look. 

The six warnings were all Atoms, three per storefront: one language (EN), no update in 259 days, and release notes that read, in full, "Bug fixes and enhancements". That doesn't make it a bad app: it has a 4.83 average from over 10,000 US ratings. It does mean a rival could win on freshness and localization.

For "my" app, the table shows things worth asking about: 7 of 10 iPhone slots and only 3 iPad screenshots, while Streaks fills nearly all of both. Streaks uses only 7 of its 30 title characters, a brand-first name that leaves keyword space unused.

The --json file keeps the same rows plus a checked_at timestamp, so you can diff two runs.

The walls you'll hit

1. The API doesn't return the subtitle, keywords or promotional text. This is the big one for an "ASO audit". The subtitle and the 100-character keyword field are two of the three text fields that Apple indexes for search, and the Lookup API has neither. I checked the full key list of the response, and they aren't there. Promotional text is missing too. That's why the table prints not in API instead of pretending. For your own app, read those fields from App Store Connect. For rivals, you can see the subtitle on the store page, but you'll never see their keyword field anywhere.

2. Storefronts really differ. Habitify's description was 2,997 characters in the US and 3,823 in the UK, with a different second sentence. Ratings are per storefront too: Streaks had 27,354 ratings in the US and 4,236 in the UK. Prices come back in local currency ($5.99 vs £5.99). Audit each market you care about separately, and never add up ratings across storefronts and call the total "global". An id that isn't sold in a storefront just disappears from the results with no error. The script warns about it on stderr instead of failing silently.

3. Characters are not bytes. Apple's 30-character limit counts characters. PHP's strlen counts bytes. "Lose It! – Calorie Counter" came back as 26 characters but 28 bytes, because the en dash is three bytes in UTF-8. With strlen, a title in German, Japanese or with a single emoji can look over the limit when it isn't. Use mb_strlen, mb_strimwidth and mb_str_pad everywhere you measure or trim listing text.

4. supportedDevices lies about iPad. My first version flagged Fabulous for having zero iPad screenshots, because its supportedDevices list includes dozens of iPad models. But iPhone-only apps run on iPad too, so that list includes iPads for almost everything. The reliable signal is features containing iosUniversal. Streaks and Habitify have it; Fabulous, Strava and Atoms don't.

5. Responses are cached. The response I got carried cache-control: max-age=38287, which is about ten and a half hours, and it was served through a CDN. If you just shipped a new description, the API may show the old one for hours. Don't use this tool to confirm that a release went live a minute ago, and record checked_at with every run so you know what you were looking at.

6. Rate limits and terms. Apple doesn't publish firm limits for the Lookup API. The related Search API documentation has long suggested about 20 calls per minute, and people do see 403 responses when they go faster. The script batches ids into one call per storefront, pauses between storefronts, sends a clear user agent, and retries 403, 429 and network errors with back-off. It does not retry a 400, which is what you get for a bad country code like xx. Scraping the web store page for the subtitle is tempting, but that page isn't an API, it changes without notice, and automated scraping can break Apple's terms.

Where PeekPanda saves time

This script is good for one job: a quick, repeatable check of a listing as it is today. It can't tell you what happens around the listing. That includes which paid ads a rival runs and for how long, which searches they show up in, or what organic content is driving their installs.

That's the part I use PeekPanda* for. Every iOS app gets a case file, and its Listing & about tab shows the current store description, monetization facts, screenshots and the date of the store observation behind them. The other tabs hold dated paid-ad observations, observed search positions with popularity and difficulty, matched organic content, and download and revenue estimates, labelled as estimates. It doesn't keep listing history, so if you want to see how a listing changed over time, save this script's JSON output on a schedule.

Wrap-up

  • One PHP file and Apple's Lookup API give you a side-by-side listing audit in seconds, with no key.
  • Count characters with mb_* functions, audit per storefront, and use iosUniversal for iPad.
  • Be honest about the gaps: no subtitle, no keyword field, and cached data.

Run it on your own app first. The warnings are usually things you already knew and kept putting off.

*I build it.