Teleport

Continue a flow on the web from a route on: an iOS app hands the shopper to their browser, they pay with Stripe there, and come back to the app with access.

Teleport moves the rest of a flow to the web. When the flow reaches a teleporting route inside an iOS app, the shopper's browser opens on that route, on your project's web paywall domain, with the flow's answers carried along. They pay with Stripe there and come back to the app, which is credited automatically.

Turn it on in config.ts, with every default:

export default definePaywall({
  products: { card: "live:price_1ABC…:14days-free", annual: "com.acme.pro.annual" },
  teleport: true,
});

Then teleport a push from the router:

router.push("checkout", { teleport: true });

Or name the route in the config, so a push to it and a Stripe purchase teleport without asking, and the teleport is prepared when the paywall opens:

teleport: { route: "checkout", fallbacks: { card: "annual" } },

purchase("card") is the same call as everywhere else.

The teleport example is a complete flow: one question in the app, then the plan in the browser.

What happens

In the app. Three things teleport:

  • A push asked to. router.push(route, { teleport: true }) (or replace) opens the route in the browser instead of pushing it, and the paywall stays where it is.
  • A push to teleport.route. The same, without the option.
  • A Stripe purchase. purchase("card") opens teleport.route in the browser, or the route it was tapped on when there is none, with card as the product the shopper tapped. It resolves once the teleport settles: completed when they paid, abandoned when they cancelled or the checkout expired.

While the shopper is in the browser, the app shows Superwall's teleport screen over the paywall: "Finish in your browser", with Complete in Browser (opens it again), Continue In-App Instead (only with a fallback), and Cancel. The app keeps checking the purchase, in the background too. Once it is paid, the SDK redeems it and the paywall dismisses (or stays, with postPurchase: "stay").

A second tap while the browser is open reopens the same tab, switched to the product tapped this time, so the shopper never ends up with two checkouts. When they come back to the app, the screen goes and the purchase is checked once more behind a loader; if it is still unpaid after a few seconds it counts as abandoned. Cancel, closing the paywall, or paying in the app keeps the checkout watched for a few minutes, so a payment the shopper finishes anyway is still credited, and the next tap reopens the same tab.

On the web. The same paywall opens in the browser on the teleported route. Every useQueryState value the flow had in the app is already set, so the web page picks up where the app left off, and useTeleport().productReference is the product the shopper tapped. From there, navigation is ordinary:

  • router.push moves forward on the web;
  • router.back() and close() on the teleported route return to the app;
  • router.canGoBack() is true on the teleported route, so a back button stays on screen.

A Stripe purchase on the web page pays in the page, with Apple Pay or the card sheet (whatever checkout says, Apple Pay by default), never on the hosted page, and only Stripe products are sold there. Once it completes, the page says "You're all set" with Return to App, or opens the app at once, see completion. The app opens through its universal link, falling back to its URL scheme when the link doesn't open it.

The page sells one purchase per presentation. Opened again once paid, it shows "You're all set" instead of selling again; and when the app opened another checkout for the same presentation and that one got paid, the page switches to say so.

Instant opening

Creating a teleport takes a round-trip, so it is done ahead of the tap. A paywall with teleport.route prepares that teleport when it opens: when the SDK presents it, never while it preloads in the background. The tap then opens the browser at once, carrying any answer that changed since. For a route you reach with router.push(route, { teleport: true }), prepare it yourself, as you would prefetch a checkout:

const { prefetch } = useTeleport();

React.useEffect(() => {
  prefetch("plans");
}, [prefetch]);

A prepared teleport stays usable for ten minutes, only for the route it was prepared for, and is made again when the trial offer changed since. A trial offer that changes while the browser is open moves the open checkout to it, in the same tab.

Options

teleport: true takes every default. The object form changes what you name:

KeyDefaultWhat it does
routenoneThe route that teleports by default: a push to it, a Stripe purchase, and the teleport prepared when the paywall opens. Without it, a Stripe purchase teleports the route it was tapped on, and only { teleport: true } teleports a push.
overlaytrueShow the teleport screen in the app. false keeps the paywall as it is, for a flow that leaves midway (an onboarding that continues on the web), or for your own screen built with useTeleport(). The purchase is watched either way.
fallbacksnoneA store product the teleport screen offers in place of a Stripe one, keyed by the Stripe product: { card: "annual" }. Both sides are typed against your products. Continue In-App Instead buys it through the App Store, and the Stripe purchase() resolves with that purchase. Shown only once the store has priced it.
completion"confirmation"After a web purchase: "confirmation" shows "You're all set" with Return to App, "direct" opens the app at once.
introductoryOfferEligibility"automatic"Whether the web checkout offers the trial: "automatic" when the App Store says the shopper is eligible, "eligible" always, "ineligible" never. The App Store's trial history doesn't bind a Stripe purchase, so this is separate from the paywall's own introductoryOfferEligibility.

Your own teleport screen

The teleport screen and the "You're all set" screen are TeleportScreen, which the build mounts for any paywall with teleport. Set overlay: false and draw your own from useTeleport(). Its status turns "waiting" exactly when the built-in screen would go up (once the app is in the background, after a moment if it never is, or when the SDK asks just before it opens the browser), and stays "waiting" until the purchase settles, so your screen behaves like Superwall's. Once the shopper is back in the app, returned turns true while the purchase is checked one last time:

import { usePurchase, useTeleport } from "superwall/hooks";

const teleport = useTeleport();
const { purchase } = usePurchase();
if (teleport.status !== "waiting" || teleport.returned) return null;
const fallback = teleport.fallbackReference;
return (
  <Sheet>
    <p>Finish your purchase in Safari.</p>
    <Button onPress={teleport.open}>Open Safari</Button>
    {fallback ? <Button onPress={() => purchase(fallback)}>Pay with Apple instead</Button> : null}
    <Button onPress={teleport.cancel}>Cancel</Button>
  </Sheet>
);

status is one of:

statusWhereCarries
"idle"anywherenothing
"waiting"in the app, while the shopper is in the browserproductReference, fallbackReference, returned
"arrived"on the web page, before the purchaseproductReference
"completed"on the web page, once paidproductReference, paidElsewhere (another checkout of the presentation was paid)

Buying the fallback (or any store product) while the teleport is in flight hands it over: the Stripe purchase() resolves with the in-app purchase. On the web page, returnToApp() goes back. To tell the teleport page from any other web page, read useDevice().host, which is "web-teleport" there.

To follow what happens, for example to count how often shoppers cancel, pass callbacks:

const [cancels, setCancels] = React.useState(0);
useTeleport({
  onOpen: () => {},                          // the browser opened, the first time or again
  onReturn: () => {},                        // the shopper came back to the app mid-purchase
  onCancel: () => setCancels((n) => n + 1),  // the shopper tapped Cancel
  onComplete: () => {},                      // paid on the web, being redeemed
});

onCancel is the Cancel button only. A teleport that ends unpaid any other way, the shopper back without paying or the checkout expired, resolves its purchase() as abandoned without calling onCancel.

Requirements and fallbacks

  • SuperwallKit 4.18.0 or later. On an older SDK, and on Android, nothing teleports: the route is pushed in the app as usual, and a Stripe purchase opens Superwall's hosted checkout instead, see Web checkout.
  • A web paywall domain on the project's Stripe app. Teleport is refused without one; the purchase resolves failed.
  • The app's URL scheme (and its team and bundle id, for the universal link) on the dashboard, so the web page can open the app again. Without them the "You're all set" screen tells the shopper to go back on their own.
  • superwall dev doesn't teleport: a preview has no API key. Push and open the paywall from the app to try it.

Attribution

A teleported purchase is attributed to the paywall the app showed, exactly as with the editor's checkout page: the presentation the SDK reported, its placement and experiment, and the shopper's identity and device, all recorded when the teleport is created. Checkouts opened on the web page inherit them from the teleport.

How is this guide?

On this page