RevenueCat
Set up RevenueCat for in-app purchases on iOS, Android, and macOS.
Requirements
To set up RevenueCat for your Floot app, you will need:
- A RevenueCat account.
- An App Store Connect account, for iOS and macOS.
- A Google Play Console account, for Android.
- A real device. Simulators and emulators do not support the full in-app purchase flow; see Sandbox testing.
iOS, Android and macOS are all wired up. macOS needs one extra flag; see In-app purchases on macOS.
Setup
Turn RevenueCat on
RevenueCat ships disabled. The SDK is never configured and the Edge Functions never call RevenueCat's API until you say so, so a project that monetizes purely through Stripe, or not at all, can leave this alone.
Requires ENABLE_REVENUECAT
RevenueCat is off in a new project. Set `ENABLE_REVENUECAT=true` in both `.env.local` and `supabase/.env.local` to use this section.
This flag is declared twice, and both copies matter. The app's copy decides
whether Purchases.configure() runs; Supabase's copy decides whether the Edge
Functions demand RevenueCat's secrets. Setting only one leaves the two halves
disagreeing about what is enabled.
ENABLE_REVENUECAT=trueENABLE_REVENUECAT=trueTurning it on makes four variables mandatory. On the Supabase side this is enforced: the Edge Functions validate their environment at startup and refuse to boot if either secret is missing, which is deliberate, a half-configured payment integration should fail loudly at deploy time rather than quietly at checkout.
| Variable | File | What it is |
|---|---|---|
REVENUECAT_SECRET_API_KEY | supabase/.env.local | Your project's secret API key, used to read subscriber entitlements and to erase a subscriber on account deletion |
REVENUECAT_WEBHOOK_SECRET | supabase/.env.local | The bare token from the webhook's authorization header (see Webhooks) |
REVENUECAT_PUBLIC_APPLE_KEY | .env.local | The App Store app's public SDK key, used on iOS and macOS |
REVENUECAT_PUBLIC_ANDROID_KEY | .env.local | The Play Store app's public SDK key, used on Android |
Secret and public keys are not interchangeable
The public SDK keys go in the app's .env.local and ship inside your
binary, that is what they are for. REVENUECAT_SECRET_API_KEY grants full
read/write access to your RevenueCat project and belongs only in
supabase/.env.local, never in the Flutter app.
SDK keys
Go to RC dashboard → your project → API keys (sidebar).
- Under SDK API keys, copy the
Public API keyof each app config and paste it into the app's.env.local. - Under Secret API keys, create a key with read and write access to
subscribers and paste it into
supabase/.env.local.
REVENUECAT_PUBLIC_APPLE_KEY="appl_..."
REVENUECAT_PUBLIC_ANDROID_KEY="goog_..."REVENUECAT_SECRET_API_KEY="<your_revenuecat_secret_api_key>"Restore behaviour
In RC dashboard → your project → Project settings → General,
set Restore behavior to Transfer to new App User ID.
Both settings work: the app handles the resulting TRANSFER webhook and
reconciles either way. But "Transfer" is the one that makes a
Restore purchases tap from a different account do the
intuitive thing: move the purchase to the account that is asking for it.
Store setup
Your store is where products and prices actually live; RevenueCat imports them. Both stores have their own well-maintained guides, so this section only covers what is specific to your project.
Create the app in App Store Connect
App Store Connect → Apps → + → New App.
Two fields have to match your project rather than being free choices:
- Platforms: tick
macOSas well if you plan to ship the desktop build with in-app purchases. - Bundle ID: the identifier already in your project. Open the
iosfolder in Xcode (right click →Open in Xcode), thenRunner(sidebar) →General→Runner(underTARGETS) →Identity→Bundle Identifier.
Everything else (name, SKU, primary language, user access) is yours to pick.
Create the in-app purchase products
Follow RevenueCat's guide: iOS product setup.
Note the Product ID of every product you create; it becomes
prices.id in your database, verbatim. See Plans and
prices.
Enable the In-App Purchase capability
Open the ios folder (and macos, if you ship it) in Xcode →
Runner (under TARGETS) → Signing & Capabilities → + Capability
→ In-App Purchase.
This one is on you: the kit cannot add a capability to a signed target.
Connect App Store Connect to RevenueCat
RC dashboard → your project → Apps & providers →
Add app config → App Store, then paste the same bundle ID as
above.
RevenueCat's own App Store setup guide walks through the four credentials it asks for, each generated in App Store Connect and each downloadable exactly once:
| Credential | Where it comes from |
|---|---|
In-App Purchase key (.p8) | Users and Access → Integrations → In-App Purchase |
| App-Specific Shared Secret | Apps → your app → App Information |
| App Store Server Notification URL | RevenueCat gives you the URL; paste it into App Information → App Store Server Notifications, in both the production and sandbox fields |
App Store Connect API key (.p8) + Issuer ID + Vendor number | Users and Access → Integrations → App Store Connect API (role: App Manager); the vendor number is on Payments and Financial Reports |
Both .p8 files can be downloaded once and never again. Store them
somewhere you will still have them next year.
Entitlements and offerings
RevenueCat groups store products into entitlements (what the user gets) and offerings (what you show them).
Import your products
RC dashboard → Product catalog (sidebar) → Products →
Import next to the store app you want to pull from.
Create an entitlement
Product catalog → Entitlements → + New Entitlement. Give it an
identifier such as basic or premium. That identifier becomes
plans.id in your database, verbatim.
Attach products to it
Open the entitlement → Attach in the Associated Products tab bar →
pick the products it should unlock.
Source: RC Entitlements.
Plans and prices
Unlike Stripe, RevenueCat does not sync its catalog into your database. The
paywall reads plans and prices from Supabase, so you mirror your RevenueCat
catalog there by hand, once.
Entitlements become rows in plans. Products become rows in prices.
Insert the plans
INSERT INTO
plans (id, name, provider, description, platform, active)
VALUES
('entlxxx123', 'Basic', 'revenuecat', 'Perfect for getting started.', 'android', TRUE),
('entlxxx321', 'Premium', 'revenuecat', 'Unlock everything.', 'apple', TRUE);| Column | Value |
|---|---|
id | The RevenueCat entitlement identifier |
name | If you also use Stripe, keep the names identical (case sensitive); otherwise anything |
provider | revenuecat |
platform | android or apple, required for RevenueCat plans; apple covers both iOS and macOS |
active | FALSE hides the plan from the paywall without deleting it |
Insert the prices
INSERT INTO
prices (id, plan_id, amount, currency, interval, type, active)
VALUES
('myapp_999_1m_00', 'entlxxx123', 9.99, 'usd', 'monthly', 'recurring', TRUE),
('myapp_9999_1y_00', 'entlxxx123', 99.99, 'usd', 'yearly', 'recurring', TRUE),
('myapp_2999_1m_00', 'entlxxx321', 29.99, 'usd', 'monthly', 'recurring', TRUE),
('myapp_29999_1y_00', 'entlxxx321', 299.99, 'usd', 'yearly', 'recurring', TRUE);| Column | Value |
|---|---|
id | The store product identifier, exactly as the store has it (on Play, the base plan id alone) |
plan_id | The entitlement this product is attached to |
amount, currency | As set in the store |
interval | One of the price_interval values in supabase/migrations/*_init.sql; most commonly weekly, monthly, yearly |
type | one_time or recurring |
active | FALSE hides the price from the paywall |
Tip
Running Supabase locally, put these statements in a
supabase/db_seeds/*.sql file instead so a supabase db reset keeps them.
Insert the plans before the prices; prices.plan_id references plans.id.
A missing row is a silent paywall and a failed restore
These rows are the only mapping between RevenueCat's identifiers and your
database. A purchase whose entitlement/product pair has no matching
plans/prices row cannot be recorded: the webhook has nothing to write, and
a restore answers PLAN_NOT_CONFIGURED.
Webhooks
RevenueCat is the source of truth for entitlements; the subscriptions table is
a replica of it. Webhook events keep the replica current, and the integration
does not grant access without them: a completed purchase never unlocks
anything.
Eleven event types are handled:
| Group | Events |
|---|---|
| Purchases | INITIAL_PURCHASE, NON_RENEWING_PURCHASE, RENEWAL, PRODUCT_CHANGE |
| Lifecycle | CANCELLATION, UNCANCELLATION, EXPIRATION, SUBSCRIPTION_PAUSED, SUBSCRIPTION_EXTENDED |
| Grants | TEMPORARY_ENTITLEMENT_GRANT |
| Account moves | TRANSFER |
Anything else is acknowledged and ignored, not rejected, so leaving the
dashboard on All events is harmless, just noisier. A payload that cannot be
parsed at all is acknowledged and dropped rather than left to burn through
RevenueCat's five retries.
Create the webhook
Go to RC dashboard → Integrations (sidebar) → Webhooks
(under RevenueCat core tools) → Add new configuration.
- Name: anything; it only distinguishes multiple endpoints.
- Webhook URL: see the tabs below.
RevenueCat does not accept http, so localhost:54321 cannot be used
directly. A port
forward from a
VS Code-based editor is the quickest workaround, but its URL is temporary
and the port tends to revert to private each time you quit the editor.
For a stable URL use Zrok (open source) or
Ngrok.
In the editor terminal: PORTS (tab bar) → Forward a Port → 54321, then
right click the port → Port Visibility → Public.
<generated.forward.address>/functions/v1/revenuecat-webhookExample:
https://3k5jk495-54321.abc1.devtunnels.ms/functions/v1/revenuecat-webhook
- Authorization header value: mandatory, and the handler compares it
against
REVENUECAT_WEBHOOK_SECRETbyte for byte.
Generate a token
bash echo -n <username>:<password> | base64 The username and password are arbitrary; nothing looks them up. Keep a note of the generated value; you need it twice.
Paste it into RevenueCat, with the prefix
Bearer <generated_key>Example:
Bearer dXNlcm5hbWU6cGFzc3dvcmQ=
Paste it into Supabase, without the prefix
REVENUECAT_WEBHOOK_SECRET="dXNlcm5hbWU6cGFzc3dvcmQ="RevenueCat sends Bearer <token>; the handler strips the Bearer and
compares what is left. Storing the prefix in
REVENUECAT_WEBHOOK_SECRET makes every event fail with a 401
INVALID_SIGNATURE.
- Environment to send events for: production, sandbox, or both.
- Apps / Event type (under Events filter):
All appsandAll eventsare fine.
→ Add webhook (lower right)
Source: RC Webhooks.
Restore purchases
Store purchases outlive an account: a user reinstalls, signs in on a new phone, or creates a second account against the same Apple ID. Both stores require an app that sells non-consumables or subscriptions to offer a way to recover them.
It is offered in three places, and only when context.canRestorePurchases is
true:
bool get canRestorePurchases => Env.ENABLE_REVENUECAT && supportsIAP;Stripe subscriptions have no store receipt, so the action stays hidden wherever purchases are not made through a store.
| Surface | What it looks like |
|---|---|
| The built-in paywall | An "Already subscribed? Restore purchases" text button under the price list |
| Account settings | A "Restore purchases" row |
| RevenueCat's own paywall | Its own restore button, wired to the same code path through onRestoreCompleted |
The first two show a confirmation dialog first, because restoring is not free of consequence: it moves the purchase, unlinking it from any other account it is currently attached to.
Every entry point runs the same two steps:
Purchases.restorePurchases() in the SDK, which re-syncs the device's store
receipt into RevenueCat. Its return value is deliberately discarded.
A call to the revenuecat-reconcile Edge Function, which is the sole
authority on what the user ends up with.
Why the second step exists
RevenueCat only emits webhooks when subscriber state changes. If a webhook
was lost, restoring on the client changes nothing at RevenueCat and therefore
produces no webhook at all: the database stays wrong. revenuecat-reconcile
reads RevenueCat's REST API directly and repairs the row, which is why it, not
the SDK call, decides the outcome.
The function answers with one of five outcomes, and the UI shows honest copy for each rather than a blanket success/failure toast:
| Outcome | Meaning | Toast |
|---|---|---|
granted | An active entitlement was found and access was written | Your purchase has been restored |
refreshed | The existing RevenueCat row was updated in place | Your purchase has been restored |
revoked | RevenueCat has nothing active; a stale row was removed | Your subscription is no longer active |
none | Nothing to restore, including the App Review account that never purchased | No purchases were found to restore |
managed_elsewhere | The user already has an active subscription from another provider, which is never touched | You already have an active subscription through provider |
Only three conditions are real errors: NOT_AUTHENTICATED, RC_UNAVAILABLE
(RevenueCat unreachable, the user may retry) and PLAN_NOT_CONFIGURED (the
entitlement/product pair has no matching row; see Plans and
prices). Failures get their own copy too, so a restore
that cannot run never looks like a restore that found nothing.
Multi-tenancy only
This section applies only to projects generated with `--multi-tenancy`.
A member whose subscription is already covered by their organization is told
so, rather than being allowed to attach a personal store purchase on top of
it; that is the multi-tenancy case of managed_elsewhere.
Nothing schedules it
revenuecat-reconcile runs only when a user asks. It is authenticated
(verify_jwt = true) and reconciles exactly the caller's own row, so there is
no pg_cron job for it and no batch mode, unlike the sweeps that back the
Stripe cleanup queue and the trial-ending notifications. If you want a
periodic repair pass, it is yours to build.
Which paywall renders
Two paywalls ship. Which one a user sees is decided by
context.canShowRevenueCatPaywall:
bool get canShowRevenueCatPaywall =>
isMobile &&
Env.ENABLE_REVENUECAT &&
!Env.ENABLE_STRIPE_MOBILE &&
Env.ENABLE_REVENUECAT_PAYWALL;All four conditions must hold. In particular:
isMobileis iOS and Android only; macOS always gets the built-in paywall, even withENABLE_IAP_MACOSon.- Turning on
ENABLE_STRIPE_MOBILEtakes precedence and returns mobile to Stripe.
Requires ENABLE_REVENUECAT_PAYWALL
RevenueCat-hosted paywall is off in a new project. Set `ENABLE_REVENUECAT_PAYWALL=true` in `.env.local` to use this section.
Unlike ENABLE_REVENUECAT, this one exists only in the app's .env.local.
Nothing on the Supabase side reads it.
ENABLE_REVENUECAT_PAYWALL=trueRevenueCat's Paywalls let you change the whole purchase screen from the dashboard, with no code change and no app update.
What actually gates access is the database
Neither paywall grants anything. Access is decided by isGated() in
lib/core/presentation/utils/access_gate.dart, and its billing half,
hasActiveSubscription(), reads the user's subscriptions row. Not
RevenueCat's CustomerInfo, and not the SDK's entitlements.
So a purchase unlocks the app only once the webhook (or a restore) has written that row. The paywall page listens for the change and moves the user on by itself; there is nothing to wire up.
Customizing the built-in paywall (plan descriptions, feature lists, lifetime prices, or removing the paywall altogether) is covered on the App Paywall page.
Handling business logic
Floot provides sensible defaults for subscriptions and one-time payments, but you can extend the handlers to fit your business.
Subscriptions
Subscription access is managed automatically from the entitlement the user
bought. Extra logic, awarding credits, coins, or anything else, belongs in
supabase/functions/revenuecat-webhook/_events/rc_sub_handlers.ts.
One time payments
To handle one-time payments (non-renewing purchases), edit
supabase/functions/revenuecat-webhook/_events/non_renewing_purchase.ts and
follow the comments starting with // ! ....
Free trials
Trials are configured in the store, not in RevenueCat and not in your database. On Apple they are introductory offers; on Google Play they are offers on a base plan.
One introductory offer per subscription group
Both stores enforce this, and neither your app nor RevenueCat can override it: a customer gets one trial across every tier in a group, not one per tier. See Apple and Google Play.
Two things are RevenueCat-specific and easy to miss:
- The store grants the trial; your database only advertises it. A trial you
configure in the store applies at purchase whether or not your database knows
about it; the user simply is not told. To show it on the built-in paywall,
set
trial_dayson the matchingpricesrow yourself. (RevenueCat's own paywall reads the offer from the store and needs nothing.) - Trials cannot be offered on one-time purchases; the
pricestable enforces that with a check constraint.
UPDATE prices
SET trial_days = 7
WHERE id = '<your_price_id>';Everything after that (eligibility, the paywall badge, the trial-ending notification schedule, and how to let a user trial more than once) is provider-agnostic and lives on the Free Trials page.
In-app purchases on macOS
The RevenueCat SDK is configured on macOS as well as iOS and Android, but the in-app purchase surfaces stay hidden until you opt in: a macOS build distributed outside the App Store has no store to buy from.
Requires ENABLE_IAP_MACOS
macOS in-app purchases is off in a new project. Set `ENABLE_IAP_MACOS=true` in `.env.local` to use this section.
ENABLE_IAP_MACOS=trueWith it on, macOS uses the App Store app's REVENUECAT_PUBLIC_APPLE_KEY, buys
against plans whose platform is apple, and gets the Restore purchases
action. It keeps the built-in paywall either way; see Which paywall
renders.
Remember to add the In-App Purchase capability to the macos Runner target
in Xcode, exactly as for iOS.
Sandbox testing
You don't need to make real purchases to test your subscriptions. The store sandboxes behave like the real thing without charging anyone.
Test on a real device. Simulators and emulators do not support every in-app purchase flow and will not reflect what your users see.
If you point a sandbox webhook at a locally forwarded URL, remember the webhook
still has to reach revenuecat-webhook; serve the functions with:
supabase functions serve --env-file .env.local --import-map ./functions/deno.json --no-verify-jwtSource: RC Sandbox Testing.