revenuecat
| Package | Kind | Provides the role | Needs | Choose with |
|---|---|---|---|---|
smf_revenuecat | infrastructure | Purchases | values in .env, such as dart_define | -m revenuecat |
revenuecat sells the premium access of the app with RevenueCat, through the purchases_flutter package. The user buys a plan of the default offering of a RevenueCat project, such as a monthly or a yearly subscription or a payment once, and restores it on another device. The app knows whether the user has premium access before its first frame.
The code of the app never uses the package. It uses appPurchases, which the purchases role generates; see Purchases. To show the plans to the user, add paywall, which brings this module with it.
What it adds to the app
| In the app | What it is |
|---|---|
purchases_flutter: ^10.18.0 in the dependencies | The SDK of RevenueCat. |
lib/core/purchases/revenuecat_purchases_service.dart | RevenueCatPurchasesService, the purchases service of the app on Purchases; createRevenueCatPurchasesService(), which creates it; and revenueCatKey(), which chooses the API key for the platform and the build. |
Three keys in .env | REVENUECAT_APPLE_KEY, REVENUECAT_GOOGLE_KEY and REVENUECAT_TEST_KEY, the public API keys of RevenueCat, through the role of the values in .env. |
| The section "RevenueCat" of the README | The way to a first purchase in the Test Store of RevenueCat, what a release needs, which key a build takes, and what the app does without a key. |
Whichever module provides it, the purchases role adds these files:
| File | What it holds |
|---|---|
lib/core/purchases/app_purchases.dart | appPurchases, through which the code of the app reads the access of the user, gets the plans, buys and restores, and initPurchases(). It exports the types of the answers, so the code of the app imports this file alone. |
lib/core/purchases/purchases_service.dart | PurchasesService, the interface that this module implements, with AppAccess, Plan, IntroOffer, BillingPeriod and PurchasesFailure. |
lib/core/purchases/restore_purchases_setting.dart, in an app with a settings screen | RestorePurchasesSetting, the row of the settings screen that restores purchases and tells what it found. |
bootstrap() awaits initPurchases() in its platform phase, which creates the service and starts appPurchases with it. This is bootstrap() of an app with this module and dart_define, which comes with it:
Future<void> bootstrap() async {
await initPurchases();
}
In an app with sign-in, initAuth() comes first, so the purchases belong to the user who is signed in; see Purchases.
The module needs nothing in the native projects. The Billing Library of Google Play declares its permission itself, and the In-App Purchase capability of iOS has no entitlement.
In the guide for coding agents of the app, the purchases role describes appPurchases under "Purchases". The module adds which file is on purchases_flutter, that the premium access is the entitlement premium, which key a build takes and why that rule stays, that nothing calls RevenueCat before it is configured, how a grant made on the RevenueCat dashboard reaches the app, and what a test answers in place of the platform side of the package.
The premium access in RevenueCat
The premium access of the app is the entitlement premium in RevenueCat. Every product of the current offering unlocks it, so attach each product to premium. A plan of the app is a package of the current offering, which the RevenueCat dashboard calls the default offering, and the id of a Plan is the identifier of its package. The app shows the plans in the order of the offering.
| In RevenueCat | In the app |
|---|---|
The entitlement premium, active | PremiumAccess, with expiresAt from its expiration date (null for a payment once), renews, inTrial and the manageUrl of the customer |
No active premium | NoAccess |
| A package of the current offering | A Plan, with the price, the period and the intro offer of its product in the store |
| No current offering, or one without packages | plans() fails with notConfigured, and its hint tells what to make in RevenueCat |
The steps by hand of the module set this up; see Finish the setup.
The API keys
RevenueCat has a public API key for each app of a project: the App Store app, the Play Store app, and the Test Store, a store of RevenueCat itself in which you buy without an account at Apple or Google. They go into .env:
REVENUECAT_APPLE_KEY=appl_...
REVENUECAT_GOOGLE_KEY=goog_...
REVENUECAT_TEST_KEY=test_...
revenueCatKey() chooses the key that a build configures RevenueCat with:
| Build | Key |
|---|---|
A debug build, with REVENUECAT_TEST_KEY filled in | REVENUECAT_TEST_KEY, the Test Store |
| A debug build without it, and every profile or release build | REVENUECAT_APPLE_KEY on iOS, REVENUECAT_GOOGLE_KEY on Android |
A profile or release build never takes the key of the Test Store: RevenueCat stops such a build on purpose. So to try the sandbox of a store in a debug build, leave REVENUECAT_TEST_KEY empty.
The keys are public. Each is compiled into the app, where anyone can read it, as with every value of .env. A secret API key of RevenueCat, which starts with sk_, never goes into the app, and revenueCatKey() refuses one.
While the key that a build takes has no value, the app runs without purchases. It never calls RevenueCat, appPurchases.value stays NoAccess, every call fails with the reason notConfigured, and a debug build prints which key to fill in. The same goes for a secret key, a Test Store key in a profile or release build, and a platform other than Android and iOS.
What the service adds to the package
The purchases role promises more than purchases_flutter does in a few places, and the service makes up the difference:
| What the service does | Why |
|---|---|
| It configures RevenueCat when it is created, and calls nothing else of RevenueCat before that has completed. Without a key, it calls nothing at all. | iOS stops an app that calls RevenueCat before it is configured. |
A configuration that fails, also for want of the platform side of the plugin (MissingPluginException), leaves the service not configured, and every call fails with notConfigured and a hint. | The app starts and runs without purchases rather than crash. |
identify logs a user of the app in with logIn, and logs out for the user of the device. A log out of an anonymous user, which RevenueCat fails with the code 22, counts as done. | The role says whose the purchases are, and the user of the device is the anonymous user of RevenueCat. |
It reads the code of an error with int.tryParse. | An error of the plugin itself has a code that is no number, as on Android before the first configuration. |
A purchase that RevenueCat gives no active entitlement premium for fails with notConfigured, with a hint that names the product. | Every plan gives premium access, so such a product is not attached to premium yet. A restore gives the access once it is. |
| On iOS, a plan has its intro offer only when iOS says that the user may take it. | A user who had a trial of the group gets no second one, and the paywall would promise one. |
| On Android, a plan has the title of its product without the name of the app. | Google Play appends the name of the app to the title. |
| The access of a plan that renews takes its period from the offerings, which it waits for at most a second. | The customer info of RevenueCat has no period. The offerings come at once from what the device keeps, so the wait only ends a read that would wait for the network: the access then has no period, and a later access has it. |
Each error of RevenueCat becomes a reason of the role:
| Reason | Codes of RevenueCat |
|---|---|
pending | paymentPendingError |
network | networkError, offlineConnectionError, productRequestTimeout, apiEndpointBlocked |
alreadyOwned | productAlreadyPurchasedError, receiptAlreadyInUseError, receiptInUseByOtherSubscriberError, purchaseBelongsToOtherUser |
notAllowed | purchaseNotAllowedError |
unavailable | productNotAvailableForPurchaseError, purchaseInvalidError, ineligibleError |
notConfigured | configurationError, invalidCredentialsError, invalidAppleSubscriptionKeyError, insufficientPermissionsError |
storeProblem | storeProblemError, invalidReceiptError, missingReceiptFileError, unexpectedBackendResponseError, unknownBackendError, customerInfoError, systemInfoError, signatureVerificationFailed, testStoreSimulatedPurchaseError |
unknown | Any other code, and an error that is not of RevenueCat |
A cancel is no failure: appPurchases.purchase() completes with false. The developerHint of a failure has the code and the message of RevenueCat. For an API key that RevenueCat refuses, it names the key of .env to fix.
RevenueCat answers from a cache of the customer info. An entitlement that you grant on the RevenueCat dashboard, or with its API, reaches the app once the cache is emptied with Purchases.invalidateCustomerInfoCache() and the customer info is read again, or once the SDK refreshes it itself. appPurchases asks for the access again each time the user comes back to the app.
Finish the setup
Right after smf create the keys are empty, so the app runs without purchases. To buy a plan in a debug build with nothing but a RevenueCat account:
- Create a project in RevenueCat. A new project has a Test Store with products.
- Create the entitlement
premiumand attach the products to it. - Add the packages of those products to an offering, and make it the default one.
- Copy the public API key of the Test Store, which starts with
test_, intoREVENUECAT_TEST_KEYin.env. - Run the app with
flutter run --dart-define-from-file=.envand buy a plan. The Test Store shows a dialog of its own, where "Test valid purchase" completes the purchase.
For a release, the app sells in the App Store and in Google Play, and RevenueCat checks each purchase with the store. That takes the steps by hand of the app, which the README lists in its section "Finish the setup". The table links to the click-by-click guide of each:
| Step | What you do |
|---|---|
purchases.store_agreements | Sign the agreement for paid apps in App Store Connect, and set up a payments profile in Google Play Console. |
purchases.store_products | Create the products of the plans in both stores. |
revenuecat.entitlement_and_offering | Create the entitlement premium and the default offering with a package for each plan. On Google Play a lifetime plan is a one-time product: give it the type Non-consumable in RevenueCat, or RevenueCat consumes the purchase and the user can buy the plan again. |
revenuecat.api_keys | Add the App Store app and the Play Store app to the project, and copy the public API keys of the three apps into .env. |
revenuecat.app_store_key | Give RevenueCat the In-App Purchase key of App Store Connect, with which it checks the purchases on iOS. |
revenuecat.play_service_account | Give RevenueCat a service account with access to Google Play, with which it checks the purchases on Android. |
The first two are steps of the purchases role, which every provider that sells in the stores needs. An app with paywall has one more, for the pages of its terms of use and its privacy policy.
What the module does not do
- It sells the packages of the default offering alone, and the one entitlement
premium. Other offerings and other entitlements of the project do not reach the app. - It adds no paywall of RevenueCat (
purchases_ui_flutter). The screens that sell come frompaywallor from your own code, throughappPurchases. - It sells on Android and iOS only.
- It restores nothing on its own. The app restores where the user asks for it, such as on the paywall or in the row of the settings screen.
- It grants nothing from a server. A grant made on the dashboard reaches the app as described above.
In tests
purchases_flutter has no platform side for tests and no platform interface: Purchases is a class of static methods over the method channel purchases_flutter. A test that runs main() or bootstrap() answers that channel itself, with setMockMethodCallHandler of TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger. The listeners and the last customer info of Purchases are static, so they last for a whole test file. The guide for coding agents of the app says the same. A test of a screen needs none of this: it gives appPurchases.start() a service of its own; see Purchases.
Whichever module provides the purchases, a fresh user who buys a plan has premium access, the access stays at the next start of the app, and the purchases of each user stay apart. SMF's CI checks this in running apps with the role, over a stand-in for RevenueCat, its backend and the store account of a device, which starts as on a first launch. For this module, it checks the key that each build takes and that the app reads the keys from .env; that without a key, or without the platform side of the plugin, the service calls nothing and fails every call with a hint; what each call sends to RevenueCat; the reason of each code of RevenueCat; and the plans and the access on Android and on iOS, from a purchase, from the customer info and from the next launch. On a device, it checks that RevenueCat is configured exactly when the app has a key, and that the access and the plans of the app are those that RevenueCat gives. See Contributing.
Choosing it
To choose it without the question of smf create:
smf create my_app -m revenuecat
The module requires the values of .env, and smf create adds the only module that provides them:
Adding flutter_core: the only provider of the app entry role, which every app needs.
Adding dart_define: the only provider of the env role, which revenuecat requires.
paywall requires the purchases role, so with it smf create adds this module without a question while it is the only one that provides the purchases.