I was building toward Shipaton, RevenueCat's hackathon, so I added a RevenueCat subscription to a side project I'd built with Expo and submitted it to the App Store. The first submission came back rejected on three counts.
Two of them I could fix by rereading the guidelines. The third was the annoying one: the subscribe button wouldn't respond. On my simulator, the exact same code let me buy the thing without any trouble.
What gave it away was opening my server logs and finding nothing there. Not an error. Not a single line.
I'm on Expo SDK 54, react-native-purchases v10, with a NestJS backend.
"Shipping subscriptions with RevenueCat: from store setup to environment layout" (3 parts)
- My subscribe button was greyed out, and that got my app rejected ← you are here
- Android needed almost no code changes, and still ate a full day in configuration
- Where to draw the line between test and production, when there is no right answer
1. The vocabulary: three words that do three different jobs
1.1 How IAP and RevenueCat relate
IAP (In-App Purchase) is the store's own billing machinery. You reach it through StoreKit on iOS and Google Play Billing on Android. Payment and receipts are handled entirely on the store side. You don't get to build your own checkout.
RevenueCat is a layer on top of that. It flattens the two stores' IAP into a single model, so you can ask who bought what and whether it's still active.
So billing itself belongs to the store, and what RevenueCat gives you is a layer that makes that state easy to work with. Even with a single store, it saves you from writing receipt validation and renewal tracking yourself.
1.2 Product, Entitlement, Offering
The vocabulary is where RevenueCat first trips you up. Product, Entitlement and Offering all sound alike, and the names don't tell you how they connect. You also have to wire up all three before a single product comes back.
The trick is noticing that the three do genuinely different jobs.
| Term | What it is | Its job | Watch out for |
|---|---|---|---|
| Product | Something you listed on a store | What you sell | Each store has its own copy |
| Entitlement | The state of "this feature is unlocked" | What the user can use | The only thing your app checks |
| Offering / Package | The set you put on the paywall | What you show right now | Skip this and you get zero products |
Note
The namespremium_monthly,premium_yearly,premiumanddefaultbelow are all made up for this article. None of them are reserved, so you can use whatever strings you like. The exception is identifiers starting with$rc_, like$rc_monthlyand$rc_annual: those are RevenueCat's conventional names, and using them makes it easier for the SDK to tell monthly from annual.
Drawn out, the connections look like this.
Again, premium_monthly and friends are placeholders for whatever product IDs you pick.
Products are per store. The "monthly plan" you registered on the App Store and the one on Google Play are two different products. Add Android later and one plan turns into two products.
Entitlements collapse that back down. All four products above end in the same place: Premium is unlocked. Your app only ever reads the entitlement, so it never needs to know which store or which plan the purchase came from. Add stores or plans and the check in your app stays exactly as it was.
An Offering is what you put on screen right now. Packages are the slots, "the monthly one" and "the annual one", and you drop each store's product into a slot. Your app fetches the offering and lays out the slots it gets back.
The payoff of keeping this separate is that you can change how you sell without shipping an app update. If you decide to push the annual plan, or run a limited-time plan, you swap the offering in RevenueCat and the paywall changes.
Note
As the arrows show, two lines leave each product: one to an entitlement, one to an offering. One of them is not enough. Wire up only the entitlement and you land in the state where buying grants access, but the paywall has nothing on it.Later in this article, I forget the offering half and walk away convinced I'm done.
2. Rejected on three counts
Review came back in one to three days, with three items.
| Guideline | What they flagged |
|---|---|
| 4.8 Login Services | Google sign-in with no equivalent privacy-preserving option |
| 3.1.2(c) Subscriptions | No functional links to the EULA and privacy policy inside the purchase flow |
| 2.1(b) App Completeness | The subscribe button is greyed out |
The first two tell you what to do if you read them. The third was the problem, because it worked on my machine.
3. Rejection 1: I only offered Google sign-in
Apple's rule is simple. If you offer Google, you have to offer an equivalent privacy-preserving login too, which in practice means adding Sign in with Apple.
Four things to do: add expo-apple-authentication; set ios.usesAppleSignIn: true and the plugin in app.config.ts; add an endpoint on the API that verifies the identity token; and put the official Apple button on the login screen with at least as much visual weight as the Google one. Then enable the "Sign In with Apple" capability on your App ID in the Apple Developer portal.
Note
Server-side verification is just public key verification against Apple's JWKS, so you don't need a private key or a Services ID. That part was easier than I'd braced for. Enabling the capability invalidates your provisioning profile, but the nexteas buildregenerates it for you.
4. Rejection 2: my policy link only existed in one place
I'd put a Privacy Policy URL in the App Store metadata and assumed that covered it. It did not.
Warning
You need the links in the app and in the metadata. Either one alone will fail.
- In the app: put tappable links to the EULA and the privacy policy on the paywall screen (
WebBrowser.openBrowserAsync)- In the metadata: the Privacy Policy URL field in App Store Connect. For the EULA, link it from the description if you use Apple's standard one, or fill the EULA field if yours is custom
When I resubmitted, I attached a screen recording in Resolution Center. Apple asks for one explicitly on 3.1.2.
5. Rejection 3: the subscribe button stayed grey
Here's the real story.
I opened the build I'd distributed through TestFlight, and there was no price.
The amount shows as —. RevenueCat returned zero products, so there's no price to render, and the button does nothing. This is the screen the reviewer saw.
Let me kill one common misreading first.
Warning
Grey does not mean "still in review". Products are supposed to show up in Sandbox even while the IAP status says "Waiting for Review". The rejection email says as much. Reviewers go all the way through a Sandbox purchase, so if the button isn't live at the moment you submit, you fail.
So this wasn't going to fix itself once review passed. It had to be fixed right then.
And the awkward part: the same code showed products just fine in the simulator. I couldn't reproduce it locally. Works here, broken only in what I shipped, which is the worst place to be.
6. Not a single log line
You can't attach a debugger to a distribution build, so I needed some way to see what was happening from the outside.
I kept the RevenueCat state in a module-level variable, attached it to every request header through an axios interceptor, and logged it on the server.
// src/lib/iap-diagnostics.ts
const state: IapDiagSnapshot = {
offeringId: null,
packageCount: null,
lastError: null,
lastUpdatedAt: null,
};
export const updateIapDiagnostics = (patch: Partial<IapDiagSnapshot>): void => {
Object.assign(state, patch, { lastUpdatedAt: new Date().toISOString() });
};
export const getIapDiagnosticsHeader = (): string | null => {
if (!state.lastUpdatedAt) return null;
return JSON.stringify({
offering: state.offeringId,
pkg: state.packageCount,
err: state.lastError ? state.lastError.slice(0, 200) : null,
});
};
// src/lib/api/axios-instance.ts
axiosInstance.interceptors.request.use((config) => {
const diag = getIapDiagnosticsHeader();
if (diag) config.headers.set('X-Iap-Diag', diag);
return config;
});
I opened the server logs and went looking for requests from the TestFlight build.
There was nothing.
Not an error. The diagnostic line simply never appeared.
The header is only attached once something has written RevenueCat state at least once. Zero lines meant that write had never run, which meant the app was never even reaching RevenueCat's initialization.
So it wasn't that products failed to load. Billing wasn't running at all.
Note
The absence of the log was the answer. When you go in looking for an error, an empty result reads as "I haven't looked hard enough yet". Decide up front on something that must always appear when things are healthy, and its absence becomes evidence.
| What the log says | What it means |
|---|---|
pkg:2, err:null |
Healthy. The button should be enabled |
pkg:0, err:"...has no packages..." |
StoreKit returned nothing. Paid Apps agreement, or the product wiring |
pkg:0, err:"No current offering..." |
No offering is marked current in RevenueCat |
err:"Offerings fetch failed [CODE]..." |
The fetch itself threw. Wrong API key lands here |
| nothing at all | Initialization was never reached. The API key isn't in the build |
7. The cause: eas build doesn't read .env
Once I knew initialization wasn't running, the code told me why immediately. When the API key is missing, I'd chosen to disable billing rather than crash.
// src/providers/purchases-provider.tsx
const apiKey = getApiKey();
if (!apiKey) {
console.warn('[Purchases] RevenueCat API key is not set. Subscription features disabled.');
setIsReady(true);
return; // ← bails out here, so the diagnostic line is never written either
}
That early return is what produced a build where nothing happens at all.
So why was the key missing from the distribution build?
Caution
eas buildnever reads.env.
- A local dev build (
expo run:iosand friends) reads.envand injectsEXPO_PUBLIC_*into your JS. That's why the simulator showed productseas buildonly looks atenvineas.jsonand at EAS Environment Variables. If the key isn't registered there, you never reachPurchases.configure()
The difference between the simulator and TestFlight wasn't the code and it wasn't the environment. It was how the key gets delivered.
The fix is to register the key with EAS.
# register it in both environments so billing also works in development builds
eas env:create --name EXPO_PUBLIC_REVENUECAT_IOS_API_KEY --value "appl_xxxxxxxx" \
--visibility sensitive --environment development --environment production
Note
You can't picksecretfor--visibility. AnythingEXPO_PUBLIC_is baked into the binary, so it can't be kept secret in the first place. Choosingsensitiveis about keeping the key out of git; the fact that it ships inside the app is by design.
After a rebuild the diagnostic line read pkg:2, err:null and the button came alive. That was the real fix for 2.1(b).
8. The order I should have done all this in
Looking back, every place I got stuck was something the right order would have avoided. These tasks depend on each other, and doing a later one before an earlier one finishes is wasted effort.
8.1 Start the business setup first
This is the paperwork under "Business" in App Store Connect.
Caution
Until the Paid Apps agreement is active, your app can't fetch a single product. Correct code and correct RevenueCat configuration don't matter; you get back an empty list. Apple's side takes anywhere from a few hours to two days, so start it before anything else.
In App Store Connect you need to accept the Paid Apps agreement, add a bank account, complete the tax forms (W-8BEN-E if you're a Japanese entity) and, if you distribute in the EU, the DSA trader declaration. Build while you wait.
One clarification: "Apple handles the taxes" is a half-truth. Apple handles consumption tax, VAT, sales tax and withholding. Declaring your own income is still on you.
8.2 Add the API key before wiring the three layers
This is the key RevenueCat uses to query Apple for product information. You issue it in App Store Connect and register it in RevenueCat, so it spans two dashboards. Issue it under Users and Access → Integrations → App Store Connect API, then add it to your RevenueCat project settings.
Warning
Skip this and the Store Status in RevenueCat's product list sits at "Could not check", which makes product delivery flaky. Also, App Store Connect lets you download the.p8file exactly once. Lose it and you're issuing a new one.
Then wire up the three layers from section 1 in the RevenueCat dashboard. Once you create an offering, don't forget to hit Make current. Your app asks for the offering that is current, so if you skip that click, products come back empty. I managed to produce exactly that state once: everything configured, nothing on the paywall.
8.3 Put the products in the same Subscription Group
This part happens in App Store Connect, under Monetization → Subscriptions. Subscription Group is an Apple concept with no counterpart in RevenueCat.
Warning
When you create the monthly and annual products, put them in the same Subscription Group. In separate groups Apple no longer treats the move as a plan change, so upgrades and downgrades don't work. The same user can end up subscribed to both, which is a double-billing risk. Within one group Apple also handles the proration for you.On the same screen, each subscription requires a screenshot under its review information. One taken from the Sandbox purchase screen is accepted.
8.4 The only way out of "Missing Metadata"
You can fill in every field on the product and the status still sits here.
Nothing on the screen tells you what's missing. I went back and forth on this one for a while too.
Caution
The only way out is to attach the product to an app version and submit it for review.Distribution → the version → pick a build → In-App Purchases and Subscriptions → attach → submit for review.
A product can't get there on its own. It only becomes submittable together with an app version.
You don't have to wait for the review to be approved, though. The moment you press submit in App Store Connect, purchase testing works in TestFlight. I'd convinced myself that testing had to wait for approval and put off submitting, which cost me a few days for nothing.
8.5 Record three kinds of offering failure, not one
The diagnostic table in section 6 only works because I never collapsed these failures into a single error.
const loadOffering = async () => {
try {
const offerings = await Purchases.getOfferings();
// failure 1: no offering is marked current
if (!offerings.current) {
setLastOfferingError('No current offering configured in RevenueCat dashboard');
return;
}
// failure 2: there is an offering, but no packages (store propagation, etc.)
if ((offerings.current.availablePackages ?? []).length === 0) {
setLastOfferingError(`Offering "${offerings.current.identifier}" has no packages`);
setOffering(offerings.current);
return;
}
setLastOfferingError(null);
setOffering(offerings.current);
} catch (err) {
// failure 3: it threw. A wrong API key shows up here
const e = err as { code?: string; message?: string };
setLastOfferingError(`Offerings fetch failed [${e?.code ?? 'UNKNOWN'}]: ${e?.message}`);
}
};
Different causes need different fixes. Had I logged all of these as "couldn't load products", the triage in section 6 wouldn't have been possible.
8.6 Never hardcode the price in your UI
Prices are decided in App Store Connect and Google Play Console. Your app should render the price string the RevenueCat SDK hands back, and nothing else.
// in the app: don't assemble the amount, just print the display string from the SDK
const label = pkg.product.priceString; // e.g. "¥1,000"
Warning
Picking the same price tier does not produce the same yen amount on the App Store and Google Play. Apple uses price tiers, Google is configured per country, so matching the USD price still leaves the yen prices apart. On top of that, Apple's yen prices shift tiers with exchange rates.Use the string from the SDK and each store shows its own correct amount. Write the number into your code or your design docs and it will be a lie on at least one store.
9. An aside: subscriptions renew at a different speed while testing
I bought a subscription, walked away for a bit, came back, and the billing state had rolled forward several times. Test environments deliberately compress the subscription period.
The confusing part is that the speed is set by the account you bought with, not by the environment. A TestFlight build also runs against sandbox, so "TestFlight" doesn't mean "slow".
| Account used for the purchase | Renewal speed | Stops after |
|---|---|---|
| A TestFlight tester (a normal Apple ID) | Once a day, whatever the period | 6 renewals |
| A Sandbox Apple Account | One month becomes 5 minutes by default. Four rates to choose from | 12 renewals |
What confused me was using a normal Apple ID in TestFlight. I sat there expecting a renewal within minutes, and nothing happened until the next day. Buy with a Sandbox account instead and it cycles several times in minutes, so if you step away you come back to an expired subscription. Check the billing state right after purchase.
You can pick the compression rate in TestFlight too, as long as you sign in with a Sandbox account.
Android's license testers have their own, different table of compressed periods, so iOS intuitions won't carry over. That's for the next part.
One more thing about Sandbox purchases in the simulator. The simulator has no Settings → App Store → Sandbox Account entry. You end up typing the Sandbox tester's credentials straight into the purchase dialog. That was unstable for me, and purchases kept getting treated as cancelled. Recreating the tester usually fixed it, but switching to a real device and TestFlight turned out to be faster than fighting it.
10. Resubmitting, and getting stuck again
Three items fixed, so all that was left was submitting. Or so I thought.
Warning
Replying in Resolution Center is not a resubmission. A reply doesn't move you back into review. It's supplementary, and useful for attaching a screen recording, but you still have to press "Update review content" on the version page.
Builds are locked while review is in progress. To swap one out you withdraw the submission and take the version back to "Prepare for Submission".
Then, when I went to resubmit, the "In-App Purchases and Subscriptions" section had vanished from the page. That's the field I'd finally managed to fill in back in section 8.4, so I assumed it had come unattached.
Note
That section only appears while you have unsubmitted IAPs in "Ready to Submit". Once submitted, an IAP is tracked separately as awaiting review or approved, so the section disappearing means it's attached. That's the normal state. Check the status under Monetization → Subscriptions instead.
11. Wrapping up
The code that calls the SDK came to about 200 lines, initialization and entitlement checks included. The time went into four things, none of which are code.
- Waiting for Apple's business setup to go active (a few hours to two days)
- Working out how to escape "Missing Metadata"
- Finding out why only the distribution build had no products
- Navigating the resubmission process
Recording three distinct kinds of "no products" is what let me look at the log once and conclude "it's none of the three, it isn't running at all". Collapsed into a single error, I'd have taken a much longer route.
The nastiest part, though, was an early return I wrote myself. It was meant as a kindness, not crashing when the key is missing, and what it actually built was a state that leaves no trace anywhere. Honestly, crashing would have told me sooner. That same code path now writes the missing key into the diagnostic log.
Next up: adding billing on Android. The code barely changed, and the configuration still gave me trouble.
New territory is hard work. Good luck out there, fellow Shipaton entrants.




Top comments (0)