How we recognise the customer
You will see the ways a browser payment reaches the person in your app and what happens when a way fails.
Before you start
- The project access key. Needed for requests from your server — where to get it: “Authentication”.
- Project ID — for requests from the device. Cabinet: “Project settings” → “App connection” → “For developers”.
- The Android package name in “App connection”. Without it we cannot build the Google Play install link, and the customer is left with the email.
What links to what
A browser payment goes to a buyer identifier (guid), and the app requests the entitlement by the same one. So the move has one job: deliver to the app the very identifier the payment went to.
There are six ways, and they back each other up: if one misses, the next picks up.
| If you have | Way | How reliable |
|---|---|---|
| user accounts in the app | your user identifier | does not depend on the move at all |
| the app already installed | handoff code in the return link | arrives if the link opened the app |
| Android, app installed after payment | Install Referrer | arrives on install from Google Play |
| iOS, app installed after payment, no tracker | device fingerprint | a match is not guaranteed |
| AppsFlyer or Adjust connected | tag in the tracker deep link | as reliable as the tracker |
| none of the above worked | email with a code | always works, but the customer takes a manual step |
Your user identifier
If people create an account in the app, do not wait for the move: create the customer with us in advance and send them to payment with a ready identifier.
curl -X POST "https://api.subster.ai/s2s/v1/users" \
-H "Authorization: Bearer sk_live_…" \
-H "Content-Type: application/json" \
-d '{ "externalId": "client-user-42", "email": "[email protected]" }'
# { "guid": "abc123def456", "user_id": "abc123def456",
# "paywallUrl": "https://pay.example.com/?pass=…&email=user%40example.com&origin=app" }
At least one of the two fields is required, otherwise 400. user_id in the response is the same identifier. paywallUrl comes if the project has a payment page address (“Return to the app”, section “Selling with a button inside the app”), otherwise null.
A repeat with the same externalId returns the same identifier — store this pair on your side.
Email alone cannot identify a person: anyone can give someone else's address. Until the email is verified, we will not return an existing identifier for it but create another customer. The email becomes verified when the customer redeems the handoff code from their payment: follows the return link or enters the code by hand.
Which identifier wins in the paywall address
This concerns you if you open the paywall address yourself — with your in-app browser or a link in an email. If you open it with our library, the customer becomes its device identifier; look up the entitlement by that.
The address has no bare identifier; instead it has a signed pass: it lives 24 hours and is exchanged for the identifier on our server. The origin=app tag is already in the address, and our libraries append their own device identifier to it. The parsing rule:
- tag and a non-empty identifier in the address — that identifier is the customer, even with a pass next to it;
- pass without an identifier in the address — the pass exchange gives the customer;
- an identifier without the tag is not taken as the customer.
So open the address as is and do not append your identifier: it would override the pass and take the payment to a value nobody will ever ask about.
Handoff code in the return link
The app is already on the device: after payment we open it with the return link, which carries a one-time handoff code. With the library, the code is exchanged by its call — step 3 of that page. Without the library, by a request:
GET /public/handoff/resolve?code=<код>
→ { "success": true, "data": { "guid": "abc123def456", "projectId": "…" } }
The response is wrapped: the identifier is in data.guid, not at the top level. The exchange accepts up to ten requests a minute from one IP address, then 429.
The code lives 72 hours and expires on the first successful exchange. Save the identifier right away: a repeat request with the same code gets 404. We give the same answer for an expired or non-existent code.
The handoff code and the install token are different values of one kind: eight characters, single-use, exchanged by this same request. The code comes in the return link, on the post-payment screen and in the email, and lives 72 hours. The install token comes via Install Referrer and the tracker deep link, and lives 30 days.
Refusals from this page's public addresses carry no machine code (except the bundleId field below), with a body like { "statusCode": 404, "message": "…" }: branch on the response status. Limits for all addresses are listed in “Authentication”.
Install Referrer on Android
A way iOS lacks: Google Play itself delivers the handoff token to the app. With our library, calling Web2AppSdk.identify() at launch is enough — it reads and exchanges the value itself.
The post-payment screen button leads to the store by our link …/store/apps/details?id=<package>&referrer=<token>. Google Play keeps the value and gives it to the installed app. The install token lives 30 days — click and install can be weeks apart.
If it failed. If the app was installed bypassing Google Play — manually from a package, from another store, on a device without Google services — the value will not reach the app. The library then tries the device fingerprint, and on a miss calls onNeedEmail: show the email field — the last way in the table.
Without the library: Install Referrer
Read the value yourself with the Google Play Install Referrer library and exchange it with the same request as the handoff code. Google Play returns exactly what was in our link, that is, the token itself.
// build.gradle: implementation("com.android.installreferrer:installreferrer:<version>")
val client = InstallReferrerClient.newBuilder(context).build()
client.startConnection(object : InstallReferrerStateListener {
override fun onInstallReferrerSetupFinished(responseCode: Int) {
if (responseCode == InstallReferrerClient.InstallReferrerResponse.OK) {
val token = client.installReferrer.installReferrer
// exchange: GET https://api.subster.ai/public/handoff/resolve?code=<token>
// the buyer id is in data.guid of the response
}
client.endConnection()
}
override fun onInstallReferrerServiceDisconnected() {}
})
The exchange expires the token like the handoff code: a repeat request with the same value gets 404. The same refusal on the very first exchange means the app was not installed by our link or the token expired — show the email field.
Device fingerprint
The way for iOS when the app is installed after payment and the project has no tracker: the return link does not survive install, and iOS has no Install Referrer equivalent. On Android the library takes this way when Install Referrer failed.
The post-payment page leaves a device fingerprint with us: we take the IP address and the browser header on the server, and screen resolution, time zone and language from the browser. On first launch the app sends its traits and gets the identifier if the match is confident and unique.
With the library (from version 0.7.0) this is done by the identify call without a code: on iOS Web2App.identify(deepLinkValue: nil), on Android the same call as for Install Referrer.
The rules are strict:
- an exact match is searched for two hours after payment, a less exact one, by IP address, for thirty minutes;
- if two payments fit the traits and the device language does not tell them apart, both are refused;
- a candidate with a contradicting trait does not fit even with a matching IP address.
No advertising identifiers or tracking permission are needed: we request neither.
If it failed. Shared Wi-Fi, Apple's private relay or an iPad with a “desktop” browser cause a miss. A miss gets the same 404 as a disabled mechanism: from it you cannot tell whether there was a candidate. The library reports this separately — .needsEmailFallback on iOS, the same “email needed” signal on Android — and the email remains.
How to turn it off. “Project settings” → “App connection” → “Main” tab → the “How the app recognises your buyer” branch → the “Do not recognise by device” option. The fingerprint is then not collected at all. The “Through an ad tracker” option does not turn off device recognition.
Without the library: fingerprint
Send the request from the device itself: the IP address is part of the fingerprint, and we take it from the connection. A request through your server will never match. The limit is ten requests a minute from one IP address.
curl -X POST "https://api.subster.ai/public/handoff/resolve-by-fingerprint" \
-H "Content-Type: application/json" \
-d '{"projectId":"<PROJECT_ID>","platform":"ios","osVersion":"18.6",
"screen":"393x852","timezone":"Europe/Berlin","language":"de-DE"}'
| Field | What to pass |
|---|---|
projectId | project ID; required |
platform | ios or android; required |
osVersion | system version; required, but not used in matching |
screen | screen size in logical points as the browser sees it: on iOS UIScreen.main.bounds, not pixels. Orientation does not matter; required |
timezone | time zone in IANA format, on iOS TimeZone.current.identifier. Formally optional, but without it only the less exact IP-address match in the first thirty minutes remains |
language | device language, de-DE, on iOS Locale.preferredLanguages.first; separates two candidates from one address |
bundleId | optional: Bundle ID without Team ID, or the package name. If you send it and the cabinet has a value for this platform, they must match — otherwise 404. Invalid characters — 400 with the code VALIDATION_BUNDLE_ID_FORMAT |
{ "success": true, "data": { "guid": "abc123def456", "matchMethod": "fingerprint" } }
matchMethod has two values: fingerprint — an exact match, ip — a less exact one, by IP address. A fingerprint is single-use: a successful identification expires it, so save the identifier right away.
Tag in the tracker deep link
If you have AppsFlyer or Adjust connected, the handoff token travels right in the tracker deep link. We build the install link on the server from the tracker link set in the cabinet.
Place in the cabinet: “Project settings” → “App connection” → “Main” tab → the “How the app recognises your buyer” branch → the “Through an ad tracker” option. It has two fields: “Which tracker do you use” and “Your app link inside the tracker”.
| Tracker | Where the token is | Other fields |
|---|---|---|
| AppsFlyer | deep_link_sub1, duplicated in af_sub1 | deep_link_value always holds the word handoff, not the token; deep_link_sub2 — paywall ID, deep_link_sub3 — project ID, deep_link_sub4 — where the button led: app or store |
| Adjust | user_id, duplicated in adj_label | in a classic app.adjust.com link the parameters are also packed into deep_link= if the project has a return scheme: decode the value as the address <scheme>://handoff?user_id=<token>&… and take user_id from its parameters |
The token is exchanged like the handoff code: with the library via Web2App.identify(deepLinkValue:) on iOS or Web2AppSdk.identifyWithDeepLinkValue on Android, without it via GET /public/handoff/resolve?code=<token>.
If it failed. Adjust links like *.go.link and *.adj.st require the tracker's own adj_t parameter: without it the move answers 404, and we cannot fix that on our side — copy the link from the Adjust dashboard in full. For iOS the install link is built only with a configured tracker; without a tracker, the device fingerprint and the email remain.
Email with a code
The last line when everything else missed: the handoff code is lost, the device changed, the app was reinstalled. The app shows an email field and sends the address: with the library via requestEmailRecovery(email), without it via a request.
curl -X POST "https://api.subster.ai/public/handoff/email-recovery/request" \
-H "Content-Type: application/json" \
-d '{ "projectId": "<PROJECT_ID>", "email": "[email protected]" }'
The response is always 204 — it does not reveal whether we know this address. If the address is found, it gets an email with a return link and a handoff code; from there the way is the same as above.
Two limits apply at once: five requests per fifteen minutes from one IP address, then 429, and twenty minutes between emails to one address. The second is not visible in the response: the app gets 204, but no repeat email goes out. Tell the customer the email will not arrive instantly, and do not offer a retry until twenty minutes pass.
The customer sees the same code on the post-payment screen, labelled “Or enter the code manually:”. You build the field for it in the app: strip spaces, convert the input to uppercase and pass it to the same exchange as the code from the link. The code is compared letter for letter, and one typed in lowercase will not be found.
An identifier belongs to one organization
An identifier is bound to the project where it first appeared. It can pay in any project of your organization — for example, if funnels are split by platform or by country.
An identifier from another organization's project is rejected before any money moves: 400 with the code VISITOR_ID_FOREIGN_PROJECT in the code field, with a reason for the developer next to it. Branch on the code, not the text.
Next
- Check the entitlement — what to request with a ready identifier and how to read the answer.
- Return to the app — set up the links the browser opens the app with.
- SDK — which calls do all of the above for you on iOS and Android.
- Troubleshooting — what to check when a customer paid but has no access in the app.