Three of My Apps Had the Wrong Price on My Own Website

Mino was listed as free while charging $2.99. Timeflip charged $0.99 while being free. Nobody noticed for months, because nothing was comparing the pages to the store. Here's the 150 lines that now do.

I ship ten iOS apps on my own. Their prices, minimum OS versions and feature lists are written down in four places: App Store Connect, the marketing site at applestan.com, this site, and the odd blog post that mentions them in passing.

Only one of those four is the truth. The other three are copies, and copies rot.

Last week I went looking, and found that three apps had prices on my own websites that were simply wrong:

  • Mino was listed as free. It costs $2.99, and had done since version 1.2.
  • Timeflip was listed at $0.99. It had gone free.
  • PhotoStrip was listed at a flat $4.99. It is a free download with a $4.99 unlock.

Two of those are the worst kind of wrong. Telling someone an app is free when it costs money sends them to a paywall they were not expecting. Telling them it costs money when it is free just loses the download quietly.

There were softer errors underneath. Mino had gained PDF merging and splitting in 1.2 and the page still described a compression-only app. Negink had moved off subscriptions to one-time credit packs and the pricing table still advertised $7.99 a week. Wallora had grown from 15 art styles to 25. ComicFlow had gone from 8 localisations to 18.

None of this was noticed for months, and the reason is boring: nothing was checking.

The actual failure mode

It is tempting to file this under carelessness. I do not think that is what it is.

Every one of these errors was created by a correct action. I raised Mino’s price on purpose. I made Timeflip free on purpose. I moved Negink to credit packs on purpose. Each was a deliberate decision made in App Store Connect, which is exactly where you make it.

The failure was that the decision had four downstream consequences and I only executed one of them. Not because I forgot the website exists, but because the website is not in the room when you change a price. You are in App Store Connect, the change takes effect, the thing you wanted is done, and the feedback loop closes.

There is no error. No build fails. No test goes red. The site keeps serving a page that was true in March.

This is the general shape of it: a fact copied into a second place with no mechanism to reconcile them will drift, and the drift is silent. Silent is the operative word, because I did not need better discipline. I needed something that fails loudly.

The check that already existed and I was not using

Apple has run a public lookup endpoint for years. No key, no auth, no SDK:

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

You get back JSON with the app’s current price, version, minimum OS version, release date, release notes and canonical URL. You can pass a comma-separated list of IDs and get all of them in one request.

That is the entire missing ingredient. The truth was queryable the whole time.

const url = `https://itunes.apple.com/lookup?id=${ids.join(',')}` +
            `&country=us&entity=software`;
const { results } = await (await fetch(url)).json();
const store = new Map(results.map((r) => [String(r.trackId), r]));

Two small conversions make the comparison work. The store gives you formattedPrice as a display string, and the site stores a bare number:

const storePriceToNumber = (formatted) =>
  !formatted || /^free$/i.test(formatted) ? '0'
                                          : formatted.replace(/[^0-9.]/g, '');

And minimum OS arrives as 18.6 while the site writes iOS 18.6+, iPadOS 18.6+, so pull the first version number out and compare that.

After that it is just a loop and an inequality. The whole store comparison is under fifty lines.

What I actually built

It grew into a single script with no dependencies that both sites can be run through. It checks seven things:

checkcatches
storeprice, minimum OS and publish date drifting from the App Store
versionan app shipping a new version since the last run
linksan App Store URL pointing at the wrong app, or a slug Apple has renamed
cross-sitemy two sites disagreeing about the same app
price-prosea dollar amount in an app’s copy that is not its price or a known IAP
claima per-app statement that has become untrue
banned-phrasepre-release codenames and phrases I have decided never to publish

Three of those are worth explaining because they were not obvious to me at the start.

Prices hiding in prose

Fixing the JSON that feeds a page does not fix the page. Mino’s price lived in structured data, in the hero, in an FAQ answer and in a paragraph three-quarters of the way down a blog post from March.

So the checker scans every file under an app’s directory for anything matching \$\d+\.\d{2}, and compares each hit against a set: the app’s own price, plus its known in-app purchase prices from a config file. Anything else is flagged.

const allowed = new Set(CONFIG.iap[app.slug] ?? []);
if (app.price !== '0') allowed.add(app.price);

for (const m of text.matchAll(/\$(\d+\.\d{2})\b/g)) {
  if (!allowed.has(m[1])) report(app, file, m[1]);
}

Listing the IAP prices explicitly turned out to be the useful part, not a workaround. Writing them into a config forced me to state, in one place, exactly which amounts each app is allowed to mention.

It needs an exclusion list. Blog posts that compare competitors are full of other people’s prices, so paths like /blog/ and the free tools section are skipped for this particular check.

Claims that are not numbers

The price scan cannot catch “Mino is completely free”, because that sentence contains no dollar sign. It was sitting in a blog post, and it is the single most misleading thing on the site, because it is a full sentence a reader will believe.

So there is a second list, per app, of statements that have become false:

"mino": [{
  "pattern": "Mino[^.]{0,70}(completely free|is free|for free)",
  "why": "Mino costs $2.99. It was free until 1.2.",
  "severity": "error"
}]

These get checked in blog posts too. A wrong price in a comparison post misleads a reader no matter how old the post is.

The first version of this had a bug that is obvious in hindsight. A rule looking for the word “subscription” near “Negink” fired on the sentence “Negink has no subscription”, which is the correction, not the error. So each rule takes an optional unless pattern, tested against the surrounding sentence:

if (rule.unless) {
  const window = text.slice(m.index - 90, m.index + m[0].length + 90);
  if (new RegExp(rule.unless, 'i').test(window)) continue;
}

Any rule that matches a keyword rather than a claim needs this. Negations are the normal way people write corrections, and a checker that flags every correction gets switched off within a week.

Version bumps as an early warning

The most useful check turned out to be the least clever one. The script records the version of each app on every run. When a version changes, it says so:

ComicFlow went 3.2 to 3.3 since the last check. Read the release notes and update the page.

It cannot know what changed. It does not need to. Every one of my soft errors, the missing merge feature, the wrong style count, the wrong language count, entered the site at a version bump. Flagging the bump puts the question in front of me at the only moment I can answer it cheaply.

Two details that decide whether you keep using it

It has to block something. The script exits non-zero on errors and is wired into my deploy script:

"deploy": "npm run build && npm run factcheck && firebase deploy"

A check I have to remember to run is a check I will stop running. A check that stands between me and shipping is one I cannot skip by accident.

It has to shut up when nothing is wrong. My first version wrote a timestamp into its state file on every run, so a check that found nothing still left a modified file in git status. That is precisely how you train yourself to ignore a tool. It now writes only when a version actually moved.

Same instinct applies to warnings. Errors block the deploy. Everything advisory is a warning that does not. If everything blocks, you will start passing the flag that skips it.

Where it does not help

It compares my sites to the store. It cannot tell me the store itself is wrong, and mine was: the App Store description for Negink still described the subscription that version 2.0 removed, while the release notes for that same version announced the credit packs. The listing contradicted itself and no tool of mine could have known which half to believe. I had to go and decide.

It also cannot check taste, or whether a sentence is any good, or whether a screenshot still shows the current UI. It checks facts that have exactly one correct value.

That is a narrow job. It is also the job where I was making every single one of my mistakes.

If you ship more than one app

The version that would have saved me is genuinely small. Fetch the lookup endpoint for your app IDs, compare formattedPrice and minimumOsVersion against whatever your site thinks, exit non-zero on a mismatch, and put it in front of your deploy. That is an afternoon, and it covers the errors that actually embarrass you.

Everything after that is refinement. The prose scanning, the claim rules, the negation guards, all of it exists because I kept finding a new place the same fact had been copied to.

Which is the real lesson, and it is not about App Store metadata at all. I did not have a discipline problem. I had ten apps, four copies of every fact, and nothing that failed when they disagreed.