Flootdocs

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

FileWhat it holds
ios/Runner/Info.plistCFBundleURLSchemes
android/app/src/main/AndroidManifest.xmldata android:scheme
macos/Runner/Info.plistCFBundleURLSchemes
pubspec.yamlmsix_config → protocol_activation
.env.localOAUTH_REDIRECT_URL
supabase/config.tomladditional_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.

ios/Runner/Info.plist
<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.

.env.local
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:

supabase/config.toml
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:

supabase/.env.local
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.

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.

WhereWhat it holds
android/app/src/main/AndroidManifest.xmlandroid:host on the autoVerify intent filter
.env.localWEB_APP_URL="https://app.myapp.com"
iOS, in Xcodeapplinks:app.myapp.com under Associated Domains, which you add
macos/Runner/*.entitlementsThe 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:

PathWhat links to it
/authenticateInvitation 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-returnEvery 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:

web/.well-known/apple-app-site-association
{
  "applinks": {
    "details": [
      {
        "appIDs": ["APPLE_TEAM_ID.com.acme.myApp"],
        "components": [
          { "/": "/authenticate" },
          { "/": "/accept-invite/*" },
          { "/": "/payment-return" }
        ]
      }
    ]
  }
}
android/app/src/main/AndroidManifest.xml
<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.

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=%2Fbilling

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

  1. A components entry in web/.well-known/apple-app-site-association.
  2. A <data android:path> or android:pathPrefix in the autoVerify intent filter.
  3. The return-to allowlist, _isReturnTarget in lib/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.

PlatformThe app opens whenOtherwise
iOSIt is installed and the Apple file verifiedThe browser, on the web app
AndroidIt is installed and assetlinks.json verifiedThe browser, on the web app
macOSYou opted macOS in and the default browser is SafariThe browser, on the web app
Windows, LinuxNeverThe browser, on the web app
WebThe 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.

Every link that reaches the app, by the scheme or by a verified link, goes through openAppLink(...):

lib/core/presentation/views/widgets/deep_link_listener.dart
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.

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-url Edge Function, which answered Stripe's return with a redirect to <scheme>://payments?.... Delete supabase/functions/handle-stripe-url/, its test under supabase/functions/tests/, and its [functions.handle-stripe-url] entry in supabase/config.toml.
  • APP_URL_SCHEME, which the functions required once ENABLE_STRIPE was on. Remove it from supabase/.env.local, supabase/.env.example and the functions' env schema, supabase/functions/_shared/env.ts.
  • The payments handler in DeepLinkListener, 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:

Terminal
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_URL in .env.local and .env: see The web domain.
  • The /payment-return route, in lib/features/payments/presentation/router/ (payment_return_link.dart, and paymentReturnRoute in payments_router.dart, registered in lib/core/presentation/router/router_config.dart), and StripePayments building every return on it.
  • The return-to after sign-in: AuthRouter and the sign-in pages carry a redirectTo through every sign-in step, and on the web OAuth returns to the page it started from (SupaAuth.webOAuthReturnUrl).
  • The verified links: the https case in DeepLinkListener, the two files in web/.well-known/, the autoVerify intent filter, and the macOS AppDelegate override. 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.

On this page