<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: nicoinu</title>
    <description>The latest articles on DEV Community by nicoinu (@nicoinu).</description>
    <link>https://dev.to/nicoinu</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4058000%2Fc99badd6-8b65-4e5e-ba8a-8aa20863be2b.png</url>
      <title>DEV Community: nicoinu</title>
      <link>https://dev.to/nicoinu</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/nicoinu"/>
    <language>en</language>
    <item>
      <title>Cutting store commission from 30% to 15%: what each application actually asks</title>
      <dc:creator>nicoinu</dc:creator>
      <pubDate>Thu, 20 Aug 2026 12:43:41 +0000</pubDate>
      <link>https://dev.to/nicoinu/cutting-store-commission-from-30-to-15-what-each-application-actually-asks-abl</link>
      <guid>https://dev.to/nicoinu/cutting-store-commission-from-30-to-15-what-each-application-actually-asks-abl</guid>
      <description>&lt;p&gt;Apple and Google both have a program that drops in-app purchase commission from 30% to 15%. Neither one is automatic. If you do not apply, you keep paying 30%.&lt;/p&gt;

&lt;p&gt;This is the walkthrough: which page to open, what you get asked, and when the rate actually changes. Everything here comes from the official documentation and from the application screens themselves.&lt;/p&gt;

&lt;p&gt;The rates are current as of August 2026. They change, so check the official pages before you act on any number.&lt;/p&gt;




&lt;h2&gt;
  
  
  1. Where the discount actually applies
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;What you sell&lt;/th&gt;
&lt;th&gt;App Store&lt;/th&gt;
&lt;th&gt;Google Play&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;One-time in-app purchases, paid apps&lt;/td&gt;
&lt;td&gt;30% → 15% after applying&lt;/td&gt;
&lt;td&gt;30% → 15% after enrolling&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Subscriptions, first year&lt;/td&gt;
&lt;td&gt;30% → 15% after applying&lt;/td&gt;
&lt;td&gt;15% either way&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Subscriptions, second year onward&lt;/td&gt;
&lt;td&gt;15% either way&lt;/td&gt;
&lt;td&gt;15% either way&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;On the App Store, a subscriber who accumulates more than one year of paid service in the same subscription group already moves you to 15%, program or not.&lt;/p&gt;

&lt;p&gt;Applying to the program gets you that 15% from the first billing cycle instead of the thirteenth month.&lt;/p&gt;

&lt;p&gt;On Google Play, auto-renewing subscriptions are 15% regardless of enrollment. The fee table spells it out: 15% "for automatically renewing subscription products purchased by subscribers, regardless of revenue earned by the developer each year."&lt;/p&gt;

&lt;p&gt;That was not always true. Until December 2021, Google also charged 30% for the first year and 15% from month 13. On January 1, 2022 the distinction disappeared. If you remember Play subscriptions being 30%, that is where the memory comes from.&lt;/p&gt;

&lt;p&gt;So on Google, enrollment matters for everything that is not a subscription.&lt;/p&gt;

&lt;p&gt;Both programs cap out at 1 million USD per year.&lt;/p&gt;

&lt;p&gt;One detail worth getting right: Apple does not count your sales. It counts proceeds, meaning what is left after Apple's commission and certain taxes and adjustments. That is the number you see paid out in App Store Connect.&lt;/p&gt;

&lt;p&gt;Your sales can be above 1 million USD while your proceeds are still below it. Check the payouts rather than assuming you are over the line.&lt;/p&gt;




&lt;h2&gt;
  
  
  2. Applying on the App Store
&lt;/h2&gt;

&lt;p&gt;Only the Account Holder can submit this. Other roles cannot reach the form at all, so if someone else holds that role, sort that out first.&lt;/p&gt;

&lt;h3&gt;
  
  
  2.1 Accept the paid apps agreement first
&lt;/h3&gt;

&lt;p&gt;Open "Agreements, Tax, and Banking" in App Store Connect and check that the latest Paid Applications Agreement (Schedule 2 to the Apple Developer Program License Agreement) is accepted.&lt;/p&gt;

&lt;p&gt;Accept it there if it is not. The first question on the application form is this exact check, and you cannot move past it otherwise.&lt;/p&gt;

&lt;h3&gt;
  
  
  2.2 Answer the two questions
&lt;/h3&gt;

&lt;p&gt;Sign in to your Apple Developer account and go to the Small Business Program page, then Enroll.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://developer.apple.com/app-store/small-business-program/" rel="noopener noreferrer"&gt;https://developer.apple.com/app-store/small-business-program/&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The form asks two questions and nothing else.&lt;/p&gt;

&lt;p&gt;The first is the agreement from 2.1. Pick "Yes, I have accepted." and continue.&lt;/p&gt;

&lt;p&gt;The second asks about Associated Developer Accounts. An account counts as associated if any of these are true.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;You hold more than 50% of its ownership or shares&lt;/li&gt;
&lt;li&gt;It holds more than 50% of your ownership or shares&lt;/li&gt;
&lt;li&gt;You have ultimate decision-making authority over it&lt;/li&gt;
&lt;li&gt;It has ultimate decision-making authority over you&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For each one, you provide the name, Team ID, Account Holder email address, and a description of the relationship.&lt;/p&gt;

&lt;p&gt;If there are none, say so and submit. That is the whole application.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Caution&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;When you do have associated accounts, the 1 million USD threshold is measured against their combined proceeds. Apple states that if your organization controls other accounts, or is controlled by one, the collective proceeds must stay under 1 million USD to qualify.&lt;/p&gt;

&lt;p&gt;App transfers are allowed, but the proceeds for that calendar year attach to both the account that initiates the transfer and the account that accepts it. Moving revenue to a second account does not lower the combined total.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  2.3 Wait for it to take effect
&lt;/h3&gt;

&lt;p&gt;Approval is not the same as the rate changing. Your proceeds are adjusted 15 days after the end of the fiscal month in which the enrollment is approved.&lt;/p&gt;

&lt;p&gt;Apple's own example: approved on February 10, 2022, adjusted starting March 14, 2022.&lt;/p&gt;

&lt;p&gt;The clock starts at approval, not submission, and Apple's fiscal months do not line up with calendar months. How long approval takes is not published anywhere.&lt;/p&gt;




&lt;h2&gt;
  
  
  3. Enrolling on Google Play
&lt;/h2&gt;

&lt;p&gt;This happens on the Play Console page at Developer account &amp;gt; Associated developer accounts.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Without a payments profile you cannot open that page at all.&lt;/strong&gt; If you have not started monetizing yet, set the payments profile up first.&lt;/p&gt;

&lt;h3&gt;
  
  
  3.1 Create an account group
&lt;/h3&gt;

&lt;p&gt;Click "Create account group" and enter a name. Use the legal name for a company, or your developer name for a personal account.&lt;/p&gt;

&lt;p&gt;Click "Create account group" again to confirm. Whichever account you used becomes the main developer account for the group.&lt;/p&gt;

&lt;p&gt;What if you only own one developer account? You still have to create the group. Enrollment in the 15% tier requires one.&lt;/p&gt;

&lt;h3&gt;
  
  
  3.2 Answer the two questions
&lt;/h3&gt;

&lt;p&gt;Creating the group takes you straight to two questions. Does your legal entity own any other Play Console developer accounts, and does any other account publish apps under branding similar to yours.&lt;/p&gt;

&lt;p&gt;Answer no to both, hit Save, and you are done.&lt;/p&gt;

&lt;p&gt;If you answer yes, you add the other accounts by developer account ID or by the package name of one of their apps. The account ID is on the Developer account page, and package names sit under the app name on the All apps page.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Warning&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Adding an account sends an approval request to that account's admins. The group is not complete until every associated account agrees to join, and you cannot enroll in the 15% tier while any request is still pending.&lt;/p&gt;

&lt;p&gt;Requests you sent show up under "Sent requests" on the same page.&lt;/p&gt;

&lt;p&gt;A developer account can only belong to one group at a time. Removing an account from a group can cost it certain benefits, including the 15% enrollment, so do not reshuffle groups casually.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  3.3 Enroll in the 15% tier
&lt;/h3&gt;

&lt;p&gt;Once the group is complete, a card appears on the same page offering enrollment in the 15% service fee. "Review and enroll" opens the confirmation screen.&lt;/p&gt;

&lt;p&gt;That screen tells you the 15% fee applies to the combined annual sales of every developer account in the group, up to 1 million USD, and lists the accounts it covers.&lt;/p&gt;

&lt;p&gt;Google applies it from the enrollment date. There is no waiting period like Apple's. The screen says it directly: enroll after July 1, 2021 and the program starts on the day you enroll.&lt;/p&gt;




&lt;h2&gt;
  
  
  4. What happens when you pass 1 million USD
&lt;/h2&gt;

&lt;p&gt;On Google Play, once the account group's combined revenue passes 1 million USD, every account in the group pays 30% for the rest of that year. The count is per calendar year, so it resets on January 1.&lt;/p&gt;

&lt;p&gt;The App Store also returns you to the standard rate for the rest of the year. The difference is how you get back.&lt;/p&gt;

&lt;p&gt;Falling below 1 million USD is not enough on its own. You re-qualify for 15% the year after that. Cross the line in 2026, drop below it in 2027, and you are back at 15% in 2028.&lt;/p&gt;




&lt;h2&gt;
  
  
  5. Google Play's fee model is changing, and in some places it already has
&lt;/h2&gt;

&lt;p&gt;Google announced a new service fee structure in March 2026. It is rolling out by region: EEA, the UK, and the US switched on June 30, 2026, Australia and Japan follow on September 30, 2026, South Korea on December 31, 2026, and everywhere else on September 30, 2027.&lt;/p&gt;

&lt;p&gt;Under the new model, the rate depends on whether a transaction comes from a "new install" or an "existing install."&lt;/p&gt;

&lt;p&gt;What decides it is not the buyer but the date that user first installed the app from Google Play, or first updated it from Google Play. On or after the launch date in their region makes it a new install.&lt;/p&gt;

&lt;p&gt;New installs look like this. Billing fees are charged separately.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Non-recurring&lt;/th&gt;
&lt;th&gt;Recurring&lt;/th&gt;
&lt;th&gt;First purchase of a paid app&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Standard&lt;/td&gt;
&lt;td&gt;20%&lt;/td&gt;
&lt;td&gt;10%&lt;/td&gt;
&lt;td&gt;20%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Enrolled in the new apps and games programs&lt;/td&gt;
&lt;td&gt;15%&lt;/td&gt;
&lt;td&gt;10%&lt;/td&gt;
&lt;td&gt;15%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;First 1 million USD of annual revenue&lt;/td&gt;
&lt;td&gt;10%&lt;/td&gt;
&lt;td&gt;10%&lt;/td&gt;
&lt;td&gt;10%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Existing installs move the standard rate to 25% and the program rate to 20%. The row for the first 1 million USD of annual revenue stays at 10% in both tables.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Caution&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Transactions that go through Google Play billing carry a billing fee on top of the service fee. It is 5% in the US, the UK, and the EEA. For every other region, including Japan and Australia, Google says the rate will be announced later.&lt;/p&gt;

&lt;p&gt;If you are modeling your net revenue for a region that has not launched yet, that number is still blank.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Subscriptions land in the 10% row of the new table. Look at the US, UK, and EEA numbers, though, and the 5% billing fee brings the total back to 15%, which is exactly where it is today. Whether the change is a discount depends on the billing fee in your region.&lt;/p&gt;

&lt;p&gt;Google has not said whether the "first 1 million USD" row is decided by the existing 15% service fee tier enrollment.&lt;/p&gt;




&lt;h2&gt;
  
  
  6. Before you start
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;[ ] Who the Account Holder is on the Apple side&lt;/li&gt;
&lt;li&gt;[ ] Whether the latest Paid Applications Agreement is accepted&lt;/li&gt;
&lt;li&gt;[ ] Whether you have a Google payments profile&lt;/li&gt;
&lt;li&gt;[ ] If you added associated accounts, whether they have all approved&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;One more thing that is easy to miss: enrolling with the stores does not update your analytics.&lt;/p&gt;

&lt;p&gt;If you use something like RevenueCat, change the commission setting in the dashboard too. Otherwise your reported net revenue keeps getting calculated at 30%. Their blog post covers the settings.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://www.revenuecat.com/blog/engineering/small-business-program" rel="noopener noreferrer"&gt;https://www.revenuecat.com/blog/engineering/small-business-program&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Apple is an agreement plus two questions. Google is a group plus two questions. Neither takes long, and neither happens on its own.&lt;/p&gt;




&lt;h2&gt;
  
  
  References
&lt;/h2&gt;

&lt;p&gt;App Store Small Business Program (Apple)&lt;/p&gt;

&lt;p&gt;&lt;a href="https://developer.apple.com/app-store/small-business-program/" rel="noopener noreferrer"&gt;https://developer.apple.com/app-store/small-business-program/&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Service fees (Google Play Console Help)&lt;/p&gt;

&lt;p&gt;&lt;a href="https://support.google.com/googleplay/android-developer/answer/112622" rel="noopener noreferrer"&gt;https://support.google.com/googleplay/android-developer/answer/112622&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Create and manage account groups (Google Play Console Help)&lt;/p&gt;

&lt;p&gt;&lt;a href="https://support.google.com/googleplay/android-developer/answer/10627869" rel="noopener noreferrer"&gt;https://support.google.com/googleplay/android-developer/answer/10627869&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Google Play's lower service fees (Google Play Console Help)&lt;/p&gt;

&lt;p&gt;&lt;a href="https://support.google.com/googleplay/android-developer/answer/16954621" rel="noopener noreferrer"&gt;https://support.google.com/googleplay/android-developer/answer/16954621&lt;/a&gt;&lt;/p&gt;

</description>
      <category>ios</category>
      <category>android</category>
      <category>mobile</category>
      <category>startup</category>
    </item>
    <item>
      <title>Where to draw the line between test and production, when there is no right answer</title>
      <dc:creator>nicoinu</dc:creator>
      <pubDate>Mon, 03 Aug 2026 06:54:35 +0000</pubDate>
      <link>https://dev.to/nicoinu/where-to-draw-the-line-between-test-and-production-when-there-is-no-right-answer-4fic</link>
      <guid>https://dev.to/nicoinu/where-to-draw-the-line-between-test-and-production-when-there-is-no-right-answer-4fic</guid>
      <description>&lt;p&gt;With billing working on both iOS and Android, the last thing I couldn't settle was where to draw the line between test purchases and production purchases.&lt;/p&gt;

&lt;p&gt;The official docs don't answer it. They name two options, list the pros and cons of each, and stop. There's no criterion for picking one.&lt;/p&gt;

&lt;p&gt;RevenueCat's own support has answered it both ways, depending on the question. Asked how to handle multiple Expo app variants, they said to create separate Apps inside one Project. Asked about running out of webhooks, they said to split by Project. Both are official answers.&lt;/p&gt;

&lt;p&gt;So this isn't an area where you can copy somebody's correct answer. You have to decide from your own conditions, so I turned the branch points into a diagnostic chart. This is a record of that.&lt;/p&gt;

&lt;p&gt;I'm on Expo SDK 54 and &lt;code&gt;react-native-purchases&lt;/code&gt; v10, with a NestJS backend. The backend has a test environment and a production environment, and the mobile app switches between them by build profile.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;"Shipping subscriptions with RevenueCat: from store setup to environment layout" (3 parts)&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;a href="https://dev.to/nicoinu/my-subscribe-button-was-greyed-out-and-that-got-my-app-rejected-d42"&gt;My subscribe button was greyed out, and that got my app rejected&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/nicoinu/android-needed-almost-no-code-changes-and-still-ate-a-full-day-in-configuration-a9m"&gt;Android needed almost no code changes, and still ate a full day in configuration&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;Where to draw the line between test and production, when there is no right answer ← you are here&lt;/li&gt;
&lt;/ol&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  1. Vocabulary first: "Sandbox" and "your test environment" are different things
&lt;/h2&gt;

&lt;p&gt;The thing that muddies environment design most is using the single word "environment" for two different axes. So let me separate the terms before anything else.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Term&lt;/th&gt;
&lt;th&gt;What it is&lt;/th&gt;
&lt;th&gt;What it decides&lt;/th&gt;
&lt;th&gt;Watch out for&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Sandbox / Production&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;The store's transaction type&lt;/td&gt;
&lt;td&gt;Whether a given purchase is a test purchase or a real one&lt;/td&gt;
&lt;td&gt;RevenueCat detects this per transaction. &lt;strong&gt;There's nothing to configure&lt;/strong&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Your test / production environment&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;The API and database you run yourself&lt;/td&gt;
&lt;td&gt;Which backend the app is talking to&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;RevenueCat does not detect this for you&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;RevenueCat Project&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;The top-level box holding Apps, Customers and Entitlements&lt;/td&gt;
&lt;td&gt;The boundary for configuration and customers&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;The Customer ID space is per Project.&lt;/strong&gt; Same Project means a shared space&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;RevenueCat App&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;One store app registered inside a Project&lt;/td&gt;
&lt;td&gt;Maps to a bundle ID or package name&lt;/td&gt;
&lt;td&gt;iOS and Android are separate Apps. &lt;strong&gt;Separate Apps still share an ID space if the Project is the same&lt;/strong&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Entitlement&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;RevenueCat's state for "Premium is active"&lt;/td&gt;
&lt;td&gt;The billing state itself&lt;/td&gt;
&lt;td&gt;Not the same as payment. &lt;strong&gt;It can be granted without one&lt;/strong&gt; (section 2)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;App User ID&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;The ID linking a RevenueCat Customer to your user&lt;/td&gt;
&lt;td&gt;Whose purchase this is&lt;/td&gt;
&lt;td&gt;Pass your database's sequential ID straight through and it can collide across environments&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Webhook filters&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Settings that narrow where RevenueCat sends events&lt;/td&gt;
&lt;td&gt;Which events go where&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;They do nothing for purchase state the app fetches directly through the SDK&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The conclusion up front: &lt;strong&gt;splitting Sandbox from Production does not stop users in your test database and your production database from mixing.&lt;/strong&gt; The store's transaction type and your own backends are separate axes.&lt;/p&gt;

&lt;p&gt;You have to decide which axis you actually want to split before you design Projects, bundle IDs and App User IDs.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. My dashboard was full of customers I didn't recognise
&lt;/h2&gt;

&lt;p&gt;What sent me down this road was something odd on the RevenueCat dashboard.&lt;/p&gt;

&lt;p&gt;The Customers list held entries like &lt;code&gt;1&lt;/code&gt;, &lt;code&gt;2&lt;/code&gt; and &lt;code&gt;14&lt;/code&gt;, &lt;strong&gt;bare numbers with nothing else&lt;/strong&gt;, and I started wondering how test and production were supposed to be distinguished at all.&lt;/p&gt;

&lt;p&gt;The reason turned out to be immediate. I was passing my database's sequential &lt;code&gt;userId&lt;/code&gt; straight into &lt;code&gt;appUserID&lt;/code&gt; on &lt;code&gt;Purchases.configure()&lt;/code&gt;. And as the table in section 1 says, &lt;strong&gt;the App User ID space is scoped to a Project.&lt;/strong&gt; My test and production environments used the same Project, so both sets of &lt;code&gt;userId&lt;/code&gt; were flowing into the same space.&lt;/p&gt;

&lt;p&gt;Here's where it gets bad.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;userId=42 in the test environment makes a test purchase
  → RevenueCat Customer "42" becomes entitled
userId=42 in production (a different person) logs in
  → the SDK resolves Customer "42" and grants entitlement to someone who never paid
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A production database starts fresh, so its IDs begin at 1. &lt;strong&gt;They collide head-on with the IDs I'd been testing with.&lt;/strong&gt; As production users accumulate, sooner or later somebody lands on a test customer.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Warning&lt;/strong&gt;&lt;br&gt;
&lt;strong&gt;Webhook settings cannot prevent this.&lt;/strong&gt; RevenueCat webhooks can filter by environment, app and event type, but the flow above never touches a webhook. It happens &lt;strong&gt;at the moment the app's SDK talks to RevenueCat directly and receives purchase state&lt;/strong&gt;, so no amount of server-side defence will fire.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;This wasn't a bug in my code. It was a consequence of how I'd structured things. That's what sent me looking for how to split it properly, and straight into the "there is no answer" problem from the intro.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Does RevenueCat have a concept of environments?
&lt;/h2&gt;

&lt;p&gt;I went looking for the setting that splits environments. There isn't one. &lt;strong&gt;RevenueCat has no switch for toggling between your test environment and your production environment.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Two sentences in the docs explain why.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;There's no concept of a sandbox or production &lt;em&gt;user&lt;/em&gt; in RevenueCat, since the same App User Id can have both production and non-production receipts.&lt;br&gt;
(&lt;a href="https://www.revenuecat.com/docs/test-and-launch/sandbox" rel="noopener noreferrer"&gt;Sandbox Testing&lt;/a&gt;)&lt;/p&gt;

&lt;p&gt;App User IDs are case-sensitive and are scoped to a whole Project.&lt;br&gt;
(&lt;a href="https://www.revenuecat.com/docs/customers/identifying-customers" rel="noopener noreferrer"&gt;Identifying Customers&lt;/a&gt;)&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The first says one App User ID can hold both test and real receipts. In other words, &lt;strong&gt;RevenueCat Customers are designed to span environments by default.&lt;/strong&gt; The Sandbox data toggle on the dashboard switches which transactions you see. It does not split the Customer.&lt;/p&gt;

&lt;p&gt;The second is the one I walked into. The only boundary for the ID space is the Project.&lt;/p&gt;

&lt;p&gt;Those are the only two axes of separation on offer, which means &lt;strong&gt;"how do I split environments" reduces to "how do I split Projects" and "how do I design App User IDs".&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Laid out concretely, there are four places you can split, and &lt;strong&gt;each row constrains the one below it.&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Where you can split&lt;/th&gt;
&lt;th&gt;Options&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Bundle ID / package name&lt;/td&gt;
&lt;td&gt;One / one per environment (&lt;code&gt;com.example.app&lt;/code&gt; and &lt;code&gt;com.example.app.dev&lt;/code&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Store app registration&lt;/td&gt;
&lt;td&gt;One / one per environment&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;RevenueCat Project&lt;/td&gt;
&lt;td&gt;One / one per environment&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;App User ID namespace&lt;/td&gt;
&lt;td&gt;Raw userId / per-environment prefix / unguessable ID&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Keep one bundle ID and you keep one store app registration. That thins out the practical benefit of splitting Projects, which leaves you having to separate environments at the App User ID level. How you attack that constraint is the choice in the next section.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Looking for a fix, I found two official approaches
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://www.revenuecat.com/docs/guides/environment-strategies" rel="noopener noreferrer"&gt;App Environment Strategies&lt;/a&gt; presents two. On Single Project, the docs say:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Unified API Keys:&lt;/strong&gt; This approach allows you to use the same RevenueCat API keys across all environments, reducing complexity in your app configuration.&lt;/p&gt;

&lt;p&gt;This approach is best for indie projects or cases where the added complexity of multiple projects is unnecessary.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Separate Projects opens with a warning.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;This approach increases the risk of projects becoming out of sync, leading to possible issues between environments.&lt;/p&gt;

&lt;p&gt;Significant duplication is necessary. You will need to manually replicate all aspects for each environment... This includes paywalls, targeting rules, offerings, and more.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;So both options are laid out, but there's no criterion for choosing between them. What surprised me while researching is that the community answers shift with the context of the question.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Context of the question&lt;/th&gt;
&lt;th&gt;Answer&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://community.revenuecat.com/sdks-51/expo-how-to-use-with-multiple-app-variants-6521" rel="noopener noreferrer"&gt;How to handle multiple Expo app variants&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Create an App per variant inside one Project (= Setup B)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://community.revenuecat.com/third-party-integrations-53/webhook-limitations-how-to-handle-webhooks-for-multi-environment-setup-6983" rel="noopener noreferrer"&gt;Webhook limits make a multi-environment setup impossible to wire&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Split Projects per environment (= Setup C)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://community.revenuecat.com/general-questions-7/what-s-the-recommended-way-to-exclude-test-development-users-3395" rel="noopener noreferrer"&gt;How to exclude test users from metrics&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Split Projects (charts can be filtered by Project)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;And in &lt;a href="https://community.revenuecat.com/dashboard-tools-52/best-environments-strategy-2646" rel="noopener noreferrer"&gt;Best environments strategy&lt;/a&gt;, the downside of splitting Projects is put like this:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;No downside in having separate accounts or projects for your dev vs. production environments, apart from having to duplicate the setup which may introduce the possibility of typos, etc.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The same duplication gets a &lt;code&gt;warning&lt;/code&gt; in the documentation and a "no downside" from support, so opinion inside the company looks split. That makes "follow the official recommendation" hard to act on. You're left deciding from your own conditions, which is what the chart in section 5 is for.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. How to actually decide: three setups and a diagnostic chart
&lt;/h2&gt;

&lt;h3&gt;
  
  
  5.1 The chart
&lt;/h3&gt;

&lt;p&gt;What this chart decides is &lt;strong&gt;how many store app registrations and RevenueCat Projects you're going to maintain.&lt;/strong&gt; The more you split, the fewer accidents of things mixing, and the more times you rebuild the same configuration by hand. The question is where you settle that trade-off.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2Fpako%3AeNptkk1PwzAMhv-KlfMQH0eEQBu7cOFD3QWtHNLEpYY0qexkqNr231GCxjbGNY6f97GTtTLBorpWrQtfptMcYTGvPQDAy8W6VvMAY0jgES3EACtkasebhs9vI0qEIbHptKBA7Dik9-6n1CEwagcSA6Pc1Wq7Q8LZ2e2mVo-hVhtYLGu1yJgq3wMTVsiZRFIwDbb5OOfL4CiC9mPsyL_X6u0P7xUlA18u_yhbaltk9LEABz1-aecEAsPAZFCA_H4Y7S0MHGwykYI_sL48Trlf1qrCmAa4L81VkWtGeObwgSbu9XaNle4RSKAlj8Xz6nS1jQvms_B6EouOVsi5ks2aRM7-LEUiJxMTa-fGA8WrY8XZr-LsUDF56xAe5jIpp8HjqfOO9MzBoAhYEkODI18mQJ9fOUdMfyOmBfbkcR_wH19NVI_ca7Lqeq1ih33-dxZbnVxU2-03f-_Z6A%3D%3D%3Ftype%3Dpng" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2Fpako%3AeNptkk1PwzAMhv-KlfMQH0eEQBu7cOFD3QWtHNLEpYY0qexkqNr231GCxjbGNY6f97GTtTLBorpWrQtfptMcYTGvPQDAy8W6VvMAY0jgES3EACtkasebhs9vI0qEIbHptKBA7Dik9-6n1CEwagcSA6Pc1Wq7Q8LZ2e2mVo-hVhtYLGu1yJgq3wMTVsiZRFIwDbb5OOfL4CiC9mPsyL_X6u0P7xUlA18u_yhbaltk9LEABz1-aecEAsPAZFCA_H4Y7S0MHGwykYI_sL48Trlf1qrCmAa4L81VkWtGeObwgSbu9XaNle4RSKAlj8Xz6nS1jQvms_B6EouOVsi5ks2aRM7-LEUiJxMTa-fGA8WrY8XZr-LsUDF56xAe5jIpp8HjqfOO9MzBoAhYEkODI18mQJ9fOUdMfyOmBfbkcR_wH19NVI_ca7Lqeq1ih33-dxZbnVxU2-03f-_Z6A%3D%3D%3Ftype%3Dpng" alt="diagram" width="772" height="1152"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The three questions work differently from one another.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Question 0: do you need to go through the real stores?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;If all you want is to exercise the flow from tapping buy to the entitlement appearing, you don't need store configuration at all. RevenueCat's Test Store (section 6) lets you pick success, failure or cancellation without touching App Store Connect or Play Console. It runs in CI too.&lt;/p&gt;

&lt;p&gt;Anyone who answers "no" here doesn't need to split anything yet. &lt;strong&gt;The need shows up when you start handing builds to real devices through TestFlight or internal testing and want the store's real purchase flow.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Question 1: do you want different paywalls or prices?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;This comes first because &lt;strong&gt;the paywall composition (the Offering) and the rules that target it are per-Project settings in RevenueCat.&lt;/strong&gt; If you need a different set of plans in test, or want to try a different price, splitting Projects is the only way to get it. Nothing else is negotiable after that, so it settles first.&lt;/p&gt;

&lt;p&gt;If "test just needs to show the same screen as production" is fine, the later options stay open. In practice, solo projects rarely need per-environment pricing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Question 2: do you need to block misdelivery structurally?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;With one bundle ID, your test build and your production build are the same app as far as the stores are concerned. &lt;strong&gt;You cannot structurally prevent uploading a test build to a production track.&lt;/strong&gt; Careful process is all you have.&lt;/p&gt;

&lt;p&gt;What decides it is team size and release procedure. If you're the only one releasing and can check the steps every time, Setup A is enough. If several people touch it, or releases are automated and nobody is looking, splitting bundle IDs into physically separate apps earns its cost.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Note&lt;/strong&gt;&lt;br&gt;
I ended up at Setup A by answering "same is fine" to question 1 and "process discipline is enough" to question 2. But that was partly because the decision to keep one bundle ID had already been made upstream. Starting from zero, I think I'd have taken question 2 more seriously.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  5.2 Setup A: one bundle ID, one Project
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;What you create&lt;/td&gt;
&lt;td&gt;One store app, one set of IAP products, one RevenueCat Project, one API key per platform&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Non-negotiable&lt;/td&gt;
&lt;td&gt;Give App User IDs an environment namespace (section 7), restrict Sandbox Testing Access (section 6)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;The price you pay&lt;/td&gt;
&lt;td&gt;No structural block on misdelivering test builds. Can't install test and production side by side on one device. Can't split TestFlight tester groups by environment&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Per-environment paywall&lt;/td&gt;
&lt;td&gt;Not possible&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Fits when&lt;/td&gt;
&lt;td&gt;Solo development. Billing gets verified infrequently&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Revisit when&lt;/td&gt;
&lt;td&gt;The team grows. You need a per-environment paywall. A misdelivery actually happens&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;This is what the docs recommend for indie projects. If the decision to keep one bundle ID is already made upstream, this is where you land.&lt;/p&gt;

&lt;h3&gt;
  
  
  5.3 Setup B: split bundle IDs, one Project
&lt;/h3&gt;

&lt;p&gt;Register &lt;code&gt;com.example.app&lt;/code&gt; and &lt;code&gt;com.example.app.dev&lt;/code&gt; with the stores, and put two Apps inside the same RevenueCat Project (the &lt;code&gt;.dev&lt;/code&gt; suffix is just one convention). This is the shape RevenueCat support recommends when asked about Expo app variants.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;What you create&lt;/td&gt;
&lt;td&gt;Two store apps, &lt;strong&gt;two sets of IAP products&lt;/strong&gt;, one RevenueCat Project holding two Apps, two API keys&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Non-negotiable&lt;/td&gt;
&lt;td&gt;Give App User IDs an environment namespace (&lt;strong&gt;the Project is the same, so the ID space is still shared&lt;/strong&gt;), keep the test app unreleased&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;The price you pay&lt;/td&gt;
&lt;td&gt;Duplicated store registrations and IAP definitions. Two review processes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Per-environment paywall&lt;/td&gt;
&lt;td&gt;Not possible (Offerings are a per-Project setting)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Fits when&lt;/td&gt;
&lt;td&gt;Team development. You want the misdelivery path structurally closed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Revisit when&lt;/td&gt;
&lt;td&gt;You need a per-environment paywall&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Creating two sets of IAP products is not optional.&lt;/strong&gt; Split the bundle ID without creating store entries and you fetch zero products. That's a store rule, not a RevenueCat one, so there's no way around it. On iOS, RevenueCat support states it plainly in &lt;a href="https://community.revenuecat.com/general-questions-7/how-should-i-use-my-staging-app-with-revenue-cat-bundle-id-mismatch-1181" rel="noopener noreferrer"&gt;this thread&lt;/a&gt;, and a wildcard bundle ID won't substitute. Android is the same.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;When calling play store from the SDK, billing client uses the applicationId. On the backend we do the same. So if they are different, it won't fetch any product.&lt;br&gt;
(&lt;a href="https://community.revenuecat.com/sdks-51/android-app-with-different-package-ids-for-development-and-production-not-able-to-fetch-products-2390" rel="noopener noreferrer"&gt;Android app with different package ids&lt;/a&gt;)&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Google Play does not treat a &lt;code&gt;.dev&lt;/code&gt; suffix as a development build. It's a different app. And the docs say to keep the test app unreleased ("these separate apps should stay unreleased with only the PROD app released into production"), so you take on managing an app that sits in the store without ever passing review.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Warning&lt;/strong&gt;&lt;br&gt;
&lt;strong&gt;Setup B still needs App User ID design.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;What gets separated is the store-side app and the RevenueCat App. &lt;strong&gt;The RevenueCat Project stays as one, so the Customer ID space that Project owns is still shared.&lt;/strong&gt; A Customer created from the test app and one created from the production app are the same Customer if the ID matches.&lt;/p&gt;

&lt;p&gt;"I split the store apps, so nothing mixes" is wrong. What decides whether things mix is the Project.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  5.4 Setup C: one Project per environment
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;What you create&lt;/td&gt;
&lt;td&gt;Two store apps, two sets of IAP products, &lt;strong&gt;two RevenueCat Projects&lt;/strong&gt;, two API keys&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Non-negotiable&lt;/td&gt;
&lt;td&gt;Replicate paywalls, Offerings, Entitlements and targeting rules across both Projects, and keep them in sync afterwards&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;The price you pay&lt;/td&gt;
&lt;td&gt;Duplicate maintenance. The docs themselves warn it "increases the risk of projects becoming out of sync"&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Per-environment paywall&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Possible. This is the one decisive advantage of this setup&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;App User ID design&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Not needed&lt;/strong&gt; (the ID space is split by Project)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Fits when&lt;/td&gt;
&lt;td&gt;You want to try per-environment paywalls or prices. You prioritise complete data separation&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The easiest thing to overlook in that replication is the paywall. &lt;strong&gt;RevenueCat paywalls are remote configuration, so the display changes without shipping a new build.&lt;/strong&gt; If you build out a paywall in the test Project and forget to mirror it to production, the change never enters your release flow, so no review and no test will catch it.&lt;/p&gt;

&lt;p&gt;There is one mitigating factor. Projects under the same App Store Connect account can share an In-App Purchase Key.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Note&lt;/strong&gt;&lt;br&gt;
One risk doesn't go away even with split Projects: &lt;strong&gt;shipping a production build that carries the test Project's API key.&lt;/strong&gt; RevenueCat support names this as the biggest concern with Project separation. Unless you nail down how keys get injected, the separation buys you less than it looks. How I pass keys through EAS is in part 1.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  5.5 The three side by side
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Setup A&lt;/th&gt;
&lt;th&gt;Setup B&lt;/th&gt;
&lt;th&gt;Setup C&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Bundle IDs&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Store app registrations&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;IAP product definitions&lt;/td&gt;
&lt;td&gt;1 set&lt;/td&gt;
&lt;td&gt;2 sets&lt;/td&gt;
&lt;td&gt;2 sets&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;RevenueCat Projects&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;App User ID namespacing&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Required&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Required&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Not needed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Per-environment paywall (Offering)&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Structural block on misdelivery&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Duplicate configuration&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;td&gt;Store side only&lt;/td&gt;
&lt;td&gt;Store side and RevenueCat side&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Scale it fits&lt;/td&gt;
&lt;td&gt;Solo&lt;/td&gt;
&lt;td&gt;Team&lt;/td&gt;
&lt;td&gt;Team&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The Test Store and StoreKit Configuration Files combine with any of the three (section 6).&lt;/p&gt;

&lt;h2&gt;
  
  
  6. Three mechanisms that save you from splitting
&lt;/h2&gt;

&lt;p&gt;There are risks you can close out while staying on Setup A. This was the most valuable part of the research.&lt;/p&gt;

&lt;h3&gt;
  
  
  6.1 Sandbox Testing Access
&lt;/h3&gt;

&lt;p&gt;A Project's General settings hold a three-way choice for whether non-production purchases grant entitlements (&lt;a href="https://www.revenuecat.com/docs/projects/sandbox-access" rel="noopener noreferrer"&gt;Sandbox Testing Access&lt;/a&gt;).&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Setting&lt;/th&gt;
&lt;th&gt;Behaviour&lt;/th&gt;
&lt;th&gt;Official use case&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Anybody (default)&lt;/td&gt;
&lt;td&gt;Every non-production purchase grants entitlements and virtual currency&lt;/td&gt;
&lt;td&gt;"recommended for early development or internal QA testing"&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Allowed App User IDs only&lt;/td&gt;
&lt;td&gt;Only allowlisted app_user_ids are granted&lt;/td&gt;
&lt;td&gt;"useful when running restricted tests (e.g: Google Play closed testing)"&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Nobody&lt;/td&gt;
&lt;td&gt;Non-production purchases grant nothing&lt;/td&gt;
&lt;td&gt;When test purchases must not affect entitlements&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;No money moves in a sandbox purchase, so what this setting decides is only &lt;strong&gt;whether that purchase creates billing state.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The default is Anybody, which means that if you've configured nothing, every test purchase you've made during development has created billing state.&lt;/strong&gt; Those &lt;code&gt;1&lt;/code&gt;, &lt;code&gt;2&lt;/code&gt; and &lt;code&gt;14&lt;/code&gt; customers from section 2 were sitting there entitled, off test purchases, before I'd sold anything to anybody.&lt;/p&gt;

&lt;p&gt;Ship to production like that and this happens.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[Before launch] test purchases during development leave
  Customers "1", "2" and "14" holding active entitlements

[After launch] the production database starts issuing IDs at 1
  The first real user gets userId=1
  → the app calls Purchases.logIn("1")
  → it resolves the existing Customer "1"
  → the entitlement created during development is still valid. Premium without paying
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Structurally this is the collision from section 2, except &lt;strong&gt;this one starts with your day-one signups&lt;/strong&gt; and works its way up, because the low IDs are the ones you hammered during development.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Tightening this setting also removes entitlements that were already granted.&lt;/strong&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Previously granted entitlements will be removed. If a customer no longer qualifies under the updated setting, any active entitlements previously granted from non-production purchases will be automatically removed.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;So it isn't only prevention. &lt;strong&gt;It also cleans up the billing state you accumulated during development.&lt;/strong&gt; Virtual currency stays, though ("Virtual currency already granted will remain.").&lt;/p&gt;

&lt;p&gt;Which gives you this by phase.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Phase&lt;/th&gt;
&lt;th&gt;Setting&lt;/th&gt;
&lt;th&gt;Why&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;During development&lt;/td&gt;
&lt;td&gt;Anybody (leave the default)&lt;/td&gt;
&lt;td&gt;You want your own test purchases to grant entitlements. Tighten it and you can't verify anything&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Before launching to production&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Allowed App User IDs only&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Clear the accumulated state and stop real users from picking it up&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Nobody isn't really selectable. &lt;strong&gt;Purchases from TestFlight and Google Play internal testing are sandbox purchases even when the distributed build is production-signed&lt;/strong&gt; (part 1). Set it to Nobody and testers never become entitled, so you can't hand out a build and verify anything. Test Store purchases are non-production too, so they stop as well. As long as you're still iterating on billing, Allowed App User IDs only is as tight as you can go.&lt;/p&gt;

&lt;p&gt;Tightening after launch does remove the entitlements, but it doesn't undo the fact that somebody had Premium for free until then. &lt;strong&gt;Nothing failed, so there's no error and no log, and no way to notice.&lt;/strong&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Warning&lt;/strong&gt;&lt;br&gt;
&lt;strong&gt;This setting alone isn't enough. The allowlist matches on the app_user_id string.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;If both test and production pass a bare &lt;code&gt;42&lt;/code&gt;, they're the same string, so "allow only the test 42" is not expressible. Only once you add the prefix from section 7 and get &lt;code&gt;dev_42&lt;/code&gt; and &lt;code&gt;prod_42&lt;/code&gt; can you register just the &lt;code&gt;dev_&lt;/code&gt; side.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;6.1 is not a replacement for section 7. It's a second layer on top of it.&lt;/strong&gt; The order is: introduce the prefix from section 7, then this setting, then launch.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The operational cost of the allowlist is adding IDs by hand as testers accumulate. It's still cheaper than maintaining paywalls and Offerings twice, and this one issue is not a reason to split Projects.&lt;/p&gt;

&lt;h3&gt;
  
  
  6.2 Webhook filters
&lt;/h3&gt;

&lt;p&gt;The &lt;a href="https://www.revenuecat.com/docs/integrations/webhooks" rel="noopener noreferrer"&gt;Webhooks&lt;/a&gt; settings offer two narrowing controls: send to one App in the Project or all of them, and send production purchases only, sandbox only, or both.&lt;/p&gt;

&lt;p&gt;So on a single Project you can route sandbox events to your development API and production events to your production API. &lt;strong&gt;Webhook separation is not a reason to split Projects.&lt;/strong&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Warning&lt;/strong&gt;&lt;br&gt;
That said, &lt;strong&gt;a Project is capped at five webhooks, and you can't reuse the same URL.&lt;/strong&gt; Five is plenty for a single app, but it needs watching if you keep several apps in one Project.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  6.3 Test Store
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://www.revenuecat.com/docs/test-and-launch/sandbox/test-store" rel="noopener noreferrer"&gt;Test Store&lt;/a&gt;, which landed in December 2025, changes the premise of this whole discussion. I found out about it after I'd already finished the store configuration, so I never used it. It should help anyone walking the same path from here, so I'm writing up what I found.&lt;/p&gt;

&lt;p&gt;It's a fake store RevenueCat provides, processing purchases in place of the App Store and Google Play.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;test purchases behave like real purchases and subscriptions: they update &lt;code&gt;CustomerInfo&lt;/code&gt;, trigger entitlements, and appear in your RevenueCat dashboard&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Without registering a single product with a store, you can exercise the buy button, the &lt;code&gt;CustomerInfo&lt;/code&gt; update, and the entitlement grant.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Where it earns its keep is local development and CI.&lt;/strong&gt; Real store test purchases need a human to work the payment sheet, so they can't be automated, whereas the Test Store lets you specify success, failure or cancellation and drive it from test code. Branches like "keep Premium until the period ends even after cancellation" become checkable without waiting on sandbox's accelerated renewals.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What it can't confirm is equally clear.&lt;/strong&gt; It doesn't go through real payment processing or the store's purchase sheet, so &lt;strong&gt;it tells you nothing about whether your product configuration in App Store Connect or Play Console is correct.&lt;/strong&gt; It doesn't replace distribution testing through TestFlight or internal testing either. Every hour I burned in parts 1 and 2 was on that side of the line, so the Test Store wouldn't have saved any of it.&lt;/p&gt;

&lt;p&gt;There are two constraints. The first is renewal count.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Each test subscription will renew automatically up to 5 times&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;It stops after five, then cancels, and the entitlement goes inactive. The second is that you must not ship it.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Warning&lt;/strong&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Never submit an app to the App Store or Google Play that is configured with a Test Store API key.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;I've seen secondary sources say the API key is prefixed &lt;code&gt;test_&lt;/code&gt;, but &lt;strong&gt;the official documentation page doesn't state it&lt;/strong&gt;. You'll want to check the actual value in the dashboard.&lt;/p&gt;


&lt;/blockquote&gt;

&lt;p&gt;To summarise, the Test Store is for development and CI. If you're at the "I just want to run the purchase flow locally and in CI" stage, you don't yet need to claim a dev bundle ID and create store entries. It doesn't make the Setup B and C discussion go away. It moves the point where you have to have it.&lt;/p&gt;

&lt;h2&gt;
  
  
  7. What I actually implemented: environments in the App User ID
&lt;/h2&gt;

&lt;p&gt;Having decided not to change the setup, the collision from section 2 had to be stopped on the App User ID side. In Setups A and B, this is the last line of defence against customers mixing.&lt;/p&gt;

&lt;p&gt;The idea is plain: &lt;strong&gt;prefix the ID with the environment and treat it as a separate namespace.&lt;/strong&gt; Test becomes &lt;code&gt;dev_42&lt;/code&gt;, production becomes &lt;code&gt;prod_42&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  7.1 Implementation
&lt;/h3&gt;

&lt;p&gt;This only holds if the app and the server follow the same convention, so I put it somewhere both can reach.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// shared module, referenced by both the app and the server&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;APP_ENVIRONMENTS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;DEV&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;dev&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;PROD&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;prod&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;AppEnvironment&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;APP_ENVIRONMENTS&lt;/span&gt;&lt;span class="p"&gt;)[&lt;/span&gt;&lt;span class="kr"&gt;keyof&lt;/span&gt; &lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;APP_ENVIRONMENTS&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;SEPARATOR&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;_&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="cm"&gt;/**
 * Mobile passes EXPO_PUBLIC_APP_ENV, the API passes NODE_ENV.
 * Only "production" and "prod" count as prod. Everything else falls back to dev.
 */&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;resolveAppEnvironment&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;AppEnvironment&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;normalized&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nf"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;toLowerCase&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;normalized&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;production&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;normalized&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;prod&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;APP_ENVIRONMENTS&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;PROD&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;APP_ENVIRONMENTS&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;DEV&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;buildRevenueCatAppUserId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;AppEnvironment&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
  &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;}${&lt;/span&gt;&lt;span class="nx"&gt;SEPARATOR&lt;/span&gt;&lt;span class="p"&gt;}${&lt;/span&gt;&lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="cm"&gt;/** Returns null for another environment's prefix, anonymous IDs, and unprefixed IDs. */&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;parseRevenueCatAppUserId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;appUserId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;AppEnvironment&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;prefix&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;}${&lt;/span&gt;&lt;span class="nx"&gt;SEPARATOR&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;appUserId&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startsWith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;prefix&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;raw&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;appUserId&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;prefix&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="c1"&gt;// Number.parseInt would happily accept "14abc" as 14, so match strictly&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="sr"&gt;/^&lt;/span&gt;&lt;span class="se"&gt;\d&lt;/span&gt;&lt;span class="sr"&gt;+$/&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;userId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parseInt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nb"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isSafeInteger&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;userId&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;userId&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three deliberate choices in there. &lt;strong&gt;Environment resolution falls back to dev.&lt;/strong&gt; If the check fails, landing in dev does less damage than polluting the production namespace. &lt;strong&gt;The parser doesn't use bare &lt;code&gt;parseInt&lt;/code&gt;.&lt;/strong&gt; &lt;code&gt;Number.parseInt("14abc", 10)&lt;/code&gt; returns &lt;code&gt;14&lt;/code&gt;. And &lt;strong&gt;an unprefixed, bare ID is not processed in any environment&lt;/strong&gt;, so an event arriving from an older build that predates the convention never gets attached to the wrong person.&lt;/p&gt;

&lt;p&gt;Rebind whenever the login state changes.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// src/providers/purchases-provider.tsx&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;currentAppUserId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;getRevenueCatAppUserId&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;currentAppUserId&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;lastAppUserIdRef&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;currentAppUserId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;customerInfo&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;info&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;Purchases&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;logIn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;currentAppUserId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nf"&gt;setCustomerInfo&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;info&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;info&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;Purchases&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;logOut&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="nf"&gt;setCustomerInfo&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;info&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="nx"&gt;lastAppUserIdRef&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;currentAppUserId&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Note&lt;/strong&gt;&lt;br&gt;
&lt;strong&gt;&lt;code&gt;logOut()&lt;/code&gt; generates a new anonymous ID.&lt;/strong&gt; Calling logOut then logIn to switch accounts leaves one stray anonymous customer behind. For a switch, call &lt;code&gt;logIn()&lt;/code&gt; directly.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  7.2 Where this approach stops
&lt;/h3&gt;

&lt;p&gt;It isn't a cure-all, so here are its limits.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Warning&lt;/strong&gt;&lt;br&gt;
&lt;strong&gt;The environment prefix approach is not in the official documentation.&lt;/strong&gt; Neither &lt;a href="https://www.revenuecat.com/docs/customers/identifying-customers" rel="noopener noreferrer"&gt;Identifying Customers&lt;/a&gt; nor the environment strategies page describes it. It's my own workaround for the single-Project constraint, not an official recommendation.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;And a prefix only solves namespace collision. &lt;strong&gt;Unguessability remains a separate requirement.&lt;/strong&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;App User IDs should not be guessable&lt;br&gt;
A non-guessable pseudo-random ID, like a UUID (RFC 4122 version 4), is recommended&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The docs say this because &lt;code&gt;app_user_id&lt;/code&gt; is itself the key that resolves billing state. Adding a prefix doesn't help if the ID body is easy to guess. &lt;strong&gt;The two solve different problems, so you stack them.&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Element&lt;/th&gt;
&lt;th&gt;Problem it solves&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;The &lt;code&gt;dev_&lt;/code&gt; / &lt;code&gt;prod_&lt;/code&gt; prefix&lt;/td&gt;
&lt;td&gt;ID collision across environments (section 2)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;An unguessable ID body (UUIDv4 or similar)&lt;/td&gt;
&lt;td&gt;The official "should not be guessable" requirement&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Combined, you get something like &lt;code&gt;prod_3f2b1c8e-...&lt;/code&gt;. The code above uses sequential IDs to show the mechanism, but &lt;strong&gt;a setup that ships to a store should use an unguessable body.&lt;/strong&gt; That also happens to satisfy the general rule about not handing your database's primary key to an external service.&lt;/p&gt;

&lt;p&gt;There are values you must not use for &lt;code&gt;app_user_id&lt;/code&gt;. RevenueCat blocks &lt;code&gt;no_user&lt;/code&gt;, &lt;code&gt;null&lt;/code&gt;, &lt;code&gt;none&lt;/code&gt;, &lt;code&gt;nil&lt;/code&gt;, &lt;code&gt;(null)&lt;/code&gt;, &lt;code&gt;NaN&lt;/code&gt;, the empty string, &lt;code&gt;unidentified&lt;/code&gt;, &lt;code&gt;undefined&lt;/code&gt;, &lt;code&gt;unknown&lt;/code&gt;, &lt;code&gt;anonymous&lt;/code&gt;, &lt;code&gt;guest&lt;/code&gt;, &lt;code&gt;-1&lt;/code&gt;, &lt;code&gt;0&lt;/code&gt;, &lt;code&gt;[]&lt;/code&gt;, &lt;code&gt;{}&lt;/code&gt;, &lt;code&gt;[object Object]&lt;/code&gt;, and &lt;strong&gt;any ID containing &lt;code&gt;/&lt;/code&gt;&lt;/strong&gt;. The length limit is 100 characters. Don't use email addresses or the IDFA (guessable, and the IDFA rotates). A hardcoded constant is forbidden too: it makes every install the same user, so people who never purchased end up entitled.&lt;/p&gt;

&lt;h2&gt;
  
  
  8. Wrapping up
&lt;/h2&gt;

&lt;h3&gt;
  
  
  8.1 The hardest part
&lt;/h3&gt;

&lt;p&gt;Almost none of the difficulty in this article was in the implementation.&lt;/p&gt;

&lt;p&gt;The hardest part was that &lt;strong&gt;researching it didn't produce an answer.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Getting rejected on iOS (part 1) and losing a day to Android configuration (part 2) were problems where I was stuck, but a correct answer existed somewhere. Read the error, search the docs, and failing that read your own logs, and you land.&lt;/p&gt;

&lt;p&gt;Environment layout wasn't like that. The documentation and support don't fully agree with each other, and there was almost nothing written about it in Japanese.&lt;/p&gt;

&lt;p&gt;What makes it worse is how hard the mistakes are to detect. Get the configuration wrong and purchases still succeed. The app still works. You can eventually spot that a user ID from your test environment and a different person in production have become the same customer, but not one error was raised along the way.&lt;/p&gt;

&lt;p&gt;I wrote this up because I think the error handling, the review requirements and one worked example of reasoning about environment layout are worth leaving behind.&lt;/p&gt;

&lt;h3&gt;
  
  
  8.2 What to decide on
&lt;/h3&gt;

&lt;p&gt;Which setup fits obviously depends on the project, but: Setup C if you need per-environment paywalls, Setup B if you want misdelivery of test builds structurally blocked, Setup A otherwise.&lt;/p&gt;

&lt;p&gt;Here are the inputs I used, gathered up.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Splitting bundle IDs splits your store app registrations. There's no avoiding it&lt;/li&gt;
&lt;li&gt;Splitting Projects duplicates paywalls, Offerings and targeting rules. And because the paywall is remote configuration, a missed update never enters your release flow&lt;/li&gt;
&lt;li&gt;The "test purchases entitle production users" risk closes with two layers: the App User ID prefix and Sandbox Testing Access. That setting also clears state that's already been granted, so tighten it before launch. This alone is not a reason to split Projects&lt;/li&gt;
&lt;li&gt;Webhooks can be routed per App and per environment inside one Project. The cap of five is the thing to watch&lt;/li&gt;
&lt;li&gt;If you only need local and CI testing, the Test Store lets you skip store configuration entirely&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In Setups A and B, App User ID design is the defence line. It's also a workaround of my own that isn't in the docs. Since the prefix only covers environment collision, the official "use an unguessable ID" requirement has to be satisfied separately, in the ID body.&lt;/p&gt;

&lt;h2&gt;
  
  
  References
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://www.revenuecat.com/docs/guides/environment-strategies" rel="noopener noreferrer"&gt;App Environment Strategies&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.revenuecat.com/docs/customers/identifying-customers" rel="noopener noreferrer"&gt;Identifying Customers&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.revenuecat.com/docs/test-and-launch/sandbox" rel="noopener noreferrer"&gt;Sandbox Testing&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.revenuecat.com/docs/projects/sandbox-access" rel="noopener noreferrer"&gt;Sandbox Testing Access&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.revenuecat.com/docs/test-and-launch/sandbox/test-store" rel="noopener noreferrer"&gt;RevenueCat Test Store&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.revenuecat.com/docs/integrations/webhooks" rel="noopener noreferrer"&gt;Webhooks&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>revenuecat</category>
      <category>reactnative</category>
      <category>expo</category>
      <category>iap</category>
    </item>
    <item>
      <title>Android needed almost no code changes, and still ate a full day in configuration</title>
      <dc:creator>nicoinu</dc:creator>
      <pubDate>Sun, 02 Aug 2026 14:16:25 +0000</pubDate>
      <link>https://dev.to/nicoinu/android-needed-almost-no-code-changes-and-still-ate-a-full-day-in-configuration-a9m</link>
      <guid>https://dev.to/nicoinu/android-needed-almost-no-code-changes-and-still-ate-a-full-day-in-configuration-a9m</guid>
      <description>&lt;p&gt;Once subscriptions were working on iOS, I added Android billing to the same app.&lt;/p&gt;

&lt;p&gt;The app code I changed came to one place, in practice: the branch that picks an API key per platform. And I had already written that back when I did iOS.&lt;/p&gt;

&lt;p&gt;The configuration is where the day went. Play Console, RevenueCat, and a handful of Android behaviours I didn't know about. This is a record of that.&lt;/p&gt;

&lt;p&gt;I'm on Expo SDK 54, &lt;code&gt;react-native-purchases&lt;/code&gt; v10, with a NestJS backend.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;"Shipping subscriptions with RevenueCat: from store setup to environment layout" (3 parts)&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;a href="https://dev.to/nicoinu/my-subscribe-button-was-greyed-out-and-that-got-my-app-rejected-d42"&gt;My subscribe button was greyed out, and that got my app rejected&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;Android needed almost no code changes, and still ate a full day in configuration ← you are here&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/nicoinu/where-to-draw-the-line-between-test-and-production-when-there-is-no-right-answer-4fic"&gt;Where to draw the line between test and production, when there is no right answer&lt;/a&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  1. First, the vocabulary: a Play product has two levels
&lt;/h2&gt;

&lt;p&gt;In part 1 I described how RevenueCat's Product, Entitlement and Offering do three different jobs. Adding Android doesn't change that structure. &lt;strong&gt;You just get more Products.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;What changes is how you create a Product, and this is where Google diverges sharply from Apple.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;App Store Connect&lt;/th&gt;
&lt;th&gt;Google Play Console&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Shape of a product&lt;/td&gt;
&lt;td&gt;One product, one level&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;A "base plan" inside a "subscription": two levels&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Monthly vs yearly&lt;/td&gt;
&lt;td&gt;Put them in the same Subscription Group&lt;/td&gt;
&lt;td&gt;Create them as separate subscriptions (&lt;strong&gt;there is no group concept&lt;/strong&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ID you register in RevenueCat&lt;/td&gt;
&lt;td&gt;&lt;code&gt;premium_monthly&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;&lt;code&gt;premium_monthly:monthly&lt;/code&gt;&lt;/strong&gt; (subscription ID : base plan ID)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;On Google Play you first create a container called a subscription, then create a base plan inside it that holds the billing period and the price. The base plan is the thing that actually charges.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Note&lt;/strong&gt;&lt;br&gt;
&lt;code&gt;premium_monthly&lt;/code&gt; and &lt;code&gt;monthly&lt;/code&gt; in that table are &lt;strong&gt;names I made up for this article&lt;/strong&gt;. Neither is a reserved word, so you pick both strings yourself.&lt;/p&gt;

&lt;p&gt;What matters is that when you register the product in RevenueCat, those two levels become &lt;strong&gt;a single ID joined by a colon&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Those two levels are also what cost me half a day later on.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  2. The code I changed came to one place
&lt;/h2&gt;

&lt;p&gt;I'd written the platform split during the iOS work. This is the only thing I touched in the app for Android.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// src/providers/purchases-provider.tsx&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;getApiKey&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;Platform&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;OS&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ios&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;EXPO_PUBLIC_REVENUECAT_IOS_API_KEY&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;Platform&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;OS&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;android&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;EXPO_PUBLIC_REVENUECAT_ANDROID_API_KEY&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Since that was already there, all I really did was add the Android key to my environment variables.&lt;/p&gt;

&lt;p&gt;The server needed zero changes. In the webhook RevenueCat sends, the field naming the store flips from &lt;code&gt;APP_STORE&lt;/code&gt; to &lt;code&gt;PLAY_STORE&lt;/code&gt;, and the existing handling takes it from there. &lt;strong&gt;Neither the app nor the server looks at anything but the entitlement&lt;/strong&gt;, so adding a store doesn't add a branch.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Note&lt;/strong&gt;&lt;br&gt;
You don't have to add the Android billing permission by hand either. Autolinking in &lt;code&gt;react-native-purchases&lt;/code&gt; merges it into &lt;code&gt;AndroidManifest.xml&lt;/code&gt;. There was no native configuration work at all.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;So the substance of "adding Android" was configuration from the start.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Snag 1: not a single product came back
&lt;/h2&gt;

&lt;p&gt;I created the subscription and base plan in Play Console and activated them. I created a GCP service account, connected it to RevenueCat, registered the Android products, and attached them to an entitlement.&lt;/p&gt;

&lt;p&gt;I opened the purchase screen and got zero products. The log had this.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Offerings fetch failed [23] ConfigurationError
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With this body.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;You have configured the SDK with a Play Store API key, but there are no
Play Store products registered in the RevenueCat dashboard for your offerings.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Open RevenueCat's Products screen and they're right there. The Entitlements column even says "1 Entitlements".&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fsf5kak1ey37wueohe5fg.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fsf5kak1ey37wueohe5fg.png" alt="The RevenueCat Products list, with " width="799" height="213"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Registered. Attached. Still not delivered. I spent about an hour going back and forth over the Play Console side. Is the base plan active, is there a price on it, is the app published to a track. All of it was fine.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Six words: "for your offerings"
&lt;/h2&gt;

&lt;p&gt;Then I reread the error.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;there are no Play Store products registered in the RevenueCat dashboard &lt;strong&gt;for your offerings&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;It doesn't say there are no products. It says there are no products &lt;strong&gt;for your offerings&lt;/strong&gt;. I had been reading straight past those last three words.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Caution&lt;/strong&gt;&lt;br&gt;
RevenueCat configuration has &lt;strong&gt;three stages&lt;/strong&gt;, and stopping at the second one gets you zero products.&lt;/p&gt;


&lt;pre class="highlight plaintext"&gt;&lt;code&gt;1. Products      register the product      premium_monthly:monthly
      ↓
2. Entitlements  attach it to premium      ← this is what shows "1 Entitlements"
      ↓
3. Offerings     put it in a Package on default   ← the one you forget
&lt;/code&gt;&lt;/pre&gt;


&lt;p&gt;Finish stage two and the Products list reports "1 Entitlements". &lt;strong&gt;That is the trap: it looks done.&lt;/strong&gt; But the app fetches from the Offering, so skipping stage three leaves you with an Offering that holds no Play products at all.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;In the vocabulary section of part 1, I drew Product with two arrows coming out of it, one to Entitlement and one to Offering. The Offering arrow is exactly the one I forgot. I drew the diagram myself and then walked into it.&lt;/p&gt;

&lt;p&gt;The fix is just RevenueCat → Offerings → &lt;code&gt;default&lt;/code&gt; → add the Android products to each Package. &lt;strong&gt;Don't remove the iOS products.&lt;/strong&gt; A Package means something like "the monthly slot", so the App Store product and the Play Store product sit in it side by side.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Snag 2: the release build failed lint
&lt;/h2&gt;

&lt;p&gt;With products showing up, I built for distribution. &lt;code&gt;lintVitalRelease&lt;/code&gt; failed, on &lt;code&gt;ExtraTranslation&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The cause was the &lt;code&gt;locales&lt;/code&gt; setting in &lt;code&gt;app.config.ts&lt;/code&gt;. &lt;strong&gt;I had written it thinking of iOS only, and it applies to Android too.&lt;/strong&gt; It generates &lt;code&gt;values-b+{lang}/strings.xml&lt;/code&gt; per locale, and iOS-only keys (the usage descriptions for camera and microphone) were being emitted into them. There's no matching string on the default locale, so lint calls it an extra translation and fails the build.&lt;/p&gt;

&lt;p&gt;I fixed it by nesting those under an &lt;code&gt;"ios"&lt;/code&gt; key inside the locale JSON so they never reach Android.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Warning&lt;/strong&gt;&lt;br&gt;
This is the kind of problem you cannot see from iOS. &lt;strong&gt;I should have pushed one release build through the moment I added Android.&lt;/strong&gt; The development build passes, so it stays hidden until you're about to distribute.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  6. Snag 3: the app I'd just fixed wouldn't start
&lt;/h2&gt;

&lt;p&gt;I fixed the lint error, built locally, installed to the device, and got the old behaviour. My change looked like it hadn't taken.&lt;/p&gt;

&lt;p&gt;I started suspecting configuration again. This time two causes were stacked on top of each other.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Caution&lt;/strong&gt;&lt;br&gt;
&lt;strong&gt;First.&lt;/strong&gt; I use &lt;code&gt;appVersionSource: "remote"&lt;/code&gt;, so &lt;code&gt;versionCode&lt;/code&gt; in &lt;code&gt;android/app/build.gradle&lt;/code&gt; was still sitting at its initial value of 1. The device had the build Play had delivered (31 at the time), so &lt;strong&gt;the install was being refused as a downgrade.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Second.&lt;/strong&gt; &lt;code&gt;adb install&lt;/code&gt; &lt;strong&gt;can return exit code 0 even when it fails.&lt;/strong&gt; My build script took that as success and moved on, and what actually launched was the old app.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;So my fix had never reached the device, and I was reading behaviour as though it had. I suspected the code, then the configuration, and skipped the one thing worth suspecting first: whether the thing was installed at all.&lt;/p&gt;

&lt;p&gt;I now sync &lt;code&gt;versionCode&lt;/code&gt; in the build script to something above what's on the device, and verify the install result explicitly.&lt;/p&gt;

&lt;h2&gt;
  
  
  7. Snag 4: the manage button opened nothing
&lt;/h2&gt;

&lt;p&gt;Purchases were going through, so I checked the cancellation path. Tapping "Manage subscription" produced a generic error and opened nothing.&lt;/p&gt;

&lt;p&gt;I took the long way round here too. I suspected the device couldn't open Play's subscription screen, so I fired it with &lt;code&gt;am start&lt;/code&gt;, and it opened fine. I thought the management URL from RevenueCat might be empty, but the code wasn't even getting that far. I assumed a configuration mistake, but there was not one matching error anywhere.&lt;/p&gt;

&lt;p&gt;I found it the moment I stopped swallowing the exception.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Before. The exception isn't bound, so nothing reaches the log&lt;/span&gt;
&lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;manageSubscriptions&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;Alert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;alert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;t&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;common.error&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nf"&gt;t&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;pricing.error.manageFailed&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I changed &lt;code&gt;catch {}&lt;/code&gt; to &lt;code&gt;catch (err)&lt;/code&gt;, logged it, rebuilt, and had the answer on the first run.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[Purchases] showManageSubscriptions failed
  [Error: This method is not available in the current platform.]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Caution&lt;/strong&gt;&lt;br&gt;
&lt;code&gt;Purchases.showManageSubscriptions()&lt;/code&gt; is an &lt;strong&gt;iOS-only API&lt;/strong&gt;. It isn't implemented on Android. The management URL was never empty. The method simply doesn't exist on the platform.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;iOS has a system-provided management sheet. &lt;strong&gt;Android has no equivalent.&lt;/strong&gt; You have to send the user to the relevant Play Store screen yourself.&lt;/p&gt;

&lt;p&gt;The fix branches on platform and keeps three levels of fallback.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// src/providers/purchases-provider.tsx&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;STORE_SUBSCRIPTION_URL&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Record&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;ios&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://apps.apple.com/account/subscriptions&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;android&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://play.google.com/store/account/subscriptions&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;manageSubscriptions&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// showManageSubscriptions() is iOS-only and always fails when called on Android&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;Platform&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;OS&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ios&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;Purchases&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;showManageSubscriptions&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;warn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;[Purchases] showManageSubscriptions failed&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;fallbackUrl&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;customerInfo&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;managementURL&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="nx"&gt;STORE_SUBSCRIPTION_URL&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;Platform&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;OS&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;fallbackUrl&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`No subscription management URL available for platform &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;Platform&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;OS&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;Linking&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;openURL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;fallbackUrl&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Warning&lt;/strong&gt;&lt;br&gt;
The hardcoded URL at the third level is there because &lt;strong&gt;the management URL RevenueCat returns can be empty on a perfectly healthy path.&lt;/strong&gt; The type is &lt;code&gt;string | null&lt;/code&gt;, and it comes back empty for test purchases and in the window right after a purchase while state is still syncing. Getting stuck here removes the user's way to cancel, so I keep a last resort.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  8. What the four had in common
&lt;/h2&gt;

&lt;p&gt;Looking back at the day, none of the four snags was technically hard. What was hard is that &lt;strong&gt;they were failing without looking like they were failing.&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Where I got stuck&lt;/th&gt;
&lt;th&gt;Why the failure was hidden&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;No products returned&lt;/td&gt;
&lt;td&gt;"1 Entitlements" in the Products list made it look finished&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Release build failed&lt;/td&gt;
&lt;td&gt;The development build passes, so it stays out of sight until distribution&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Fix didn't take&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;adb install&lt;/code&gt; returned 0 after failing&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Manage screen wouldn't open&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;catch {}&lt;/code&gt; threw the exception away&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;An error you can see is an error you can read. The nasty case is when &lt;strong&gt;a healthy-looking screen or exit code is covering a failure.&lt;/strong&gt; What I added over that day wasn't a feature. It was emulator startup logging, install verification, and exception output on the purchase screen.&lt;/p&gt;

&lt;h2&gt;
  
  
  9. Test settings that keep you off a real card
&lt;/h2&gt;

&lt;p&gt;I push to the internal test track and verify on a real device.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pnpm build:development:android    &lt;span class="c"&gt;# build the AAB&lt;/span&gt;
pnpm submit:development:android   &lt;span class="c"&gt;# upload to the internal test track&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In Play Console you register your testers' email addresses, and each tester opens the opt-in URL and accepts. Without that, they can't even see the app.&lt;/p&gt;

&lt;p&gt;On top of that, &lt;strong&gt;there is a second registration you need.&lt;/strong&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Caution&lt;/strong&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Users incur actual charges for their test track purchases &lt;strong&gt;unless the user is a license tester.&lt;/strong&gt;&lt;br&gt;
(&lt;a href="https://developer.android.com/google/play/billing/test" rel="noopener noreferrer"&gt;Test your Google Play Billing Library integration&lt;/a&gt;)&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;strong&gt;Buy with an account that isn't registered as a license tester and you are charged on a real credit card.&lt;/strong&gt; That holds even for a build installed from the internal test track. It isn't decided by the track. It's decided by whether the purchasing account is a license tester.&lt;/p&gt;

&lt;p&gt;You register them under Settings → License testing in Play Console. You can tell it worked by whether the purchase sheet says "Test card, always approves". &lt;strong&gt;If you see an actual card, stop right there.&lt;/strong&gt;&lt;/p&gt;


&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  10. On Android, a cancellation path is mandatory
&lt;/h2&gt;

&lt;p&gt;The management link I fixed in section 7 is not a nice-to-have on Android.&lt;/p&gt;

&lt;p&gt;Google Play policy requires that you &lt;strong&gt;provide a way to cancel from inside the app&lt;/strong&gt; (&lt;a href="https://support.google.com/googleplay/android-developer/answer/140504" rel="noopener noreferrer"&gt;Manage and cancel subscriptions&lt;/a&gt;). Linking out to the Play Store management screen is the straightforward way to satisfy it.&lt;/p&gt;

&lt;p&gt;Apple's review requirement for the purchase screen (3.1.2(c)) asks for something different: that the subscription auto-renews, its duration, its price, and links to terms and privacy. &lt;strong&gt;An in-app management link isn't on that list.&lt;/strong&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Warning&lt;/strong&gt;&lt;br&gt;
So the bug in section 7 reads as "one button doesn't work" if you're only looking at iOS, and as a policy risk on Android. Shipping it broken wasn't an option.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  11. Wrapping up
&lt;/h2&gt;

&lt;p&gt;Three things came out of adding Android.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Absorbing platform differences in the first implementation really does pay.&lt;/strong&gt; Because I'd written the API key branch during the iOS work, app code changes were close to zero. The flip side is that anywhere I skipped a branch because iOS worked fine, like &lt;code&gt;showManageSubscriptions()&lt;/code&gt;, Android surfaced it immediately.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Don't carry iOS concepts over unexamined.&lt;/strong&gt; There is no Subscription Group, product IDs are two levels joined together, there's no native management API, and even the renewal interval during testing is different. About all the two have in common is the word "store".&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Make failures look like failures.&lt;/strong&gt; Don't write &lt;code&gt;catch {}&lt;/code&gt;, don't trust an exit code, don't treat a dashboard label as proof of completion. All four of these were preventable by that alone.&lt;/p&gt;

&lt;p&gt;Next: where to draw the line between development billing and production billing. RevenueCat's own docs offer two options and stop there, and it's an area where staff answers point in opposite directions depending on who asked.&lt;/p&gt;

&lt;h2&gt;
  
  
  References
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://developer.android.com/google/play/billing/test" rel="noopener noreferrer"&gt;Test your Google Play Billing Library integration&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.revenuecat.com/docs/getting-started/entitlements/android-products" rel="noopener noreferrer"&gt;RevenueCat: Google Play Product Setup&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.revenuecat.com/docs/service-credentials/creating-play-service-credentials" rel="noopener noreferrer"&gt;RevenueCat: Google Play Service Credentials&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.expo.dev/submit/android/" rel="noopener noreferrer"&gt;Expo: Submit to Google Play Store&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>revenuecat</category>
      <category>reactnative</category>
      <category>expo</category>
      <category>android</category>
    </item>
    <item>
      <title>My subscribe button was greyed out, and that got my app rejected</title>
      <dc:creator>nicoinu</dc:creator>
      <pubDate>Sat, 01 Aug 2026 16:22:14 +0000</pubDate>
      <link>https://dev.to/nicoinu/my-subscribe-button-was-greyed-out-and-that-got-my-app-rejected-d42</link>
      <guid>https://dev.to/nicoinu/my-subscribe-button-was-greyed-out-and-that-got-my-app-rejected-d42</guid>
      <description>&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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, &lt;strong&gt;the exact same code let me buy the thing without any trouble&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;What gave it away was opening my server logs and finding nothing there. Not an error. Not a single line.&lt;/p&gt;

&lt;p&gt;I'm on Expo SDK 54, &lt;code&gt;react-native-purchases&lt;/code&gt; v10, with a NestJS backend.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;"Shipping subscriptions with RevenueCat: from store setup to environment layout" (3 parts)&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;My subscribe button was greyed out, and that got my app rejected ← you are here&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/nicoinu/android-needed-almost-no-code-changes-and-still-ate-a-full-day-in-configuration-a9m"&gt;Android needed almost no code changes, and still ate a full day in configuration&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/nicoinu/where-to-draw-the-line-between-test-and-production-when-there-is-no-right-answer-4fic"&gt;Where to draw the line between test and production, when there is no right answer&lt;/a&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  1. The vocabulary: three words that do three different jobs
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1.1 How IAP and RevenueCat relate
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;IAP (In-App Purchase)&lt;/strong&gt; 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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;RevenueCat is a layer on top of that.&lt;/strong&gt; It flattens the two stores' IAP into a single model, so you can ask who bought what and whether it's still active.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2Fpako%3AeNplz8EKgkAQgOFXGeZUUHSPEEwhwiDRU7gdNnfMhXVH1rWI6N1j8SReh_9jZr5YsyLcY2P4XbfSebgUwgIAxHleCbzx6ED2vcA7bLcRlGlWCSzoRXakRPowEHifSJlmUxSa0rOjTPvDw-2i1TnOgS3oa7le5Kew6MT8NAS5kR84amO0fc5kbJVjrZa6SGYHTahlowboR1e3ciAYvPQUKG6wI9dJrXD_Rd9SF55X1MjRePz9_gOzVHE%3D%3Ftype%3Dpng" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2Fpako%3AeNplz8EKgkAQgOFXGeZUUHSPEEwhwiDRU7gdNnfMhXVH1rWI6N1j8SReh_9jZr5YsyLcY2P4XbfSebgUwgIAxHleCbzx6ED2vcA7bLcRlGlWCSzoRXakRPowEHifSJlmUxSa0rOjTPvDw-2i1TnOgS3oa7le5Kew6MT8NAS5kR84amO0fc5kbJVjrZa6SGYHTahlowboR1e3ciAYvPQUKG6wI9dJrXD_Rd9SF55X1MjRePz9_gOzVHE%3D%3Ftype%3Dpng" alt="diagram" width="644" height="350"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;So billing itself belongs to the store, and what RevenueCat gives you is &lt;strong&gt;a layer that makes that state easy to work with&lt;/strong&gt;. Even with a single store, it saves you from writing receipt validation and renewal tracking yourself.&lt;/p&gt;

&lt;h3&gt;
  
  
  1.2 Product, Entitlement, Offering
&lt;/h3&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;The trick is noticing that &lt;strong&gt;the three do genuinely different jobs&lt;/strong&gt;.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Term&lt;/th&gt;
&lt;th&gt;What it is&lt;/th&gt;
&lt;th&gt;Its job&lt;/th&gt;
&lt;th&gt;Watch out for&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Product&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Something you listed on a store&lt;/td&gt;
&lt;td&gt;What you sell&lt;/td&gt;
&lt;td&gt;Each store has its own copy&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Entitlement&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;The state of "this feature is unlocked"&lt;/td&gt;
&lt;td&gt;What the user can use&lt;/td&gt;
&lt;td&gt;The only thing your app checks&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Offering / Package&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;The set you put on the paywall&lt;/td&gt;
&lt;td&gt;What you show right now&lt;/td&gt;
&lt;td&gt;Skip this and you get zero products&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Note&lt;/strong&gt;&lt;br&gt;
The names &lt;code&gt;premium_monthly&lt;/code&gt;, &lt;code&gt;premium_yearly&lt;/code&gt;, &lt;code&gt;premium&lt;/code&gt; and &lt;code&gt;default&lt;/code&gt; below are &lt;strong&gt;all made up for this article&lt;/strong&gt;. None of them are reserved, so you can use whatever strings you like. The exception is identifiers starting with &lt;code&gt;$rc_&lt;/code&gt;, like &lt;code&gt;$rc_monthly&lt;/code&gt; and &lt;code&gt;$rc_annual&lt;/code&gt;: those are RevenueCat's conventional names, and using them makes it easier for the SDK to tell monthly from annual.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Drawn out, the connections look like this.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2Fpako%3AeNp9kVtrwkAQhf_KMM-VYh4XERRsH1J0aR-zUsZkcqF7CcuGEsT_LpqNjdj2cXbOOfPN7BFzVzAKLLX7zmvyAd7elQUAWM0zhau2hY_gPC8O_nnZejZNZz6Ns6HWvcI9zGZLkOlFKin_oooF_HRjUJIpfHWu0gxSU_9blHiMHMzrvyl6Jj91JFMIsrYjfWNY_8cwBImHvMEq0_n1YfeSKdyVJfvGVgIKLqnT4TZBpkmUjee7lpttpnBjQxM0G7ZBQJw6OU8Ujgvfl2MXn9CwN9QUKI4YajaXb4sUeDqdAU3LjlI%3D%3Ftype%3Dpng" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2Fpako%3AeNp9kVtrwkAQhf_KMM-VYh4XERRsH1J0aR-zUsZkcqF7CcuGEsT_LpqNjdj2cXbOOfPN7BFzVzAKLLX7zmvyAd7elQUAWM0zhau2hY_gPC8O_nnZejZNZz6Ns6HWvcI9zGZLkOlFKin_oooF_HRjUJIpfHWu0gxSU_9blHiMHMzrvyl6Jj91JFMIsrYjfWNY_8cwBImHvMEq0_n1YfeSKdyVJfvGVgIKLqnT4TZBpkmUjee7lpttpnBjQxM0G7ZBQJw6OU8Ujgvfl2MXn9CwN9QUKI4YajaXb4sUeDqdAU3LjlI%3D%3Ftype%3Dpng" alt="diagram" width="748" height="478"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Again, &lt;code&gt;premium_monthly&lt;/code&gt; and friends are placeholders for whatever product IDs you pick.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Products are per store.&lt;/strong&gt; 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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Entitlements collapse that back down.&lt;/strong&gt; 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.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fxhuzu96bf28n3hmmxktl.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fxhuzu96bf28n3hmmxktl.png" alt="The Entitlements screen in RevenueCat, with a single identifier named premium" width="799" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;An Offering is what you put on screen right now.&lt;/strong&gt; 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.&lt;/p&gt;

&lt;p&gt;The payoff of keeping this separate is that &lt;strong&gt;you can change how you sell without shipping an app update&lt;/strong&gt;. If you decide to push the annual plan, or run a limited-time plan, you swap the offering in RevenueCat and the paywall changes.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Foo878js1paw688ahcki4.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Foo878js1paw688ahcki4.png" alt="Packages inside an Offering, with a product in the monthly slot and the annual slot" width="800" height="346"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Note&lt;/strong&gt;&lt;br&gt;
As the arrows show, &lt;strong&gt;two lines leave each product&lt;/strong&gt;: one to an entitlement, one to an offering. &lt;strong&gt;One of them is not enough.&lt;/strong&gt; Wire up only the entitlement and you land in the state where buying grants access, but the paywall has nothing on it.&lt;/p&gt;

&lt;p&gt;Later in this article, I forget the offering half and walk away convinced I'm done.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  2. Rejected on three counts
&lt;/h2&gt;

&lt;p&gt;Review came back in one to three days, with three items.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Guideline&lt;/th&gt;
&lt;th&gt;What they flagged&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;4.8 Login Services&lt;/td&gt;
&lt;td&gt;Google sign-in with no equivalent privacy-preserving option&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3.1.2(c) Subscriptions&lt;/td&gt;
&lt;td&gt;No functional links to the EULA and privacy policy inside the purchase flow&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2.1(b) App Completeness&lt;/td&gt;
&lt;td&gt;The subscribe button is greyed out&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The first two tell you what to do if you read them. The third was the problem, because &lt;strong&gt;it worked on my machine&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Rejection 1: I only offered Google sign-in
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;Four things to do: add &lt;code&gt;expo-apple-authentication&lt;/code&gt;; set &lt;code&gt;ios.usesAppleSignIn: true&lt;/code&gt; and the plugin in &lt;code&gt;app.config.ts&lt;/code&gt;; 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.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Note&lt;/strong&gt;&lt;br&gt;
Server-side verification is &lt;strong&gt;just public key verification against Apple's JWKS&lt;/strong&gt;, 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 next &lt;code&gt;eas build&lt;/code&gt; regenerates it for you.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  4. Rejection 2: my policy link only existed in one place
&lt;/h2&gt;

&lt;p&gt;I'd put a Privacy Policy URL in the App Store metadata and assumed that covered it. It did not.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Warning&lt;/strong&gt;&lt;br&gt;
&lt;strong&gt;You need the links in the app and in the metadata. Either one alone will fail.&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;In the app: put &lt;strong&gt;tappable links&lt;/strong&gt; to the EULA and the privacy policy on the paywall screen (&lt;code&gt;WebBrowser.openBrowserAsync&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;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&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;

&lt;p&gt;When I resubmitted, I attached a screen recording in Resolution Center. Apple asks for one explicitly on 3.1.2.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Rejection 3: the subscribe button stayed grey
&lt;/h2&gt;

&lt;p&gt;Here's the real story.&lt;/p&gt;

&lt;p&gt;I opened the build I'd distributed through TestFlight, and there was no price.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ftq9ibb3qicz6qxt71oqe.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ftq9ibb3qicz6qxt71oqe.png" alt="The price renders as an em dash and the subscribe button does nothing" width="647" height="289"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The amount shows as &lt;code&gt;—&lt;/code&gt;. RevenueCat returned zero products, so there's no price to render, and the button does nothing. This is the screen the reviewer saw.&lt;/p&gt;

&lt;p&gt;Let me kill one common misreading first.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Warning&lt;/strong&gt;&lt;br&gt;
&lt;strong&gt;Grey does not mean "still in review".&lt;/strong&gt; 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 &lt;strong&gt;if the button isn't live at the moment you submit, you fail.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;So this wasn't going to fix itself once review passed. It had to be fixed right then.&lt;/p&gt;

&lt;p&gt;And the awkward part: &lt;strong&gt;the same code showed products just fine in the simulator&lt;/strong&gt;. I couldn't reproduce it locally. Works here, broken only in what I shipped, which is the worst place to be.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. Not a single log line
&lt;/h2&gt;

&lt;p&gt;You can't attach a debugger to a distribution build, so I needed some way to see what was happening from the outside.&lt;/p&gt;

&lt;p&gt;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.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// src/lib/iap-diagnostics.ts&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;IapDiagSnapshot&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;offeringId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;packageCount&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;lastError&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;lastUpdatedAt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;updateIapDiagnostics&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;patch&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Partial&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;IapDiagSnapshot&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="k"&gt;void&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nb"&gt;Object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;assign&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;patch&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;lastUpdatedAt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;toISOString&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;getIapDiagnosticsHeader&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;lastUpdatedAt&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;offering&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;offeringId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;pkg&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;packageCount&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;err&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;lastError&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;lastError&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// src/lib/api/axios-instance.ts&lt;/span&gt;
&lt;span class="nx"&gt;axiosInstance&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;interceptors&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;use&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;config&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;diag&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;getIapDiagnosticsHeader&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;diag&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;X-Iap-Diag&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;diag&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;config&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I opened the server logs and went looking for requests from the TestFlight build.&lt;/p&gt;

&lt;p&gt;There was nothing.&lt;/p&gt;

&lt;p&gt;Not an error. &lt;strong&gt;The diagnostic line simply never appeared.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;So it wasn't that products failed to load. Billing wasn't running at all.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Note&lt;/strong&gt;&lt;br&gt;
&lt;strong&gt;The absence of the log was the answer.&lt;/strong&gt; 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.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;What the log says&lt;/th&gt;
&lt;th&gt;What it means&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;pkg:2, err:null&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Healthy. The button should be enabled&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;pkg:0, err:"...has no packages..."&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;StoreKit returned nothing. Paid Apps agreement, or the product wiring&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;pkg:0, err:"No current offering..."&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;No offering is marked current in RevenueCat&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;err:"Offerings fetch failed [CODE]..."&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The fetch itself threw. Wrong API key lands here&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;nothing at all&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Initialization was never reached. The API key isn't in the build&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  7. The cause: eas build doesn't read .env
&lt;/h2&gt;

&lt;p&gt;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.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// src/providers/purchases-provider.tsx&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;apiKey&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;getApiKey&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;apiKey&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;warn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;[Purchases] RevenueCat API key is not set. Subscription features disabled.&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nf"&gt;setIsReady&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;// ← bails out here, so the diagnostic line is never written either&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That early return is what produced a build where nothing happens at all.&lt;/p&gt;

&lt;p&gt;So why was the key missing from the distribution build?&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Caution&lt;/strong&gt;&lt;br&gt;
&lt;strong&gt;&lt;code&gt;eas build&lt;/code&gt; never reads &lt;code&gt;.env&lt;/code&gt;.&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A local dev build (&lt;code&gt;expo run:ios&lt;/code&gt; and friends) reads &lt;code&gt;.env&lt;/code&gt; and injects &lt;code&gt;EXPO_PUBLIC_*&lt;/code&gt; into your JS. &lt;strong&gt;That's why the simulator showed products&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;eas build&lt;/code&gt; only looks at &lt;code&gt;env&lt;/code&gt; in &lt;code&gt;eas.json&lt;/code&gt; and at EAS Environment Variables. If the key isn't registered there, you never reach &lt;code&gt;Purchases.configure()&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;

&lt;p&gt;The difference between the simulator and TestFlight wasn't the code and it wasn't the environment. It was &lt;strong&gt;how the key gets delivered&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The fix is to register the key with EAS.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# register it in both environments so billing also works in development builds&lt;/span&gt;
eas &lt;span class="nb"&gt;env&lt;/span&gt;:create &lt;span class="nt"&gt;--name&lt;/span&gt; EXPO_PUBLIC_REVENUECAT_IOS_API_KEY &lt;span class="nt"&gt;--value&lt;/span&gt; &lt;span class="s2"&gt;"appl_xxxxxxxx"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--visibility&lt;/span&gt; sensitive &lt;span class="nt"&gt;--environment&lt;/span&gt; development &lt;span class="nt"&gt;--environment&lt;/span&gt; production
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Note&lt;/strong&gt;&lt;br&gt;
You can't pick &lt;code&gt;secret&lt;/code&gt; for &lt;code&gt;--visibility&lt;/code&gt;. Anything &lt;code&gt;EXPO_PUBLIC_&lt;/code&gt; is baked into the binary, so it can't be kept secret in the first place. Choosing &lt;code&gt;sensitive&lt;/code&gt; is about keeping the key out of git; the fact that it ships inside the app is by design.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;After a rebuild the diagnostic line read &lt;code&gt;pkg:2, err:null&lt;/code&gt; and the button came alive. That was the real fix for 2.1(b).&lt;/p&gt;

&lt;h2&gt;
  
  
  8. The order I should have done all this in
&lt;/h2&gt;

&lt;p&gt;Looking back, every place I got stuck was something &lt;strong&gt;the right order would have avoided&lt;/strong&gt;. These tasks depend on each other, and doing a later one before an earlier one finishes is wasted effort.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2Fpako%3AeNpFkc1qwkAUhV_lMJu2oCn920gRNLquaKGLjItJ5sYMJjNh5o5WxG0foI_YJykZoV1eDt93DtyzqJwmMRF1645VozzjfSEtAMwKKR4yzPq-JZQxGEshIBDH_rX099PblTJ6iAPUzhN1ZHmEUtn9CKw-76TYYjyeYl5I8Zjhw3jCmg5kI-WKbwK48URo1Yl8SMaVdzpWjJ-vbywtG26TNN1vdU3e2J0U2-u8eZLnhRRPGXJPigncEPqrJMDYYRw27Dwhd9ZSxX90nuhFIcVzhhmzqpqB7sAOykL1PQ7kg3EWymqEWHbmn14kellI8ZJhTTsTmHxq39MJR8MNlrNNIstoWo3aeWgT2JsysnFWiq0YiY58p4wWk7MYuoc3aKpVbFlcLr_slIfj%3Ftype%3Dpng" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2Fpako%3AeNpFkc1qwkAUhV_lMJu2oCn920gRNLquaKGLjItJ5sYMJjNh5o5WxG0foI_YJykZoV1eDt93DtyzqJwmMRF1645VozzjfSEtAMwKKR4yzPq-JZQxGEshIBDH_rX099PblTJ6iAPUzhN1ZHmEUtn9CKw-76TYYjyeYl5I8Zjhw3jCmg5kI-WKbwK48URo1Yl8SMaVdzpWjJ-vbywtG26TNN1vdU3e2J0U2-u8eZLnhRRPGXJPigncEPqrJMDYYRw27Dwhd9ZSxX90nuhFIcVzhhmzqpqB7sAOykL1PQ7kg3EWymqEWHbmn14kellI8ZJhTTsTmHxq39MJR8MNlrNNIstoWo3aeWgT2JsysnFWiq0YiY58p4wWk7MYuoc3aKpVbFlcLr_slIfj%3Ftype%3Dpng" alt="diagram" width="276" height="702"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  8.1 Start the business setup first
&lt;/h3&gt;

&lt;p&gt;This is the paperwork under "Business" in App Store Connect.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Caution&lt;/strong&gt;&lt;br&gt;
&lt;strong&gt;Until the Paid Apps agreement is active, your app can't fetch a single product.&lt;/strong&gt; 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 &lt;strong&gt;start it before anything else&lt;/strong&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h3&gt;
  
  
  8.2 Add the API key before wiring the three layers
&lt;/h3&gt;

&lt;p&gt;This is the key RevenueCat uses to query Apple for product information. &lt;strong&gt;You issue it in App Store Connect and register it in RevenueCat&lt;/strong&gt;, so it spans two dashboards. Issue it under Users and Access → Integrations → App Store Connect API, then add it to your RevenueCat project settings.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Warning&lt;/strong&gt;&lt;br&gt;
Skip this and the Store Status in RevenueCat's product list sits at &lt;strong&gt;"Could not check"&lt;/strong&gt;, which makes product delivery flaky. Also, &lt;strong&gt;App Store Connect lets you download the &lt;code&gt;.p8&lt;/code&gt; file exactly once&lt;/strong&gt;. Lose it and you're issuing a new one.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Then wire up the three layers from section 1 in the RevenueCat dashboard. Once you create an offering, don't forget to hit &lt;strong&gt;Make current&lt;/strong&gt;. 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.&lt;/p&gt;

&lt;h3&gt;
  
  
  8.3 Put the products in the same Subscription Group
&lt;/h3&gt;

&lt;p&gt;This part happens &lt;strong&gt;in App Store Connect&lt;/strong&gt;, under Monetization → Subscriptions. Subscription Group is an Apple concept with no counterpart in RevenueCat.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Warning&lt;/strong&gt;&lt;br&gt;
When you create the monthly and annual products, &lt;strong&gt;put them in the same Subscription Group&lt;/strong&gt;. 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.&lt;/p&gt;

&lt;p&gt;On the same screen, each subscription &lt;strong&gt;requires a screenshot&lt;/strong&gt; under its review information. One taken from the Sandbox purchase screen is accepted.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  8.4 The only way out of "Missing Metadata"
&lt;/h3&gt;

&lt;p&gt;You can fill in every field on the product and the status still sits here.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F6ut72d2hltqf48bau4h5.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F6ut72d2hltqf48bau4h5.png" alt="Subscription status stuck on Missing Metadata" width="798" height="151"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Nothing on the screen tells you what's missing. I went back and forth on this one for a while too.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Caution&lt;/strong&gt;&lt;br&gt;
&lt;strong&gt;The only way out is to attach the product to an app version and submit it for review.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Distribution → the version → pick a build → In-App Purchases and Subscriptions → attach → submit for review.&lt;/p&gt;

&lt;p&gt;A product can't get there on its own. It only becomes submittable together with an app version.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;You don't have to wait for the review to be approved, though. &lt;strong&gt;The moment you press submit in App Store Connect, purchase testing works in TestFlight.&lt;/strong&gt; I'd convinced myself that testing had to wait for approval and put off submitting, which cost me a few days for nothing.&lt;/p&gt;

&lt;h3&gt;
  
  
  8.5 Record three kinds of offering failure, not one
&lt;/h3&gt;

&lt;p&gt;The diagnostic table in section 6 only works because I never collapsed these failures into a single error.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;loadOffering&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;offerings&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;Purchases&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getOfferings&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

    &lt;span class="c1"&gt;// failure 1: no offering is marked current&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;offerings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nf"&gt;setLastOfferingError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;No current offering configured in RevenueCat dashboard&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="c1"&gt;// failure 2: there is an offering, but no packages (store propagation, etc.)&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;offerings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;availablePackages&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="p"&gt;[]).&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nf"&gt;setLastOfferingError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`Offering "&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;offerings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;identifier&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;" has no packages`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="nf"&gt;setOffering&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;offerings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="nf"&gt;setLastOfferingError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nf"&gt;setOffering&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;offerings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// failure 3: it threw. A wrong API key shows up here&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;err&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;code&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;message&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
    &lt;span class="nf"&gt;setLastOfferingError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`Offerings fetch failed [&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;code&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;UNKNOWN&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;]: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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.&lt;/p&gt;

&lt;h3&gt;
  
  
  8.6 Never hardcode the price in your UI
&lt;/h3&gt;

&lt;p&gt;Prices are decided in App Store Connect and Google Play Console. &lt;strong&gt;Your app should render the price string the RevenueCat SDK hands back, and nothing else.&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// in the app: don't assemble the amount, just print the display string from the SDK&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;label&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;pkg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;product&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;priceString&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;// e.g. "¥1,000"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Warning&lt;/strong&gt;&lt;br&gt;
&lt;strong&gt;Picking the same price tier does not produce the same yen amount on the App Store and Google Play.&lt;/strong&gt; 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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  9. An aside: subscriptions renew at a different speed while testing
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;The confusing part is that &lt;strong&gt;the speed is set by the account you bought with, not by the environment&lt;/strong&gt;. A TestFlight build also runs against sandbox, so "TestFlight" doesn't mean "slow".&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Account used for the purchase&lt;/th&gt;
&lt;th&gt;Renewal speed&lt;/th&gt;
&lt;th&gt;Stops after&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://developer.apple.com/help/app-store-connect/test-a-beta-version/subscription-renewal-rate-in-testflight/" rel="noopener noreferrer"&gt;A TestFlight tester (a normal Apple ID)&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Once a day, whatever the period&lt;/td&gt;
&lt;td&gt;6 renewals&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://developer.apple.com/help/app-store-connect/test-in-app-purchases/manage-sandbox-apple-account-settings/" rel="noopener noreferrer"&gt;A Sandbox Apple Account&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;One month becomes 5 minutes by default. &lt;strong&gt;Four rates to choose from&lt;/strong&gt;
&lt;/td&gt;
&lt;td&gt;12 renewals&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;You can pick the compression rate in TestFlight too, as long as you sign in with a Sandbox account.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;One more thing about Sandbox purchases in the simulator. &lt;strong&gt;The simulator has no Settings → App Store → Sandbox Account entry.&lt;/strong&gt; 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 &lt;strong&gt;switching to a real device and TestFlight turned out to be faster than fighting it&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  10. Resubmitting, and getting stuck again
&lt;/h2&gt;

&lt;p&gt;Three items fixed, so all that was left was submitting. Or so I thought.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Warning&lt;/strong&gt;&lt;br&gt;
&lt;strong&gt;Replying in Resolution Center is not a resubmission.&lt;/strong&gt; 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.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;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".&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Note&lt;/strong&gt;&lt;br&gt;
That section only appears &lt;strong&gt;while you have unsubmitted IAPs in "Ready to Submit"&lt;/strong&gt;. Once submitted, an IAP is tracked separately as awaiting review or approved, so &lt;strong&gt;the section disappearing means it's attached. That's the normal state.&lt;/strong&gt; Check the status under Monetization → Subscriptions instead.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  11. Wrapping up
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Waiting for Apple's business setup to go active (a few hours to two days)&lt;/li&gt;
&lt;li&gt;Working out how to escape "Missing Metadata"&lt;/li&gt;
&lt;li&gt;Finding out why only the distribution build had no products&lt;/li&gt;
&lt;li&gt;Navigating the resubmission process&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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 &lt;strong&gt;leaves no trace anywhere&lt;/strong&gt;. Honestly, crashing would have told me sooner. That same code path now writes the missing key into the diagnostic log.&lt;/p&gt;

&lt;p&gt;Next up: adding billing on Android. The code barely changed, and the configuration still gave me trouble.&lt;/p&gt;

&lt;p&gt;New territory is hard work. Good luck out there, fellow Shipaton entrants.&lt;/p&gt;

&lt;h2&gt;
  
  
  References
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://www.revenuecat.com/docs/getting-started/entitlements/ios-products" rel="noopener noreferrer"&gt;RevenueCat: iOS Product Setup&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.revenuecat.com/docs/service-credentials/itunesconnect-app-specific-shared-secret/in-app-purchase-key-configuration" rel="noopener noreferrer"&gt;RevenueCat: In-App Purchase Key Configuration&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.revenuecat.com/docs/test-and-launch/sandbox" rel="noopener noreferrer"&gt;RevenueCat: Sandbox Testing&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://developer.apple.com/app-store/review/guidelines/" rel="noopener noreferrer"&gt;App Store Review Guidelines&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.expo.dev/eas/environment-variables/" rel="noopener noreferrer"&gt;Expo: Environment variables in EAS Build&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>revenuecat</category>
      <category>reactnative</category>
      <category>expo</category>
      <category>ios</category>
    </item>
  </channel>
</rss>
