Return to the app
You will set up how a person who paid in the browser gets into your app with access already open.
The whole path
- The customer pays on a web page and lands on the post-payment screen: the “Open the app” and “Download the app” buttons and an access code.
- Behind “Open the app” is the return link — an address on our handoff domain with a one-time handoff code at the end:
https://<project ID>.go.<our domain>/handoff/<CODE>. The code lives 72 hours; we also email the same link to the customer. - Then the device that opened the link decides everything.
| Where the link was opened | What the customer sees | What the app does |
|---|---|---|
| phone, app installed | the system opens the app: on iOS via Universal Links, on Android via App Links | takes the code from the address and exchanges it for a buyer identifier (guid) — step 3 |
| phone, no app | the post-payment screen buttons lead to the app store; the email link opens our page with the access code and a request to install the app first | recognises the customer after install, without the link — how exactly |
| computer | the access code on the post-payment screen and on our page the link leads to, with a request to open it on the phone | nothing: the person will open the email on the phone or enter the code in the app |
We send no push after payment: the app learns access is open when it requests the entitlement.
Before you start
- The project admin role in the cabinet. Without it, the project owner enters the three values from step 1; you do the rest. Who invites whom and which roles exist — “Project and team” and “Hand off to a developer” on the owner's track.
- Our library for iOS or Android. The move works without it too, but you exchange the code with a request yourself.
- API address and project ID. Both are in the cabinet: “App connection” → “For developers”.
Step 1. Provide three values about the app
Cabinet: “Project settings” → “App connection” → “Main” tab → the “If the app is already installed — open it right away” branch. On the “Your own server” path the branch is hidden: open it with the “Show the remaining settings” link under the list of branches.
| Field | What to enter | Where to get it |
|---|---|---|
| iOS — Team ID and Bundle ID in one line | ABCDE12345.com.example.app | Apple Developer → Membership and the Bundle Identifier in Xcode |
| Android — package name | com.example.android | applicationId of the app module |
| Android — signing key fingerprint (SHA-256) | AA:BB:CC:… | Play Console → App integrity |
You need the fingerprint of the key Google Play signs the app with at publication, not of the upload key. These are confused most often, and then App Links stop working without a single message.
With empty fields the move still happens, but the long way round: the customer is recognised by the device fingerprint — phone traits such as screen, time zone and language — or by the code from the email, with extra steps for them.
We build the association files apple-app-site-association and assetlinks.json and serve them on the handoff domain ourselves once the values are saved. You do not host them.
Step 2. Bind the handoff domain to the build
There is one handoff domain per project, and the cabinet does not show it. Take it from the return link — the part between https:// and /handoff/ on the “Open the app” button on the post-payment screen and in the email. A test card run also gives you the link — “Troubleshooting”, section “Check without waiting for a real payment”.
On iOS, add it to Associated Domains: applinks:<project ID>.go.<our domain>.
On Android, declare the same address in the manifest:
<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"
android:host="<project ID>.go.<our domain>"
android:pathPrefix="/handoff/" />
</intent-filter>
The association files are visible from a browser: open https://<handoff domain>/.well-known/assetlinks.json — the response contains your package name and the signing key fingerprint. So you see the values from step 1 arrived before you build.
Step 3. Parse the link in the app
The code is the last segment of the path. Pass it to the library — it exchanges the code for a buyer identifier and stores it.
// iOS — from scene(_:continue:) or .onContinueUserActivity
// url.pathComponents = ["/", "handoff", "<CODE>"]
guard url.pathComponents.count >= 3, url.pathComponents[1] == "handoff" else { return }
Web2App.identify(deepLinkValue: url.pathComponents[2]) { result in
// .success(guid) / .failure
}
// Android — from onCreate and onNewIntent; same code, different method name
val segments = intent.data?.pathSegments.orEmpty() // ["handoff", "<CODE>"]
if (segments.size == 2 && segments[0] == "handoff") {
Web2AppSdk.identifyWithDeepLinkValue(segments[1]) { result -> }
}
The code expires on the first exchange. Opening the same link again gives an error on Android, although the identifier was saved the first time; on iOS, success with the saved identifier. So on an error, read the entitlement first and offer the email with a link again only on an empty answer.
On iOS the call exchanges the code only while the library holds no identifier. If it has already created its own — openWebPaywall and openQuizEmbedded do that — the saved one is returned and the code stays unused, although the payment by the link went to a different identifier.
You can check this in advance: Web2App.currentGuid() returns the stored identifier, or nil if there is none yet. The library creates it on identification or on the first display of the paywall or quiz — “SDK”, step 3. If the value is not empty, exchange the code with a request and request the entitlement by the identifier from its response.
The buyer identifier never travels in the link in plain form and is not accepted as a ?guid= parameter: only the one-time code carries the identity.
Step 4. Add the return scheme
The scheme is the second way into the app: the “Open the app (alternative way)” and “Close” buttons on the post-payment screen use it. It cannot replace Universal Links: the email link opens the app only through them.
The cabinet shows a ready string like w2a-3f1e2d3c://handoff?code=<code> in the same branch. The scheme name is the part before ://; register it identically in both builds, because we give both the same string. On iOS the name is declared in CFBundleURLSchemes, on Android with a separate intent-filter for the scheme.
The string shown is not saved yet. Open “Does the app already have its own scheme?”, click “Restore ours” or enter your own name, and save the settings — otherwise neither button appears on the post-payment screen.
The code is in the code parameter; it is the same handoff code as in the return link: same 72 hours, and an exchange expires it for both links. Where to pass it depends on who opened the paywall:
- Your
openWebPaywallcall, and you are waiting for its result. Pass the address toWeb2App.handleReturnURL(_:)on iOS orWeb2AppSdk.handleReturnUrl(url, onResult)on Android. This call does not spend the handoff code: access arrives by the already saved identifier, and the email link keeps working. - The person paid in a regular browser. Pass the code to the call from step 3; without the library, to the same request.
The embedded windows — openWebPaywallEmbedded and openQuizEmbedded — do not use the scheme: the outcome and closing come to the display callback without a link, and they do not need handleReturnURL.
The platforms return the result of these methods differently:
- Android. The entitlement arrives in
onResult: the library polls it once a second, up to ten times. - iOS. The method has no callback. It closes the open paywall, and the result arrives in the completion of the
openWebPaywallcall that opened the screen: polling runs once every two seconds, up to thirty times.
Step 5. Check that the app reached us
With the library, the first-open mark is sent automatically as soon as it recognises the customer. Without the library, send it after the first successful code exchange:
curl -X POST "https://api.subster.ai/public/handoff/app-callback" \
-H "Content-Type: application/json" \
-d '{"guid":"…","projectId":"…","device":"ios","event":"app_installed"}'
The response is 204 with no body; it does not return the entitlement. The request body is always required: on an empty one you get 400, and the install is not recorded. The device field takes ios or android. We accept up to 60 marks a minute from one IP address.
By this mark, the cabinet's “For developers” tab shows “The app is calling us.” and the date of the last install. It is the fastest way to confirm the move works.
Selling with a button inside the app
People who already have the app can buy in the browser: a button in the app opens your paywall, and the customer is already recognised on it.
- Enter the address. Cabinet: “Project settings” → “App connection” → “Main” tab, “Payment from inside the app” section, “Your payment page address” field. You need the https address of a published paywall.
- Get the link. When the button is pressed, your server creates the customer with a
POST /s2s/v1/usersrequest using an access key — request and response. Next to the buyer identifier, the response haspaywallUrl— your address with the customer's payment pass. - Open the link as is, appending nothing: with our library via
openWebPaywall, or your own in-app browser. The library waits for the entitlement itself, but the payment then goes to its device identifier (currentGuid()), not the one from the response — request the entitlement from the server by it too. Without the library, request the entitlement by the identifier from the response.
Do not store the link: the pass in it lives one day, so request a new one on every press. If the cabinet field is empty, null comes instead of the link, and the identifier as usual.
The library can also open the screen by its ID, without a request to your server: Web2App.openWebPaywall(paywallId:) on iOS, Web2AppSdk.openWebPaywallById on Android. Copy the ID in the cabinet: “Funnels” → “Paywalls” tab → ⋮ in the screen's row → “Copy the ID”. This works only if the domain is bound to the paywall itself: a screen inside a bound funnel is not found by ID.
Next
- How we recognise the customer — six ways a payment finds the person, including installing the app after payment.
- Check the entitlement — what to request by the buyer identifier and how to read the answer.
- Troubleshooting — what to check when a customer paid but has no access in the app.