Purchases

How checkout works, what happens after a purchase, and how to take it over.

Beta

The Web SDK is in beta and its API may change between releases.

The default: Superwall handles it

Create an instance without a purchaseController and the SDK runs the standard checkout itself: Stripe checkout inside the paywall, post-checkout handling, ?code= redemption when a page loads with a redemption link, and a background entitlements refresh.

const sw = createSuperwall({ apiKey: "pk_…" });

Products come from the paywall you configured in the dashboard. Subscription status updates on its own once the purchase settles.

After a purchase

Post-checkout handling, handler.onPurchase, and sw.redeem() require Web SDK 0.3.0 or later. checkout.redemptionCodes requires 0.3.1 or later. In 0.3.0 it is always empty.

When a web checkout completes, the paywall hands the SDK the result and does nothing else. The SDK owns every step after that, including closing the paywall. By default it:

  1. Grants access if the purchase is claimed. When the server has already tied the purchase to this browser and user (claimed: true), the SDK applies the signed entitlements token, marks the user ACTIVE, and refreshes entitlements in the background. When the purchase is not claimed (claimed: false), the SDK grants nothing on the web.
  2. Closes the paywall. handler.onDismiss fires and register() resolves with a purchased result.

If you pass handler.onPurchase, it receives the checkout result first, including any redemption codes, redirect URL, and deep links, and replaces both steps.

Two things the SDK never does on its own:

  • It never redeems the checkout's redemption codes. They arrive in checkout.redemptionCodes, and the unclaimed ones stay available for the buyer's mobile app. checkout.deepLinks and Superwall's redemption page open the app to redeem one. Your own redirect page receives a code as redemption_code and has to pass it on.
  • It never navigates the page. If the checkout has a redirect URL, the SDK hands it to you as checkout.redirectUrl.

To follow the redirect, read it from the purchased result:

await sw.register({
  placement: "upgrade",
  handler: {
    onDismiss: (info, result) => {
      if (result.type === "purchased" && result.checkout?.redirectUrl) {
        location.assign(result.checkout.redirectUrl); // or router.push(...)
      }
    },
  },
});

The redirect URL is the purchase button's redirect if it has one, otherwise the app's redirect URL, otherwise Superwall's redemption page. A button or app redirect carries redemption_code and the checkout context as query parameters.

The same data is on the value register() returns:

const result = await sw.register({ placement: "upgrade" });

if (result.type === "presented" && result.result.type === "purchased") {
  const { transaction, claimed, redemptionCodes } = result.result.checkout ?? {};
}

The checkout payload

CheckoutCompletion is passed to handler.onPurchase and carried as checkout on a purchased result.

Prop

Type

checkout is absent when a purchase does not go through the paywall's Stripe checkout, such as in test mode or a purchase your custom PurchaseController completes itself.

Handling the purchase yourself

Pass handler.onPurchase to register() to replace the default handling entirely. The SDK then does not grant entitlements and does not close the paywall.

This fits a buy on the web, use in the app flow. Show the unclaimed redemption codes, hand the deep links to an Open in app button, or send the buyer to checkout.redirectUrl:

await sw.register({
  placement: "upgrade",
  handler: {
    onPurchase: (info, checkout) => {
      sw.dismiss(); // the paywall stays open until you close it

      showOpenInApp({
        ios: checkout.deepLinks?.ios,
        android: checkout.deepLinks?.android,
        codes: checkout.redemptionCodes.filter((c) => !c.claimed).map((c) => c.code),
        fallback: checkout.redirectUrl,
      });
    },
  },
});

The paywall stops responding after checkout, and register() stays pending until you call sw.dismiss(). Calling it inside onPurchase is fine. register() then resolves as purchased, carrying the same checkout.

To also grant access on the web from onPurchase:

  • When checkout.claimed is true, call sw.purchases.refreshCustomerInfo().
  • When checkout.claimed is false, call sw.redeem(code) with the code of an unclaimed entry in checkout.redemptionCodes (0.3.1 or later). That code is then spent, so the buyer cannot use it in your mobile app.

In React, usePlacement does not pass onPurchase through. Call register on the instance from useSuperwall() when you need it.

To watch completed checkouts without changing what the SDK does, for logging or analytics, use the onCheckoutCompleted delegate method or the checkoutCompleted event instead. See Events.

Redeeming a code

sw.redeem(code) redeems a redemption_… code for the current user. Use it for codes from a completed checkout, from another device, or from your own backend. Pass the code as-is, prefix included.

const result = await sw.redeem("redemption_abc123");

switch (result.type) {
  case "success":  // result.entitlements
  case "expired":
  case "invalid":
  case "error":    // result.error
}

On success, customerInfo updates and subscriptionStatus becomes ACTIVE if the code granted entitlements. redeem never throws. Failures resolve with one of the other types. It fires the onWillRedeemLink and onDidRedeemLink delegate methods, and works with the default controller or a custom purchaseController.

With the default purchase controller, a page that loads with a ?code=redemption_… query parameter is redeemed automatically at startup. You only need sw.redeem() for codes that reach you another way.

Reading the catalog

const products = await sw.purchases.getProducts();

Products come from the parsed config. Expect an empty array before configuration lands, or when the config carries no products.

Restoring

await sw.purchases.restore();

Discount codes

When a paywall is on screen, sw.activePaywall exposes controls for Stripe promotion codes.

const active = sw.activePaywall.value;

if (active) {
  const outcome = await active.redeemDiscount("LAUNCH20");
  if (!outcome.valid) console.log("rejected:", outcome.reason);
}

Redeeming validates the code against the checkout backend, re-prices the paywall's Stripe products, and forwards the code to every Stripe web checkout session created afterwards, including rebuilding sessions that were already prefetched.

active.clearDiscount();

clearDiscount is fire-and-forget and returns void. The paywall sends no acknowledgement, so there is nothing to await.

Behavior to know:

  • redeemDiscount resolves after roughly ten seconds with { valid: false, reason: "timeout" } if the paywall never replies, which happens when it has no Stripe products loaded.
  • A second redeemDiscount while one is in flight supersedes the first, which settles as { valid: false, reason: "superseded" }. clearDiscount() settles an in-flight redemption the same way.
  • It rejects with a DiscountError for an empty or whitespace-only code, when no paywall is presented (including a stale activePaywall handle from a paywall that has since been dismissed), and when the presenter has no message channel.
  • A discount does not survive dismissal. Re-apply it after each presentation.
  • reason is an open union. Known values include code_not_found, code_invalid, no_valid_products, no_applicable_products, error, timeout, superseded, and paywall_dismissed. Do not write an exhaustive switch over it.

This is the programmatic equivalent of the Apply Discount tap action in the paywall editor. See Discount codes for setting the code up in Stripe.

Running your own checkout

To bill through your own system while keeping the SDK's paywalls and placements, push the outcome in yourself:

sw.purchases.setSubscriptionStatus({
  status: "ACTIVE",
  entitlements: [
    { id: "pro", type: "SERVICE_LEVEL", isActive: true, productIds: [] },
  ],
});

The SDK treats what you push as truth for client-side state. It is still not a security boundary. See Server-side gating.

Taking over the controller

For full control of the transaction, pass a purchaseController at creation. It replaces the built-in automatic controller, including automatic ?code= redemption and the background entitlements refresh, so you own those too. Call sw.redeem() yourself for returning ?code= links.

const sw = createSuperwall({
  apiKey: "pk_…",
  purchaseController: myPurchaseController,
});

Post-checkout handling still runs with a custom controller: a completed paywall checkout is handled by the default or by your handler.onPurchase, whichever controller is installed.

There is no runtime setter. The controller is wired once, at createSuperwall.

How is this guide?

On this page