🚀 Join the waitlist now! waitlist.floot.dev
LogoFlootdocs
Payments

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:

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.

.env.local
ENABLE_REVENUECAT=true
supabase/.env.local
ENABLE_REVENUECAT=true

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

VariableFileWhat it is
REVENUECAT_SECRET_API_KEYsupabase/.env.localYour project's secret API key, used to read subscriber entitlements and to erase a subscriber on account deletion
REVENUECAT_WEBHOOK_SECRETsupabase/.env.localThe bare token from the webhook's authorization header (see Webhooks)
REVENUECAT_PUBLIC_APPLE_KEY.env.localThe App Store app's public SDK key, used on iOS and macOS
REVENUECAT_PUBLIC_ANDROID_KEY.env.localThe 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 key of 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.
.env.local
REVENUECAT_PUBLIC_APPLE_KEY="appl_..."
REVENUECAT_PUBLIC_ANDROID_KEY="goog_..."
supabase/.env.local
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 macOS as well if you plan to ship the desktop build with in-app purchases.
  • Bundle ID: the identifier already in your project. Open the ios folder in Xcode (right click → Open in Xcode), then Runner (sidebar) → General → Runner (under TARGETS) → 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:

CredentialWhere it comes from
In-App Purchase key (.p8)Users and Access → Integrations → In-App Purchase
App-Specific Shared SecretApps → your app → App Information
App Store Server Notification URLRevenueCat 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 numberUsers 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);
ColumnValue
idThe RevenueCat entitlement identifier
nameIf you also use Stripe, keep the names identical (case sensitive); otherwise anything
providerrevenuecat
platformandroid or apple, required for RevenueCat plans; apple covers both iOS and macOS
activeFALSE 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);
ColumnValue
idThe store product identifier, exactly as the store has it (on Play, the base plan id alone)
plan_idThe entitlement this product is attached to
amount, currencyAs set in the store
intervalOne of the price_interval values in supabase/migrations/*_init.sql; most commonly weekly, monthly, yearly
typeone_time or recurring
activeFALSE 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:

GroupEvents
PurchasesINITIAL_PURCHASE, NON_RENEWING_PURCHASE, RENEWAL, PRODUCT_CHANGE
LifecycleCANCELLATION, UNCANCELLATION, EXPIRATION, SUBSCRIPTION_PAUSED, SUBSCRIPTION_EXTENDED
GrantsTEMPORARY_ENTITLEMENT_GRANT
Account movesTRANSFER

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-webhook

Example: https://3k5jk495-54321.abc1.devtunnels.ms/functions/v1/revenuecat-webhook

  • Authorization header value: mandatory, and the handler compares it against REVENUECAT_WEBHOOK_SECRET byte 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

supabase/.env.local
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 apps and All events are 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.

SurfaceWhat it looks like
The built-in paywallAn "Already subscribed? Restore purchases" text button under the price list
Account settingsA "Restore purchases" row
RevenueCat's own paywallIts 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:

OutcomeMeaningToast
grantedAn active entitlement was found and access was writtenYour purchase has been restored
refreshedThe existing RevenueCat row was updated in placeYour purchase has been restored
revokedRevenueCat has nothing active; a stale row was removedYour subscription is no longer active
noneNothing to restore, including the App Review account that never purchasedNo purchases were found to restore
managed_elsewhereThe user already has an active subscription from another provider, which is never touchedYou 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:

lib/core/presentation/utils/extensions/build_context_x.dart
bool get canShowRevenueCatPaywall =>
    isMobile &&
    Env.ENABLE_REVENUECAT &&
    !Env.ENABLE_STRIPE_MOBILE &&
    Env.ENABLE_REVENUECAT_PAYWALL;

All four conditions must hold. In particular:

  • isMobile is iOS and Android only; macOS always gets the built-in paywall, even with ENABLE_IAP_MACOS on.
  • Turning on ENABLE_STRIPE_MOBILE takes 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.

.env.local
ENABLE_REVENUECAT_PAYWALL=true

RevenueCat'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_days on the matching prices row yourself. (RevenueCat's own paywall reads the offer from the store and needs nothing.)
  • Trials cannot be offered on one-time purchases; the prices table 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.

.env.local
ENABLE_IAP_MACOS=true

With 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:

Terminal
supabase functions serve --env-file .env.local --import-map ./functions/deno.json --no-verify-jwt

Source: RC Sandbox Testing.

On this page