Skip to content

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:

https://apps.apple.com/<cc>/app/x/id<ASC_ID>

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 return 404.

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 store for 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 none declaration asserts an absence, so it needs proof the parser can still see. A none storefront 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 fetches PARSER_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 a 429 would 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

  1. Open the storefront URL yourself, for the territory the diff names. Do not reason about it.
  2. If the store is right and the docs are wrong: fix the app-pricing block and the human table beside it, in the same commit.
  3. 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.
  4. 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:

  1. 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 is ONE_MONTH. A customer following the marketing page could not buy what it offered. Tracked as dashecorp/dashe-nutri-e#30.
  2. 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 as dashecorp/dashe-reward-e#158.
  3. A US price inferred from a Norwegian price point id. See the rule.