Munchable's free tier is five scans a month. Premium is unlimited. The screen that explains this exists in the mobile app, and on iOS and Android it cannot sell you anything, because Apple's and Google's anti-steering rules do not allow an app to take a payment outside their billing systems or to steer you to a place that will.
We do not use in-app purchase. Premium is bought on the web, with Stripe, and the app unlocks it from the account. So the paywall is informational on native, and that constraint produced a more interesting screen than a purchase button would have.
The price it shows is real and it is the website's own: you can read it at munchable.app/#pricing, and if you are outside the euro area you will see a different currency there, from a fixed table rather than a conversion of a pound figure.
One file, three contexts
The same Expo app ships as iOS, Android, and a browser build through react-native-web. In a browser, a checkout link is perfectly allowed. So the screen has exactly one branch in it:
{Platform.OS === 'web' ? (
<Button label="Get Premium" variant="gold" icon="zap" onPress={onGetPremium} />
) : (
<Text variant="body" muted>
Premium is available on our website. Visit munchable.app to see pricing and
upgrade, then it unlocks here automatically once you are signed in.
</Text>
)}
That Text is the part I want to point at. It is not a Pressable. There is no Linking.openURL, no underline, no arrow icon, no <a>. The domain appears as a word in a sentence, and the reader types it into a browser themselves if they feel like it.
That is not me being timid about the rules. It is reading them as written: the thing prohibited is the steering, and a tappable button that lands on a payment page is steering however it is dressed. A sentence that says where the product's website is, is a fact about the world. Keeping those two clearly distinguishable in the code, rather than hiding a link behind a muted style, means nobody has to relitigate the question during the next review cycle.
The other half of the branch is the honest trade: because the native screen cannot convert, it is tuned to be clear rather than persuasive. One sentence, one info icon, no urgency, no countdown.
The screen still has to be good
A paywall that cannot take money is easy to leave as a settings pane with a number in it. It was that for a while, and the doc comment on the current version is the postmortem:
This is the moment the app asks to be paid for, so it gets the same confident piece of design that sold it in the first place.
It is now one warm card, the same closing card the marketing site ends every page with, with the brand headline, the price one size down from the headline ("the headline is the bold moment, the price is the fact under it"), the VAT notice as a single caption, and three perks.
Three perks, because one was deleted. The comment above the list records what it was doing:
"Verdict aware shopping lists and meal ideas" used to sit here and was doing two dishonest jobs at once: nothing shipped behind it, and recipes are now free for everyone, so selling them would put a paywall on the screen whose job is to rescue an "avoid" verdict.
Selling a feature that does not exist is the ordinary sin. The second one is subtler and worse: the recipe list is what the app offers somebody who has just been told the thing in their hand does not suit them, and charging for that would mean charging at the exact moment the app is supposed to help.
There is also no close X in the corner. "Maybe later" sits under the card, and it reads "Keep scanning" instead when the person still has scans left, because they came here voluntarily rather than hitting a wall. One labelled way out is calmer than two unlabelled ones.
Unlocking without a restart
The entitlement is the server's to know and the client's to reflect:
/**
* Premium entitlement, sourced from the signed-in account (SHARED CONTRACT). The
* server owns the truth; the client only reflects it.
*/
The hub re-checks it every time it regains focus:
useFocusEffect(useCallback(() => {
rearmScanner();
void useEntitlement.getState().refresh();
...
}));
So the full purchase journey is: read the sentence, pay on the web in your own browser, come back to the app, and the moment the scanner screen is in front of you again Premium is on. No restart, no "restore purchases" button, no receipt validation, because there is no receipt: there is an account, and the account either has a subscription or does not. The anonymous device session is always free, which is one line in the same store.
Losing IAP costs real conversion. What it buys is a billing system with one source of truth, no store-specific receipt plumbing, and no platform cut, and I would not trade back.
The web branch's own problem: a currency nobody was shown
In the browser build the button works, and it asks our API to open a Stripe Checkout Session. That is where a second, less obvious constraint lives.
The website resolves which currency to charge from the request's country. The app's paywall resolves the price from the phone's region, and those two can genuinely disagree: a phone set to English (United States) being carried around the Netherlands. If the price on screen is in one currency and the session would charge another, the right answer is not to pick one.
So the client sends back the currency it printed, and the route can refuse:
POST { currency? } -> { url } | 409 { error: 'price_changed', currency }
No session is opened. The 409 names the currency this person is actually charged in, the paywall rewrites its own price to that, says so in a caption, and the next press pays the figure now on screen. A session is never created in a currency the person was not shown.
The price itself comes from one shared package that both the site and the app read, with a fixed amount per currency. The euro price is the euro-area price, not a conversion of the pound price, so somebody who reads the figure in the app and then buys it in a browser sees one number twice. Tax is inclusive, which is why the caption can promise that the figure is what gets charged. The billing terms are at munchable.app/terms if you want the formal version.
What I would tell someone starting this
If you are building a subscription app that does not want in-app purchase, the engineering is not the hard part. The hard part is that the native screen has to do a job with one hand tied: explain the value, show a true price, and let the person leave, all without reaching for the single UI element every growth guide assumes you have.
Write the constraint into the code as a comment where the branch is, not in a wiki. The next person to open that file will otherwise add a link, reasonably, and never know why it was not already there.
If you want to see the paid side of this, it is all on the web: the plan comparison and the price in your currency at munchable.app/#pricing, and the free tier is five scans a month across all eight conditions listed at munchable.app/conditions.
Top comments (0)