S Subster

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

  1. 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.
  2. 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.
  3. Then the device that opened the link decides everything.
Where the link was openedWhat the customer seesWhat the app does
phone, app installedthe system opens the app: on iOS via Universal Links, on Android via App Linkstakes the code from the address and exchanges it for a buyer identifier (guid) — step 3
phone, no appthe 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 firstrecognises the customer after install, without the link — how exactly
computerthe access code on the post-payment screen and on our page the link leads to, with a request to open it on the phonenothing: 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

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.

FieldWhat to enterWhere to get it
iOS — Team ID and Bundle ID in one lineABCDE12345.com.example.appApple Developer → Membership and the Bundle Identifier in Xcode
Android — package namecom.example.androidapplicationId 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.

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:

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:

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.

  1. 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.
  2. Get the link. When the button is pressed, your server creates the customer with a POST /s2s/v1/users request using an access key — request and response. Next to the buyer identifier, the response has paywallUrl — your address with the customer's payment pass.
  3. 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