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.
ENABLE_STRIPE=trueTurning 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).
| Variable | What it is |
|---|---|
STRIPE_SECRET_KEY | Your account's secret key |
STRIPE_WEBHOOK_SECRET | Signing secret for the webhook endpoint (see Webhooks) |
Secret key
Find it in your Stripe dashboard and copy the Secret Key.
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).
Return links
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=%2FbillingThe 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.
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:
| Group | Events |
|---|---|
| Checkout | checkout.session.completed |
| Customers | customer.created, customer.deleted |
| Invoices | invoice.paid |
| Prices | price.created, price.updated, price.deleted |
| Products | product.created, product.updated, product.deleted |
| Subscriptions | customer.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.
stripe loginYou'll be prompted to open a browser window to complete the login process.
Redirect Stripe events to your Supabase local instance.
stripe listen --forward-to localhost:54321/functions/v1/stripe-webhookYou 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.
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.
supabase functions serve --env-file .env.local --import-map ./functions/deno.json --no-verify-jwtBilling 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.
deno run --allow-net --allow-env --env-file=supabase/.env.local \
supabase/scripts/create_restricted_portal_config.tsSTRIPE_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.
deno run --allow-net --allow-env --env-file=supabase/.env.local \
supabase/scripts/create_confirm_portal_config.tsSTRIPE_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 secret | In production |
|---|---|
ENABLE_STRIPE | true |
STRIPE_SECRET_KEY | Your live secret key, sk_live_... |
STRIPE_WEBHOOK_SECRET | The signing secret of the live endpoint (see Webhooks) |
STRIPE_PORTAL_CONFIGURATION_ID | The 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:
| Platform | Paywall provider |
|---|---|
| Web, Windows, Linux | Stripe |
| iOS, Android | RevenueCat, unless Requires ENABLE_STRIPE_MOBILE |
| macOS | Stripe, 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.
ENABLE_STRIPE_MOBILE=trueOn 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.