Web-to-app funnels (redemption)

This page provides details on delivering a subscription bought on the web to the app with a Purchasely redemption deeplink (Web2App)

Context

Web2App is the opposite direction of App-to-web funnels. App2Web sends a user from a Purchasely Paywall to a web checkout. Web2App gives a subscription bought on the web to the app.

The funnel has five steps:

  1. The user buys a subscription on the web, through a Stripe checkout.
  2. The user gets an email with a redemption link.
  3. The user taps the link on the phone and lands in the app.
  4. The SDK exchanges the redemption token for the subscription.
  5. The SDK refreshes the entitlements and tells the app the result.
🚧

SDK v6.1.0+ required

The redemption result API is available from v6.1.0 on iOS, Android, React Native, Flutter and Cordova.

The redemption deeplink

The redemption link has this shape:

{app_scheme}://ply/redeem/{token}?auid={anonymous_user_id}

The app does not build this link. The Purchasely backend creates the token, the email carries the link, and the landing page opens it.

The SDK parses this link like any other Purchasely deeplink. The query parsing is generic, so the SDK ignores an unknown parameter. The landing page can therefore add a parameter without a new SDK release.

On Android the SDK intercepts the deeplink on its own, and the manual call is only a fallback. On iOS you pass the link to the SDK. You can also give the link to the initialization chain, which every platform accepts.

Deeplink implementation

To manage deeplinks you need to do up to 2 things:

  1. Pass the deeplink to the Purchasely SDK when it is received by the application (not required on Android — see below)
  2. Optionally control when Purchasely is allowed to display content over your interface, for deeplinks and for campaigns

1. Passing the deeplink to Purchasely SDK

📘

Android handles deeplinks automatically

Since v6, the Android SDK intercepts Purchasely deeplinks on its own (it reads the foreground activity's intent on create and resume). You don't need to call handleDeeplink yourself. The manual call below is only useful as a fallback for activities using singleTask / singleTop launch modes that receive the deeplink in onNewIntent without calling setIntent(intent).

To enable the Purchasely SDK to analyze the deeplink, the app provides it using the following code:

// ---------------------------------------------------
// If you are **NOT** using SceneDelegate
// ---------------------------------------------------

// AppDelegate.swift

import Purchasely

func application(_ application: UIApplication, open url: URL, sourceApplication: String?, annotation: Any) -> Bool {
	// You can chain calls to multiple handler using a OR
	return Purchasely.handleDeeplink(url) 
}

// ---------------------------------------------------
// If you are using SceneDelegate
// ---------------------------------------------------

// SceneDelegate.swift

import Purchasely

func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {

	// …

	if let url = connectionOptions.urlContexts.first?.url {
		_ = Purchasely.handleDeeplink(url)
	}
}

func scene(_ scene: UIScene, openURLContexts URLContexts: Set<UIOpenURLContext>) {
	if let url = URLContexts.first?.url {
		_ = Purchasely.handleDeeplink(url)
	}
}
class MyActivity : FragmentActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        // Optional on Android: the SDK already intercepts deeplinks automatically.
        // Keep this only as a fallback (e.g. singleTask/singleTop without setIntent()).
        val data = intent.data
        if (data != null) {
            // Purchasely SDK returns true if it handles the deeplink
            val isHandledByPurchasely = Purchasely.handleDeeplink(data)
        }
    }

}
Purchasely.handleDeeplink('app://ply/presentations/')
          .then((value) => console.log('Deeplink handled by Purchasely ? ' + value));
Purchasely.handleDeeplink('app://ply/presentations/')
          .then((value) => print('Deeplink handled by Purchasely ? $value'));
// If you grab the deeplink inside your Cordova code you can call
Purchasely.handleDeeplink("app://ply/presentations/", (handled) => {
	console.log("Was deeplink handled by Purchasely? " + handled);
});
📘

Passing the deeplink at start

When your app is launched from a deeplink (cold start), you can hand it to the SDK directly at initialization instead of waiting for the SDK to be ready. Every platform provides the method on the initialization chain, so no separate handleDeeplink call is needed. The SDK resolves the link once start completes, with the configuration loaded and the user applied.

// iOS
Purchasely.apiKey("YOUR_API_KEY").handleDeeplink(url).start { error in }
// Android
Purchasely.Builder(context).handleDeeplink(intent.data).build().start { error -> }
// React Native
await Purchasely.builder('YOUR_API_KEY').handleDeeplink(url).stores(['google']).start();
// Flutter
await Purchasely.apiKey('YOUR_API_KEY').handleDeeplink(url).stores([PLYStore.google]).start();
// Cordova — the builder method is deeplink(), not handleDeeplink()
await Purchasely.builder('YOUR_API_KEY').deeplink(url).stores([Purchasely.Store.google]).start();

A deeplink that reaches the SDK before the configuration settles is never lost. The SDK queues it and resolves it as soon as start finishes, whichever way your app handed it over. A Web2App redemption link never waits behind a queued paywall link: iOS puts it at the front of the queue, and Android hands it to the redemption intake out of band.

2. Forbidding the display

By default, Purchasely deeplinks are displayed immediately when they are received.

If your app has a launch routine that must complete before a screen can be shown (splash screen, onboarding, login, displaying an ad…), you can temporarily prevent Purchasely from displaying deeplinks, then re-enable it once you are ready:

// Prevent the display (e.g. while your onboarding is on screen)
Purchasely.allowDeeplink(false)

// Re-enable it once your app is ready — any queued deeplink displays immediately
Purchasely.allowDeeplink(true)
// Prevent the display (e.g. while your onboarding is on screen)
Purchasely.allowDeeplink = false

// Re-enable it once your app is ready — any queued deeplink displays immediately
Purchasely.allowDeeplink = true
Purchasely.allowDeeplink(false);
// later
Purchasely.allowDeeplink(true);
Purchasely.allowDeeplink(false);
// later
Purchasely.allowDeeplink(true);
Purchasely.allowDeeplink(false);
// later
Purchasely.allowDeeplink(true);
📘

You only need this if you want to defer deeplinks. If you do nothing, deeplinks display as soon as they are received.

3. Forbidding campaigns

Campaigns follow the same principle through their own flag, allowCampaigns, which is true by default (campaigns display immediately). To gate campaigns behind a launch flow, set it to false and flip it back to true when ready — any campaign queued meanwhile displays immediately:

Purchasely.allowCampaigns(false)
// later
Purchasely.allowCampaigns(true)
Purchasely.allowCampaigns = false
// later
Purchasely.allowCampaigns = true
Purchasely.allowCampaigns(false);
// later
Purchasely.allowCampaigns(true);
Purchasely.allowCampaigns(false);
// later
Purchasely.allowCampaigns(true);
Purchasely.allowCampaigns(false);
// later
Purchasely.allowCampaigns(true);

allowDeeplink and allowCampaigns are independent: gating one does not affect the other.

The deeplink gate does not apply

A redemption link is not subject to allowDeeplink. The SDK intercepts the link out of band, before the routing branch and before the gate. allowDeeplink(false) therefore does NOT block a redemption.

A user who taps a redemption link in an email always gets the subscription, whatever the deeplink gate says.

Security

The redemption token is a bearer credential. Whoever holds the token can claim the subscription. The SDK therefore keeps the token out of four places:

  • The deeplink waiting list.
  • The map that the SDK uses to deduplicate a raw URI.
  • Every analytics event.
  • Every log line.

The SDK deduplicates a redemption with a SHA-256 hash of the token. The SDK never uses the token itself for the deduplication.

Getting the result of a redemption

Register your callback in the initialization chain. Every SDK provides the method there.

Purchasely
    .apiKey("X-API-KEY")
    .webRedemptionDelegate(self, appHandlesRedemptionAlert: false)
    .start()
Purchasely.Builder(applicationContext)
    .apiKey("X-API-KEY")
    .webRedemptionListener { result -> }
    .stores(listOf(GoogleStore()))
    .build()
    .start { error -> }
await Purchasely.builder('X-API-KEY')
  .webRedemptionListener((result) => {
    if (result.isSuccess) {
      // result.context?.subscription
    }
  })
  .stores(['google'])
  .start();
await Purchasely.apiKey('X-API-KEY')
    .webRedemptionListener((result) {
      if (result.isSuccess) {
        // result.context?.subscription
      }
    })
    .stores([PLYStore.google])
    .start();
await Purchasely.builder('X-API-KEY')
    .webRedemptionListener(function (result) {
        if (result.isSuccess) {
            // result.context.subscription
        }
    })
    .stores([Purchasely.Store.google])
    .start();
❗️

Register in the chain, not after start()

A redemption can settle during start(). The ply/redeem link can be what launches the app, and a previous launch can leave a token pending. A listener that your app adds after start() misses exactly the case that the feature exists for.

The three bridges also provide a runtime function, addWebRedemptionListener, for an app that must replace the listener later. That path carries the risk above.

The registration method of each platform

PlatformRegistrationCallback
iOSwebRedemptionDelegate(_ value: PLYWebRedemptionDelegate?, appHandlesRedemptionAlert: Bool = false)webRedemptionCompleted(result: PLYWebRedemptionResult)
AndroidwebRedemptionListener(listener) or webRedemptionListener(appHandlesRedemptionAlert, listener), on Purchasely.Builder and on the Purchasely { } DSLPLYWebRedemptionListener.onRedemptionCompleted
React NativewebRedemptionListener(callback, appHandlesRedemptionAlert?)the callback
FlutterwebRedemptionListener(listener, [appHandlesRedemptionAlert])the listener
CordovawebRedemptionListener(callback, appHandlesRedemptionAlert?)the callback

The result

PropertyValue
isSuccesstrue for a granted redemption, false for a failure
contextThe redemption context. null on a failure, and nullable on a success
context.subscriptionThe subscription that the redemption granted. This is the type that userSubscriptions() returns
replaytrue when the server reports that the token was redeemed before. Always false on a failure
errorCodeThe backend error code: EXPIRED_REDEMPTION_TOKEN or INVALID_REDEMPTION_TOKEN. null on a success, and null on a failure that never reached the server
errorMessageThe reason, in English. null on a success. It never carries the token

Android is the one exception to the shape of that table. The Kotlin PLYWebRedemptionResult is a sealed class of two cases, so the properties sit on the case instead of on one flat object:

  • Success(context, replay)
  • Failure(errorCode, errorMessage)

The replay value is a verdict about the token, not an observation of the user. The SDK keeps no cache and calls the server on every attempt.

The delivery contract

The contract is the same on every platform:

  • The SDK calls your app on the main thread.
  • The SDK calls your app exactly once for each settled redemption.
  • Only one redemption runs at a time. The first link wins.
  • Other deeplinks queue during a redemption. The SDK replays them in the arrival order when the redemption settles.
  • The SDK makes at most three attempts when the server answers with a retryable error.

One guarantee is specific to Android: a watchdog cancels a hung redemption after 90 seconds, and the redemption then settles with a generic error. The user therefore always gets an outcome.

Who owns the outcome screen

The appHandlesRedemptionAlert parameter decides who draws the result.

ValueThe SDK showsThe SDK calls your app
false (the default)Its own success or failure popinAfter the user acknowledges the popin
trueNothingAs soon as the redemption settles

Set the value to true when your app must show its own result screen.

🚧

Show errorMessage to the user, and do not log it

On the expired case, the SDK builds an errorMessage of this shape:

Redemption link has expired. A new link was sent to <masked address>.

iOS and Android behave the same way here, and every bridge carries the same value. The masked address is present only when the backend supplies a hint, and it is absent otherwise. The matching analytics event drops the hint on purpose, because the boundary that matters is the device: an analytics event goes to the Purchasely backend and to any third-party listener that your app registered, and the result of the callback never leaves the process.

Your app needs the hint. With appHandlesRedemptionAlert at true you suppressed the built-in popin, which is the only other place the hint appears, so your own screen is the only way to tell the user where the fresh link went.

Display errorMessage on your result screen. Do NOT forward it to your own analytics stack, and do NOT forward it to a crash reporter. Either one sends the masked address off the device.

Purchase context restore

A successful redemption can return a versioned purchase_context. This object describes the web funnel that made the sale.

The SDK restores the built_in_attributes of that object, for example utm_source, utm_medium and utm_campaign. The SDK also restores its custom_attributes. The SDK writes all of them as user attributes BEFORE the entitlement refresh.

Two results follow:

  • Every event after the redemption already carries those attributes.
  • An Audience can target on those attributes.

The SDK ignores an unknown version and does not fail the receipt. A response with no purchase_context keeps the earlier behaviour.

Events

The SDK reports the result of a redemption as a UI/SDK event:

ResultAndroid, React Native, Flutter, CordovaiOS
SuccessREDEMPTION_CONSUMEDredemptionConsumed
FailureREDEMPTION_FAILEDredemptionFailed

Neither event existed before v6.1.0. A replay is a success, so a replay produces the success event on every platform. Read the replay value on the result to tell a first redemption from a replay.

📚 See the List of UI/SDK events for the attributes of these events.

The anonymous user id

The redemption link carries the web anonymous id of the buyer in the auid parameter.

The SDK adopts that id only when it holds no anonymous id yet. The web identity and the app identity therefore stay the same person. An id that your app supplies in the initialization chain always wins over the id in the link. Read Identifying users for that method and for its rules.


Did this page help you?