Flootdocs
Payments

Stripe

Set up Stripe to enable secure payment processing in your Floot app.

Requirements

To set up Stripe for your Floot app, you will need:

  • A Stripe account.
  • The Stripe CLI installed on your machine (required only if you plan to test payments locally.)

Setup

Turn Stripe on

Stripe ships disabled. Nothing in the backend talks to Stripe until you say so, so a project that monetizes purely through RevenueCat, or not at all, can leave this alone.

Requires ENABLE_STRIPE

Stripe is off in a new project. Set `ENABLE_STRIPE=true` in `supabase/.env.local` to use this section.

supabase/.env.local
ENABLE_STRIPE=true

Turning it on makes two variables mandatory. The Edge Functions validate their environment when they boot and refuse to boot if any of them is missing, which is deliberate: a half-configured payment integration should fail loudly rather than quietly at checkout. The check runs on a function's first request, not when you deploy it: a deploy with a missing value still succeeds, and then every function fails until the value is set (see Every Edge Function fails to boot).

VariableWhat it is
STRIPE_SECRET_KEYYour account's secret key
STRIPE_WEBHOOK_SECRETSigning secret for the webhook endpoint (see Webhooks)

Secret key

Find it in your Stripe dashboard and copy the Secret Key.

supabase/.env.local
STRIPE_SECRET_KEY="sk_test_..."

Nothing reads it from the database

The Edge Functions read the key from their environment, never from Supabase Vault. Locally that is supabase/.env.local; a deployed backend does not read that file, so in production the live key is a function secret (see In production).

After a Checkout or a visit to the billing portal, Stripe sends the user back to an https link on your web app's domain, /payment-return, which carries the page the purchase started from and forwards there:

https://app.myapp.com/payment-return?to=%2Fbilling

The web build takes the domain from its own page. Native builds read it from WEB_APP_URL in the app's env file, which floot create --web-domain filled in; while it is empty, a payment on a native build fails before Stripe is called.

.env.local
WEB_APP_URL="https://app.myapp.com"

On native, Checkout and the portal open in the system browser, and the return opens the installed app on the same page once the platform has verified your domain. Where it does not (Windows, Linux, a Mac not opted in, or before the association files are served), the return opens the web app on that page, after sign-in when the browser is signed out. Your Stripe account needs no setting for any of it: each session carries its own return URLs. See Verified links for the route and what each platform does.

Webhooks

Stripe is the source of truth for products, prices, customers and subscriptions; your database is a replica of it. Webhook events are what keeps the replica current, so the integration does not work at all without them: products never appear in the paywall, and a completed checkout never grants access.

Thirteen event types are handled:

GroupEvents
Checkoutcheckout.session.completed
Customerscustomer.created, customer.deleted
Invoicesinvoice.paid
Pricesprice.created, price.updated, price.deleted
Productsproduct.created, product.updated, product.deleted
Subscriptionscustomer.subscription.created, customer.subscription.updated, customer.subscription.deleted

Anything else is ignored, not rejected, so a wider endpoint selection is harmless, just noisier.

Events Redirection

Stripe webhooks events should be redirected to your Supabase instance, and this is how to do it.

Here are the steps to receive Stripe events locally using the Stripe CLI (required).

Login to your Stripe account using the CLI.

Terminal
stripe login

You'll be prompted to open a browser window to complete the login process.

Redirect Stripe events to your Supabase local instance.

Terminal
stripe listen --forward-to localhost:54321/functions/v1/stripe-webhook

You should see a message confirming that the webhook is set up and listening for events.

Ready! Your webhook signing secret is whsec_...

You can now copy the whsec_... value and add it to your Supabase local environment file.

supabase/.env.local
STRIPE_WEBHOOK_SECRET="whsec_..."

stripe listen mints a new signing secret every time you run it. If webhooks start returning signature errors after a restart, this is why: copy the new value across and restart supabase functions serve.

Serve the Supabase functions locally to handle Stripe webhooks.

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

Billing portal configurations

Stripe's hosted billing portal is driven by a Billing Portal Configuration. Left to its own devices your app uses the account's default one, and that default decides things your app should be deciding, most importantly whether a customer can edit their subscription quantity straight from Stripe's UI.

Two provisioning scripts ship with your project. Both are re-runnable and both clone your account's current default configuration, changing exactly one field.

Run them once per Stripe account and per mode (test and live keys have separate configurations), then paste the printed ids into supabase/.env.local. For live mode, pass an env file holding your live STRIPE_SECRET_KEY instead, and set the printed ids as function secrets (see In production).

The restricted configuration, which removes quantity from the updates a customer may make.

Terminal
deno run --allow-net --allow-env --env-file=supabase/.env.local \
  supabase/scripts/create_restricted_portal_config.ts
supabase/.env.local
STRIPE_PORTAL_CONFIGURATION_ID="bpc_..."

Seat-based billing only

This section applies only to projects generated with `--seat-based-billing`.

Requires ENABLE_SEAT_BASED_BILLING

Seat-based billing is off in a new project. Set `ENABLE_SEAT_BASED_BILLING=true` in both `.env.local` and `supabase/.env.local` to use this section.

The confirm configuration, which adds quantity back, for one flow only.

Terminal
deno run --allow-net --allow-env --env-file=supabase/.env.local \
  supabase/scripts/create_confirm_portal_config.ts
supabase/.env.local
STRIPE_PORTAL_CONFIRM_CONFIGURATION_ID="bpc_..."

What this pair of configurations protects is explained on Seat-Based Billing.

Why two configurations rather than one

The app's own plan-switch flow sends a subscription_update_confirm session whose quantity is fixed server-side, not chosen by the customer. Stripe validates default_allowed_updates against that flow all the same, so the restricted configuration makes it fail with a 400. create-stripe-portal therefore uses the restricted configuration for customer-facing flows and the confirm configuration only for plan switches. One configuration cannot do both.

Products & prices

Important

Do not create customers from your Stripe dashboard, let the app take care of it, because it attaches useful metadata to them in the creation process.

Once you've completed the steps above, connect to your Stripe account and create your products and prices. The product.* and price.* webhook events sync them into your Supabase database, where the paywall reads them.

Create them after the webhook endpoint exists. A product or price created before it never reaches the database, and editing it later does not add it: the *.updated events change only rows that are already there. The same goes for each new, empty backend: products made before its endpoint never reach it. Create them again, then archive the old ones.

Here is a guide on how to manage products and prices in Stripe.

In production

Everything above that went into supabase/.env.local is a function secret on your deployed backend, with live-mode values. Test mode and live mode are separate in Stripe, each with its own keys, webhook endpoints, signing secrets, portal configurations and products, so set each value from live mode:

Function secretIn production
ENABLE_STRIPEtrue
STRIPE_SECRET_KEYYour live secret key, sk_live_...
STRIPE_WEBHOOK_SECRETThe signing secret of the live endpoint (see Webhooks)
STRIPE_PORTAL_CONFIGURATION_IDThe id the restricted script printed for live mode

Seat-based billing only

This section applies only to projects generated with `--seat-based-billing`.

Requires ENABLE_SEAT_BASED_BILLING

Seat-based billing is off in a new project. Set `ENABLE_SEAT_BASED_BILLING=true` in both `.env.local` and `supabase/.env.local` to use this section.

With seat-based billing, STRIPE_PORTAL_CONFIRM_CONFIGURATION_ID joins them, from the confirm script run for live mode.

Set them with the rest of the function secrets: on Supabase Cloud, see Function secrets and deploy; self-hosted, see Function secrets. The app's release .env needs WEB_APP_URL for the return links (see The release .env).

Handling business logic

When integrating Stripe with your Floot app, you may want to customize how your backend responds to payment events. Floot provides sensible defaults for handling subscriptions and one-time payments, but you can extend or override this logic to fit your business needs. The following sections explain where and how to implement custom business logic for different payment scenarios.

Subscriptions

Subscription access is managed automatically based on the duration you configure when creating your offering. If you need to implement additional logic, such as awarding credits, coins, or other custom actions for subscribers, handle it in the following file: supabase/functions/stripe-webhook/_events/subscription_events.ts.

One time payments

To handle one-time payments, edit the file supabase/functions/stripe-webhook/_events/checkout_events.ts and follow the instructions marked with comments starting with // ! ....

Customer cleanup

When a user deletes their account, the matching Stripe customer has to be retired too, otherwise a cancelled account leaves a card on file and a billable object at Stripe with nothing in your database pointing at it.

This happens asynchronously: deleting a public.customers row queues a cleanup that is drained through the Stripe REST API. A 404 counts as success, since the customer being absent is the desired state. If that first attempt is lost, the stripe-cleanup-drain-sweep cron job collects the queue at 43 minutes past every hour, so nothing is dropped, it is only ever late.

The sweep runs locally too

On a developer machine it will happily retire Stripe test-mode customers for rows you deleted while testing. To stop that in a local database:

SELECT cron.unschedule('stripe-cleanup-drain-sweep');

This is billing hygiene, not erasure. Stripe treats DELETE /v1/customers as object lifecycle management: it leaves a tombstone still retrievable by GET, carrying name, email and metadata. Erasing that data needs Stripe's Redaction API, which is asynchronous and holds records for a 90-day floor.

Free trials

Note

Free trials can only be offered for subscriptions; they are not supported for one-time payments.

With Stripe, a trial is a property of a price, set through its metadata.

Important

Ensure that webhook events are correctly forwarded to your Supabase instance. Learn more in the Events Redirection section.

This option is recommended because it allows you to keep track of the free trials you are currently offering via your Stripe Dashboard.

Stripe Dashboard → Products section and create a new product or select an existing one.

Under the Pricing section, create a new price or edit an existing one.

In the Metadata section, add a key named trial_days and set its value to the number of free trial days you want to offer. For example, to provide a 7-day free trial, enter 7 as the value. This will trigger a price.updated webhook event, ensuring the trial period is synced with your Supabase database.

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.

Stripe on Mobile

By default the paywall uses Stripe on web, Windows and Linux, and hands off to RevenueCat wherever the platform has a real in-app-purchase store. Two flags decide that split:

PlatformPaywall provider
Web, Windows, LinuxStripe
iOS, AndroidRevenueCat, unless Requires ENABLE_STRIPE_MOBILE
macOSStripe, unless Requires ENABLE_IAP_MACOS

The predicate is supportsIAP in lib/core/presentation/utils/extensions/build_context_x.dart. Note that ENABLE_STRIPE_MOBILE does not affect macOS, and ENABLE_IAP_MACOS does not affect phones; they gate different platforms.

This section covers the first exception: putting Stripe back on iOS and Android.

Requires ENABLE_STRIPE_MOBILE

Stripe on mobile is off in a new project. Set `ENABLE_STRIPE_MOBILE=true` in `.env.local` to use this section.

To activate Stripe on mobile (iOS and Android), enable it in your Flutter environment file. Note that this one lives in the app's .env.local, not Supabase's.

.env.local
ENABLE_STRIPE_MOBILE=true

On a phone, Checkout opens in the system browser, and the return brings the user back into the app through a verified link on your web domain. Set up Deep-link domains before you ship it, or the return lands on the web app instead. See Return links.

On this page