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:
- The user buys a subscription on the web, through a Stripe checkout.
- The user gets an email with a redemption link.
- The user taps the link on the phone and lands in the app.
- The SDK exchanges the redemption token for the subscription.
- The SDK refreshes the entitlements and tells the app the result.
SDK v6.1.0+ requiredThe 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:
- Pass the deeplink to the Purchasely SDK when it is received by the application (not required on Android — see below)
- 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 automaticallySince 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
handleDeeplinkyourself. The manual call below is only useful as a fallback for activities usingsingleTask/singleToplaunch modes that receive the deeplink inonNewIntentwithout callingsetIntent(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 startWhen 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
handleDeeplinkcall is needed. The SDK resolves the link oncestartcompletes, 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
startfinishes, 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 = truePurchasely.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 = truePurchasely.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 afterstart()A redemption can settle during
start(). Theply/redeemlink can be what launches the app, and a previous launch can leave a token pending. A listener that your app adds afterstart()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
| Platform | Registration | Callback |
|---|---|---|
| iOS | webRedemptionDelegate(_ value: PLYWebRedemptionDelegate?, appHandlesRedemptionAlert: Bool = false) | webRedemptionCompleted(result: PLYWebRedemptionResult) |
| Android | webRedemptionListener(listener) or webRedemptionListener(appHandlesRedemptionAlert, listener), on Purchasely.Builder and on the Purchasely { } DSL | PLYWebRedemptionListener.onRedemptionCompleted |
| React Native | webRedemptionListener(callback, appHandlesRedemptionAlert?) | the callback |
| Flutter | webRedemptionListener(listener, [appHandlesRedemptionAlert]) | the listener |
| Cordova | webRedemptionListener(callback, appHandlesRedemptionAlert?) | the callback |
The result
| Property | Value |
|---|---|
isSuccess | true for a granted redemption, false for a failure |
context | The redemption context. null on a failure, and nullable on a success |
context.subscription | The subscription that the redemption granted. This is the type that userSubscriptions() returns |
replay | true when the server reports that the token was redeemed before. Always false on a failure |
errorCode | The backend error code: EXPIRED_REDEMPTION_TOKEN or INVALID_REDEMPTION_TOKEN. null on a success, and null on a failure that never reached the server |
errorMessage | The 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.
| Value | The SDK shows | The SDK calls your app |
|---|---|---|
false (the default) | Its own success or failure popin | After the user acknowledges the popin |
true | Nothing | As soon as the redemption settles |
Set the value to true when your app must show its own result screen.
ShowerrorMessageto the user, and do not log itOn the expired case, the SDK builds an
errorMessageof 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
appHandlesRedemptionAlertattrueyou 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
errorMessageon 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:
| Result | Android, React Native, Flutter, Cordova | iOS |
|---|---|---|
| Success | REDEMPTION_CONSUMED | redemptionConsumed |
| Failure | REDEMPTION_FAILED | redemptionFailed |
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.
Updated 20 days ago

