App pricing¶
The rule¶
Never infer one territory's price from another territory's. Read the storefront for the territory you need, or say you could not read it. There is no third option — no conversion, no "the US column of the same price point", no arithmetic on an exchange rate.
Apple's price matrix puts each territory on its own rung. Norway is priced in round kroner, the US
on the .99 ladder, and the same price point id maps to numbers that were never chosen together.
On 2026-09-10 a US price was derived from a Norwegian price point id and produced $4.69 for a
product that sells for $4.99 — a confident, sourced-looking, wrong answer.
The same rule applies to time: a price you read last month is not a price. Re-read it.
The source of truth¶
The public App Store product page, and nothing else:
Fetch it with a browser User-Agent and follow redirects (the x slug redirects to the app's real
slug). It needs no credentials — no App Store Connect key, no token, nothing to rotate or leak.
Three outcomes, and they mean different things:
| Response | Meaning |
|---|---|
200 with an "In-App Purchases" row in the Information list |
The app sells these products, at these prices, on this storefront |
200 with no such row |
The app sells nothing on this storefront |
404 (small body) |
The app has never been listed on the store |
429, any other status, or markup that no longer parses |
Inconclusive. Not a pass. Retry with backoff |
Inside the Information list, each product renders as an adjacent span pair —
<span>Basic Plan</span> <span>$4.99</span>.
Why the storefront and not App Store Connect¶
ASC tells you what exists as a record. The storefront tells you what a customer can actually
buy, which is the only thing that matters when a marketing page promises a plan. Fast-E, for
instance, still holds a Premium subscription group in ASC with two MISSING_METADATA products;
the storefront correctly shows nothing for sale. The ASC record is a footnote, the storefront is
the fact.
ASC also costs a credential. The storefront costs nothing, so the check can run unattended on a cron in a repo that holds no App Store keys.
What is checked, and where¶
Each app reference skill carries a machine-readable block beside its human-readable table:
```app-pricing
asc_id: 6753125391
storefront us:
- Basic Plan = $4.99
- Pro Plan = $11.99
- Premium Plan = $24.99
- Ultimate Plan = $39.99
storefront no:
- Basic Plan = 59,00 kr
```
A storefront is declared one of three ways:
- an explicit list — these products, at these prices, must be what renders;
none— the storefront must render no In-App Purchases section at all;not_listed— the app is not on the store yet and Apple must return404.
The human tables above each block are what people read; the block is what CI reads. They are kept in the same section on purpose, so fixing one without the other looks wrong.
Territory coverage is pinned, not optional. REQUIRED_STOREFRONTS in
scripts/check-app-pricing.mjs lists the territories each app must declare (Nutri-E: us and
no). Deleting a storefront no: section would otherwise stop checking Norway forever with both
CI jobs green — which is precisely how failure 3 below becomes un-catchable again. A declaration
missing a required territory is exit 2, not a pass. Adding or dropping a territory means editing
that constant on purpose.
Current declarations:
| App | ASC id | us | no |
|---|---|---|---|
| Nutri-E | 6753125391 |
4 monthly plans, $4.99–$39.99 | 4 monthly plans, 59–499 kr |
| Reward-E | 6756241157 |
Premium Monthly $1.99, Premium Yearly $14.99 | — |
| Fast-E | 6759206116 |
none |
— |
| Count-E | 6759394446 |
none |
— |
| Drink-E | 6759526344 |
not_listed |
— |
| Heart-E | 6759680314 |
not_listed |
— |
All seven storefront readings verified 2026-09-10.
The checker¶
scripts/check-app-pricing.mjs — Node built-ins only, no dependencies, no credentials.
node scripts/check-app-pricing.mjs # all apps
node scripts/check-app-pricing.mjs --app nutri-e # one app
Exit codes, matching check-cross-cutting-sync.mjs:
| Code | Meaning |
|---|---|
0 |
In sync |
1 |
Drift — the diff is on stdout, naming each missing, extra, or repriced product |
2 |
Internal error: rate limit, unreachable storefront, unparseable markup, malformed block |
Exit 2 is deliberately not a pass. A checker that reports "in sync" when it cannot see the
storefront is worse than no checker, because it launders an unknown into a green tick. So: a 429
is retried four times with exponential backoff and then fails the run; a 200 whose In-App
Purchases markup no longer parses fails the run rather than reading as "sells nothing"; a 200
that is not an App Store product page at all fails the run too.
The hard case: telling "sells nothing" from "cannot read"¶
Both look identical — no products found. If Apple restructures the In-App Purchases heading, the extractor stops matching and every page starts looking like a free app. Two guards keep that from turning into a wrong answer:
- A storefront that declares products but renders none is a parser break, not a mass deletion.
Reporting it as drift would print
MISSING from storefor every real, sellable product and then tell the reader to remove them from the docs — recreating failure 1 in reverse, at scale. Four products vanishing from Apple at once is far less likely than one markup change, and the run cannot tell them apart, so it reports neither: exit 2, with the URL to open by hand. - A
nonedeclaration asserts an absence, so it needs proof the parser can still see. Anonestorefront only "passes" in a run where some other storefront actually yielded a product pair. If none did — say--app fast-e, whose only claim is that nothing is for sale — the checker fetchesPARSER_CANARY(Nutri-E,us, known to sell four things) to prove the extractor still works. If the canary comes back empty or unreadable, the run is inconclusive rather than green. In a full run the canary costs nothing extra, because Nutri-E was fetched anyway.
The success line names what proved it: extractor proven by https://apps.apple.com/.... If that
clause is missing from a green run, something is wrong with the checker itself.
scripts/check-app-pricing.test.mjs runs node --test against saved HTML fixtures in
scripts/fixtures/app-pricing/ with no network. Most of the tests exist to prove the checker
fails — a mutated price, a product missing from the store, an undocumented product, a 429, a
404 on an app declared listed, markup that stopped parsing, a renamed heading that blinds the
parser (including on an app declared none), and a deleted territory.
The declared prices are asserted against the captured fixture page, not against literals typed into the test file. So an Apple-approved price change is a two-file edit — recapture the fixture, update the skill — and the unit-test job never goes red under a name implying the live store disagreed when nothing was fetched. Comparing against the live store is the checker's job, not the test file's.
Automation¶
.github/workflows/app-pricing-drift.yml:
- unit-tests — on every PR touching the checker, its fixtures, or any app reference skill. No network.
- live-check — weekly cron (Mondays 07:00 UTC) plus
workflow_dispatch. Deliberately not on PRs: Apple rate-limits, and a429would fail PRs for reasons unrelated to the change.
On a failed cron run, nothing is pushed anywhere. This repo has no scheduled-failure notifier —
its Discord workflows are all PR-scoped — so the visibility is the same as for
cross-cutting-docs-sync.yml: GitHub emails the workflow file's last committer, and the run shows
red in the Actions tab. That is the existing mechanism, stated plainly rather than dressed up. If
it proves too quiet, the fix is one repo-wide notifier for scheduled failures, not a bespoke step
in this workflow.
When the check fails¶
- Open the storefront URL yourself, for the territory the diff names. Do not reason about it.
- If the store is right and the docs are wrong: fix the
app-pricingblock and the human table beside it, in the same commit. - If the docs are right and the store is wrong, that is a live customer-facing defect — a plan nobody can buy, or a price nobody agreed to. File it against the app repo.
- If the failure is exit 2, nothing has been established. Re-run later; do not "resolve" it by editing the docs. In particular, exit 2 with a parser break message means the products are probably still on sale and the extractor needs fixing — deleting them from the block would be exactly the wrong repair.
What this was built to catch¶
Three real failures, all found on 2026-09-10, all of which this check now catches:
- Nutri-E documented annual plans that do not exist.
$99 / $199 / $349, listed in the reference skill and sold on the marketing site. There were never any annual products — the ASC subscription group holds four subscriptions and every one isONE_MONTH. A customer following the marketing page could not buy what it offered. Tracked asdashecorp/dashe-nutri-e#30. - Reward-E's app repo documented product ids that exist nowhere, at
$4.99 / $29.99, against real store prices of$1.99 / $14.99— a 2–2.5x error in the first table anyone touching subscriptions would copy. Tracked asdashecorp/dashe-reward-e#158. - A US price inferred from a Norwegian price point id. See the rule.