DEV Community

nicoinu
nicoinu

Posted on

Where to draw the line between test and production, when there is no right answer

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.

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.

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.

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.

I'm on Expo SDK 54 and react-native-purchases 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.

"Shipping subscriptions with RevenueCat: from store setup to environment layout" (3 parts)

  1. My subscribe button was greyed out, and that got my app rejected
  2. Android needed almost no code changes, and still ate a full day in configuration
  3. Where to draw the line between test and production, when there is no right answer ← you are here

1. Vocabulary first: "Sandbox" and "your test environment" are different things

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.

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

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

You have to decide which axis you actually want to split before you design Projects, bundle IDs and App User IDs.

2. My dashboard was full of customers I didn't recognise

What sent me down this road was something odd on the RevenueCat dashboard.

The Customers list held entries like 1, 2 and 14, bare numbers with nothing else, and I started wondering how test and production were supposed to be distinguished at all.

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

Here's where it gets bad.

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
Enter fullscreen mode Exit fullscreen mode

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

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

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.

3. Does RevenueCat have a concept of environments?

I went looking for the setting that splits environments. There isn't one. RevenueCat has no switch for toggling between your test environment and your production environment.

Two sentences in the docs explain why.

There's no concept of a sandbox or production user in RevenueCat, since the same App User Id can have both production and non-production receipts.
(Sandbox Testing)

App User IDs are case-sensitive and are scoped to a whole Project.
(Identifying Customers)

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

The second is the one I walked into. The only boundary for the ID space is the Project.

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

Laid out concretely, there are four places you can split, and each row constrains the one below it.

Where you can split Options
Bundle ID / package name One / one per environment (com.example.app and com.example.app.dev)
Store app registration One / one per environment
RevenueCat Project One / one per environment
App User ID namespace Raw userId / per-environment prefix / unguessable ID

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.

4. Looking for a fix, I found two official approaches

App Environment Strategies presents two. On Single Project, the docs say:

Unified API Keys: This approach allows you to use the same RevenueCat API keys across all environments, reducing complexity in your app configuration.

This approach is best for indie projects or cases where the added complexity of multiple projects is unnecessary.

Separate Projects opens with a warning.

This approach increases the risk of projects becoming out of sync, leading to possible issues between environments.

Significant duplication is necessary. You will need to manually replicate all aspects for each environment... This includes paywalls, targeting rules, offerings, and more.

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.

Context of the question Answer
How to handle multiple Expo app variants Create an App per variant inside one Project (= Setup B)
Webhook limits make a multi-environment setup impossible to wire Split Projects per environment (= Setup C)
How to exclude test users from metrics Split Projects (charts can be filtered by Project)

And in Best environments strategy, the downside of splitting Projects is put like this:

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.

The same duplication gets a warning 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.

5. How to actually decide: three setups and a diagnostic chart

5.1 The chart

What this chart decides is how many store app registrations and RevenueCat Projects you're going to maintain. 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.

diagram

The three questions work differently from one another.

Question 0: do you need to go through the real stores?

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.

Anyone who answers "no" here doesn't need to split anything yet. 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.

Question 1: do you want different paywalls or prices?

This comes first because the paywall composition (the Offering) and the rules that target it are per-Project settings in RevenueCat. 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.

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.

Question 2: do you need to block misdelivery structurally?

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

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.

Note
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.

5.2 Setup A: one bundle ID, one Project

What you create One store app, one set of IAP products, one RevenueCat Project, one API key per platform
Non-negotiable Give App User IDs an environment namespace (section 7), restrict Sandbox Testing Access (section 6)
The price you pay 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
Per-environment paywall Not possible
Fits when Solo development. Billing gets verified infrequently
Revisit when The team grows. You need a per-environment paywall. A misdelivery actually happens

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.

5.3 Setup B: split bundle IDs, one Project

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

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

Creating two sets of IAP products is not optional. 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 this thread, and a wildcard bundle ID won't substitute. Android is the same.

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.
(Android app with different package ids)

Google Play does not treat a .dev 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.

Warning
Setup B still needs App User ID design.

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

"I split the store apps, so nothing mixes" is wrong. What decides whether things mix is the Project.

5.4 Setup C: one Project per environment

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

The easiest thing to overlook in that replication is the paywall. RevenueCat paywalls are remote configuration, so the display changes without shipping a new build. 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.

There is one mitigating factor. Projects under the same App Store Connect account can share an In-App Purchase Key.

Note
One risk doesn't go away even with split Projects: shipping a production build that carries the test Project's API key. 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.

5.5 The three side by side

Setup A Setup B Setup C
Bundle IDs 1 2 2
Store app registrations 1 2 2
IAP product definitions 1 set 2 sets 2 sets
RevenueCat Projects 1 1 2
App User ID namespacing Required Required Not needed
Per-environment paywall (Offering) No No Yes
Structural block on misdelivery No Yes Yes
Duplicate configuration None Store side only Store side and RevenueCat side
Scale it fits Solo Team Team

The Test Store and StoreKit Configuration Files combine with any of the three (section 6).

6. Three mechanisms that save you from splitting

There are risks you can close out while staying on Setup A. This was the most valuable part of the research.

6.1 Sandbox Testing Access

A Project's General settings hold a three-way choice for whether non-production purchases grant entitlements (Sandbox Testing Access).

Setting Behaviour Official use case
Anybody (default) Every non-production purchase grants entitlements and virtual currency "recommended for early development or internal QA testing"
Allowed App User IDs only Only allowlisted app_user_ids are granted "useful when running restricted tests (e.g: Google Play closed testing)"
Nobody Non-production purchases grant nothing When test purchases must not affect entitlements

No money moves in a sandbox purchase, so what this setting decides is only whether that purchase creates billing state.

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

Ship to production like that and this happens.

[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
Enter fullscreen mode Exit fullscreen mode

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

Tightening this setting also removes entitlements that were already granted.

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.

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

Which gives you this by phase.

Phase Setting Why
During development Anybody (leave the default) You want your own test purchases to grant entitlements. Tighten it and you can't verify anything
Before launching to production Allowed App User IDs only Clear the accumulated state and stop real users from picking it up

Nobody isn't really selectable. Purchases from TestFlight and Google Play internal testing are sandbox purchases even when the distributed build is production-signed (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.

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

Warning
This setting alone isn't enough. The allowlist matches on the app_user_id string.

If both test and production pass a bare 42, 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 dev_42 and prod_42 can you register just the dev_ side.

6.1 is not a replacement for section 7. It's a second layer on top of it. The order is: introduce the prefix from section 7, then this setting, then launch.

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.

6.2 Webhook filters

The Webhooks 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.

So on a single Project you can route sandbox events to your development API and production events to your production API. Webhook separation is not a reason to split Projects.

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

6.3 Test Store

Test Store, 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.

It's a fake store RevenueCat provides, processing purchases in place of the App Store and Google Play.

test purchases behave like real purchases and subscriptions: they update CustomerInfo, trigger entitlements, and appear in your RevenueCat dashboard

Without registering a single product with a store, you can exercise the buy button, the CustomerInfo update, and the entitlement grant.

Where it earns its keep is local development and CI. 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.

What it can't confirm is equally clear. It doesn't go through real payment processing or the store's purchase sheet, so it tells you nothing about whether your product configuration in App Store Connect or Play Console is correct. 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.

There are two constraints. The first is renewal count.

Each test subscription will renew automatically up to 5 times

It stops after five, then cancels, and the entitlement goes inactive. The second is that you must not ship it.

Warning

Never submit an app to the App Store or Google Play that is configured with a Test Store API key.

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

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.

7. What I actually implemented: environments in the App User ID

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.

The idea is plain: prefix the ID with the environment and treat it as a separate namespace. Test becomes dev_42, production becomes prod_42.

7.1 Implementation

This only holds if the app and the server follow the same convention, so I put it somewhere both can reach.

// shared module, referenced by both the app and the server
export const APP_ENVIRONMENTS = { DEV: 'dev', PROD: 'prod' } as const;
export type AppEnvironment = (typeof APP_ENVIRONMENTS)[keyof typeof APP_ENVIRONMENTS];

const SEPARATOR = '_';

/**
 * Mobile passes EXPO_PUBLIC_APP_ENV, the API passes NODE_ENV.
 * Only "production" and "prod" count as prod. Everything else falls back to dev.
 */
export const resolveAppEnvironment = (value: string | null | undefined): AppEnvironment => {
  const normalized = value?.trim().toLowerCase();
  if (normalized === 'production' || normalized === 'prod') return APP_ENVIRONMENTS.PROD;
  return APP_ENVIRONMENTS.DEV;
};

export const buildRevenueCatAppUserId = (env: AppEnvironment, userId: number): string =>
  `${env}${SEPARATOR}${userId}`;

/** Returns null for another environment's prefix, anonymous IDs, and unprefixed IDs. */
export const parseRevenueCatAppUserId = (
  appUserId: string,
  env: AppEnvironment,
): number | null => {
  const prefix = `${env}${SEPARATOR}`;
  if (!appUserId.startsWith(prefix)) return null;

  const raw = appUserId.slice(prefix.length);
  // Number.parseInt would happily accept "14abc" as 14, so match strictly
  if (!/^\d+$/.test(raw)) return null;

  const userId = Number.parseInt(raw, 10);
  return Number.isSafeInteger(userId) && userId > 0 ? userId : null;
};
Enter fullscreen mode Exit fullscreen mode

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

Rebind whenever the login state changes.

// src/providers/purchases-provider.tsx
const currentAppUserId = getRevenueCatAppUserId(user?.id) ?? null;
if (currentAppUserId === lastAppUserIdRef.current) return;

if (currentAppUserId) {
  const { customerInfo: info } = await Purchases.logIn(currentAppUserId);
  setCustomerInfo(info);
} else {
  const info = await Purchases.logOut();
  setCustomerInfo(info);
}
lastAppUserIdRef.current = currentAppUserId;
Enter fullscreen mode Exit fullscreen mode

Note
logOut() generates a new anonymous ID. Calling logOut then logIn to switch accounts leaves one stray anonymous customer behind. For a switch, call logIn() directly.

7.2 Where this approach stops

It isn't a cure-all, so here are its limits.

Warning
The environment prefix approach is not in the official documentation. Neither Identifying Customers nor the environment strategies page describes it. It's my own workaround for the single-Project constraint, not an official recommendation.

And a prefix only solves namespace collision. Unguessability remains a separate requirement.

App User IDs should not be guessable
A non-guessable pseudo-random ID, like a UUID (RFC 4122 version 4), is recommended

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

Element Problem it solves
The dev_ / prod_ prefix ID collision across environments (section 2)
An unguessable ID body (UUIDv4 or similar) The official "should not be guessable" requirement

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

There are values you must not use for app_user_id. RevenueCat blocks no_user, null, none, nil, (null), NaN, the empty string, unidentified, undefined, unknown, anonymous, guest, -1, 0, [], {}, [object Object], and any ID containing /. 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.

8. Wrapping up

8.1 The hardest part

Almost none of the difficulty in this article was in the implementation.

The hardest part was that researching it didn't produce an answer.

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.

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.

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.

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.

8.2 What to decide on

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.

Here are the inputs I used, gathered up.

  • Splitting bundle IDs splits your store app registrations. There's no avoiding it
  • Splitting Projects duplicates paywalls, Offerings and targeting rules. And because the paywall is remote configuration, a missed update never enters your release flow
  • 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
  • Webhooks can be routed per App and per environment inside one Project. The cap of five is the thing to watch
  • If you only need local and CI testing, the Test Store lets you skip store configuration entirely

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.

References

Top comments (0)