Deep Links
The custom URL scheme, the verified links on your web domain, the paths the app claims, and what happens when a link misses the app.
The scheme
The kit opens the app from two kinds of link:
- The custom scheme,
com.acme.myapp://..., which brings OAuth sign-in back to the app. It is wired for iOS, Android, macOS and Windows. - Verified links on your web app's domain,
https://app.myapp.com/..., which Stripe's returns and invitations use. See Verified links below.
floot create derives the URL scheme from the two things you gave it, and
writes it into every platform file for you.
scheme =
<org>+.+<project name>with the underscores removed and everything lowercased.
So floot create my_app --org com.acme produces the scheme
com.acme.myapp. Without --org the organization defaults to
com.example, giving com.example.myapp.
One value is spelled differently
OAUTH_APPLE_CLIENT_ID keeps the capital letter: com.acme.myApp, not
com.acme.myapp. It is not a deep-link scheme at all, see the Apple client
id below.
Every example below uses the scheme from floot create my_app --org com.acme.
Substitute your own.
Where it already is
| File | What it holds |
|---|---|
ios/Runner/Info.plist | CFBundleURLSchemes |
android/app/src/main/AndroidManifest.xml | data android:scheme |
macos/Runner/Info.plist | CFBundleURLSchemes |
pubspec.yaml | msix_config → protocol_activation |
.env.local | OAUTH_REDIRECT_URL |
supabase/config.toml | additional_redirect_urls |
Changing the scheme means changing it in all six. The sections below show each one.
Scheme customization
Go to ios/Runner/Info.plist → CFBundleURLTypes → CFBundleURLSchemes.
There are two schemes in that array. Edit the second one; the first is your Google reversed client id, which Google Sign-In fills in and which must stay exactly as Google issued it.
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleTypeRole</key>
<string>Editor</string>
<key>CFBundleURLSchemes</key>
<array>
<string>your_google_ios_REVERSED_client_id</string>
<string>com.acme.myapp</string>
</array>
</dict>
</array>Where else the scheme is used
The scheme is not only a platform setting. The app's OAuth redirect is built from it, and it has to agree with the platform files or the redirect lands nowhere, usually as a browser sitting on a blank page rather than as an error.
The app's OAuth redirect
OAUTH_REDIRECT_URL is always <scheme>://<path>. floot create sets the path
to oauth-callback; keep it and change only the scheme.
OAUTH_REDIRECT_URL="com.acme.myapp://oauth-callback"Supabase Auth refuses to redirect anywhere that is not on its allow-list, so the same full URL has to be there too. It already is, as the second entry:
additional_redirect_urls = ["https://127.0.0.1:3000", "com.acme.myapp://oauth-callback"]config.toml sets this list for your local stack. Your deployed backend
keeps its own, and the callback goes there too, next to your web app's
paths: on Supabase Cloud under Authentication → Configuration → URL
Configuration → Redirect URLs, self-hosted in GOTRUE_URI_ALLOW_LIST. Set
the Site URL beside it (GOTRUE_SITE_URL self-hosted): Auth builds its
links on it, and a new Cloud project leaves it at http://localhost:3000.
See The app's callback on the
backend.
supabase config push can send the list from config.toml instead, but it
sends your local values unless you override them; read Alternative: settings
from config.toml
first.
The Apple client id
OAUTH_APPLE_CLIENT_ID looks like the scheme but is not one, and it is the one
value you must not lowercase:
OAUTH_APPLE_CLIENT_ID="com.acme.myApp"It is your Apple Services ID, and it has to match the iOS bundle identifier that
Flutter generated, where the project name is camel-cased rather than flattened.
config.toml reads it straight into [auth.external.apple].client_id, so
"correcting" it to match the deep-link scheme breaks Sign in with Apple. See
Apple Sign-In.
Verified links
A link on your web app's domain, such as
https://app.myapp.com/payment-return?to=%2Fbilling, opens the installed app
instead of the browser once the platform has checked that the domain and the
app belong together: Universal Links on iOS and macOS, App Links on Android.
The kit sends two kinds of link this way:
- Stripe's returns, after a Checkout or a visit to the billing portal.
- Invitations to an organization Multi-tenancy only.
When the app is not installed, or the platform does not hand the link over, the same link opens the web app on the same page. So every link the kit sends works with or without the app.
OAuth keeps the custom scheme above, and notification emails' plain links to your website keep opening the website.
The web domain
floot create --web-domain app.myapp.com writes your web app's host into the
project. Left out, it is the placeholder app.example.com: the project builds
and runs, and every link opens the browser until you set your own.
| Where | What it holds |
|---|---|
android/app/src/main/AndroidManifest.xml | android:host on the autoVerify intent filter |
.env.local | WEB_APP_URL="https://app.myapp.com" |
| iOS, in Xcode | applinks:app.myapp.com under Associated Domains, which you add |
macos/Runner/*.entitlements | The same entry, once you opt macOS in |
The two Apple rows are steps of yours, with the values each association file
needs: see Deep-link domains.
Changing the domain later means these places, WEB_APP_URL in .env too,
the backend's WEBSITE_URL (below), and serving the two association files
from the new domain.
WEB_APP_URL is the origin native builds put in Stripe's return links. While
it is empty, a payment on a native build fails before Stripe is called. Copy
it into .env for release builds. The web build ignores it and uses its own
origin, so a preview on another domain returns to itself.
Invitation links are built on the backend's WEBSITE_URL instead, so its host
must be the same domain for invitations to open the app.
The claimed paths
The app claims three paths on the domain, not the whole domain:
| Path | What links to it |
|---|---|
/authenticate | Invitation and magic-link emails: the gateway that signs the visitor in |
/accept-invite/* | Multi-tenancy only The invitation page, which the gateway forwards to and some invitations link to directly |
/payment-return | Every Stripe return, with any query |
Every other path stays in the browser, and one of them has to: on the web,
OAuth sign-in comes back to the page it started from, with a ?code= the
browser alone can finish, because the browser holds the PKCE verifier the
exchange needs. A claimed root would pull that return into an installed app on
the same phone, which cannot finish it. Android before 15 cannot exclude a
path by its query, so the claim lists paths rather than excluding a few.
The two places that claim them:
{
"applinks": {
"details": [
{
"appIDs": ["APPLE_TEAM_ID.com.acme.myApp"],
"components": [
{ "/": "/authenticate" },
{ "/": "/accept-invite/*" },
{ "/": "/payment-return" }
]
}
]
}
}<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="https" />
<data android:host="app.myapp.com" />
<data android:path="/authenticate" />
<data android:pathPrefix="/accept-invite/" />
<data android:path="/payment-return" />
</intent-filter>APPLE_TEAM_ID is a placeholder you fill in; so is the fingerprint in
web/.well-known/assetlinks.json. See
Deep-link domains.
The Stripe return link
Requires ENABLE_STRIPE
Stripe is off in a new project. Set `ENABLE_STRIPE=true` in `supabase/.env.local` to use this section.
Every Stripe return goes through one route, /payment-return, on the web app's
domain. to names the page the purchase started from:
https://app.myapp.com/payment-return?to=%2FbillingStripePayments builds it for Checkout's success and cancel URLs and for the
billing portal's return, from the location its caller passes: the paywall, the
billing pages and the payment recovery banner each pass their own. The route
needs a signed-in user, and forwards to to once there is one. On native,
Checkout and the portal open in the system browser, which hands the return
back to the app.
to must be a location inside the app: a path with a single leading / and an
optional query, with no scheme, host or fragment. Anything else, a missing
to included, lands on the default page after sign-in, so a crafted link
cannot send a signed-in user to another site.
Because every return uses the one route, a new purchase page needs no change to the claim: pass its location, and the return comes back to it.
Adding a claimed path
A new path that should open the app from outside needs three edits, which stay in step by hand:
- A
componentsentry inweb/.well-known/apple-app-site-association. - A
<data android:path>orandroid:pathPrefixin theautoVerifyintent filter. - The return-to allowlist,
_isReturnTargetinlib/features/auth/presentation/router/auth_router.dart, if a signed-out visitor should come back to it after sign-in rather than land on the default page. It refuses any location with a scheme or a host, and stays an allowlist on purpose: what it accepts decides where a stranger can be sent the moment they sign in.
The iOS and macOS entitlements name the domain only, so they do not change. Deploy the web app, so the new Apple file is served, before the release that claims the path.
When the link misses the app
| Platform | The app opens when | Otherwise |
|---|---|---|
| iOS | It is installed and the Apple file verified | The browser, on the web app |
| Android | It is installed and assetlinks.json verified | The browser, on the web app |
| macOS | You opted macOS in and the default browser is Safari | The browser, on the web app |
| Windows, Linux | Never | The browser, on the web app |
| Web | The link is the page itself |
Two of those rows are by design:
- Windows and Linux have no verified links, so Stripe's returns and invitations land on the web app.
- macOS lands on the web app until you opt in, and always from a Chromium default browser, such as Chrome, Arc or Edge: those do not hand Universal Links to apps on macOS.
The web app takes it from there. A signed-out visitor signs in first, by any method, and is then taken to the page the link named.
After a return that missed it, an app left open in the background still shows the billing state from before the purchase until its view reloads.
Handling deep links in the app
Every link that reaches the app, by the scheme or by a verified link, goes
through openAppLink(...):
void openAppLink(Uri uri) {
final router = goRouterOrNull;
if (router == null) return;
// `<scheme>://authenticate?...` and `https://app.myapp.com/authenticate?...`
// both open the sign-in gateway.
final authenticateUri = _normalizeAuthenticateUri(uri);
if (authenticateUri != null) {
router.go(authenticateUri.toString());
return;
}
// A verified link on WEB_APP_URL's host opens the route it names, path and
// query, as the web app would. The router's guards still decide who may see it.
if (_isWebAppLink(uri)) {
router.go(Uri(path: uri.path, query: uri.hasQuery ? uri.query : null).toString());
}
// 👇 your handling logic here!
}It is called for both entry points: the link that cold-started the app, and any link that arrives while it is already running. A verified link on another host, or any link on the web build, is left alone: on the web the link is the page itself, and the router already reads it.
On macOS, a Universal Link reaches openAppLink through
macos/Runner/AppDelegate.swift, which hands it to app_links: the package
does not receive one there by itself. The override ships in every project and
does nothing until you opt macOS in.
Upgrading a project created before verified links
Projects created before verified links returned from Stripe through a custom
scheme hop. Nothing updates an existing project, so the change is yours to
make by hand. The quickest way through is to create a project with the same
floot create answers and your --web-domain, and compare it with yours for
the files below.
What went away:
- The
handle-stripe-urlEdge Function, which answered Stripe's return with a redirect to<scheme>://payments?.... Deletesupabase/functions/handle-stripe-url/, its test undersupabase/functions/tests/, and its[functions.handle-stripe-url]entry insupabase/config.toml. APP_URL_SCHEME, which the functions required onceENABLE_STRIPEwas on. Remove it fromsupabase/.env.local,supabase/.env.exampleand the functions' env schema,supabase/functions/_shared/env.ts.- The
paymentshandler inDeepLinkListener, and the access token that rode in Stripe's return URLs.
On a hosted backend, also delete the deployed function and its secret. On Supabase Cloud:
supabase functions delete handle-stripe-url --project-ref <ref>
supabase secrets unset APP_URL_SCHEME --project-ref <ref>Self-hosted, the function goes with the folder at your next functions deploy;
drop APP_URL_SCHEME from the functions service's environment.
What replaced it:
WEB_APP_URLin.env.localand.env: see The web domain.- The
/payment-returnroute, inlib/features/payments/presentation/router/(payment_return_link.dart, andpaymentReturnRouteinpayments_router.dart, registered inlib/core/presentation/router/router_config.dart), andStripePaymentsbuilding every return on it. - The return-to after sign-in:
AuthRouterand the sign-in pages carry aredirectTothrough every sign-in step, and on the web OAuth returns to the page it started from (SupaAuth.webOAuthReturnUrl). - The verified links: the
httpscase inDeepLinkListener, the two files inweb/.well-known/, theautoVerifyintent filter, and the macOSAppDelegateoverride. Then the steps on Deep-link domains and Hosting requirements. - The Auth redirect list gains your web app's paths, since web OAuth now returns to them: see Auth redirect URLs.